先讲一个我印象很深的故障。某个晚上同事在群里发消息用户明明支付成功了订单状态却是失败后台查余额还莫名少了两百。刚开始排查是不是事务提交逻辑的 Bug查了两小时定位不到最后才发现是有人把数据库连接串里的隔离级别参数误配成了读未提交。从那一刻开始我给自己定了个规矩凡是接手数据库项目第一件事就是确认数据库事务的隔离级别第二件事是确认所有关键 SQL 是快照读还是当前读。这是一个平时存在感极低、一旦出错影响极大的基础参数。这篇文章不聊概念定义就用做项目的思路把隔离级别从原理、配置到企业级实战一次讲清楚。不管你是刚接触事务的初级开发还是已经写过不少业务代码的老手只要还在跟数据库打交道这篇文章里一定有你值得对照检查的地方。1. 先看并发事务到底会乱成什么样1.1 事务里的“隔离”到底在隔离什么数据库事务有经典 ACID 四要素原子性、一致性、隔离性、持久性。前三个大家都很熟但真正出问题最多、最容易被误解的是隔离性。简单说隔离性就是当多个事务同时操作同一批数据时每个事务看起来像是在独占数据库互不干扰。但计算机世界里没有免费午餐想让所有事务完全隔离就得付出性能代价想让性能高起来就得允许一定程度的数据“互相看见”。隔离级别就是这个取舍的刻度表。可以把它理解成多人会议室的投影屏读未提交相当于任何人没保存的草稿都直接投到大屏上读已提交相当于只有点击保存后的内容才会投影可重复读相当于每个人都有一份会议开始时的现场录像串行化则相当于会议室一次只让一个人发言。投影方式越严格会议进行得越慢但大家看到的内容也越一致不会出现一个人讲了一半推翻重讲、其他人已经照着错误内容干了半天的情况。1.2 三个经典并发问题脏读Dirty Read事务 A 修改了一行数据但还没提交事务 B 读到了这个未提交的修改然后事务 A 回滚了。B 拿着一个根本不存在的修改继续做业务判断。在电商场景里这相当于用户付款成功后订单服务读到扣款结果就盲目发货但扣款事务最终回滚了于是货发了、钱没收。不可重复读Non-Repeatable Read事务 A 先查一次某行值还是 500事务 B 修改同一行并把事务提交把 500 改成了 300事务 A 再一次查同一行发现变成了 300。两次读同一个东西结果却不一样。这在银行对账场景里很致命一张报表从头读到尾每个字段都是不同时间点的快照账永远对不上。幻读Phantom Read事务 A 按条件查询第一次查到一个范围内的 10 行记录事务 B 插入了一行也满足该条件的新记录并提交事务 A 用同样的条件再查一次发现多出来一行。这多出来的行像幻觉一样无法用锁住已有行的方式避免因为锁不住“还不存在的记录”。这三类问题层层递进脏读是读了别人的半成品不可重复读是同一行前后读不一致幻读是同一个范围前后行数不一致。把这三个问题按照允许发生的程度排列就自然得到了四种隔离级别。1.3 快照读和当前读绕不开的底层概念理解隔离级别之前必须先建立两个概念快照读和当前读。这也是很多技术资料语焉不详、最终读者实战时一头雾水的根源。普通 SELECT 语句是快照读它不会去加锁读的是某个时刻的“历史版本”数据走的是多版本并发控制机制。而 SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE、DELETE、INSERT 这些操作都是当前读。当前读必须拿到最新已提交的数据并且给相关记录加上锁防止其他事务同时修改。为什么隔离级别的测试结果经常和预想不一样因为快照读走 MVCC 避开锁当前读走锁机制硬碰硬。我们在业务里查一条记录很多时候用的是快照读但它和后面要讲的间隙锁、记录锁完全不是一套逻辑。很多隔离级别的“诡异现象”本质都是没分清这两条路线。2. 隔离级别对比与选择逻辑2.1 四个级别和现象速查表SQL 标准定了四个隔离级别名称即现象隔离级别脏读不可重复读幻读SQL标准定义典型使用场景READ UNCOMMITTED可能可能可能几乎不用除非对准确性零要求READ COMMITTED不可能可能可能大多数互联网业务读多写少REPEATABLE READ不可能不可能可能MySQL InnoDB 下基本可防报表统计、事务内多次读一致SERIALIZABLE不可能不可能不可能对账、极低并发、强一致性场景这四个级别的约束是逐渐收紧的。注意一个细节标准 SQL 定义里 REPEATABLE READ 仍然可能发生幻读但因为 MySQL InnoDB 在可重复读级别下额外引入了间隙锁和 next-key lock所以从实际表现来看幻读在 MySQL 下基本被挡住了。这是 MySQL 跟标准不一样的地方也是很多人面试回答出错的地方。后面第五章我详细展开。2.2 默认隔离级别的玄机不同数据库默认隔离级别不一样MySQL InnoDB 默认 REPEATABLE READOracle 默认 READ COMMITTEDPostgreSQL 默认 READ COMMITTED。默认值本身就是设计者给使用者的建议隐含了他们对自己并发模型和复制方案的判断。从实践角度看RC 和 RR 之间的选择核心就看一个业务需求事务内多次执行相同查询是否必须保证看到完全一致的数据。如果业务要生成一致性报表事务里第一次查到 1000 行执行到一半数据被别人删了两行第二次查变成 998 行报表就没法做了。这种需求必须用 REPEATABLE READ。反过来如果只是普通在线交易每一条 SQL 拿到“当时已提交的最新数据”就够了用 READ COMMITTED 能减少锁的持有时间提升并发效率。很多团队盲目沿用 MySQL 默认的 RR但业务根本不需要事务内重复读一致也有些团队听别人说 RC 并发高就把隔离级别全部改成 RC结果报表接口的统计数据和明细对不上。这两种都不可取选型应该由业务特征决定而不是由默认值或道听途说决定。2.3 别把高隔离级别当“免死金牌”SERIALIZABLE 看起来最完美三个问题全部避免但它的实现方式是让所有读操作都变成当前读甚至可能把读锁升级为范围锁。这意味着并发事务之间几乎没有任何交错空间所有涉及同一范围的操作都变成排队执行。我见过有团队为了防止并发下单出问题把订单表所在数据库全部改成 SERIALIZABLE结果线上大促时数据库连接数被打满压测一跑直接雪崩。而 READ UNCOMMITTED 虽然性能最猛但脏读带来的问题通常比性能收益严重得多生产环境用它是给自己埋雷。实际主流业务无非是在 RC 和 RR 之间做选择其他两个级别作为理解隔离级别的理论坐标更有价值。3. MVCC 和锁隔离级别背后的真实工作机制3.1 MVCC 的隐藏列与版本链以 InnoDB 为例每一行记录除了业务字段还藏着三个重要列DB_TRX_ID 记录最近一次修改该行的事务 IDDB_ROLL_PTR 是指向 undo log 中旧版本记录的指针DB_ROW_ID 是行 ID。每次 UPDATE 产生新版本时旧版本不会立刻消失而是通过撤销日志保留下来新版本指针指回旧版本形成一条版本链。这样设计的意义在于快照读不需要加锁只需要沿着版本链找到“对当前事务可见”的那个历史版本。它解决的是读写互相阻塞的问题。想象一下火车站售票窗口如果每个乘客看余票都得把窗口独占其他乘客全等着效率就太低了。MVCC 相当于给每个乘客发一张“历史时刻表”你在某个时刻查到的余票只反映那一刻的状态不需要挡住别人买票。3.2 ReadView 与可见性判断快照读读取数据时InnoDB 会生成一张 ReadView也就是当前系统里“活跃事务”的快照包含活跃事务 ID 列表和大小边界。判断一行数据新版本是否对当前事务可见规则如下如果行的 DB_TRX_ID 小于当前未提交事务的最小 ID说明该版本在事务开始前就已提交可见。如果行的 DB_TRX_ID 大于当前未提交事务的最大 ID说明该版本是由未来事务生成的不可见。如果 DB_TRX_ID 落在活跃事务列表中说明这个版本尚未提交不可见需要沿着版本链找更早的版本。如果 DB_TRX_ID 已提交且又不在活跃列表里可见。这套判断机制看起来绕但实际非常高效。我常说它像小区门口的访客名单门卫看一眼你的房号在不在“尚未入住”名单里就能决定让你进还是把你拦下不需要挨家挨户问。3.3 RC 与 RR 的快照策略差异为什么 READ COMMITTED 会不可重复读而 REPEATABLE READ 不会核心区别在于 ReadView 的生成时机。在 READ COMMITTED 级别下事务内每条普通 SELECT 语句执行时都会生成一个新的 ReadView所以后一条语句能看到前一条语句之后提交的数据。事务 A 先查余额ReadView 里没有事务 B 的提交记录读到 500事务 B 提交后事务 A 的第二条 SELECT 再次生成新 ReadViewB 的提交已经可见于是读到 300。两次读不一致。在 REPEATABLE READ 级别下事务 A 的第一条 SELECT 生成的 ReadView 会被整个事务复用之后再执行多少条 SELECT 都用同一个活跃事务快照。B 的事务 ID 从一开始就被认为“不可见”所以无论 B 怎么提交A 在这个事务内看到的都是第一次 SELECT 时刻的版本。这就是“可重复读”的底层来源。3.4 锁机制从记录锁到 next-key lock快照读不碰锁但当前读不行。以 UPDATE 为例MySQL 必须先拿到被修改行的行锁防止其他事务同时改它。行锁有两种共享锁和排他锁。共享锁之间兼容多个事务可以同时读排他锁与任何其他锁都不兼容写的时候别人不能读也不能写。这里要特别注意这里说的读是当前读普通快照读并不受排他锁影响因为它读的是历史版本。REPEATABLE READ 下处理幻读靠的是间隙锁和 next-key lock。间隙锁锁住的是两个索引记录之间的“空位”让别的事务无法在空位里插入新记录next-key lock 是记录锁和间隙锁的组合既锁住当前记录又锁住这个记录前面的间隙。当一条当前读 SQL 按范围扫描时InnoDB 会把扫描经过的索引区间加上 next-key lock。这就是为什么在 RR 级别下一个事务执行 SELECT ... FOR UPDATE 扫描某个范围后另一个事务往这个范围里插入新数据会被阻塞。RC 级别下没有间隙锁只有记录锁。所以并发事务可以往扫描范围里插入新记录从而发生幻读。这也是 RC 和 RR 在真实性能上差异很大的原因之一间隙锁在带来更高隔离性的同时显著扩大了锁的范围增加了阻塞和死锁概率。4. 实操测试在数据库里亲手验证四种现象4.1 建表与初始化数据纸上谈兵终究不算数。我用 MySQL 8.0 搭了一个最小化测试环境建一张账户表和三条测试数据挨个验证。CREATE TABLE account ( id INT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(50) NOT NULL, balance INT NOT NULL DEFAULT 0 ) ENGINEInnoDB; INSERT INTO account (user_name, balance) VALUES (user_a, 500); INSERT INTO account (user_name, balance) VALUES (user_b, 1000);测试前约定开两个数据库会话分别模拟两个并发事务。所有隔离级别调整必须针对会话级别调整全局修改会影响其他连接容易干扰结果。4.2 实测脏读第一步验证脏读。会话 A 开启事务后把 user_a 的余额从 500 改到 300但不提交。此时会话 B 先设置成 READ UNCOMMITTED执行查询-- 会话 B SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT balance FROM account WHERE id 1;结果返回 300这个 300 是会话 A 还没提交的数据。随后让会话 A 执行 ROLLBACK余额回滚到 500会话 B 手里那个 300 就成了脏数据。接着我把会话 B 改成 READ COMMITTED再执行同样的查询这次不会返回 300 了。这一组对比实验已经把脏读的原理讲得非常直白。4.3 实测不可重复读第二步测不可重复读。会话 A 先开启事务执行普通 SELECT把会话 B 隔离级别设为 READ COMMITTED开启事务后先查一次 user_a 的余额得到 500。然后会话 A 执行更新把余额改成 300 并提交。会话 B 再执行同一条 SELECT结果变成 300。同一个事务里同一条 SQL 两次查询结果不一致这就是不可重复读。再把会话 B 调整成 REPEATABLE READ重复同样的操作顺序会话 B 先查一次得到 500会话 A 更新为 300 并提交会话 B 再查一次依然是 500。SQL 标准里 RR 应该解决的就是这个问题实测效果非常直观。注意每次切换隔离级别后要重新开启一个新事务因为在事务进行中修改隔离级别是不生效的。4.4 实测幻读与间隙锁第三步测幻读。把会话 B 设为 READ COMMITTED开启事务后执行SELECT COUNT(*) FROM account WHERE balance 600;当前表里 user_a 余额 500、user_b 余额 1000所以结果应该是 1。此时会话 A 插入一条 user_c 余额 400 并提交会话 B 再执行同样的 count结果变成 2。同一个事务内同样的范围查询行数前后不同幻读实锤。把会话 B 换成 REPEATABLE READ 重试第一次 count 得到 1A 插入提交后B 再 count 还是 1因为快照读复用 ReadView。这一步很多人验证过但如果加一个当前读场景情况就有意思了-- 会话 BRR 级别 START TRANSACTION; SELECT * FROM account WHERE balance BETWEEN 100 AND 600 FOR UPDATE;此时会话 A 想执行 INSERT INTO account VALUES (3, user_c, 400)会被 blocked直到会话 B 提交或回滚A 的插入才能成功。这就是 next-key lock 在起作用B 不只是锁住了已有的两行还把 100 到 600 这个范围内的空位也锁住了A 无论往哪个空位插都会被挡下。用实测结果说话MySQL 的 RR 级别确实能防住大部分幻读场景但防住它的不是快照读而是锁。4.5 测试时的几个细节实测中有两个容易踩坑的点。第一隔离级别必须在事务开启前设置一旦 START TRANSACTION 之后再 SET SESSION 不会影响当前事务。第二不同客户端工具连接数据库时可能复用连接查询到的隔离级别不一定是你以为的那个操作前先把当前级别 SELECT transaction_isolation 打出来确认。第三点也重要测试时候要小心“自证陷阱”。比如会话 B 在 RR 级别下显示隔离效果是因为它其实根本没有真正读取到最新数据如果业务代码依赖“查到最新余额再判断”就必须用当前读否则会出现业务逻辑和测试结论对不上的情况。5. 数据库实现差异与历史原因5.1 PostgreSQL、Oracle 和 MySQL 的同名不同命隔离级别四个名字是 SQL 标准定的但每个数据库实现方式天差地别跨库迁移最容易在这个地方踩坑。PostgreSQL 的默认隔离级别是 READ COMMITTED它的 REPEATABLE READ 通过快照实现和 MySQL 一样是事务内复用快照但它没有 next-key lock 机制。这意味着在 PostgreSQL 的 RR 级别下另一个事务插入了一条满足条件的新行后当前事务再次执行同样的范围查询可能看到比以前更多的行。也就是说PostgreSQL 的 RR 能防不可重复读但标准定义下的幻读依然可能发生。如果从 MySQL 迁到 PostgreSQL还抱着“RR 一定不会出现幻读”的认知写业务就要出问题。Oracle 更特殊它只提供 READ COMMITTED 和 SERIALIZABLE 两个级别。Oracle 的 SERIALIZABLE 也是走快照而不是锁事务里所有读都是一致快照写发生在读之后时如果发现数据已变化会直接报错。同一个词在三个数据库里行为完全不同所以隔离级别这个东西从来不能只看名字做判断要看具体引擎的落地机制。5.2 MySQL 默认 RR 的历史账MySQL InnoDB 把默认隔离级别定为 REPEATABLE READ并不全是因为业务上大家都需要一致性读还有一个很现实的历史原因主从复制兼容。早期 MySQL 主从复制使用基于 SQL 语句的 binlog 格式在 READ COMMITTED 级别下事务的加锁顺序和提交顺序可能和从库重放 SQL 的顺序不一致导致从库最终数据与主库不一致。这种错乱隐蔽且危害大排查起来很难。为了规避这个坑MySQL 干脆把默认隔离级别定成 REPEATABLE READ配合 statement 格式的 binlog让主从在绝大多数场景下能保持结果一致。今天来看这个默认值更像历史遗留。binlog 已经有了 ROW 格式按实际变更记录复制对隔离级别不再敏感RC 级别用于生产环境从复制安全角度看是可行的。很多团队在改造过程中把隔离级别改成 RC 之后并没有出现复制不一致原因就在这里。5.3 生产环境能不能改成 RC能不能改核心看两条一是 binlog 格式必须要 ROW否则不建议轻易动默认隔离级别二是业务上有没有依赖 RR 提供的“事务内一致性读”。如果业务对账、统计报表要求一个事务内所有查询结果整齐划一那 RR 就是刚需。但如果业务是典型的在线交易单条 SQL 拿最新已提交数据就够用RC 的优势就非常明显没有间隙锁导致锁范围变小死锁概率降低并发度上升。我实际带过一个支付类的模拟项目从 RR 切到 RC 后死锁告警量肉眼可见地下降接口耗时也稳定了许多。切换之前团队是把所有“先查再改”的 SQL 检查了一遍确认没有依赖事务内快照一致性才下的决定。6. 选级别、排查坑和调整方案6.1 按场景选级别的经验套路给一个可以抄作业的选型套路高并发在线交易、库存扣减、订单流转选择 READ COMMITTED配合行锁、乐观锁、唯一索引兜底业务 SQL 尽量短小精悍。报表统计、批量计算、事务内多次读取同一数据集选择 REPEATABLE READ让事务内的快照固定下来避免统计结果漂移。对账、批量资金划拨等低并发强一致场景可以使用 SERIALIZABLE但要压测确认并发量能扛得住。READ UNCOMMITTED生产环境我基本不推荐除非是允许一定脏数据的实时大屏展示且明确知道后果。这套套路其实就是在“并发能力”和“读一致性”之间做平衡。没有绝对正确的答案只有适不适合当前业务的选择。6.2 调整隔离级别的标准操作调整隔离级别有三个层面的 SQL语义完全不同-- 只影响下一个事务 SET TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 影响当前会话之后所有事务 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 影响全局所有新连接 SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;查看当前隔离级别SELECT transaction_isolation;注意 MySQL 5.7 及更早版本用的变量名是 tx_isolation8.0 开始换成 transaction_isolation。改全局参数后已存在的连接池连接不会自动刷新必须让业务侧重新建连才能生效。我排查过一个线上事故就是全局改完后 DBA 以为生效了但业务连接池是老连接继续用旧级别跑了整整一周。6.3 踩过的坑和我的结论第一个坑RR 快照读导致“先查后改”的业务判断失效。事务在 RR 下用普通 SELECT 查余额得到 500此时另一个事务把余额改成 300 并提交当前事务如果直接执行 UPDATE 扣减 200当前读会基于 300 而不是快照里的 500 来算最终结果和业务预期完全不一样。这种隐藏在“快照读与当前读差异”里的问题比脏读严重得多因为它不报错只产生错误数据。解决思路是业务逻辑中依赖最新状态做判断的地方必须使用当前读比如 SELECT ... FOR UPDATE。第二个坑长事务拖垮 MVCC。无论选什么隔离级别事务只要长时间不提交旧版本数据就无法清理undo 日志会不断膨胀查询性能越来越差磁盘占用越来越高。排查早年间一个报表任务慢查询的时候发现有一个凌晨开启的备份事务锁着大量行整天没有提交undo 日志已经占了好几个 GB。第三个坑把隔离级别当并发控制手段。隔离级别只能决定“事务之间能看见多少数据”它管不了库存扣成负数、订单重复提交这种业务正确性问题。防超卖要靠 UPDATE ... SET stock stock - 1 WHERE stock 0 这种原子条件更新防重复要靠唯一索引防并发写冲突要靠版本号乐观锁。把这些手段和隔离级别配合使用而不是遇到并发问题就想着把隔离级别调到最高。最后分享一个我个人比较坚持的做法任何一次隔离级别调整都先在测试环境用压测跑一遍典型业务场景而且必须把 binlog 格式、连接池刷新、事务时长三个因素一起检查。数据库事务的隔离级别不是配置完就万事大吉的参数它和数据一致性、性能、主从复制全都纠缠在一起。多花半小时确认这些联动因素远比线上出问题后再补救省心得多。