资讯详情

资讯详情

MySQL事务隔离级别详解:从ACID到MVCC,一文打通并发控制

事务这玩意儿做 MySQL 开发的基本天天都在见但真要说清楚“隔离级别到底是什么、每个级别挡住哪些问题、线上又该配成哪个”不少干了三五年的朋友也未必能一次讲明白。这篇文章我就把这套东西从头拆到尾不绕弯子从 ACID 讲到锁和 MVCC再把四种隔离级别逐一过一遍最后用我实际排过的一个故障案例收尾。不管你是刚学数据库的新手还是准备系统梳理一遍的资深开发照着看下来应该都能有收获。1. 先把事务和 ACID 讲清楚——后面所有结论都建立在这上面1.1 事务到底是个什么“东西”数据库里的事务可以理解为一组要么全成功、要么全失败的操作。它解决的核心问题很简单多个步骤的原子一致性。举个最典型的转账场景A 账户扣 100B 账户加 100。这两条 SQL 必须作为一个整体提交如果第一条成功、第二条失败账就平不了这是任何系统都不能接受的。MySQL 中使用事务的姿势绝大多数项目里长这样START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 1; UPDATE account SET balance balance 100 WHERE id 2; COMMIT;如果中途发现余额不足、账户冻结之类的问题就用ROLLBACK回滚。InnoDB 之所以能回滚已经执行过的 UPDATE是因为它在内存里有 undo log 的机制每一行改前改后的版本都留着后面讲 MVCC 的时候会再碰它。值得注意的一个点是MySQL 里不是所有引擎都支持事务。现在的项目默认基本都是 InnoDB事务、外键这些都是 InnoDB 的活MyISAM 这类老引擎是没有事务的别说回滚了连行锁都没有。所以你在讨论事务的时候默认语境就是 InnoDB。1.2 ACID 四个特性不是摆设每个都有对应机制ACID 是事务的四个基本属性很多人能背出这四个字母但未必清楚它们分别由什么来保证特性含义InnoDB 的主要保证手段A 原子性事务内的操作要么全部成功要么全部失败undo log回滚日志C 一致性事务前后数据满足所有约束总量守恒应用逻辑 数据库约束 其余三个特性配合I 隔离性并发事务之间互相不干扰锁 MVCCD 持久性事务提交后数据不丢失redo log重做日志我见过不少同学把一致性和隔离性搞混。一致性其实是个更高层的概念数据库的一致性依赖约束、触发器更重要的是依赖应用自己去保证业务逻辑正确事务的其他三个特性都是在帮这个目标服务。也就是说ACID 是“A、I、D 三个特性共同支撑起 C”。你得理解这个关系后面分析隔离级别为什么会有取舍的时候才不会懵。InnoDB 在提交事务时会把 redo log 刷到磁盘然后才返回客户端提交成功。redo log 是顺序写的所以性能开销比随机写数据文件小很多。万一数据库崩溃重启后可以根据 redo log 重放所有已提交的修改这就保证了持久性。前面还有个细节参数innodb_flush_log_at_trx_commit如果设为 0 或 2性能会提升但可能丢失最近 1 秒内的日志这是很多 DBA 在性能和数据安全之间做权衡的开关。2. 并发不隔离会出什么事——先认识逃不掉的三类问题2.1 脏读、不可重复读、幻读一个比一个隐蔽讨论隔离级别之前得先把并发读写时可能出现的四种现象讲清楚。别看网上定义一堆其实展开成因很简单。脏读事务 A 修改了一条数据但还没提交事务 B 读到了这次未提交的修改。如果 A 后面回滚了B 拿到的是个不存在过的数据这就是“脏数据”。读未提交级别就允许这种操作实际项目中几乎没人敢用。不可重复读同一个事务内两次执行同样的 SELECT读到的结果不一样。问题出在另一个事务在这两次查询之间提交了 UPDATE 并改掉了同一行数据。比如你查订单金额是 100另一个事务马上把它改成 200 并提交你再查一次就变成 200 了。你把第一笔结果拿去发奖励第二笔拿去对账数据就对不齐。幻读同样是在一个事务内查两次但这次不是某一行变了而是结果集的行数变了。比如第一次查满足statuspending的订单有 10 条事务还没结束另一个事务插入了一条新 pending 订单并提交你再查就多了一条。这个多出来的行就像“幻影”一样所以叫幻读。还有一个高频问题叫丢失更新两个事务同时读取同一条记录各自基于旧值做计算都去 UPDATE 提交。后提交的会把先提交的结果覆盖掉前一个事务的逻辑就白算了。严格来说这个问题不属于隔离级别单独造成的MySQL 默认的可重复读下也可能出现它本质上是并发更新的冲突问题通常靠加锁或版本号来解决。2.2 这三个坑在真实业务里是什么样子很多新手觉得这些现象是理论概念现实中遇不到。实际上我做过的一个活动系统就踩过不可重复读的坑。当时有个功能是判断用户账户余额是否达到提现门槛事务里先读一次余额做判断中间用户又充值了然后再读一次做二次校验。同一个事务里面两次余额不一致导致逻辑分支乱套最后我用显式加锁才把问题压下去。真实业务里这些问题并不是一定出现它取决于并发时序和具体隔离级别。但一旦出现排查成本和业务损失都比较大。特别是涉及金额、库存、状态机这类关键数据对隔离性的理解必须到位。这也是为什么大多数项目宁愿牺牲一点点并发能力也要把隔离级别定在比较安全的位置。3. MySQL 底层到底怎么兜底的——锁与 MVCC 的双人舞3.1 InnoDB 的行锁和间隙锁先把它们分开InnoDB 的锁不是锁住整张表而是尽量锁行。按锁的粒度分主要有三类记录锁Record Lock锁住索引上的一条记录SELECT ... FOR UPDATE走主键或唯一索引时就是记录锁。间隙锁Gap Lock锁住两个索引键之间的“空隙”防止其他事务在这个区间里插入新记录。临键锁Next-Key Lock记录锁 间隙锁的组合锁住一个左开右闭的区间。集合上面三类去看幻读就容易理解了要防止事务 A 查出来 10 条 pending 订单之后却有新 pending 订单插进来就必须锁住“这个查询能覆盖到的索引范围和相邻空隙”让别的事务没法在这个区间插入新数据。这就是临键锁在做的事。不过注意间隙锁只在特定隔离级别下才生效。InnoDB 默认的可重复读REPEATABLE READ会加临键锁来防幻读如果换成读已提交READ COMMITTED间隙锁就基本失效了只剩记录锁这样幻读又可能回来。Oracle 默认隔离级别是读已提交也是因为它靠别的机制防了部分问题MySQL 的可重复读里防幻读主要靠锁加 MVCC 双管齐下。3.2 MVCC 快照读不加锁也能读到一致快照MVCC全称是多版本并发控制你可以把它理解成“数据库内存了行数据的多个历史版本”。每条记录除了业务字段InnoDB 还额外维护了DB_TRX_ID最后修改事务ID和DB_ROLL_PTR回滚指针之类的隐藏列undo log 里保存着旧版本。普通 SELECT 走的是快照读它不直接锁当前数据而是根据当前事务的快照读取某个可见版本。这个“可见性”是有规则的大致来说在自己事务开始之前已经提交的版本可见自己事务修改的版本可见其他未提交事务的版本不可见。这样一来只要隔离级别是“读已提交及以上”快照读在同一个事务里保持快照一致就可以自然规避不可重复读的大部分情况。MVCC 最大的价值是让读和写不互相阻塞。传统数据库靠行锁读要等写释放锁写也要等读完成有了 MVCC普通读直接去读历史版本完全不用等待写事务提交。3.3 当前读和快照读别混为一谈写操作和部分读操作需要拿到最新数据所以不能走快照得走当前读。以下 SQL 都属于当前读SELECT ... FOR UPDATE; SELECT ... FOR SHARE; -- 或 LOCK IN SHARE MODE UPDATE table SET ... ; DELETE FROM table WHERE ...; INSERT INTO table VALUES (...);当前读读的是最新已提交版本并且会对命中的记录加锁。当前读也好快照读也好它们各自的隔离表现不完全一致这也解释了后面要说的一个现象可重复读下快照读通常不会遇到幻读但当前读配合不当时仍然可能遇到。我举个例子你就能记住两者的差别你在 REPEATABLE READ 下开一个事务第一条 SELECT 走快照读到了一个旧一致快照紧接着另一个事务插入新记录并提交此时你再用普通 SELECT 查依然看不到那条新记录。但如果你换成SELECT ... FOR UPDATE去查当前读看到最新数据可能就把新记录查出来了。两种读的结果不一致这是排查问题时要特别留意的点。4. 四种隔离级别逐个拆解——该配哪个差异在哪4.1 读未提交READ UNCOMMITTED基本只存在于教科书读未提交允许一个事务读取另一个事务尚未提交的修改脏读这个现象在这里被合法放行。这个级别大部分数据库产品都不建议日常使用因为即使不考虑脏读它的并发控制也几乎形同虚设。什么场景适合它呢我确实见过有人为了追求极致读取性能用它对某些非敏感的统计报表跑批量查询数据有点脏也能接受排名少几号无伤大雅。但从工程稳妥角度讲我不推荐线上关键链路用这个隔离级别脏读一旦蔓延到下游很难查清楚数据污染是从哪个环节开始的。4.2 读已提交READ COMMITTED大多数业务的安全线读已提交的含义很直接一个事务只能读到其他事务已经提交的数据未提交数据读不到脏读被解决但不可重复读仍然存在。它最典型的使用者是 Oracle 和 PostgreSQL 的默认级别MySQL 里也有不少团队为了方便 binlog 和主从复制专门把默认级别切到它。它的一个细节是每次 SELECT 都会获取新的快照所以在同一事务内的两次 SELECT 结果可能不一致。如果你的业务对“事务内多次读取一致”没有严格需求读已提交是性能和正确性之间比较均衡的档位。但是有一点必须提醒MySQL 在 READ COMMITTED 下UPDATE 和 DELETE 这类当前读的加锁范围限制在记录锁不会再加间隙锁。所以如果你的业务有“防止某个区间插入新订单”的需求在这个级别下就要用别的手段比如唯一索引、应用层状态判断必要时显式SELECT ... FOR UPDATE但也要清楚它锁不住间隙。4.3 可重复读REPEATABLE READMySQL 的默认选择也是默认自信的来源可重复读指同一个事务里普通 SELECT 多次执行结果保持一致。它通过 MVCC 的快照复用 当前读时的临键锁挡住了脏读、不可重复读并对幻读做了相当程度的拦截。这也是 MySQL 官方默认配置tx_isolation默认值就是 REPEATABLE READ。很多人疑惑为什么 MySQL 不像 Oracle 一样把默认级别定为 READ COMMITTED一个很重要原因是 MySQL 的 binlog 在 statement 格式下主从复制要求所有语句在主库执行时的行为必须和从库回放时一致。REPEATABLE READ 配合临键锁能让事务执行的所有 SQL 在一个一致的视图下跑从库重放时也稳定。一旦改成 READ COMMITTED某些带排序、遍历的语句生成的 binlog 在从库上可能出现重复执行或结果不一致的情况。这也是为什么网上老有“DDL 大事务 低档默认隔离级别”的讨论。可重复读也并非没有代价。它的 MVCC 版本链保留时间更久undo log 膨胀压力更大事务迟迟不提交历史版本不能清理最后可能造成undo log疯狂成长甚至触发线上磁盘告警。后面故障案例里我会再提这个问题。4.4 串行化SERIALIZABLE最安全的“全排队”串行化是隔离级别的天花板事务基本按顺序执行读写之间都可能互相阻塞。实现方式就是在普通 SELECT 上也加共享锁读锁写入需要拿到排他锁这样并发度被压到最低性能也是最差的。什么时候才值得用我见过两种典型一种是某些对一致性要求极高的配置接口比如发布上线时要把一批配置原子替换期间不允许任何读操作看到半新半旧的数据另一种是小流量但强一致的系统比如内部管理后台的极少数核心页面。对于一般高并发业务串行化基本是把可用性往死里压不建议乱上。4.5 配置与验证SQL 走一遍就会了查看和修改事务隔离级别的常用命令如下-- 查看当前会话和全局的隔离级别 SELECT transaction_isolation; SELECT global.transaction_isolation; -- 设置当前会话隔离级别 SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 设置全局默认重启失效需要写配置文件 SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;如果要在配置文件中持久化在 MySQL 5.7 及以上一般这么写[mysqld] transaction-isolation READ-COMMITTED改完记得验证启动参数生效别只改配置文件忘记重启。有一个坑我多次遇到连接池里的旧连接不会拿到新隔离级别你用SET GLOBAL改了之后很多已存在的连接依然沿用旧配置。要确保所有连接都生效最好重启应用连接池或者逐个SET SESSION。5. 隔离级别怎么选——业务特质决定配置别照抄别人5.1 先看你的业务是读多写多、重一致还是重性能隔离级别的选择没有绝对最优只有匹配业务。我总结了一套实际项目里常用的判断逻辑业务核心是订单、账单、库存、余额这类强一致场景优先考虑 REPEATABLE READ它能让同一个事务内的多次查询保持视图一致相对不容易出阴间错误。业务是内容社区、日志统计、报表分析对“同一事务内重复读一致性”没那么敏感又希望更低的锁竞争和更小的间隙锁范围直接用 READ COMMITTED 往往更舒服。至于 READ UNCOMMITTED除非你完全接受脏读否则别碰SERIALIZABLE 只在极少数低并发强一致窗口用一般不建议作为全局默认。这里要插一句MySQL 官方默认 REPEATABLE READ 不是说所有项目都必须用它。很多互联网公司为了减少间隙锁带来的死锁喜欢把默认切到 READ COMMITTED。尤其是 binlog 格式为 ROW 的时候statement 复制兼容性问题基本消除READ COMMITTED 的收益会更明显。5.2 常见读写场景下的推荐配置我平时给团队定的默认调法是没有特殊要求数据库内统一 REPEATABLE READ单个事务保持短小快速对超大报表查询单独开只读事务并把隔离级别降到 READ COMMITTED。短事务在 REPEATABLE READ 下压力不大还天然规避了不可重复读长事务则容易膨胀 undo必须单独设计。写操作竞争激烈的热点行比如秒杀库存、点赞计数字段再高的隔离级别也救不了“先读后写”的并发覆盖问题。这种场景需要做两种补充一是原子 SQL 操作比如UPDATE stock SET count count - 1 WHERE count 0二是乐观锁版本号在 UPDATE 的 WHERE 条件里带上version 目标版本号更新成功说明版本没变失败则需要重试。隔离级别管的是“读”的一致性管不住业务自己算出来的脏结果这个界限要分清。5.3 不会选的时候会出现的三个典型现象第一个现象是“死锁变多”。全局改成 READ COMMITTED 后间隙锁少了按理说死锁概率应该降低但如果你的 SQL 都在跑二级索引范围扫描记录锁的锁顺序不一致一样会死锁。排查死锁时先SHOW ENGINE INNODB STATUS把锁等待信息捞出来看看再决定是改索引顺序还是调整隔离级别。第二个现象是“主从数据不一致自查”。如果你的 binlog 用的是 STATEMENT 格式底层隔离级别又是 READ COMMITTED某些 UPDATE 语句在不同时间点取当前读的结果可能不同从库重放就容易出现偏差。验证不够充分的话建议直接切 ROW 格式数据一致性检测也省心。第三个现象是“长事务拖垮回滚段”。不管什么隔离级别只要事务长时间不提交历史版本就清不掉。常见于代码里事务内嵌入外部 HTTP 请求或者晚上跑批任务开着事务过夜。治理手段很简单事务里只做数据库操作网络调用全部移出事务实在要跑批就给每几千行一个大事务做一次提交切分。6. 一次真实故障复盘——隔离级别相关问题的定位与修复6.1 故障现象一个订单状态流转的“幽灵数据”之前帮某个电商项目排查过一个很有意思的问题。现象是运营后台里一个订单的状态在待支付和已支付之间反复跳变但数据库里查到的字段值却是稳定的。后来发现是事务隔离级别和当前读的混用问题。业务代码逻辑大概是这样的用户在支付回调里起一个事务第一步用普通 SELECT 查订单状态发现是“待支付”第二步执行 UPDATE 把它改成“已支付”第三步提交。同一个事务里理论上可重复读会让我们看到一致快照但 UPDATE 本身是当前读它读到的是已经提交的最新数据。如果此刻运营后台恰好也改了这条订单就可能出现 UPDATE 覆盖运营修改的情况——看起来“状态跳变”其实是两个事务互相覆盖。6.2 我当时排查的思路与命令排查第一步先确认隔离级别SELECT transaction_isolation; -- 结果一般是 REPEATABLE READ第二步是把疑似热点代码的 SQL 抓出来借助performance_schema或者慢查询日志看有没有事务重叠时间窗。我直接看了相关时间段内两个连接的锁等待和事务状态SELECT * FROM information_schema.innodb_trx WHERE trx_started ...;当时两个事务确实同时存在一个跑支付回调的 UPDATE一个跑运营后台的状态修改。因为它们同时命中同一条记录记录锁互相等待于是出现了看似是状态回跳、其实是锁竞争后的结果覆盖。第三步是检查是否使用了显式锁。这种先读后写的关键路径不能依赖隐式行锁的默认行为应该用SELECT ... FOR UPDATE把订单行锁住再做业务判断和 UPDATE。这样第二个事务必须等第一个提交后才能读最新状态不会读到旧值再覆盖。6.3 最终的修复方案与经验沉淀修复时候我给出的方案是支付回调里把先读后写统一改成“当前读 排他锁”具体 SQL 如下START TRANSACTION; SELECT * FROM order WHERE id 1001 FOR UPDATE; -- 业务校验状态是否为待支付、金额是否一致、是否已处理 UPDATE order SET status paid WHERE id 1001; COMMIT;这样事务里读取就锁住了这条记录其他事务的修改必须排队等待隔离级别即便保持 REPEATABLE READ 也没问题。另外运营后台的读操作如果只是展示走快照读即可不会阻塞但运营后台的批量改状态操作也要做成带状态约束的原子 UPDATE比如UPDATE order SET status 待发货 WHERE id ... AND status已支付防止把已经进入新状态的订单覆盖回去。后来我还顺手做了一轮线上巡检把所有事务里夹杂外部 HTTP 调用的代码全部揪出来改掉并把长事务告警阈值从 10 分钟改成 5 分钟。这套操作走了下来线上相关故障再没出现过第二次。因为见多了这种和隔离级别强相关、但根因却在代码加锁姿势上的问题我现在做技术方案评审时有个习惯先问清这个写事务里是否会“先读再写”再问这个读是否需要加锁而不是直接丢一句“数据库默认是可重复读应该没问题”。隔离级别能解决一部分问题但不是所有并发正确性的银弹。你手里真正要握稳的是理解当前读、快照读、锁定范围三者之间的关系然后在关键业务路径上把锁加到位事务尽量缩短别把长期运行的大事务丢给数据库硬扛。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →