☰
MySQL InnoDB事务与锁:从ACID到死锁排查实战
2026/10/21 23:13:16 网站建设 项目流程

1. 从一次线上死锁排查说起:事务和锁是怎么"搅"在一起的

先讲个我实际遇过的场景。当时负责一个库存扣减模块,平时流量不大,某次活动大促前做压测,突然冒出大量Deadlock found when trying to get lock的报错。更让人头疼的是,报错并不是稳定复现,时有时无,压测机一停就"消失"了。A同事的第一反应是"代码里有死循环"或"连接池满了",但排查一圈发现都不是,最后翻出MySQL的日志才定位到:两条SQL在并发下互相等待对方的行锁,且这个等待关系形成了一个环。

这个事的核心,绕不开两个概念——事务和锁。很多刚接触MySQL的人会把它们当成同一个东西,或者认为锁是事务的一部分。其实它俩是两条独立的知识线,只是在整个数据库系统中被"拧"在了一起。事务解决的是"一组操作要么全部成功、要么全部失败"的原子性问题,而锁解决的是"多个并发操作同时访问同一份数据时,如何保证数据一致"的问题。没有事务,锁依然可以存在;没有锁,事务的隔离性就无从谈起。

这篇文章想做的,不是重复一遍MySQL官方文档里的定义,而是把事务和锁在InnoDB下到底怎么配合、为什么会有各种类型的锁、隔离级别和锁是什么关系、以及线上死锁到底怎么排查,用业务里能遇到的例子讲清楚。适合三类人看:刚接触数据库、被隔离级别和锁类型搞晕的后端开发;写SQL但经常遇到死锁、又不知道从哪里下手的运维;以及准备面试、想把这个话题讲得有点深度的人。

2. 事务的ACID不是口号:底层靠的是日志和版本链

2.1 redo log与undo log的分工

先说事务。ACID四个特性——原子性、一致性、隔离性、持久性——教科书上都有,但落到MySQL的InnoDB引擎里,它们不是靠"自觉"实现的,而是靠两种日志和一套版本链机制。

redo log(重做日志)负责的是持久性。InnoDB默认有个Buffer Pool,数据页不是每次都直接写磁盘,而是先改内存里的页,再按一定策略刷盘。如果数据库在内存中改了数据、但还没来得及刷盘的时候就宕机了,重启后内存里的修改就丢了。redo log解决的正是这个问题:每次事务提交前,先把修改按顺序记录到redo log里(WAL,Write-Ahead Logging),即使数据页没刷到磁盘,日志里也已经有记录了,重启后可以靠redo log把数据页"重放"回来。这个机制我用一个类比讲过很多次:你先把要做的事写在便利贴上贴在冰箱上(redo log),再去动手改桌上的账单(Buffer Pool),就算账单被猫撕了,还能照便利贴重新抄一份。

undo log(回滚日志)则负责原子性和MVCC的一部分。事务执行过程中,如果某个SQL失败了,需要把已经修改的数据恢复到事务开始前的状态。undo log记录的是"反向操作":如果是INSERT,就记录主键信息用于删除;如果是UPDATE,就记录修改前的旧值;如果是DELETE,就记录删除前的整行数据。事务回滚时,靠undo log一步步还原。这就像你在文档里改内容之前先做了一份备份,改坏了还能Ctrl+Z回去。

这两个日志容易搞混的地方在于:redo log是物理层面的日志,记录"某个页的某个偏移量被改成了什么值";undo log是逻辑层面的日志,记录"某一行数据被怎么改的"。前者保证数据不丢,后者保证操作能撤销。理解了这一点,后面理解MVCC多版本链就不会卡壳。

2.2 MVCC怎么做到"读写互不阻塞"

MVCC(Multi-Version Concurrency Control,多版本并发控制)是InnoDB在"读多写少"场景下的一招关键设计。它解决的核心矛盾是:读操作和写操作在传统锁机制下是互斥的,一个事务在写某行数据,其他事务读这行就得等;如果频繁读写相交,并发性能会非常难看。MVCC的思路是"让你读旧版本"——写事务不阻塞读事务,读事务读到的可能是某个历史版本。

具体实现上,每一行数据除了业务字段,还隐藏了几个字段:DB_TRX_ID(最近修改该行的事务ID)、DB_ROLL_PTR(指向undo log的指针,方便找到之前的版本)、DB_ROW_ID(隐式主键)。当一个事务读取某行时,它会根据当前活跃事务列表(Read View)来判断自己能看到哪个版本。简单说就是:在事务启动时生成一个"快照",能看到的版本范围被限定在快照生成那一刻已提交的事务范围内。

这个机制带来的最重要影响是:在InnoDB默认的REPEATABLE READ隔离级别下,普通的SELECT(非锁定读)是不加锁的。它走的是快照读,不需要等待其他事务释放锁。很多开发者在做高并发查询时发现"MySQL支持并发读写得很好",本质上是在享受MVCC的福利。而所谓的"当前读"(如SELECT ... FOR UPDATE、UPDATE、DELETE语句),读的是最新已提交版本,这些操作才需要加锁,也才和后面的锁机制真正挂钩。

3. InnoDB的锁类型:行锁、表锁、意向锁、间隙锁,别傻傻分不清

3.1 为什么InnoDB要区分行锁和表锁

锁的粒度,是数据库并发性能的第一道分水岭。MyISAM引擎时代只有表锁,写操作会锁住整张表,读读并发还行,读写一旦叠加就各种排队。InnoDB引入了行级锁,允许不同事务锁住不同的行,并发度大幅提升。但"按行加锁"不是没有代价——锁的数量变多了,锁管理需要更多内存和CPU。所以InnoDB的实际设计是"默认行锁,特定情况下升级或配合表级锁"。

实践中常遇到的表级锁主要是意向锁。它是一种"预告"用的锁,不锁具体的数据行,而是表示"这个事务想在表级别上申请什么类型的锁"。比如事务T1要给某一行加行级独占锁(X锁),它得先在表上加意向独占锁(IX锁);事务T2此时想对整张表加表级共享锁(S锁),发现表上有IX锁,立刻就能判断"某些行可能正在被独占写",从而阻塞。意向锁的存在,就是为了让表级锁和行级锁的冲突判断不必挨行扫描,直接看表上的意向锁即可。

这里有个很常见的认知误区:行锁并不能完全避免表锁带来的阻塞。比如ALTER TABLE这类DDL操作需要元数据锁(MDL锁),在较老版本中可能阻塞线上DML;全表UPDATE时如果无法高效使用索引,InnoDB也可能退化为逐行加锁最终表现为类似表锁的阻塞效应。所以别把"InnoDB支持行锁"理解成"任何写操作都只在行级别互相隔离"。

3.2 为什么有间隙锁

行锁锁的是"已经存在的行",但幻读问题出在"原本不存在的行"。比如事务A执行SELECT * FROM orders WHERE amount > 100 FOR UPDATE,第一次查出了3条;此时事务B插入了一条amount=200的新订单并提交;事务A再次执行同样的查询,如果只锁住了已有的3行,新插入的那行就"漏"了,导致前后两次读到的结果集不一致,这就是幻读。

InnoDB解决幻读的武器是间隙锁(Gap Lock)和临键锁(Next-Key Lock)。在REPEATABLE READ隔离级别下,InnoDB对索引记录加锁时,不只是锁住索引记录本身,还会锁住它前后的一段"间隙",阻止其他事务在这个间隙里插入新记录。临键锁是"索引记录锁+记录前面间隙锁"的组合,它锁的是一个左开右闭的区间,比如索引值为10、20、30,一个在20上添加的临键锁可能锁住(10, 20]和(20, 30]之间的范围(具体区间取决于是前开后闭还是其他规则,这里简化理解)。

为什么很多人一到"间隙锁"就懵?因为它不是"数据上的一把锁",而是"数据之间的一个禁区"。正常业务不会直接感知到它,只有在并发插入时报Deadlock或长时间等待时,你去看SHOW ENGINE INNODB STATUS里的锁信息,才会看到类似lock_mode X locks gap before rec这样的记录。

