事务和锁是保障数据一致性的核心机制,但在实际开发中,它们也是最容易出问题的地方。许多时候,程序在测试环境跑得好好的,一到线上就出现死锁、锁等待超时、数据莫名不一致等诡异现象。这些问题往往不是数据库的 bug,而是对事务和锁的机制理解不够,或者使用姿势不对。
下面列举几个最常踩的坑,以及它们的根因和解决办法。
25.2.1 死锁:互相等待导致的僵局
现象:两个或多个事务互相持有对方需要的锁资源,形成环路等待,谁都动不了。数据库检测到死锁后,会主动回滚其中一个事务,并报错 Deadlock found when trying to get lock; try restarting transaction。
典型场景:假设有一张账户表 account(id, balance),两个事务分别要对不同账户转账。
事务 A:
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;
事务 B:
START TRANSACTION;
UPDATE account SET balance = balance + 200 WHERE id = 2;
UPDATE account SET balance = balance - 200 WHERE id = 1;
COMMIT;
这两个事务几乎同时执行时,A 先锁住 id=1,B 先锁住 id=2。接着 A 试图锁 id=2,B 试图锁 id=1,互相等待对方释放锁,死锁形成。
为什么会这样:死锁的四个必要条件是互斥、持有并等待、不可抢占、循环等待。在数据库中,当以不同顺序访问相同资源时,就容易满足这些条件。
如何排查:
- 使用
SHOW ENGINE INNODB STATUS;,在输出中找到LATEST DETECTED DEADLOCK部分,它详细描述了死锁发生时的两个事务状态、它们持有的锁和等待的锁,以及最后被回滚的事务。 - 在 MySQL 8.0 中,还可以通过
performance_schema.data_locks和data_lock_waits表实时查看锁信息。
如何避免:
- 统一访问顺序:这是最根本的办法。所有事务都按照一致的顺序访问资源(例如都先操作 id 小的,再操作 id 大的),就不会形成环路。上面的例子中,如果都用
ORDER BY id先锁小 ID 再锁大 ID,死锁就不会发生。 - 缩短事务时间:尽量让事务短小精悍,减少持有锁的时间,从而降低并发冲突概率。
- 使用合适索引:如果更新语句没有命中索引,InnoDB 会扫描更多行并锁定它们,使得锁的范围变大,死锁概率随之上升。确保 DML 语句都走索引。
- 捕获重试:死锁无法 100% 避免,因此应用端必须捕获死锁异常,并实现重试逻辑。重试时最好使用退避策略,避免马上又冲突。
25.2.2 锁等待超时:一条慢 SQL 拖垮一片
现象:某个事务长时间不提交,一直持有锁,其他需要竞争这些锁的事务就会进入等待队列。当等待时间超过 innodb_lock_wait_timeout(默认 50 秒)时,这些等待事务会报错 Lock wait timeout exceeded; try restarting transaction,连锁导致大量请求失败。
常见原因:
- 长事务:一个事务开启后,中间夹杂了复杂的业务逻辑、远程调用或者人为交互(比如等待用户确认),导致事务持有锁的时间过长。
- 未提交的事务:有的开发者手动开启事务,但忘记执行 COMMIT 或 ROLLBACK,导致连接断开后才悄悄回滚。在这期间,锁一直被保持。
- 大范围 DML:例如一个 DELETE 语句没有合适的索引,扫描并锁定了大量行,阻塞了其他更新操作。
排查方法:
-- 查看当前正在执行的事务
SELECT * FROM information_schema.innodb_trx;
-- 查看当前锁等待情况
SELECT * FROM performance_schema.data_lock_waits;
-- 结合进程列表,找到回话 ID 和 SQL 文本
SHOW FULL PROCESSLIST;
重点关注 innodb_trx 表中 trx_started 时间很早但 trx_state 仍为 RUNNING 的事务,如果它的 trx_rows_locked 不为 0,则很可能就是源头。
解决办法:
- 直接干掉源头:如果确定了是某个会话持锁不释放,可以用
KILL <thread_id>;结束该会话,强制回滚事务。生产环境需谨慎。 - 缩短事务:将无关逻辑移出事务,尤其避免在事务中调用外部接口或进行耗时计算。
- 设置事务超时:MySQL 8.0 支持
SET SESSION innodb_lock_wait_timeout = 10;调低超时阈值,让等待方快速失败,同时也可以考虑innodb_rollback_on_timeout=ON确保超时后整个事务回滚(默认是只回滚当前语句,可能造成事务不完整)。 - 监控告警:对长时间的
innodb_trx进行监控和告警,提前发现长事务。
25.2.3 幻读与“可重复读”的偷鸡不成蚀把米
现象:开发者在“可重复读”(REPEATABLE READ)隔离级别下,仍然遇到了 SELECT 多次结果不一致的现象,或者在范围内插入数据时发生冲突。
经典误解:很多人以为“可重复读”就是完全避免了幻读,实际上 MySQL 的 InnoDB 在可重复读下是通过间隙锁来抑制幻读的,并不是彻底消除。间隙锁锁住了记录之间的间隙,阻止其他事务插入符合条件的新行,从而让当前事务的多次读取结果集不变。但这把双刃剑也带来了副作用:
- 间隙锁引发死锁:假设一个事务执行
SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;,它不仅锁住 id=10 和 20 的记录,还锁住 10~20 之间的间隙。如果另一个事务试图在这个范围插入 id=15,就会被阻塞;如果两个事务以相反的顺序访问间隙,就可能死锁。 - 插入意向锁冲突:插入语句会在插入行的间隙上加插入意向锁,当它与间隙锁冲突时,插入就会等待。在高并发插入的固定间隙内(比如按日期范围),容易出现大量等待。
- 快照读不锁任何东西:普通的
SELECT不加锁,读的是 MVCC 快照,它不会看到其他事务新插入的行,所以不会有幻读。但是当前读(SELECT ... FOR UPDATE或UPDATE/DELETE)会加锁,如果加锁机制不当,仍然可能出现逻辑上的幻读。例如,先通过快照读判断记录不存在,然后插入,这个间隙很可能被其他事务钻了空子。
如何应对:
- 在对数据变化敏感的业务中,优先使用锁定读(
SELECT ... FOR UPDATE)来确保后续操作基于最新数据。 - 如果业务逻辑真的要求完全串行,直接使用
SERIALIZABLE隔离级别,它会将所有普通 SELECT 隐式转化为SELECT ... FOR SHARE,代价是性能大幅下降。 - 监控间隙锁造成的锁等待,如果遇到频繁插入冲突,考虑调整索引设计,或者使用
READ COMMITTED隔离级别来关闭间隙锁(但会失去对幻读的防御,需要应用逻辑补偿)。
25.2.4 丢失更新:覆盖式写入的隐形杀手
现象:两个事务同时读取一个值,然后基于这个值计算新的结果并更新回数据库,后提交的事务覆盖了先提交事务的更新,导致前一次更新丢失。
示例:库存表 stock(id, quantity),当前数量 100。
- 事务 A 读取 quantity=100,计算减 30,得到 70,UPDATE SET quantity=70。
- 事务 B 在 A 提交前读取 quantity=100,计算减 20,得到 80,UPDATE SET quantity=80。
- 最终库存变成了 80,A 的更新被覆盖,相当于丢失了 30 的扣减。
为什么会发生:尽管 InnoDB 通过行锁保证了并发更新不会相互覆盖(因为两个 UPDATE 会串行执行),但读取和写入之间有间隙。如果两个事务都在自己的内存中计算新值,写入时没有验证原值是否被其他事务修改过,就会出现丢失更新。在 READ COMMITTED 或可重复读隔离级别下,普通 SELECT 非锁定读不会阻止这种情况。
解决方案:
- 使用锁定读:在读取时就加排他锁,阻止其他事务并发读取和修改。
SELECT quantity FROM stock WHERE id = 1 FOR UPDATE;
-- 计算后更新
UPDATE stock SET quantity = 70 WHERE id = 1;
这样事务 B 在读取时就会被阻塞,直到 A 提交。
- 乐观锁:在表中增加版本号字段
version。
SELECT quantity, version FROM stock WHERE id = 1;
-- 计算出新值 70
UPDATE stock SET quantity = 70, version = version + 1
WHERE id = 1 AND version = 查到的版本号;
如果发现影响的记录数为 0,表示已被其他事务修改,需要重试或放弃。
- 原子性递增/递减:如果操作只是加减,直接用
UPDATE stock SET quantity = quantity - 30 WHERE id = 1;,这本身就是一个原子操作,不需要先读后写。
25.2.5 索引失效导致行锁变表锁
现象:明明只是更新一行数据,却把整张表都锁住了,其他事务的插入、更新全部阻塞。
根因:InnoDB 的行锁是建立在索引上的。如果更新语句的 WHERE 条件字段没有索引,或者因为隐式类型转换、函数运算等原因导致索引失效,那么 InnoDB 就无法精确定位到要锁定的行,只好对扫描的每一行都加上锁,甚至退化为锁定所有记录和间隙。这等于变相升级为表锁。
示例:
-- name 列没有索引
UPDATE user SET status = 1 WHERE name = '张三';
这条语句会全表扫描,并对扫描到的每一行都加上排他锁,虽然看起来只更新了一行,但从锁的视角看,别人完全动弹不得。
避免方式:
- 确保
WHERE条件使用索引列,并且索引不被函数或运算破坏。 - 对于偶然执行的管理性 SQL,可以使用
EXPLAIN查看执行计划,如果 type 是ALL就要格外小心。 - 如果实在无法用索引,可以将操作放在低峰期,并合理设置超时。
25.2.6 事务未提交,连接断开导致的幽灵锁
现象:应用程序使用连接池,在执行了一系列 DML 后,程序异常退出或连接被代理超时断开,此时事务还没有提交。MySQL 检测到连接断开后会回滚事务并释放锁。但如果连接池的“连接存活检测”没有做好,旧的连接可能残留,新借出的连接可能拿到了一个处于未提交事务的会话(这种极端情况比较少见,更多是应用未显式开启事务,但 autocommit=0 导致),从而延续了原本应该回滚的锁。
这也是为什么生产环境一定要统一设置为 autocommit=1,并在需要事务时显式用 START TRANSACTION 包裹,而不是通过 SET autocommit=0 来管理事务。即使真的需要,也要在连接归还连接池前执行 ROLLBACK 或 COMMIT。
小结
事务和锁的问题万变不离其宗:锁定的是什么?锁住了多久?访问顺序是否一致?索引有没有用对? 遇到问题不要慌张,先查 innodb_trx 和 innodb_locks(或 8.0 的 performance_schema.data_locks),找到阻塞源头;再审视事务代码是否符合“短小、有序、索引友好”的原则。绝大多数并发问题都能在这个框架下解决。