ACID 四个字母里,A、C、D 在工程上相对好验证:原子性靠日志回滚,持久性靠刷盘策略,一致性是业务与约束共同保证的结果。真正让人头疼的是 I——隔离性。它的定义本身就带着妥协:完全隔离等价于串行执行,性能不可接受;降低隔离又会引入各种读写异常。工程上的隔离级别,本质是在正确性和并发度之间选一个自己能接受的点。
一、三种读异常的定义
讨论隔离级别之前,先把三个现象区分清楚,它们经常被混用:
- 脏读(Dirty Read):读到了别的事务尚未提交的数据。对方回滚后,你读到的东西就凭空消失了。
- 不可重复读(Non-repeatable Read):同一个事务内,两次读同一行得到不同结果,中间被别的事务的提交改掉了。
- 幻读(Phantom Read):同一个事务内,两次执行同一范围查询,返回的行数不同,中间被别的事务插入了新行并提交。注意区分:不可重复读针对”某一行变了”,幻读针对”多出或少掉了行”。
四个隔离级别与这些现象的对应关系如下:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不会 | 可能 | 可能 |
| REPEATABLE READ | 不会 | 不会 | 可能(标准 SQL 定义) |
| SERIALIZABLE | 不会 | 不会 | 不会 |
这张表是 ANSI SQL 的标准定义。需要注意的是,很多实际数据库(包括 InnoDB 默认的 REPEATABLE READ)通过多版本与间隙锁的组合,把幻读也挡住了大部分,比标准定义更严格。理论表格和实际实现之间总有缝隙,排查问题时要以实现为准。
二、MVCC:快照读与当前读
InnoDB 的高并发读能力主要来自 MVCC(多版本并发控制)。每行记录除了业务字段,还隐含了两个系统列:最近修改事务 ID 和回滚指针。回滚指针把该行的历史版本串成一个链,旧版本存放在 undo 日志里。
事务读取时,InnoDB 根据一个叫 Read View 的快照来判断哪些版本对自己可见。Read View 在事务第一次读的时候生成(REPEATABLE READ 下),记录了当时活跃的事务 ID 列表;每次读一行时,沿着版本链找到第一个对自己可见的版本返回。这样读操作不需要加锁,也不阻塞写。
这里有一个关键的区分:
- 快照读(Snapshot Read):普通的
SELECT,读的是历史版本,不加锁。 - 当前读(Current Read):
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、以及所有INSERT/UPDATE/DELETE,必须读最新版本并加锁。
MVCC 解决的是”读不阻塞写、写不阻塞读”,但它只对快照读生效。当事务里混用了当前读时,看到的是最新数据,和快照读的结果可能不一致,这是很多”我明明加了事务怎么还读到旧数据”困惑的来源。
一个工程上常见的坑是长事务导致 undo 链膨胀:Read View 生成得越早,能被清理的旧版本就越少,undo 日志持续堆积,查询要沿着更长的版本链回溯。这既占空间也拖慢读取,监控里看到 history list length 持续走高,往往就是长事务的信号。
三、间隙锁与幻读
REPEATABLE READ 下,快照读本身不会产生幻读(Read View 保证了两次读的可见性一致),幻读的场景通常出现在当前读里:事务 A 先 SELECT ... FOR UPDATE 查了范围,事务 B 插入新行并提交,A 再查一次就多出一行。
InnoDB 的对策是间隙锁(Gap Lock)和临键锁(Next-Key Lock)。普通行锁只锁住已存在的行,挡不住新行插入;间隙锁锁住的是索引记录之间的”间隙”,阻止其他事务往这个区间插入。临键锁是行锁加间隙锁的组合,锁住左开右闭的区间。
副作用是并发插入的冲突变多。比如按 user_id 做范围更新时,临键锁会锁住一段区间,其他事务往这个 user_id 范围内插入新订单就会被阻塞甚至死锁。等值查询且命中唯一索引时,InnoDB 会退化为只锁单行(间隙锁可以省掉),这是把业务查询尽量写成唯一键等值查询的一个实际动机。
INSERT 用的是插入意向锁,和间隙锁之间存在互斥关系:间隙锁挡住插入,插入意向锁等待间隙释放。批量插入遇到长时间不提交的事务时被卡住,追下去多半是这个机制。
四、长事务的代价
长事务不只是”持锁久”这么简单,它的危害是累积性的:
- 锁持有时间变长,死锁概率和锁等待超时都随之上升;
- undo 版本无法回收,磁盘占用和查询回溯成本增加;
- 主从复制受影响,大事务在从库上回放耗时,可能造成复制延迟;
- 回滚代价大,事务跑了很久之后失败,回滚本身可能比执行还慢,期间锁依然不释放。
工程上的惯例是把事务边界压到最小:只把必须原子的操作放进事务,RPC 调用、文件上传、消息发送这类不确定时长的操作全部挪到事务之外。一条事务里混入外部调用,等于把外部系统的响应时间变成数据库的锁持有时间。
五、默认级别与业务适配
MySQL InnoDB 默认 REPEATABLE READ,很多其他数据库默认 READ COMMITTED。这个差异在跨数据库迁移时容易踩坑:同一段 SQL 在不同默认级别下行为不同,尤其是依赖”事务内能读到其他事务已提交数据”的逻辑。
怎么选,看业务能容忍什么:
- 读多写少、对一致性要求高(账务、库存):REPEATABLE READ 或更高,关键路径上用当前读显式加锁。
- 读多写少、容忍短暂旧数据(报表、列表页):READ COMMITTED 就够,锁粒度更小,死锁更少。
- 批量对账、后台任务:READ COMMITTED 通常更合适,长时间扫描不会因为 Read View 早生成而拖累版本清理。
- SERIALIZABLE:并发代价明显,一般只在审计、结算这类对正确性极度敏感的短场景里用。
一个务实的做法是:默认 REPEATABLE READ,只在明确知道可以放宽的地方(比如只读报表)把连接会话级别临时设为 READ COMMITTED。隔离级别是会话可调的,不必全局一刀切。
六、死锁的识别与规避
死锁在高并发写场景下几乎不可避免,重点是让它快速失败、快速重试,而不是完全消灭。
识别手段有几种:
-- 查看最近一次死锁详情
SHOW ENGINE INNODB STATUSG
-- 查看当前锁等待
SELECT * FROM performance_schema.data_lock_waits;
SHOW ENGINE INNODB STATUS 里的 LATEST DETECTED DEADLOCK 段落会给出两个事务各自持有什么锁、在等什么锁、被回滚的是哪一个。这是排查死锁的第一手材料,建议在出问题时第一时间抓取保存(输出会被下一次死锁覆盖)。
规避的手段更多是工程习惯:
- 约定加锁顺序。多个事务都要更新同一组资源时,统一按主键升序处理,交叉等待就会消失。
- 缩短事务,前面已经说过,这是最有效的一招。
- 用低基数索引做范围更新时小心间隙锁,尽量收窄范围条件。
- 对死锁做重试。死锁发生后 InnoDB 会回滚代价较小的那个事务,业务层捕获错误码 1213 做有限次数退避重试即可,不要让重试变成雪崩。
隔离性的问题很少能靠改一个配置参数解决。真正起作用的是三件事的组合:对 MVCC 机制的理解、对事务边界的克制,以及对锁行为的预判。把这些变成代码评审时的检查项,比事后调参数有效得多。
Comments (0)
No comments yet — be the first.
Please Sign in to join the discussion.