之前提到的压测死锁,根因就跟间隙锁强相关。两个事务都执行"先查后插"的逻辑:判断某条记录不存在就插入。检查用的是SELECT(快照读,不加锁),插入前又要加唯一索引。两个事务同时发现"记录不存在",然后同时插入,其中一个成功,另一个因唯一键冲突等待,而成功的那个事务又持有插入间隙的锁,最终形成循环等待。解决这类问题通常有两个方向:一是换成INSERT ... ON DUPLICATE KEY UPDATE这类原子语句,二是让"判断是否存在"也走锁定读,把检查与插入合并成一个临界区。

4. 四种隔离级别:锁与MVCC怎么排列组合

4.1 隔离级别不是"配置完就完事"

SQL标准定义了四种隔离级别,MySQL的InnoDB全部支持,但实现方式不一样。最核心的差异,就是它在"用锁还是用版本"上选择了哪条路。我把它们放在一张表里对照:

隔离级别脏读不可重复读幻读InnoDB的实现方式
READ UNCOMMITTED可能可能可能读最新版本,不加锁,版本链基本不生效
READ COMMITTED不会可能可能每次SELECT都生成新的Read View;已提交读
REPEATABLE READ不会不会可能(但InnoDB用间隙锁基本防住)事务第一次SELECT时生成Read View;唯一索引和范围查询配合临键锁防幻读
SERIALIZABLE不会不会不会所有普通SELECT都自动变为锁定读,相当于全部串行

这中间最容易踩的坑是"默认隔离级别是不是最安全的"。MySQL 8.0的默认级别是REPEATABLE READ,很多业务方从Oralce(默认READ COMMITTED)迁过来后,发现同样的SQL,在MySQL里会出现莫名其妙的锁等待,或者某个"先查后写"的逻辑在并发下死锁变多。原因就是REPEATABLE READ下的间隙锁比READ COMMITTED多得多——间隙锁能防幻读,但它同时也在"锁住不存在的数据",天然增加了阻塞概率。

4.2 可重复读为什么够用,什么情况下要降级

之前提的库存场景就是一个典型。在REPEATABLE READ下,如果我事务A先查了一下某商品的库存(普通SELECT,快照读),之后商品库存被其他事务改了并提交,我在事务A里再查一次,看到的还是旧值——这在业务上很可能是可以接受的,因为事务内需要"读到的结果一致";但如果你要的是"每次查询都看到最新已提交数据"这种弱一致需求,REPEATABLE READ反而会让你觉得"数据是不是出问题了"。

比如一个报表系统,事务内有多次聚合查询,每次都希望纳入其他事务刚刚提交的新数据,这种需求放在REPEATABLE READ下就不合适。此时把全局隔离级别降为READ COMMITTED,或者对特定会话执行SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED,更能贴合业务。降级后间隙锁会被禁掉(InnoDB在READ COMMITTED下只保留记录锁和部分外键/唯一性检查锁),并发写入的等待会明显减少,代价是可能重新引入幻读——但对报表这种"多读一点少读一点无所谓"的场景,这个代价几乎可以忽略。

所以在生产环境,我一般建议:不要裸奔式使用默认隔离级别,先想清楚业务对"一致性"的真实需求。读写并发高、每个事务逻辑短、一致性要求不苛刻的,优先考虑READ COMMITTED;核心资金链路、要求事务内多次读完全一致、并且有范围查询写操作的,才优先保留REPEATABLE READ。这个取舍的底层逻辑,其实就是在"锁的代价"和"版本读的可用性"之间找平衡点。

5. 死锁的定位链路:从日志到解法的完整过程

5.1 死锁的四个条件和业务中的"教科书模型"

死锁在数据库里也一样需要四个条件:互斥、持有并等待、不可剥夺、循环等待。InnoDB的锁冲突检测机制会自动检测死锁,并把代价较小的事务回滚掉,所以很多死锁场景其实"自动恢复了",只会在日志里留下一行Deadlock found。但如果你经常在应用侧看到这个报错,说明死锁频率已经高到影响业务了。

