InnoDB 的 Read Committed:它究竟"读"的是什么、怎么读、为什么这么读
在大部分互联网业务里,读写并发远大于强一致事务,多数团队因此把 MySQL InnoDB 的隔离级别降到 Read Committed(RC,读已提交) ,以换取更高的吞吐与更少的锁冲突。可一旦降级,开发者就必须非常清楚:在 RC 下,每一条 SELECT
语句看到的"世界"并不恒定;你读到的是"此刻别人已经提交的版本",下一条查询可能立刻变样。这不是 Bug,而是 RC 的设计取舍。要写出靠谱的并发代码,得先把 RC 的读法、快照、行锁和幻读机制讲透。
什么叫"读已提交":语义与直觉
顾名思义,RC 保证你不会读到其他事务未提交 的修改,避免"脏读"。但它并不承诺"你在一个事务里多次查询同一行,结果保持不变"。在 RC 中,一致性视图是"语句级"的 :每条不加锁的 SELECT
在开始时各自创建一个 Read View ,它们互不复用。于是第一次查询时看见的是 A 版本,另一事务提交后,你再查一次,瞬间就能看见 B 版本。这就是经典的不可重复读 。同理,对"范围"的锁定在 RC 下通常不覆盖"间隙",因此另一个事务能在区间里插入新行,你再次扫描会发现"凭空多了一行",这就是幻读。
MVCC 是怎么让"读已提交"发生的
InnoDB 用 MVCC(多版本并发控制) 实现一致性读。每条记录背后有一条"版本链",由隐藏列维护:一是最近一次修改它的事务 ID,二是指向上一版本的 undo 记录。你可以把版本链想象成时间回溯的指针:如果当前版本对这条语句不可见,就顺着指针往过去走,直到遇到一个"对现在这个 Read View 可见"的历史版本,然后把那份内容返回。
RC 与 Repeatable Read(RR,可重复读)的差异在于 Read View 的生命周期。RC 下,Read View 是每条语句 临时创建;RR 下,Read View 在事务第一次一致性读时创建,并整个事务内复用。这一个"复用",带来行为上的根本差异:RR 能"锁住你的视野",避免不可重复读;RC 则让你的视野随提交即时变化,换来更高的并发度与实时性。
快照读与当前读:两种完全不同的读取路径
理解 RC 必须区分两条读路径。快照读 指不加锁的普通 SELECT
;在 RC 中,它在语句开始时拿到一张 Read View,并沿着版本链挑出"此刻已提交且对本语句可见"的版本。这类读取不阻塞写,也不会被写阻塞,是 RC 高并发表现的根基。
与之相对,当前读 指需要锁的读取:SELECT ... FOR UPDATE/LOCK IN SHARE MODE
,以及所有 UPDATE/DELETE
。它们要读的是"最新的已提交版本",并在命中行上施加行级锁。RC 下,这些锁通常是记录锁(record lock) ,也就是说只锁住命中的具体记录,而不锁住记录之间的间隙 。这就解释了为什么 RC 无法从机制上消灭幻读:你锁住了已有行,但别的事务仍可以在你关心的区间里插入新行,你下一次扫描就会看到"新幻影"。需要注意的是,InnoDB 在一些特殊检查(比如唯一约束冲突检测、外键一致性)过程中,可能临时使用到间隙相关的锁来保证约束正确,但这并不改变 RC 的总体策略:默认以记录锁为主,不铺开区间。
两段小剧场:不可重复读与幻读是如何发生的
先看"不可重复读"。事务 T1 在 RC 下启动后,执行一次普通 SELECT
,读到价格是 100;另一事务 T2 更新价格为 120 并提交;T1 再次执行同样的 SELECT
,立刻看到了 120。这里没有谁违约,RC 的承诺只是"读已提交",而非"读已提交且重复可见"。它鼓励你在需要稳定视图的地方使用更强的手段,而不是对所有查询都强制加固。
再看"幻读"。T1 用 SELECT ... FOR UPDATE
锁住了年龄在 20 到 29 之间的用户记录;与此同时,T2 在这个区间新插入了一行并提交。由于 RC 下锁定读不覆盖区间,T2 的插入顺利完成。T1 再次扫描这个范围,会发现一条新记录出现了。这条新行并不是"无锁插入",只是锁的策略没有覆盖"间隙"本身,于是区间得以被补进新成员。要把这种"范围的稳定性"也保护起来,RR 通过 next-key 锁给出了答案,但代价是更强的阻塞与更低的并发。
为什么很多团队仍然选择 RC
因为它恰好踩在"正确的权衡点"上。RC 的快照是语句级的,这意味着你总能快速看到最新的事实;它的行锁通常只覆盖命中的记录,这让插入与更新更少遭遇无谓的冲突。对于"海量读 + 普通写"的业务形态,这是收益最大的选择。代价当然也存在:你要对读-改-写 这类复合操作更谨慎,对"范围唯一""库存扣减""账户结余"这类逻辑采用显式锁 或乐观版本,而不是指望隔离级别默默兜底。
在 RC 下如何把并发做对
工程实践里,第一条原则是把需要稳定性的地方显式表达出来 。对单行的修改,先用 SELECT ... FOR UPDATE
把它锁住,再做更新;或者用乐观锁列(例如 version
),让更新语句在 WHERE
子句里同时匹配 id
与 version
,失败就重试。对"范围"的一致性需求,尽量落在结构性约束上:用唯一索引保证"只能有一个",用业务键把"范围判断"转化为"精确键命中",必要时提高隔离级别到 RR,或者用更显式的业务锁来保护一个窗口。
第二条原则是用正确的读法满足正确的实时性 。列表页、统计页更看重"新鲜",在 RC 下用普通 SELECT
就是最佳路径;对计费、库存、额度这种"不能差一口气"的写前读取,用当前读来串起一致的修改;需要跨多表拼装出"检索态宽表"的查询,不要在数据库事务中强行拼,交由搜索引擎或预计算,把一致性问题转化为数据同步问题。
第三条原则是管理好超时与重试。RC 虽然锁冲突更少,但当前读与写写冲突仍可能发生。把等待时间与重试退避设计好,别用"无限阻塞"的默认;同时监控事务等待、死锁、回滚与行锁争用,尽早暴露"热点键"与"热点范围"。
与其他隔离级别的边界清晰些更好
把 RC 放在四个隔离级别的光谱上更容易定位它的性格。Read Uncommitted 太激进,能读到未提交的脏数据,只有在极少数分析场景才有用;Repeatable Read 是 MySQL 默认,靠事务级 Read View 和 next-key 锁抑制不可重复读与幻读,换来更多等待与死锁,但对事务性业务心智更简单;Serializable几乎把并发退化成串行队列,适合非常小的强一致临界区或学术演示。RC 正好落在 RU 与 RR 之间,它消除了脏读,保留了高并发,代价是开发者需要在关键路径上补齐一致性措施。
一个更底层的注脚:Read View 如何判定"可见"
本文不执着于 InnoDB 内部字段名,但逻辑可以清楚描述:当一条一致性读语句创建 Read View 时,它会记录当下仍"活着"的事务集合以及一个"当前最大已分配事务号"的边界。随后查看某行版本的事务号:如果这个号属于"仍活着"的集合,那么这个版本对本语句不可见;如果它比"最大边界"还新,也不可见;如果比边界旧且不在活跃集合里,这个版本就是可见的。若当前版本不可见,沿着 undo 指针回退,直到找到第一个可见版本。RC 的"语句级"特性就来自于每条语句各自生成并使用一次这样的视图。
收束:当你选择 RC,你选择了自主地承担一致性的责任
Read Committed 并不复杂,它只是把"读到最新"和"减少锁冲突"放在高优先级,把"读到相同"和"范围不变"交给你自己来表达。理解 MVCC 的版本链、明白快照读与当前读的差别、知道 RC 下锁的覆盖范围,基本就掌握了这门手艺。接下来,靠显式行锁、乐观版本、结构性约束与合适的隔离级别切换,去把每一段关键业务的并发边界钉牢。等你把这些"钉子"钉好,RC 带来的吞吐与响应,就会是你系统最可靠的基石。