前阵子帮一个电商团队排查线上问题,库存表在秒杀场景被扣成了负数,业务方当时的第一反应是“把事务隔离级别改成最高不就安全了”,听得我一愣,差点以为他们要为了这一次扣减把全系统的并发全部串行化。后来更常见的情况是,开发同学知道四种隔离级别的名字,也背得出脏读、不可重复读、幻读的定义,可真到了“为什么这行 SELECT 会锁住别的事务”“为什么同一段 SQL 在 RC 和 RR 下表现完全不同”的时候,就答不上来了。
很多人把“事务隔离级别”和“数据库锁”当成两个独立的知识点去学,但这在 MySQL 里其实是同一套并发控制体系的一体两面:隔离级别描述的是“事务之间能互相看到什么”,锁描述的是“不一致的访问是如何被挡住的”,而 MVCC 负责让读写不互相阻塞。如果你只掌握隔离级别,回答不了线上死锁;只掌握锁,又很难解释为什么 RC 级别下某些锁不会出现。这篇文章就把这套链路完整拆开,从隔离级别的边界讲到 InnoDB 具体加的锁,再到真实场景下的锁等待与死锁定位。
1. 先从业务层面理解:事务并发到底在防哪几件事
1.1 隔离级别不是给你“选更强”的,而是给你“选平衡”的
事务有四大特性,ACID 里最容易被轻描淡写的是 I,也就是 Isolation(隔离性)。它要解决的核心矛盾非常朴素:两个事务同时在读写同一批数据,如果没有规则约束,结果就会乱套。MySQL 之所以提供四种隔离级别,不是为了让你在面试时背一个表格,而是给了你四挡“隔离强度”去匹配不同业务对性能和一致性的取舍。
不要把隔离级别理解成一个越强越好的开关。强度越高,通常意味着越多的等待、越多的锁开销、越低的并发度。一个合理的数据库设计者需要判断的是:这条业务到底能不能容忍读到别人未提交的数据?能不能容忍同一事务里前后两次查询结果不一致?能不能容忍明明只查了一个范围,结果却多出或少了行?
1.2 三个经典并发问题:脏读、不可重复读、幻读
我习惯用“两个人同时看一份 Excel 表格”来类比这三个问题。
脏读:事务 A 改了某行数据但还没提交,事务 B 读到了这行修改后的值。如果 A 回滚了,那么 B 刚才读到的东西就是一个“不存在过的脏数据”。例如 A 把订单金额从 100 改成 80,还没提交,B 读到了 80,然后 A 回滚,订单实际还是 100,可 B 已经按 80 做了后续操作。
不可重复读:事务 B 第一次读取某行时值是 100,第二次读取同一行时值变成了 80。问题出在事务 A 在这期间提交了修改。B 是“能重复读”的,但读不到相同的值,所以叫不可重复读。
幻读:事务 B 按条件第一次查出了 5 条记录,事务 A 插入了一条新记录并提交,B 再按同样条件查询时变成了 6 条。多出来的那条像“幻影”一样出现,所以叫幻读。
细心的读者会发现,不可重复读针对的是“某一行内容被 UPDATE”,而幻读针对的是“结果集本身多了新的行”,甚至可能涉及 DELETE 导致行变少。这两种问题的处理难度完全不同:改一行可以靠行锁挡住,但“防止别人往某个范围里插入数据”,就需要更复杂的锁机制。这也是后面为什么要讲间隙锁的原因。
1.3 锁和隔离级别的分工
可以这样理解它们的关系:隔离级别是“规则层”,它决定一个事务需要防止哪些并发问题;锁和 MVCC 是“实现层”,它们用具体手段把规则落地。
具体落到 MySQL InnoDB 上,默认的 REPEATABLE READ 并不仅仅靠锁解决幻读,而是通过 MVCC 生成了事务内的一致快照,让普通 SELECT 走“快照读”,读到的始终是最初那个版本。与此同时,对于 UPDATE、DELETE、SELECT FOR UPDATE 这类需要“当前读”的语句,InnoDB 才会动用记录锁、间隙锁、临键锁去限制其他事务的写入。也就是说,隔离级别把“要防什么”定义清楚了,数据库才知道“该加什么锁、什么时候加、加多久”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四种隔离级别逐个拆解:MySQL 和 SQL 标准并不完全一致
2.1 READ UNCOMMITTED:几乎只会出现在教材里的级别
READ UNCOMMITTED,读未提交,意思是事务可以读到其他事务尚未提交的修改。这个级别会把脏读问题完全放开,业务上几乎没有使用场景。它唯一的优点是读操作不需要生成版本快照、也不需要等待其他事务释放锁,读性能理论上最好,但付出的代价是读到随时可能被回滚的数据。
如果你在面试时说“这个级别就是用来做报表的”,那大概率会被追问一句:报表如果读到脏数据,你怎么解释结果差异?我实际看到的使用场景基本只存在于某些“只追求最大吞吐、不追求数据准确性”的统计任务里,并且通常还要配合非常短的事务。正常业务系统里,我不建议任何业务表使用 READ UNCOMMITTED,尤其在资金、库存、订单这类数据上,脏读的杀伤力是灾难性的。
2.2 READ COMMITTED:读已提交,为何还会出现不可重复读
READ COMMITTED 解决了脏读问题:事务只能读到其他事务已经提交的数据。它也是很多其他数据库(比如 PostgreSQL、Oracle 默认级别)的默认隔离级别。
但这个级别不保证“可重复读”。事务 B 在第 1 秒执行 SELECT 时,事务 A 还没提交,B 读到的是旧值;事务 A 在第 2 秒提交了修改;B 在第 3 秒再次 SELECT,会看到新值。从 B 的视角看,同一个事务里相同查询得到两个不同结果,这就是不可重复读。
很多从 Oracle 转过来的团队在 MySQL 上会遇到一个习惯差异:Oracle 默认是 RC,MySQL 默认却是 RR。如果直接把代码迁到 MySQL 而不做任何调整,RC 下会出现更多不可重复读的现象;反过来,有人为了求稳把 MySQL 调成 Serializable,又会产生大量锁等待。正确的做法是先理解每种级别在 InnoDB 里的真实实现,再结合业务选型。
2.3 REPEATABLE READ:MySQL 默认级别,但它的“可重复读”比标准多了点东西
SQL 标准中,REPEATABLE READ 只要求解决不可重复读,不要求解决幻读。但 MySQL InnoDB 通过 next-key lock 和 MVCC 快照,在 RR 级别下顺带把幻读也基本解决了。
这里的“基本”特别值得强调:它解决的是普通 SELECT(快照读)下的幻读,因为事务通过一致性视图读到的始终是第一眼看到的数据集合。对于 UPDATE、DELETE、SELECT FOR UPDATE 这类当前读,InnoDB 用间隙锁和临键锁阻止其他事务向范围内插入数据,从而避免当前读范围发生变化。
这就带出一个非常关键的面试考点:MySQL 的 RR 到底能不能完全避免幻读? 严谨的答案是“MVCC + next-key lock 让绝大多数幻读场景不会出现,但没有一种机制是银弹”。举个例子,RR 下如果一个事务先做了一次普通的快照读,之后另一个事务插入并提交了一条满足条件的新数据,当前事务再执行当前读或者 UPDATE,依然可能把这条“新出现的记录”包含进来。因此,如果业务的核心逻辑依赖绝对无幻读,不能只靠数据库默认行为,还需要在代码里用 FOR UPDATE 或把隔离级别提到 Serializable 做兜底。
2.4 SERIALIZABLE:让事务像排队一样执行
SERIALIZABLE 是最高的隔离级别,在 InnoDB 里,它会让所有普通 SELECT 自动退化成加共享锁的当前读。换句话说,只要两个事务访问的数据范围有交集,就很可能出现读写互斥,一个事务读的时候,另一个事务不能写。
这种级别适合那种并发极低、但要求数据绝对一致的场景,比如某些配置表、对账任务。但很多生产环境出现锁等待、死锁,恰恰是因为某个连接把会话隔离级别设置成了 SERIALIZABLE 而没意识到。我曾经排查过一个线上问题:一个后台定时任务批量查询订单时全部变成了加锁读,导致所有前端写操作排队,订单创建接口的 RT 从 20ms 涨到 8 秒,定位后发现是连接池初始化时有人执行了 SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE。
下面这张表能比较清楚地看出四种级别与三个问题的关系:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 并发度 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 最高 |
| READ COMMITTED | 不会 | 可能 | 可能 | 高 |
| REPEATABLE READ | 不会 | 不会 | 基本不会(InnoDB 通过 MVCC + 临键锁规避) | 中 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 低 |
这张表在任何 MySQL 面试题里都能当标准答案用,但真正的价值在于:你要能解释清楚为什么 MySQL 的 RR 会比标准里的 RR 更强一点,而这一点恰恰由锁和 MVCC 共同实现。
3. 快照读与当前读:同一个 SELECT 可能走的是完全不同的路径
3.1 两种读的触发方式和区别
在 InnoDB 里,读操作分成两类。快照读(snapshot read),也叫一致性读,指的是不加锁的普通 SELECT,它读的是事务启动时某个时间点的一致性快照,不会被其他事务的未提交修改影响。当前读(current read),则是指读取记录的最新已提交版本,并且对读到的记录加上锁。触发当前读的语句包括:
SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEUPDATE ...DELETE ...INSERT在某种程度上也算一种特殊的当前写
一个很常见的误解是“SELECT 永远不会阻塞别人”。这句话只对快照读成立。如果有人对某条记录持有了排他锁,你的 SELECT ... FOR UPDATE 一样会卡住,因为当前读需要拿到锁才能继续。
3.2 版本链和 ReadView:MVCC 的幕后工作
MVCC 的全称是 Multi-Version Concurrency Control,也就是多版本并发控制。InnoDB 为每一行记录维护了多个历史版本,这些版本通过 undo log 连接成一条版本链。事务执行快照读时,会根据当前活跃事务列表生成一个 ReadView(视图),再沿着版本链找到第一个“对当前事务可见”的版本。
在 REPEATABLE READ 下,一个事务第一次执行快照读时生成了 ReadView,之后整个事务期间都复用这一个 ReadView。所以,无论其他事务提交了多少新值,它读到的都始终以第一次生成 ReadView 时的可见性为准。这就是“可重复读”在 InnoDB 里的底层实现。
在 READ COMMITTED 下,则是每条 SELECT 语句都会生成一个新的 ReadView。其他事务一旦提交,后一条 SELECT 就能看到新提交的结果,这也解释了为什么 RC 会存在不可重复读。
用一个通俗类比来说:ReadView 相当于你进餐厅时拍的一张“菜品库存照片”,RR 模式下你整个用餐过程都拿这张照片点菜,RC 模式下你每点一次菜都重新拍一张照片,所以如果后面有人把某道菜撤了或加了,你的下一次点菜结果会不一样。
3.3 MVCC 为什么不等于万能的并发控制
MVCC 最大的价值是让“读”和“写”不互相阻塞:读事务读历史版本,写事务修改当前版本,两者各走各的。但如果出现两个事务都要写同一行,MVCC 就解决不了了。
比如两条 UPDATE 同时想把某行库存从 100 改成不同数值,数据库不可能让它们同时生效,必须用行锁保证只有一个事务修改成功,另一个等待或报错。再比如两个事务都要执行 SELECT ... FOR UPDATE 再更新,当前读就必须加锁。所以 MVCC 解决的是“读写并发”的可见性问题,而“写写冲突”还是需要靠锁来仲裁。理解这一点,你才能真正明白为什么数据库并发控制总是“隔离级别 + 锁 + MVCC”三件套一起出现,缺一不可。
4. InnoDB 的锁体系:行锁也分很多种,间隙锁最容易出问题
4.1 从锁类型矩阵看起:共享锁、排他锁、意向锁
InnoDB 实现了标准数据库的两类行级锁:
- 共享锁(S Lock):允许持锁事务读取一行数据,多个事务可以同时持有同一行的共享锁。
- 排他锁(X Lock):允许持锁事务更新或删除一行数据,同一行上不能同时存在两个排他锁,也不能同时存在排他锁和共享锁。
它们之间的兼容关系用表格表示就是:
| 锁类型 | 共享锁 S | 排他锁 X |
|---|---|---|
| 共享锁 S | 兼容 | 不兼容 |
| 排他锁 X | 不兼容 | 不兼容 |
除了行级锁,InnoDB 还有一层意向锁(Intention Lock),它是表级锁,用来表达“这个事务准备在表中某些行上加共享锁还是排他锁”。意向共享锁(IS)和意向排他锁(IX)的互斥规则比较简单,它们的意义在于:当一个事务想给整张表加锁时,可以先快速判断表里是否存在行级锁,避免逐行检查。
在实际运维中,你执行 LOCK TABLES ... WRITE 或某些 DDL 时,如果表上存在大量活跃事务的意向锁,就可能触发锁等待。这也是为什么生产环境做 DDL 要特别小心,在没有使用在线 DDL 工具的情况下,一条 ALTER TABLE 可能会被长事务卡住很久。
4.2 记录锁、间隙锁、临键锁:行锁的三种具体形态
InnoDB 的行锁并不是笼统地锁一行,而是基于索引记录来加锁。在 RR 隔离级别下,它有三种主要形态。
记录锁(Record Lock):锁住索引上的一条具体记录。例如 SELECT * FROM orders WHERE id = 10 FOR UPDATE,只要 id=10 这条记录存在且 id 是主键,InnoDB 就会在主键索引上给这条记录加排他记录锁。
间隙锁(Gap Lock):锁住两个索引记录之间的“间隙”,阻止其他事务在这个间隙里插入新记录。间隙锁只在 RR 及以上隔离级别生效,在 RC 级别基本不会出现。锁间隙的目的很明确——防止幻读,因为如果一个事务锁住了某个条件范围的所有间隙,别的事务就无法往这个范围里插入满足条件的新行。
临键锁(Next-Key Lock):它是“记录锁 + 间隙锁”的组合,不仅锁住当前记录,也锁住这条记录前面的间隙。InnoDB 默认使用临键锁来扫描和锁定索引范围,它会以“左开右闭”的区间形式存在。比如索引中有值 10、20、30,那么记录 20 上的临键锁会锁住 (10, 20] 这个范围,既防止别的事务修改 20,也防止别的事务插入 15 之类的新数据。
很多人对间隙锁的“不兼容规则”理解有误。间隙锁之间其实是互相兼容的,两个事务可以同时锁住同一个间隙,但如果有人要往这个间隙里插入记录,插入操作会被阻塞,直到间隙锁释放。这种“锁等锁”的情况在业务上往往表现为:你明明只更新了一行,另一个事务想插入一条看似无关的新数据,却被卡住了。
4.3 插入意向锁:被间隙挡住的插入
InnoDB 还有一类特殊的锁叫插入意向锁(Insert Intention Lock),它本身是一种间隙锁,表示一个事务打算在某个间隙中插入记录。多个事务如果插入的位置不冲突,可以同时持有插入意向锁;如果插入位置落在另一个事务持有的间隙锁范围内,插入事务就必须等待。
我在业务中看到过不少这样的事故:一个长事务在 RR 下执行了 DELETE FROM orders WHERE status = 1,由于 status 列上没有索引,这条语句最终在扫描过程中给大量记录加了锁,并且对扫描范围内的所有间隙也加了间隙锁。业务侧另一个服务想往 orders 表里插入新订单,结果阻塞数秒,直到超时。
后来我们把 status 列加了索引,再配合延迟删除、分批提交,问题才消失。这个案例说明,锁的影响范围并不只是“命中的行”,还取决于语句的扫描范围和索引利用情况。
5. 同一段 SQL 在不同隔离级别下的加锁差异,我用一个真实例子还原
5.1 示例表与初始数据
下面我们用一张简单的订单表来观察加锁差异:
sql复制CREATE TABLE `t_order` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`order_no` VARCHAR(32) NOT NULL,
`user_id` BIGINT NOT NULL,
`amount` DECIMAL(10,2) NOT NULL,
`status` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
初始化数据:
sql复制INSERT INTO `t_order` VALUES
(1, 'A001', 1001, 100.00, 0),
(2, 'A002', 1002, 200.00, 0),
(5, 'A003', 1005, 300.00, 0),
(10, 'A010', 1010, 400.00, 0);
注意 id 我故意没有连续插入,因为中间出现了 2 到 5、5 到 10 的空隙,这些空隙在 RR 级别下是最容易出问题的区域。
会话 A 执行:
sql复制BEGIN;
SELECT * FROM t_order WHERE user_id = 1002 FOR UPDATE;
此时它会命中 id=2 这条记录。但如果 user_id 上的索引不是唯一索引,InnoDB 在 RR 下不仅会锁住 id=2 这条主键记录,还会在辅助索引 idx_user_id 上对记录 1002 加临键锁,并把索引中 1002 前后的间隙一起锁住。具体来说,InnoDB 会扫描辅助索引,从第一条记录开始向后遍历,直到遇到第一个不满足条件的记录才停下。在这个过程中,所有扫描过的索引记录以及它们之间的间隙都会被加锁。
5.2 会话 B 想要插入时的表现
假设会话 A 在 RR 下执行了上面的 SELECT ... FOR UPDATE,会话 B 再执行一条插入:
sql复制-- 会话 B
BEGIN;
INSERT INTO `t_order` (`order_no`, `user_id`, `amount`, `status`)
VALUES ('A004', 1004, 500.00, 0);
这条插入的 user_id=1004,在索引里正好落在 1002 和 1005 之间的间隙,因此会被会话 A 持有的间隙锁挡住。会话 B 会一直等待,直到会话 A 提交或回滚。但如果会话 A 是在 RC 下执行同样的语句,就不会锁这个间隙,B 的插入可以成功执行。
我把这个例子给一个同事演示后,他脱口而出:“原来不是只有命中行才加锁,间隙也算。”没错,RR 级别下范围查询的加锁范围通常比 RC 大很多,这正是 RR 并发度低于 RC 的一个直接原因。很多团队为了提升并发能力,会把隔离级别从 RR 调整成 RC,配合 binlog 使用 ROW 格式,确实能减少间隙锁带来的锁冲突。但这个决策要看业务是否允许同一事务里出现不可重复读,不能盲目照搬。
5.3 索引失效时的锁放大效应
比隔离级别更恐怖的,是索引失效带来的全表锁放大。
sql复制-- 如果 order_no 上没索引,这条语句会怎样?
SELECT * FROM t_order WHERE order_no = 'A001' FOR UPDATE;
在 RC 下,InnoDB 会扫描主键聚簇索引的所有记录,但对不符合条件的记录,会在确认不满足条件之后放掉锁,所以在 RC 下最终真正锁住的行可能还是只有命中的那一行,只是扫描过程中带来了大量不必要的锁判断和资源消耗。
但在 RR 下情况更严重,因为 InnoDB 使用临键锁,扫描过程中它会为访问到的每条记录都加上临键锁,而不仅仅锁最终命中的行。这个过程中产生的间隙锁会让其他事务很难往表里插入任何数据,表现上接近“全表锁”。如果表数据量很大,这种语句会瞬间拖垮业务。
这个案例提醒我们,做锁分析时第一件事不是看隔离级别,而是确认 SQL 是否走对了索引。我一般会先执行 EXPLAIN 看 type 和 key,再结合 possible_keys 判断优化器可能选错的索引。对复杂的并发 SQL,索引设计失误带来的伤害远比隔离级别选择失误更严重。
5.4 binlog 格式与隔离级别的关系
很多资深 DBA 在调整隔离级别之前,还会检查 binlog 格式。原因很简单:MySQL 的主从复制依赖 binlog 把主库的变更同步到从库,而 binlog 有 STATEMENT、ROW、MIXED 三种格式。STATEMENT 格式记录的是 SQL 语句本身,如果主库和从库的隔离级别不一致,或者数据写入顺序有差异,同一条 SQL 在从库上执行时可能产生不同的锁行为和结果,导致数据不一致。
因此,有不少团队在使用 RC 隔离级别时,要求 binlog 格式必须为 ROW 或 MIXED,因为 ROW 格式记录的是行的实际变更,不依赖 SQL 语义,从库回放时不容易出现偏差。而在 RR 级别下,statement 格式对很多 SQL 仍然能保证一致性,这也是早期 MySQL 默认 RR + STATEMENT 组合的原因之一。这类知识平时写业务代码时用不到,但一旦你在生产环境调隔离级别,就必须考虑复制架构的兼容性,否则线上数据不一致的锅会非常难查。
6. 锁等待与死锁排查:不能只会看现象,还要会看现场
6.1 第一步:找出正在运行的事务和持锁会话
当业务开始超时,第一步不是盲目重启应用,而是先看数据库里有哪些事务在跑、哪些会话在等待锁。
在 MySQL 5.7 及之前,我习惯查这几张系统表:
sql复制-- 查看当前所有运行中事务
SELECT * FROM information_schema.innodb_trx\G
-- 查看锁等待关系
SELECT * FROM information_schema.innodb_lock_waits\G
到了 MySQL 8.0,锁相关信息迁移到了 performance_schema 中,推荐这样查:
sql复制-- 查看当前持锁或等待锁的记录
SELECT * FROM performance_schema.data_locks\G
-- 查看锁等待关系
SELECT * FROM performance_schema.data_lock_waits\G
这两张表的信息非常有用。data_locks 里会显示 LOCK_TYPE 是 TABLE 还是 RECORD,LOCK_MODE 是 X、S、X,REC_NOT_GAP 还是 X,GAP,以及 LOCK_INDEX、LOCK_DATA 这样的关键信息。看到 LOCK_MODE: X,GAP,意味着当前是一个间隙锁;看到 X,REC_NOT_GAP,才是纯粹的行记录锁。搞清楚这些标记,就能快速判断是“两行数据真的撞了”,还是“有间隙把插入挡住了”。
6.2 第二步:用 SHOW ENGINE INNODB STATUS 复现和解读死锁
死锁发生后,InnoDB 会自动检测回滚一个事务,应用层会收到类似 Deadlock found when trying to get lock; try restarting transaction 的报错。这个时候最有价值的排查方法是执行:
sql复制SHOW ENGINE INNODB STATUS\G
输出内容很长,其中 TRANSACTIONS 段落会记录最近一次死锁的详细信息,包括两个事务各自持有的锁和正在等待的锁。举一个我实际遇到的简化示例:
code复制TRANSACTION 4218234567, ACTIVE 5 sec starting index read
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 18 page no 5 n bits 8 index PRIMARY of table `test`.`t_order` trx id 4218234567 lock_mode X locks rec but not gap waiting
这段日志告诉你:事务 4218234567 正在等待表 t_order 主键上某一行记录的排他锁(lock_mode X),而且它是一个单纯的记录锁(locks rec but not gap),不是间隙锁。再往下看,通常能看到另一个事务持有了这行锁并等待前一个事务释放它持有的其他锁,两者形成循环等待。
死锁的本质是“两个或多个事务互相等待对方释放资源”,它不一定代表数据有问题,更多时候代表代码里多个事务获取锁的顺序不一致。比如事务 A 先更新订单表再更新用户表,事务 B 先更新用户表再更新订单表,如果两个事务并发执行,很容易互相卡死。解决办法是统一业务逻辑里的加锁顺序。
6.3 第三步:用 sys.schema_table_lock_waits 和 EXPLAIN 辅助定位
MySQL 8.0 的 sys 库里提供了一些现成视图,可以更快判断表锁等待情况:
sql复制SELECT * FROM sys.schema_table_lock_waits\G
SELECT * FROM sys.innodb_lock_waits\G
前者能直观告诉你哪张表上有 DDL 或表级锁等待,后者把锁等待关系整理得更易读,包含等待事务、阻塞事务以及对应的 SQL。拿到当前被阻塞的 SQL 之后,不要急着修改业务代码,先对 SQL 执行一次 EXPLAIN:
sql复制EXPLAIN SELECT * FROM t_order WHERE user_id = 1002 FOR UPDATE;
重点看 type 和 key。如果是 ALL 或全表扫描,说明这个查询没有走索引,后面加的锁可能被放大到很多行甚至整张表。如果 type=ref、key=idx_user_id,说明走的是普通非唯一索引,此时要特别小心间隙锁影响范围。如果 type=const 或 eq_ref,走的可能是主键或唯一索引,加锁范围通常会精确很多。
7. 事务设计中的几个常见误区和实用建议
7.1 误区一:以为把隔离级别调到最高就万事大吉
我见过一个团队为了防止并发超卖,把所有事务都调整成了 Serializable,结果下单接口在用户高峰期的成功率直线下降。原因很简单:Serializable 会让普通 SELECT 也变成加锁读,读和写之间互相阻塞,业务吞吐量大幅下降。
正确思路是:用 RR 或 RC 作为默认级别,再在需要强一致的关键路径上,显式使用 SELECT ... FOR UPDATE 或乐观锁版本号。数据库锁是最后一道防线,但最好的防线其实是把并发控制在 SQL 层面:要么通过唯一键约束,要么通过条件更新 UPDATE ... WHERE status = 0 判断影响行数。
7.2 误区二:以为只要加了事务就不会有问题
事务能保证原子性、隔离性,但它不会替你解决“更新丢失”。经典的例子是并发扣减库存:
sql复制-- 错误写法
UPDATE inventory SET stock = stock - 1 WHERE product_id = 123;
如果只执行这一条,InnoDB 的行锁已经能保证两个事务串行更新,通常不会出大问题。真正容易出问题的是“先查询再更新”的模式:
sql复制-- 典型危险写法
SELECT stock FROM inventory WHERE product_id = 123;
-- 业务代码计算 new_stock = stock - 1
UPDATE inventory SET stock = new_stock WHERE product_id = 123;
如果两个会话并发执行,第一个事务读到的 stock=10,第二个事务也读到 10,两个事务都去更新成 9,最终库存仍然只有 9,扣了两单却只减了一件。
解决方案有两个方向:一是把计算下推到 SQL,如 SET stock = stock - 1;二是使用乐观锁 UPDATE ... WHERE stock = 旧值,再判断影响行数是否为 1。如果影响行数为 0,就说明数据已被别人改过,需要重试或报错。这类问题跟你选哪种隔离级别关系不大,核心是“不要在应用层做读-改-写而不加锁保护”。
7.3 一些值得长期坚持的习惯
第一,事务必须短小,尽量不在事务里执行远程调用、消息发送、复杂的业务循环。锁持有的时间越长,阻塞其他事务的概率就越大。在我优化过的生产事故中,大量死锁和锁等待源于一个事务里塞了七八个更新操作,中间还调了外部接口,整体耗时几百毫秒,于是跟其他事务撞锁的概率成倍上升。
第二,更新和删除语句尽量走主键或唯一索引。如果你自己写的 SQL 都搞不清楚会命中的索引范围,就不要指望数据库替你优化加锁行为。写 SQL 之前先执行 EXPLAIN,在测试环境里模拟并发事务,用两个会话验证锁等待情况,成本远比上线后排查低。
第三,对 RR 下的范围查询保持敬畏。SELECT * FROM orders WHERE status = 1 FOR UPDATE 这类 SQL,如果没有 status 索引,在 RR 下基本等于把整张表的写入全部挡住。如果只是读取数据做批次处理,可以改成普通快照读,不要轻易上 FOR UPDATE。
从我这些年的实践看,事务隔离级别和数据库锁的知识不是“面试八股”,而是真正决定一个系统并发稳定性的地基知识。很多人踩了坑就急着调参数,其实根源往往是对锁机制理解不透。希望这篇文章能帮你把隔离级别、MVCC、记录锁、间隙锁这条链路串起来,下次再遇到锁等待或者隔离级别选择的问题,至少能快速定位到是因为哪一行 SQL、哪个索引设计、哪一段事务边界导致的。