用一次实际排查过程来演示完整链路。某天某系统的订单状态更新接口开始频繁报死锁,错误信息形如:

Deadlock found when trying to get lock; try restarting transaction (1) TRANSACTION: ... lock_mode X locks rec but not gap ... (2) TRANSACTION: ... lock_mode X locks rec but not gap ...

排查分四步走。第一步,先把报错SQL找出来,通常应用日志里能看到最外层语句,本例是两条UPDATE:一条更新订单主表,一条更新订单明细表,两条SQL的执行顺序在两个并发请求里刚好相反。第二步,去看SHOW ENGINE INNODB STATUS中的LATEST DETECTED DEADLOCK段,里面会列出两个事务持有的锁和等待的锁,呈典型的"A持有order_id=1的锁、等order_id=2,B持有order_id=2的锁、等order_id=1"状态。第三步,回业务代码里确认两个接口的加锁顺序——果然,一个接口先更新主表再更新明细,另一个接口先更新明细再更新主表,并发时正好交叉。

解决方式是最标准也最有效的:统一所有事务内的加锁先后顺序。团队内约定一个规则,无论哪个接口,资源永远按"主表→明细表"(或者按表名字母序)顺序访问。这样即使并发很多,锁请求的方向也是一致的,循环等待条件被破坏,死锁自然消失。除此之外,还可以把大事务拆小,减少事务持有锁的时间窗口;对更新同一行数据的高频操作,可以考虑在应用层做串行化(如按主键取模分桶)。

5.2 排查死锁的工具和"看不到锁"时的替代手段

死锁日志虽然关键,但默认情况下InnoDB只记录最近一次死锁的信息,如果死锁频率高、业务复杂,你可能需要多次复现才能抓到现场。我在实践中会配合几个手段:

  • 开启innodb_print_all_deadlocks,让所有死锁都记录到错误日志,而不是只保留最新一条;
  • 用performance_schema中的data_locks和data_lock_waits表实时查询当前锁等待关系,能直接在线上看谁在等谁;
  • 借助SHOW PROCESSLIST观察长时间运行的Waiting for table metadata lock或Waiting for row lock会话。

还有一种更隐蔽的情况:你在表结构里找不到任何大事务,但就是周期性出现锁等待。这时候要留个心眼——有可能是"APP层面的长事务",应用里开了事务却没及时提交(比如autocommit被显式关掉、还发生了异常没走rollback),事务持有的锁一直不释放。查这种问题,看information_schema.innodb_trx里事务的运行时间最直观,超过几十秒甚至几分钟还没结束的事务,往往是隐患。

排查文件讲到这,我想强调一个观点:死锁本身不是"绝对不能出现",它甚至可以被当作一种信号——说明你的并发操作存在资源竞争。最佳处理不是单纯地"死锁自动重试",而是先把死锁频率降下来,然后在应用层对偶发的死锁做有限次重试(比如3次),两者结合才稳妥。

6. 业务里的锁选型:悲观锁、乐观锁和MVCC怎么配合

6.1 悲观锁:什么时候该用SELECT ... FOR UPDATE

悲观锁的思路是"先拿锁再干活,拿到锁之前什么都不做"。对应到MySQL就是SELECT ... FOR UPDATE加锁读。它适合并发冲突概率较高、或者一旦冲突代价极大的场景。比如库存扣减,如果两个请求同时读到库存剩余1件,都以为自己能买,实际只该放行一个——这种场景下,在扣减前对库存行加锁,让第二个请求等第一个提交后再读,是最直接可靠的方案。

不过FOR UPDATE有几个细节容易翻车。第一,它必须在事务中执行才有效,如果autocommit=1,语句提交后锁就释放了,等于白锁。第二,它锁的是索引记录,如果WHERE条件没走索引,会升级到锁更多行甚至全表,线上可能把整个表的并发写卡死。第三,使用它之后,事务里不要做RPC调用、外部API请求这类耗时操作,否则锁的持有时间会拉长,别人全在排队。我见过一个项目在FOR UPDATE锁住记录后去调第三方支付接口,支付接口耗时3秒,这3秒里相关行的所有写操作全部阻塞,数据库连接池很快就满。

