ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL事务与锁机制:从ACID到死锁排查的实战指南

MySQL事务与锁机制:从ACID到死锁排查的实战指南 最开始接触MySQL的时候我一直觉得事务是个挺玄乎的词。老看到文章里写事务保证数据一致性但敲了半年SQLINSERT、UPDATE、SELECT一通操作下来也没觉得哪里需要特别小心。直到前阵子帮朋友排查一个线上Bug——两条几乎同时提交的订单库存偶尔会变成负数几个人围着查了一下午最后定位到是事务隔离级别和锁等待的问题。那是我第一次意识到MySQL里那些平时看不见的机制才是真正决定线上数据对错的关键。今晚是Day02我把收藏夹里囤的资料翻出来重新捋了一遍核心围绕事务与锁。这篇文章记录的是我自己的理解过程包括做过的实验、踩过的坑以及读死锁日志时的那段痛苦经历。适合正在学MySQL、对事务和锁的概念还处于好像懂了但不敢说懂阶段的同学也适合想系统复习一遍隔离级别与加锁规则的开发朋友。里面所有例子我都实际跑过SQL脚本也给了你可以直接复制到自己的环境里验证。1. 事务四大特性ACID不只是一张截图每个字母背后都对应一次真实事故1.1 从转账场景看ACID以及最常见的理解误区事务的四大特性——原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability教科书里通常用转账举例。A给B转账500块A扣款、B收款必须同时成功或同时失败这就是原子性转账前后总额不变这就是一致性转账过程中别人看不到中间状态这就是隔离性一旦提交成功就算数据库当场断电钱也不能丢这就是持久性。但我要说一个容易被忽略的误区很多人以为原子性是靠锁实现的其实不对。原子性主要靠的是回滚日志undo log。事务执行过程中每一条数据修改都会先记录改之前的值到undo log。如果事务中途失败InnoDB会根据undo log把数据一步步还原到事务开始之前的样子。锁在隔离性里起作用而原子性是undo log的功劳这两个机制千万别混。1.2 redo log和undo log崩溃恢复时分工完全不同的两个角色Day01我在看InnoDB存储引擎资料时看到过一句话先写日志再写数据当时没太在意。今天认真看了一遍发现这是整个持久性设计的核心。InnoDB的数据是存在磁盘上的但如果你每次修改都直接改磁盘那个页效率极低——因为一个页里有好多行你改一行也得把这个页整体读出来、改完再写回去涉及大量随机IO。所以InnoDB的做法是先在内存里的缓冲池Buffer Pool修改对应的页然后生成一条redo log把这次修改记录成物理级别的日志写到哪个页、哪个偏移量、改成了什么顺序写入磁盘。等事务提交时保证redo log落盘就行数据页本身可以留在内存里慢慢刷。这就解释了为什么MySQL宕机重启后数据不丢重启时会读取redo log把上次崩溃前已经提交但还没来得及刷盘的操作重新应用一遍。这个机制叫WALWrite-Ahead Logging日志先行数据靠后。凡是说只要commit了数据就一定已经在磁盘数据文件里的说法都是不准确的——commit时保证的是redo log在磁盘上而不是数据页在磁盘上。1.3 为什么我说理解和区分这两种日志比背概念有用得多redo log管重做undo log管回滚这两者配合起来才构成了事务完整的生命周期。实际排查问题的时候区分这两种日志特别有用。比如我遇到过一台MySQL服务异常断电后重启启动速度特别慢查看错误日志发现是在做崩溃恢复。那会儿我就靠检查redo log的大小和checkpoint的位置来判断恢复进度。反过来如果你发现一个事务运行了很久始终不提交那么它的undo log会一直占着空间甚至拖垮其他查询——这个我在后面长事务场景里还会专门讲。所以Day02我给自己立的一个规矩就是凡是涉及数据为什么不丢和数据为什么能回滚的问题先问自己一句——这归redo管还是归undo管想清楚这一层很多概念就不再是死记硬背了。2. 隔离级别逐档推演拿一个虚拟小票系统复现脏读、不可重复读和幻读2.1 四个隔离级别本质上是一道你对中间状态容忍度多高的选择题隔离级别定义了一个事务在读取数据时允许看到其他事务的哪些中间状态。MySQL的四种级别从宽松到严格分别是隔离级别脏读不可重复读幻读默认情况READ UNCOMMITTED可能可能可能几乎不用READ COMMITTED不会可能可能Oracle等默认REPEATABLE READ不会不会可能InnoDB实际解决了快照读下的幻读MySQL默认SERIALIZABLE不会不会不会并发极低时用从字面理解READ COMMITTED的意思是只读别人已提交的数据所以脏读被杜绝REPEATABLE READ的意思是同一个事务里多次读取结果保持一致SERIALIZABLE最狠直接把并发执行变成串行执行。这里有个我要特别提醒的点事务隔离级别设得越高并发能力通常越低但数据一致性越强没有免费的午餐。2.2 三个异常现象的实验脚本在自己的MySQL里跑一遍光看定义不够直观我建了一张简单的表来自测表结构如下CREATE TABLE ticket ( id int NOT NULL AUTO_INCREMENT, order_no varchar(32) DEFAULT NULL, amount decimal(10,2) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO ticket (order_no, amount) VALUES (A0001, 100.00);实验前先确认当前隔离级别SELECT transaction_isolation;我的环境默认是REPEATABLE-READ需要临时改成READ UNCOMMITTED来测试脏读SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;脏读复现过程需要开两个终端。事务1执行BEGIN; UPDATE ticket SET amount 200.00 WHERE order_no A0001;注意此时事务1还没提交。事务2在另一个终端执行BEGIN; SELECT amount FROM ticket WHERE order_no A0001;如果隔离级别是READ UNCOMMITTED事务2能查到200.00但这个值事务1随时可能回滚——读到的是一个不存在的中间值这就是脏读。我把隔离级别改成READ COMMITTED后再重复一遍事务2查到的还是100.00脏读消失。不可重复读复现过程隔离级别设为READ COMMITTED事务2先查询BEGIN; SELECT amount FROM ticket WHERE order_no A0001; -- 此时查到 100.00事务1执行更新并提交BEGIN; UPDATE ticket SET amount 300.00 WHERE order_no A0001; COMMIT;事务2再查询一次同一个事务内两次结果分别是100.00和300.00这就是不可重复读。等问题出现后我重新开一个事务把会话设为REPEATABLE READ重复同样的操作第二次查询结果仍然是100.00——因为MVCC让这个事务内看到的是同一个快照。幻读比不可重复读更隐蔽。不可重复读关注的是同一行数据内容变了幻读关注的是符合条件的记录条数变了。比如事务2执行BEGIN; SELECT COUNT(*) FROM ticket WHERE amount 50; -- 返回 1事务1插入一条amount150的新订单并提交。事务2再执行同样的统计如果能看到2条说明出现了幻读。在REPEATABLE READ级别下如果事务2走的是普通SELECT快照读InnoDB通过MVCC让查询结果保持在事务开始时的快照上新插入的行不可见所以不出现幻读。但如果事务2走的是带FOR UPDATE的当前读还是可能看到新插入的行。这也是很多人在RR级别下仍然被幻读困扰的深层原因。2.3 一个让我纠结很久的问题既然RR已经解决了快照读的幻读为什么很多团队还要改成RC我以前一直不理解MySQL官方默认RR那大家为什么还要改成RCREAD COMMITTED查了一圈资料、又看了些生产案例后我总结出几个实际原因间隙锁带来的死锁风险。RR级别会对范围条件加间隙锁两个事务同时往同一个范围插入数据时更容易互相等待形成死锁。主从复制的要求。在基于binlog的复制场景里RC级别配合row格式的binlog比RR级别少很多不确定的加锁行为管理起来更省心。RC级别下间隙锁被禁用加锁范围更小并发度更高很多互联网公司的高并发交易系统选择RC就是基于这个考量。但是注意RC级别放弃了快照读的一致性不可重复读会重新出现。所以这个选择本质上是一次业务场景的权衡没有绝对的对错。我给自己的判断标准是如果业务对同事务内一致性读要求高选RR如果追求高并发、对一行数据读到最新提交值也接受RC也完全可行。3. 锁的分类地图与死锁日志排查从一数到八再亲手制造一个死锁3.1 锁的两种维度粒度与读写模式不要混为一谈说到锁很多人会冒出十几个名词表锁、行锁、共享锁、排他锁、意向锁、记录锁、间隙锁、Next-Key Lock……其实它们分别属于两个维度搞清楚维度就不会乱按粒度分表级锁、行级锁。按读写模式分共享锁S锁、排他锁X锁。它们组合出具体类型行锁细分又包括记录锁Record Lock、间隙锁Gap Lock、Next-Key Lock记录锁间隙锁的组合。另外有个容易忽视的角色——意向锁。它的作用是告诉别人我这个事务打算在表里的某些行加锁。比如事务要在某一行加X锁首先得在表级别加一个意向排他锁IX。这样另一个事务想直接给整张表加X锁时就能通过检测意向锁快速判断表里已经有行被锁了没必要一行一行扫描去确认。意向锁的存在是为了提高锁检查效率它本身不阻塞任何行级操作。共享锁和排他锁的兼容关系是S锁和S锁兼容S锁和X锁不兼容X锁和X锁不兼容。可以理解为读读不互斥读写才互斥。日常开发里SELECT ... FOR UPDATE加的是行级X锁SELECT ... LOCK IN SHARE MODE加的是行级S锁。普通的SELECT不加锁走MVCC快照读。3.2 间隙锁与Next-Key LockRR级别下最被低估的隐形锁Record Lock锁的是已经存在的索引记录。但问题是如果锁只锁已有记录两个事务完全可能同时给一个不存在的区间插入新数据——幻读就会出现。间隙锁Gap Lock就是用来填补这个漏洞的当你在RR级别下对一个范围条件加锁时InnoDB不仅锁住命中的记录还会锁住这些记录之间的空隙防止其他事务往里面插入新行。举例说明表里id分别为1、3、5、7执行SELECT * FROM ticket WHERE id BETWEEN 3 AND 5 FOR UPDATE;InnoDB除了给id3和id5的记录加记录锁还会在(3,5)这个区间以及可能的相关间隙加间隙锁。另一个事务插入id4的记录会被阻塞插入id6的记录取决于间隙锁范围也可能被阻塞。这里有个经典的坑间隙锁锁的是索引记录之间的空隙即使你在业务上没有显式使用事务多个并发操作在RR级别下也可能因为间隙锁互相等待。很多为什么我的INSERT突然卡住不动的问题最后定位出来都是间隙锁在作怪。这个我后面会有专门的案例复盘。3.3 亲手制造死锁然后通过show engine innodb status找到元凶理论学习半天不如自己制造一次死锁印象深刻。我用上面那张ticket表准备了两条id不同的记录开了两个事务模拟经典死锁事务A先锁id1的行BEGIN; UPDATE ticket SET amount 10.00 WHERE id 1;事务B先锁id2的行BEGIN; UPDATE ticket SET amount 20.00 WHERE id 2;接着事务A尝试锁id2UPDATE ticket SET amount 10.00 WHERE id 2;此时A会阻塞因为id2的X锁被B持有。然后事务B尝试锁id1UPDATE ticket SET amount 20.00 WHERE id 1;此时B也会阻塞因为id1的X锁被A持有。两个事务互相等待死锁形成。几秒后MySQL的死锁检测机制会触发其中一个事务被回滚报错信息大致是Deadlock found when trying to get lock; try restarting transaction。排查死锁的标准操作是执行SHOW ENGINE INNODB STATUS\G在输出的LATEST DETECTED DEADLOCK段落里能看到死锁的事务ID、持有锁和等待锁的详细信息、涉及的SQL语句。我第一次看这个输出时特别懵全是十六进制的锁地址和内部编号后来慢慢习惯了核心就抓三个要素谁持有锁、谁在等锁、两条SQL分别是什么。只要能回答这三个问题死锁根因基本就清楚了。3.4 我的一点体会死锁不能只靠重启事务糊弄不少人遇到死锁的第一反应是把事务重试一下就算了。我觉得这不太够。死锁的本质是锁的获取顺序不合理重试只是补救优化才是正解。我在项目里总结出的措施包括多个事务对同一批数据的操作顺序保持一致尽量缩短事务的执行时间在RR级别下谨慎使用大范围更新如果场景允许把隔离级别降到RC来规避间隙锁引发的死锁。这些不需要全部上但至少要有意识地排查。4. 一条UPDATE语句背后的完整加锁路径从B树定位到记录锁再聊间隙锁4.1 没有索引时行锁是如何一步步变成全表锁的很多人认为行锁就一定是锁一行但InnoDB的加锁是基于索引的。如果你执行UPDATE时的WHERE条件没有命中任何索引InnoDB只能走全表扫描。扫描过程中它会访问每一行为了不让其他事务在扫描期间修改数据它会对扫描到的每一行都加锁——虽然理论上是行锁但实际效果跟锁全表几乎一样。这就是行锁变表锁的真相。所以判断一个UPDATE会锁多少行不是看表有多少行而是看扫描了多少行。条件能精准命中索引时锁的范围可能只是一两条记录条件没法用索引时锁的范围就是整张表。这也是为什么我在建表时特别重视索引设计——索引不只是查询加速它直接关系到大事务的锁范围。4.2 手动实测一条UPDATE的加锁范围我用ticket表做了一次实测表里有id1到id5共5条记录执行BEGIN; UPDATE ticket SET amount 0 WHERE id 3;由于主键索引的存在这条UPDATE会锁住id4和id5两条记录而且因为RR隔离级别还会在(3, ∞)范围加上间隙锁。在另一个事务执行INSERT INTO ticket (id, order_no, amount) VALUES (6, A0006, 50.00)时插入被阻塞——虽然id6这条新记录本身不存在但间隙锁覆盖了它要插入的位置。如果换成id2这样具体的主键值BEGIN; UPDATE ticket SET amount 0 WHERE id 2;则只对id2这条记录加X锁不会阻塞插入id6。这个实验直观地让我理解了记录锁和间隙锁的作用范围差异。4.3 回表与锁的传递为什么索引命中也可能锁更多行还有一个细节值得留意——二级索引加锁后还要不要给主键索引加锁答案是要。InnoDB更新数据时最终需要通过主键找到真正的记录。所以当你用二级索引查询并加锁时InnoDB会在二级索引上加对应的锁同时回表到主键索引在主键索引的对应记录上也加锁。也就是说一条SQL可能同时锁了两棵索引树的记录。如果二级索引本身不够精准比如有大量重复值的普通字段那么锁定的记录数量会远超你的预期。理解了这条路径再看那些我只更新了一条记录为什么锁了好多行的问题就清楚了要么是索引没建对扫描范围过大要么是二级索引列值有大量重复导致回表行数膨胀要么是RR级别下附带了大范围的间隙锁。把这几个因素按顺序排查基本能找到大部分锁范围异常问题的根源。4.4 关于UPDATE没匹配到任何行还会不会加锁的实验结论我自己有过一个疑问UPDATE一条不存在的记录是不是就不用加锁了实验结果是——在RR隔离级别下即使没有任何匹配行InnoDB依然会对WHERE条件对应的范围加间隙锁防止其他事务在更新期间插入匹配该条件的记录。这个行为在特殊场景下能保证数据一致性但也容易被滥用成通过锁定一个区间来防并发插入的手段。不过我不建议在生产环境这么搞间隙锁的代价远高于业务上几个乐观锁字段或者唯一约束能解决的方案。5. 实操最容易翻车的三个场景与常用诊断命令清单5.1 场景一长事务拖垮Undo LogMySQL的磁盘空间凭空蒸发有一次我操作的MySQL实例出现了诡异现象数据文件没怎么涨但磁盘空间一直在减少最后查出来是undo log膨胀。原因很简单一个事务打开后长时间不提交期间执行了大量UPDATE这些修改对应的undo信息一直无法清理。因为InnoDB有MVCC机制未提交事务期间之前的快照版本必须保留下来供其他事务读取。这个坑的核心教训是事务开了就尽快提交不要在事务里做耗时的外部接口调用。像那种BEGIN之后先HTTP请求第三方接口再UPDATE数据库的写法就是长事务的高发区。排查长事务可以用SELECT * FROM information_schema.innodb_trx;重点看trx_started字段凡是启动时间很久、状态为RUNNING的事务都要警惕。如果确认某个事务卡死了可以通过trx_mysql_thread_id找到对应的会话再决定是等待结束还是手动终止。5.2 场景二间隙锁范围失控INSERT突然全部卡住前阵子跟朋友排查过一个线上问题某个核心表的INSERT操作在高峰期突然大面积超时应用日志里全是Lock wait timeout exceeded。当时第一反应是行锁冲突但查看信息后发现在RR级别下有一个事务对大范围数据执行了UPDATE产生的间隙锁覆盖了业务要插入的大部分区间。所有插入都被阻塞直到那个事务提交或回滚。排查步骤我整理过一套先查当前锁等待情况SELECT * FROM performance_schema.data_lock_waits; -- 或者老版本用 SELECT * FROM information_schema.innodb_lock_waits;再查持锁事务信息SELECT * FROM information_schema.innodb_trx;必要时打开SHOW ENGINE INNODB STATUS看细节。如果是间隙锁拖垮了并发插入最直接的解决办法是确认业务能不能接受把隔离级别从RR改成RC如果必须保留RR那么拆分大事务成小批量避免一次UPDATE覆盖太多数据。还有一个小技巧是让INSERT的数据落在间隙锁覆盖范围之外——这需要结合业务数据分布来设计不能机械照搬。5.3 场景三隔离级别和并发目标不匹配误把RC当RR用或者反过来我也见过有团队在并发量很高的支付流水表上使用RR级别导致死锁频率上升后来改成RC死锁立刻减少。反过来有些报表类的业务场景需要在一个长事务里多次读取同一批数据做聚合计算结果被配置成了RC导致两次计算结果不一致生产上出现对不上账的诡异现象。隔离级别的选择我的思路比较简单粗暴先看业务对一致性的敏感度再看并发压力。一致性要求高、事务里多次读取必须一致优先RR高并发、短事务、单行操作的场景RC往往更合适。记住MySQL的RR不是唯一正确答案Oracle默认RC也不意味着RC更优关键是匹配业务模型。5.4 我平时用到的诊断命令清单下面这些命令我几乎每次怀疑数据库数据不对或者性能骤降时都会用整理在这里方便自己下次翻阅查看当前会话隔离级别SELECT transaction_isolation;查看运行中的事务SELECT * FROM information_schema.innodb_trx\G查看锁等待关系SELECT * FROM performance_schema.data_lock_waits\G查看死锁详情SHOW ENGINE INNODB STATUS\G查看表上是否有锁等待SHOW PROCESSLIST;修改锁等待超时时间SET SESSION innodb_lock_wait_timeout 5;我发现只要遇到数据莫名其妙对不上接口突然卡死MySQL磁盘空间异常减少这三类问题从事务隔离级别、锁等待、长事务三个方向入手排查命中率非常高。这套方法我已经用顺手了也希望能在你排错时帮上忙。写在最后Day02的收获今晚我最大的收获不是背熟了事务和锁的分类而是真正理解了它们为什么存在事务的ACID保证了业务逻辑的可靠性隔离级别决定了你对并发状态下数据中间状态的容忍度锁机制则是隔离性落地的具体手段。这三层环环相扣任何一层选错线上都会以各种意想不到的方式报复你。学到这里我对Day03的规划也有方向了MySQL的索引底层结构B树为什么快和SQL执行计划分析是继续深入事务与锁绕不开的基础。每次学习新概念时问一句它到底解决了什么问题不解决会有什么后果是我觉得目前最有效的学习方法。Day02的记录就先到这里下一篇见。
返回列表