1. 先讲清楚隔离级别不是“四个等级”而是“四组权衡”很多人面试被问“MySQL 事务隔离级别有哪几种”都能背出四个名字读未提交、读已提交、可重复读、串行化。但真正的难点从来不是背名字而是搞懂每个级别到底堵住了什么异常、付出了什么代价。我最近带团队做账务模块改造时发现代码里全用的是 Spring 声明式事务默认级别等于把决定权交给数据库默认值。结果两个报表任务并发一跑金额对不上。最后定位到根本不是业务计算错而是隔离级别下“读的时机”出了问题。标准 SQL 把隔离性分成了四个级别本质上就是在“并发度”和“数据一致性”之间滑动级别越高隔离越强并发能力越弱级别越低吞吐越高能容忍的异常越多。很多人把隔离级别理解成“等级越高越好”这是第一个误区。线上用哪个级别不看理论书怎么写而看业务能不能接受那些异常。MySQL 默认的事务隔离级别是 REPEATABLE READ也就是可重复读。这一点和很多其他数据库不一样其他数据库主流默认 READ COMMITTED读已提交。为什么 MySQL 要做这个看起来“偏保守”的选择后面会讲。现在先建立一个基本认知事务隔离级别不是存储引擎层面的统一标准而是由存储引擎具体实现的。MySQL 里 InnoDB 支持刚才说的四个级别MyISAM 这种老引擎根本不支持事务自然也没有隔离级别一说。1.1 为什么需要隔离级别事务要保证 ACID其中 I 是 Isolation隔离性。多个事务同时操作同一批数据时如果不加隔离就会出现各种“串味”。举一个最直观的例子A 事务给用户转钱先修改余额还没提交B 事务在另一个连接里读余额读到的是修改后的数字。然后 A 事务回滚了那 B 事务刚才读到的就是一个不存在的数字。这种数据对不上在日常业务里可能只有一条记录放大到整张订单表、整张账务表就是月末对账时差出几百万的根源。隔离性要做的事情就是限制这种交叉可见。但它不是每次都用“锁死整张表”这种粗暴手段因为那样并发太低。于是数据库提供了多档可选项让你根据业务容忍度去选。你容忍“读到别人未提交的数据”那可以用读未提交并发性最高你完全不能容忍任何异常就上串行化所有事务排队执行速度最慢。1.2 一张表看懂四种隔离级别与三类数据异常用最经典的三类异常来区分四个级别隔离级别脏读不可重复读幻读读未提交READ UNCOMMITTED可能可能可能读已提交READ COMMITTED不会可能可能可重复读REPEATABLE READ不会不会可能但 InnoDB 通过锁基本挡住串行化SERIALIZABLE不会不会不会什么是脏读就是读到了别人事务还没提交的数据而那个事务随时可能回滚。什么是不可重复读就是在同一个事务内执行两次相同的查询结果不一样因为有其他事务在两次查询之间提交了修改。什么是幻读表面上看也是“两次查询结果不一样”但严格定义是一个事务用相同条件执行两次范围查询第二次多出了原本不存在的行而这些行是其他事务插入并提交的。这三个异常最容易混淆的是不可重复读和幻读。我用一句话记住不可重复读针对的是“同一行被改”幻读针对的是“结果集多出行”。记住这一点后面做实验就清楚多了。1.3 MySQL 与标准 SQL 的细微差别标准 SQL 的定义是一套理论框架但 MySQL InnoDB 在实际实现上会做调整。最典型的是可重复读标准 SQL 认为可重复读仍然可能发生幻读但在 InnoDB 的默认隔离级别下通过 MVCC 和 Next-Key 锁普通的读取操作基本碰不到幻读只有在一些特殊的“当前读”场景下才需要额外注意。所以你不能把这张标准表直接套到 MySQL 头上必须结合 InnoDB 的锁机制和版本链机制一起理解。这就是我这篇文章要做的不只讲概念而是把不同隔离级别放到两个会话里跑一遍用实际结果说话。2. 搭实验环境把两个事务会话摆在同一张桌子上讲理论再多都不如自己动手看一次。隔离级别的很多结论看起来是背下来的实际上只要亲手跑一遍以后遇到线上问题脑子里会有非常具体的画面感。2.1 建表与初始数据我先建一张非常简单但贴合业务逻辑的表模拟账户余额CREATE TABLE account ( id INT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(50) NOT NULL, balance DECIMAL(10,2) NOT NULL ) ENGINEInnoDB; INSERT INTO account (user_name, balance) VALUES (用户A, 1000.00), (用户B, 2000.00);为什么要用 DECIMAL 而不是 FLOAT因为金额计算必须精确FLOAT 这类浮点类型会引入精度误差。你在学事务隔离级别时顺手养成这个习惯写业务时能少踩很多坑。实验环境我用 MySQL 8.0。关于查询隔离级别的变量名5.7 用tx_isolation8.0 改成了transaction_isolation这个细节在上生产环境查资料时一定要注意网上很多旧博客贴的还是 5.7 命令在 8.0 里会直接报错。2.2 确认当前隔离级别-- MySQL 8.0 SELECT transaction_isolation; -- MySQL 5.7 SELECT tx_isolation;正常情况下会返回REPEATABLE-READ。这个值是全局默认值但我们要做实验通常只需要改当前会话不要直接改全局除非你确定要影响所有新连接。SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;这条命令只对当前会话后续的事务生效其他连接不受影响非常适合做隔离级别对比实验。注意它不会影响已经开启的事务所以正确的操作顺序是先设置级别再开启事务。2.3 会话、事务与 autocommit 的实验前提做隔离级别实验至少需要两个终端也就是两个数据库连接。我习惯左边开一个长连接当“会话 A”右边开另一个连接当“会话 B”。两个会话互相独立才能模拟并发事务之间的互相干扰。还有一个非常关键的背景MySQL 默认开启了 autocommit也就是每条语句单独成为一个事务并自动提交。如果不去管它实验的效果会被“隐式提交”破坏。所以实验前先确认下面这条SELECT autocommit;返回 1 是默认值。做实验时我通常不关闭它而是在想控制事务的地方手动写START TRANSACTION然后统一用COMMIT或ROLLBACK结束。这种方式更符合业务代码里常见写法。另外提醒一下在 MySQL 中有一些操作会触发隐式提交比如ALTER TABLE、CREATE INDEX、TRUNCATE TABLE。做隔离级别实验时表结构建好之后就不要再去动它否则实验会乱掉。3. 读未提交和读已提交亲手复现脏读与不可重复读最直观的实验从读未提交开始。这个级别在实际生产里很少直接使用但它能帮你建立“脏读”的清晰概念理解了脏读后面几个级别的进步就很好懂。3.1 脏读实验操作顺序如下步骤会话 A会话 B说明1SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;两端都设置成读未提交2START TRANSACTION;A 开启事务3UPDATE account SET balance balance - 100 WHERE id 1;A 修改余额未提交4SELECT balance FROM account WHERE id 1;B 查询会看到 900.005ROLLBACK;A 回滚6SELECT balance FROM account WHERE id 1;B 再查变成 1000.00在步骤 4B 读到的是 900.00而 A 随后回滚余额回到 1000。也就是说B 刚才读到的 900 是一个“没有真正存在过”的数值。这就是脏读。如果业务里有人拿这个 900 去做后续计算比如判定用户余额是否充足、是否触发风控就会基于一个假数据做决策。所以脏读在绝大多数业务里是不可接受的。读未提交之所以允许脏读是因为它根本不生成一致性读视图而是直接读最新版本完全不隔离其他事务的未提交修改。3.2 不可重复读实验现在把两个会话都改成 READ COMMITTED步骤会话 A会话 B说明1SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;都改成读已提交2START TRANSACTION;A 开启事务3UPDATE account SET balance balance - 100 WHERE id 1;A 修改未提交4START TRANSACTION;SELECT balance FROM account WHERE id 1;B 读到 1000.00没有出现脏读5COMMIT;A 提交6SELECT balance FROM account WHERE id 1;B 再查同一条记录变成 900.00步骤 4 说明读已提交确实避免了脏读B 不会读到 A 的中间状态。但步骤 6 暴露了新问题B 在自己的同一个事务里先后执行两次完全相同的查询结果从 1000 变成 900。这就是不可重复读。不可重复读的危害在于如果一个事务先读取一批数据做统计之后读取同一批数据做校验前后结果不一致你就不知道该信哪一次。对账单、报表这类对一致性要求极高的场景这种异常很难忍。3.3 为什么 Read View 的生成时机决定了结果读已提交这个级别的名字很容易让人以为“事务开始时就定好了能看到哪些数据”。实际不是。在 InnoDB 中读已提交是每条 SELECT 语句执行时生成一个新的 Read View也就是“一致性快照视图”。翻译成人话同一个事务里每次查询都重新拍一张当时的快照所以别人在这两次查询之间提交了修改你的第二次查询自然能看到。这跟写代码里的“重新拿状态”很像。比如你写了一段函数函数开头读一次配置中间某个环节又读一次配置如果中间有人改了配置第二次拿到的就可能不一样。读已提交就是这种模式以语句为边界保证每条语句拿到的是这条语句开始时已提交的数据。理解了这一点下一节的可重复读原理就迎刃而解了——它只是把拍快照的时机改成了“事务第一次读的时候”并且一直复用这个快照。4. 可重复读和 MVCCMySQL 默认级别的“定力”从哪里来读已提交解决不了不可重复读那 MySQL 默认的可重复读是怎么做到的答案不是锁而是 MVCCMulti-Version Concurrency Control中文叫多版本并发控制。4.1 可重复读的可视化现象还是同样的操作把两个会话都设成 REPEATABLE READ步骤会话 A会话 B说明1SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;都设成可重复读2START TRANSACTION;A 开启事务3UPDATE account SET balance balance - 100 WHERE id 1;A 修改未提交4START TRANSACTION;SELECT balance FROM account WHERE id 1;B 读到 1000.005COMMIT;A 提交6SELECT balance FROM account WHERE id 1;B 再读仍然 1000.007COMMIT;SELECT balance FROM account WHERE id 1;B 提交后再读变成 900.00重点看步骤 6A 已经提交了修改但 B 在它自己的事务里再查还是 1000。这说明 B 的视角被固定在了事务开始时的某个时间点在整个事务期间除非 B 自己修改数据否则它看到的世界是不变的。4.2 MVCC 最小化理解MVCC 的核心思想是数据库不是只能存一份数据而是可以保留多个版本的修改历史。InnoDB 在每一行上会记录事务修改时产生的版本信息同时通过 undo log 保留旧版本。当某个事务需要读取数据时InnoDB 会根据当前事务的快照信息在版本链上挑出“该事务能看到的最合适版本”。可重复读下第一次 SELECT 会生成一个 Read View这个 Read View 记录了一个事务 ID 列表和边界。之后的 SELECT 都复用同一个 Read View只要版本链上的事务 ID 不在这个视图允许的范围内就继续往下找旧版本。你可以把它理解成拍了一张集体照之后有人再换衣服、再摆姿势你都当没看见因为你看的是照片。读已提交和可重复读的差别就在 Read View 的生成次数上读已提交每执行一条 SELECT 就重新拍照可重复读是事务开始时第一次读时拍照然后一直用同一张照片。这也是为什么读已提交会看到其他事务的已提交新数据而可重复读看不到。4.3 快照读和当前读的分叉看到这里你可能会冒出一个疑问MVCC 只解决了普通 SELECT 的读取那 UPDATE 怎么办比如 B 的事务里执行一条 UPDATE它总不能去更新一个旧版本吧对InnoDB 把读取分成两种快照读和当前读。快照读指的是普通 SELECT走 MVCC不加重读锁因此并发性能好。当前读指的是SELECT ... FOR UPDATE、UPDATE、DELETE这类需要拿到最新数据并加锁的操作它们不使用 Read View 快照而是直接读数据库里的最新版本同时会对涉及的行加锁。举个具体现象会话 B 在可重复读下先 SELECT 看到余额 1000随后会话 A 提交了 900 的修改。这时 B 如果执行UPDATE account SET balance balance - 50 WHERE id 1它操作的已经是 900 这个最新版本结果会变成 850而不是基于 1000 计算。如果不理解当前读很多人会把这种现象当成 Bug实际上它是 InnoDB 为了数据完整性必须做的选择。如果更新时也基于旧快照两次并发修改就会互相覆盖造成更新丢失。4.4 MySQL 为什么把默认值定为 RR历史原因是主从复制。很多年前MySQL 基于语句的复制方式也就是主库把 SQL 语句本身同步给从库重放。如果主库用读已提交一个事务内部的不同语句在从库重放时会因为快照不一致导致主从数据不一致。可重复读则保证了同一事务内所有语句看到的数据版本一致从库重放时也更容易得到和主库一致的结果。后来 binlog 默认采用基于行的格式从库直接重放数据变更这个历史因素已经弱化了很多。但默认值不会轻易改因为大量现有应用的行为都建立在 REPEATABLE READ 之上。生产环境里你在决定要不要把它改成读已提交之前心里必须清楚这不是一个“新潮必改项”而是一个权衡项。5. 幻读隐藏在边界条件的幽灵以及间隙锁的代价幻读是隔离级别里最绕的一个概念。很多人背下了定义但不敢说自己真的见过。这里我带你用可重复读的“当前读”场景把幻读和间隙锁一起讲透。5.1 先定义一个“范围查询”的幻觉场景普通快照读在可重复读下因为 Read View 固定其他事务插入并提交的新行不会出现在你的结果集里所以你不会看到幻读。但要是在同一个事务里执行当前读比如SELECT * FROM account WHERE id 2 FOR UPDATE情况就复杂了。假设表里只有 id1 和 id2 两行。会话 A 执行START TRANSACTION; SELECT * FROM account WHERE id 2 FOR UPDATE;这条 SQL 返回空结果但注意它是当前读会基于条件加锁。另一个会话 B 此时尝试插入一行 id3INSERT INTO account (user_name, balance) VALUES (用户C, 500.00);这条 INSERT 会阻塞住直到会话 A 提交或回滚。为什么因为 InnoDB 不仅要锁住已有的行还要防止在查询范围内“凭空冒出新的行”它必须锁住 id2 的整个空间范围包括当前还不存在的间隙。这种锁就叫间隙锁间隙锁和行锁合在一起组成 Next-Key 锁。可以这样理解你在一个空会议室里搞安检要把所有可能从外面进入这个空间的路口都锁上否则你刚查完没人下一秒就有人推门进来结果就变了。间隙锁锁的正是“还没人站在那里”的入口区域。5.2 间隙锁、Next-Key 锁怎么挡住幻读在可重复读级别下InnoDB 默认使用 Next-Key 锁不仅锁定命中的记录本身还锁定记录之间的间隙。回到刚才的例子表里 id 有 1 和 2条件 id2 时InnoDB 会对 id2 一直到正无穷的间隙加锁所以其他事务无法在 id3、id100 等位置插入数据。这就是 InnoDB 可重复读“额外挡住幻读”的底层原因。标准 SQL 说可重复读可能发生幻读指的是理论上没有做范围锁但 InnoDB 用 Next-Key 锁把这个理论上的漏洞基本堵上了。说“基本”是因为如果你的业务在同一个事务里既有快照读又有当前读并且当前读之后再做一次查询还是可能出现和“预期”不一致的情况。严格的生产业务里如果无法容忍任何幻读应该直接使用串行化或者更稳妥地在设计上让所有读取都走同一种模式。间隙锁的代价也很直接它把不该互斥的插入操作也互斥了。比如两个事务要往两个不同的 id 区间插入数据本来可以并发但因为间隙锁封住了公共边界区域其中一个就得等。如果索引条件是普通索引间隙锁的范围还会覆盖索引里相同值周边的所有间隙阻塞范围比想象中更大。5.3 间隙锁带来的死锁和并发下降死锁是一个值得单独拿出来讲的副作用。间隙锁之间也可能互相等待。举一个经典场景步骤会话 A会话 B1SELECT * FROM account WHERE id 2 FOR UPDATE;2SELECT * FROM account WHERE id 5 FOR UPDATE;3尝试插入 id3等待 B 的间隙锁尝试插入 id4等待 A 的间隙锁4死锁检测触发InnoDB 选择回滚一个事务死锁检测触发InnoDB 选择回滚一个事务表面上看两个事务都在等对方释放锁就构成了循环等待。InnoDB 有死锁检测机制会把其中一方回滚掉并抛出Deadlock found错误。你可能会想回滚一方听起来问题不大但实际上回滚意味着那个事务已经做过的其他操作全部作废如果你的应用没有任何重试机制用户端就直接报错了。间隙锁是“保数据一致性”和“降低并发度”之间的典型矛盾。这也是为什么很多高并发团队在分析死锁问题时会优先排查是不是默认隔离级别导致的间隙锁冲突然后评估能否切换到读已提交因为读已提交在大部分场景下只使用记录锁间隙锁使用范围要小得多。5.4 读已提交下真的没有幻读吗读已提交不能完全消灭幻读。它约束的是写入间隙的范围更小所以插入可以发生在某条查询结果范围之间导致同一个事务内两次查询结果集不一致。换句话说读已提交下快照读会看到新提交的行当前读也可能看到别的会话插入的数据只是间隙锁的约束变弱了。所以选择隔离级别的本质是选择“你能容忍哪一类异常”。如果业务逻辑不允许范围查询结果中途变化那你就得承担可重复读的间隙锁代价如果业务并发量大、插入频繁且可以容忍事务内对未锁定数据范围的轻微不一致读已提交可能更合适。没有绝对正确只有适合。6. 锁等待与死锁隔离级别之外必须掌握的另一半如果你以为理解隔离级别就够了那还只看到了一半。InnoDB 保证隔离性的另一只手是锁机制。大部分线上“卡住”的问题归根结底不是 SQL 跑得慢而是锁等待。6.1 行锁的基本规则InnoDB 支持行级锁最基本的行锁有两种模式共享锁和排他锁。共享锁也叫读锁多个事务可以同时持有同一行上的共享锁前提是大家都不修改这行。排他锁也叫写锁一旦某个事务持有某行的排他锁其他事务无论想读锁还是写锁都必须等待。写操作默认加排他锁SELECT ... LOCK IN SHARE MODE加共享锁SELECT ... FOR UPDATE加排他锁。普通的SELECT不加锁它走 MVCC 快照读这也是为什么普通 SELECT 在高并发下通常不会被 UPDATE 阻塞。举一个非常常见的压测场景两个会话同时修改同一行。步骤会话 A会话 B1UPDATE account SET balance balance - 100 WHERE id 1;2UPDATE account SET balance balance 50 WHERE id 1;步骤 2 的 UPDATE 会一直阻塞直到会话 A 提交或回滚。默认锁等待时间是 50 秒超过之后 B 会报Lock wait timeout exceeded错误。生产环境如果看到这个报错第一反应不是数据库性能差而是有别的长事务占着同一条记录的锁没释放。6.2 经典死锁演示用一个简单的顺序反转也能重现死锁。假设两个账户A 给 B 转账B 也同时给 A 转账但代码里没有统一加锁顺序。步骤会话 A会话 B1UPDATE account SET balance balance - 100 WHERE id 1;2UPDATE account SET balance balance - 100 WHERE id 2;3UPDATE account SET balance balance 100 WHERE id 2;等待 B 释放 id2 的锁4UPDATE account SET balance balance 100 WHERE id 1;等待 A 释放 id1 的锁此时 A 持有 id1 的锁在等 id2B 持有 id2 的锁在等 id1形成一个环。InnoDB 的死锁检测器会立刻发现并选择回滚一个事务来打破循环。回滚的那一方事务会报死锁错误。解决思路是在业务代码里统一对多个资源按固定顺序加锁。比如所有转账操作都先更新小 id 的账户再更新大 id 的账户就能避免绝大多数的顺序反转型死锁。这个方案听着简单但在真实业务里不同入口的代码往往由不同人写约束不统一死锁就会反复出现。6.3 如何从故障现场判断锁状态锁问题出现在线上的时候靠“重启应用”解决是最笨的办法。正确姿势是现场留证据。MySQL 提供了一系列工具视图最有用的三个-- 查看当前正在运行的事务 SELECT * FROM information_schema.innodb_trx; -- 查看锁等待关系 SELECT * FROM information_schema.innodb_lock_waits; -- 查看 MySQL 最新的死锁现场 SHOW ENGINE INNODB STATUS;SHOW ENGINE INNODB STATUS输出的比较长重点看LATEST DETECTED DEADLOCK这一节里面会明确列出死锁发生时两个事务各自执行了哪条 SQL、持有哪个锁、等待哪个锁。把这段输出保存下来比任何复盘猜测都靠谱。平时压测的时候也可以定期采样innodb_trx观察trx_state、trx_started和trx_rows_locked一旦发现某个事务长时间不结束、锁住的记录在增长基本能判断是锁问题。7. 线上隔离级别的选型建议与排障经验最后讲一讲落到生产环境时我自己的选择逻辑。这部分没有标准答案但有一些经过实践验证的参考路经验。7.1 不要轻易把默认 RR 改成 RC除非你说得清代价先说结论存量系统如果一直用默认可重复读线上也没有明显锁竞争问题不需要主动改。默认值经过了多年考验而且很多团队的业务代码天然依赖“同一个事务里两次查询结果一致”。你如果贸然把全局隔离级别改成读已提交那些你以为没有依赖的代码可能一夜之间行为变化比如报表统计里同一个事务先查汇总、再查明细结果对不上。改隔离级别是全局性变更不是简单改一行配置。什么时候考虑改成读已提交当你明确看到以下信号时高并发插入场景下频繁出现间隙锁冲突死锁日志里大量是范围查询导致的间隙锁等待业务上能接受一个事务内读取到其他事务已提交的新数据。有些云数据库默认也会把参数调成读已提交目的就是减少锁定范围提升并发写入吞吐。我的经验是切换前后必须做两件事第一拿真实业务事务做回归测试重点覆盖“同一个事务内有多次查询”的逻辑第二监控死锁数和锁等待时长对比切换前后的变化。不要只看平均响应时间。7.2 具体业务场景的推荐组合不同业务对一致性的敏感度完全不同我一般会这样分类业务类型推荐隔离级别理由账务、余额、核心交易可重复读或串行化对一致性要求极高宁可降低并发也不接受金额不一致秒杀、抢购、高并发插入读已提交减少间隙锁冲突合理设计库存扣减逻辑报表统计、数据仓库抽取读已提交或可重复读单条统计语句建议读已提交长事务关联查询建议可重复读日志记录、消息流水读已提交不需要重复锁定插入为主纯查询系统读已提交读取速度不受影响主要靠索引优化注意我并不是说核心交易就一定要串行化。账务类的并发一点也不低很多团队用可重复读配合SELECT ... FOR UPDATE做行级串行控制效果不错。真正需要串行化的场景很少通常出现在“多个事务必须严格按照先后顺序处理”的极端业务中。7.3 调整隔离级别的正确姿势如果你决定要调整尽量先做会话级验证再做全局调整。-- 会话级临时生效 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 全局级新连接生效已存在的连接不受影响 SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;注意SET GLOBAL只影响之后新建的连接已经存在的连接还是保留原来的会话级别设置。如果你要把配置持久化需要把参数写进 MySQL 配置文件比如[mysqld] transaction-isolation READ-COMMITTED修改配置后重启或动态设置具体看你的运维方式。还有一点事务开启之后再去改隔离级别是无效的必须在START TRANSACTION之前设置好。应用端如果用连接池连接会复用你需要确认连接初始化时是否会重置隔离级别否则可能出现“这条连接是旧级别、那条连接是新级别”的混乱。7.4 最后给几个实践中很容易忽略的细节第一个细节长事务是隔离级别里的隐形杀手。不管你是可重复读还是读已提交一个长期不提交的事务都会让 undo log 中的旧版本数据持续保留无法清理。可重复读下这个问题更严重因为 Read View 一直不释放大量旧版本堆积最终表现为磁盘占用暴涨、查询性能下降。线上排查慢查询时先查information_schema.innodb_trx里有没有持续了很久的事务。第二个细节锁等待超时不是无限等。默认innodb_lock_wait_timeout是 50 秒业务请求可能要不了 50 秒就直接等垮。对高并发系统我习惯把这个值调小一点比如 5 秒配合应用层重试机制。合理的做法是让数据库尽快报错应用捕获后走补偿逻辑而不是让请求长时间挂在锁上。第三个细节测试环境和生产环境隔离级别必须保持一致。很多人开发时用的本地数据库是默认配置测试环境被人改成了读已提交生产又变回可重复读。上线前不核对这个变量一旦出现间隙锁相关死锁排查成本极高。发布前把这条加进检查清单比事后熬夜看死锁日志舒服得多。第四个细节隔离级别解决的是“读写之间”的可见性问题不是所有并发问题的万能药。如果你要做“先检查库存再扣库存”这种操作普通的 SELECT 加 UPDATE 即使放在可重复读下也会出错因为普通 SELECT 是快照读UPDATE 是当前读两个读到的数据版本可能不同。这种场景必须用SELECT ... FOR UPDATE把库存行锁住然后再在同一个事务里操作。我实际参与过的几个生产事故最后定位下来都不是 SQL 写错而是对隔离级别和锁机制的理解偏差。像“事务开启后先做一个查询当快照再根据快照做更新”这类写法在可重复读下并不安全因为更新读的是最新版本。如果你能把每个操作识别成快照读还是当前读再结合读已提交和可重复读的 Read View 时机差异绝大多数线上数据不一致问题都能在头脑里定位到大概原因。MySQL 的事务隔离级别说到底是理解并发控制的一把钥匙。不要只背四个名称和异常表格要真正去搭建两个会话亲手把脏读、不可重复读、幻读的现象跑出来。跑过一次之后你对锁、MVCC、间隙锁的理解会像身体记忆一样牢固。以后线上再出问题你不会再靠猜而是能冷静说出“这里应该是可重复读的间隙锁在起作用”这种靠谱的结论。这也正是这篇实战记录最想帮到你的地方。