6.2 乐观锁:用版本号还是CAS

乐观锁的思路是"干活的时候不锁,提交时确认有没有被别人改过"。最常见的实现是给表加version字段,更新时带上WHERE version = 期望版本,受影响行数为0则说明冲突,重试或报错:

UPDATE inventory SET stock = stock - 1, version = version + 1 WHERE product_id = 123 AND version = 5;

另一种是CAS风格,直接用老值做条件。比如更新前读到stock=10,提交时:

UPDATE inventory SET stock = 10 - 1 WHERE product_id = 123 AND stock = 10;

这两种写法都能用,但我个人更推荐版本号方案。原因是:业务字段(比如库存值)本身可能因为其他逻辑被改动、或者出现浮点精度问题,拿业务值当版本条件不如独立的版本号字段干净。而且版本号方案的可读性好,排查问题时"version从5变成了6"这个记录比"stock从10变成了9"更能看出并发痕迹。

6.3 实际组合:先MVCC读,再锁定写,最后版本兜底

在真实业务中,我不会只押注一种锁方案。以典型的订单创建加库存扣减流程为例,我的习惯是:

  1. 普通SELECT做预检查:先查库存够不够,命中缓存或走快照读,不阻塞别人;
  2. 执行UPDATE扣减时走当前读:UPDATE语句本身就会加行锁,此时并发请求会在这一行上排队,天然实现串行扣减;
  3. 如果更新受影响行数为0,再决定是重试还是提示失败,避免"扣了库存但实际没扣到"这类静默问题;
  4. 对更高并发、更敏感的业务(比如秒杀),再叠加乐观锁:在扣减SQL里带条件WHERE stock >= required AND version = ?,双保险。

这套组合的本质是:尽量让大多数读操作走MVCC不打锁;把短小精确的写操作作为临界区;再用版本号应对"同一行被反复修改"的高频冲突。从我现在维护的系统来看,这套路子平均能扛住平时流量10倍以上的并发,死锁率基本降为零。

7. 一些零散但很要命的经验小结

说几个上面没展开、但实际维护数据库时经常被问到的问题。

关于自增主键与锁,插入时的自增锁在MySQL 8.0里默认是innodb_autoinc_lock_mode=2,也就是"轻量级"模式,不锁整表。但如果你用的是INSERT ... SELECT这种批量插入,它的行为会不一样,高并发下可能出现"预分配的自增值跳跃",别以为这是bug。

关于事务里倒底该不该把查询都放在最后,我的习惯是了解MySQL对锁的释放时机:在REPEATABLE READ下,事务的锁一般是"提交或回滚时才释放"。(这点也恰恰是"为什么事务要短小"的底层解释。)有些开发者在事务里先做大量只读查询,再执行UPDATE,这些查询如果是快照读,其实不怎么占锁,但如果你用的是SELECT ... FOR UPDATE做了多处加锁读,就等于把一堆锁握在自己手里,风险显著上升。

关于怎么看一条SQL有没有走索引,EXPLAIN输出里type字段如果出现ALL(全表扫描),就要警惕你的UPDATE或DELETE可能锁定了全表的记录。此外EXPLAIN不会直接告诉你锁范围,但通过possible_keys和key可以判断。

最后是"要不要完全避免死锁"这个问题。完全避免几乎是不可能的,任何并发系统中只要资源竞争存在,理论的死锁条件就可能满足。真正务实的做法是:让死锁频率降到业务可容忍的范围,应用侧保留自动重试逻辑(建议连接同一个事务重试,或整事务重试),同时把每次死锁的日志都留下来,定期复盘。数据库的锁和事务机制,说到底不是让你背概念,而是为了让你在写出"看起来能跑"的SQL之外,还能预见并发环境下它会如何表现。我后来再遇到线上锁问题,第一反应已经不是"翻文档找定义"了,而是把事务边界、锁顺序、索引使用三个层面快速过一遍——大部分问题都能在这个框架里找到答案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询