数据库死锁实战:从复现、定位到根治

数据库死锁几乎是每个后端工程师都会遇到的线上噩梦:两个事务互相等待对方持有的锁,谁也无法推进,最终其中一个被数据库强制回滚。本文从一段可复现的代码出发,带你完整走通「复现 → 定位 → 根治」全流程,把抽象的锁竞争变成可操作的排障手册。

一、死锁到底是什么

死锁并不是数据库”坏了”,而是并发事务在争夺资源时陷入的一种僵局。学术界把死锁的必要条件总结为四条:互斥、持有并等待、不可剥夺、循环等待。四条同时成立,死锁才会发生。对数据库而言,最常见的就是”循环等待”——事务 A 拿着行 1 的锁去等行 2,事务 B 拿着行 2 的锁去等行 1,彼此都不放手。理解这四条,才能有的放矢地去打破它。

二、一个最经典的死锁案例

假设有一张账户表 accounts,两个并发请求分别要把钱从 A 转给 B、从 B 转给 A。如果两个事务的更新顺序相反,就极易撞车。下面这段 SQL 在很多人的生产环境里真实上演过:

-- 事务 T1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- 事务 T2(与 T1 并发执行)
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;

当事务 T1 先锁住 id=1、事务 T2 先锁住 id=2,接着各自去更新对方已锁住的行时,就形成了循环等待。InnoDB 的死锁检测线程会在短时间内发现这个环,并强制回滚其中代价较小的事务,抛出 1213 错误(Deadlock found when trying to get lock)。被回滚的事务需要由应用层重试,否则这笔转账就”消失”了。

三、用 Docker 在本地稳定复现

要在本地亲手制造一次死锁,最干净的办法是用 Docker 起一个 MySQL,再用两个连接交错执行。这样你既能观察现象,也能在不影响生产的前提下练习定位。

# docker-compose.yml 起一个 MySQL 8
services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: rootpw
      MYSQL_DATABASE: demo
    ports: ['3306:3306']

# 用两个连接交错执行,制造死锁
import pymysql, threading

def transfer(conn, a, b):
    cur = conn.cursor()
    cur.execute('UPDATE accounts SET balance=balance-100 WHERE id=%s', (a,))
    cur.execute('UPDATE accounts SET balance=balance+100 WHERE id=%s', (b,))
    conn.commit()

c1 = pymysql.connect(host='127.0.0.1', user='root', password='rootpw', db='demo')
c2 = pymysql.connect(host='127.0.0.1', user='root', password='rootpw', db='demo')
t1 = threading.Thread(target=transfer, args=(c1, 1, 2))
t2 = threading.Thread(target=transfer, args=(c2, 2, 1))
t1.start(); t2.start(); t1.join(); t2.join()

运行后几乎必然触发 1213,这正是我们想要的”可控故障”。如果你还没有本地 MySQL 环境,可以参考 《Docker Compose 一键编排:MySQL + Redis + 后端服务》 快速拉起一套。

四、线上如何快速定位死锁

生产环境不能靠”复现”,要靠日志和状态视图。MySQL 有两个关键抓手:

-- 开启全量死锁日志(需 SUPER 权限,重启失效)
SET GLOBAL innodb_print_all_deadlocks = ON;

-- 查看最近一次死锁详情
SHOW ENGINE INNODB STATUS;

-- 查看当前锁等待超时(默认 50 秒)
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

SHOW ENGINE INNODB STATUS 输出里的 LATEST DETECTED DEADLOCK 段落会完整记录两个事务各自持有和等待的锁、执行的 SQL,以及最终被回滚的一方。默认它只保留最后一次死锁,开启 innodb_print_all_deadlocks 后每一次死锁都会写进错误日志,方便事后复盘。

字段含义排障关注点
WAITING FOR THIS LOCK事务在等哪把锁看等待的是记录锁还是间隙锁
HOLDS THE LOCK事务已持有哪把锁谁占了资源、持锁多久
WE ROLL BACK被回滚的一方它的 SQL 就是”牺牲者”

五、死锁根因的四种形态

并不是所有死锁都来自”两行互锁”。实际排障中,我把它归为四类:

形态触发场景典型 SQL破解法
行锁交叉两事务反向更新同行UPDATE … WHERE id=1/2统一加锁顺序
间隙锁RR 下范围更新再插入UPDATE … WHERE id>10降 RC 或缩小范围
外键级联更新父表触发子表锁UPDATE parent统一父子顺序
MDL 锁DDL 与 DML 并发ALTER TABLE低峰执行 DDL

其中间隙锁(Gap Lock)最容易被忽视:在可重复读隔离级别下,一个范围更新会把”不存在的间隙”也锁住,两个事务各自锁住不同间隙又互相插入,就会死锁。外键级联和元数据锁(MDL)则常出现在”业务代码 + 在线表结构变更”同时进行的窗口期。

六、根治死锁的六条军规

死锁无法 100% 杜绝,但可以把它降到几乎为零。我总结了六条可直接落地的军规:

  • 统一加锁顺序:所有事务都按同一主键顺序加锁,循环等待从根本上被打破。
# 关键:按主键升序加锁,杜绝循环等待
def lock_in_order(conn, ids):
    for _id in sorted(ids):
        conn.execute('SELECT * FROM accounts WHERE id=%s FOR UPDATE', (_id,))
  • 缩短事务:事务越短,持锁时间越短,撞车概率指数级下降。能拆的批量操作就拆。
  • 避免长事务:长事务不仅持锁久,还会拖慢 purge、膨胀 undo 日志。
  • 建好索引:没有命中索引的更新会退化成表锁或扩大间隙锁范围(延伸阅读:《MySQL 索引底层原理:B+树、覆盖索引与最左前缀》)。
  • 超时与重试:把 innodb_lock_wait_timeout 调小,配合应用层捕获 1213 后幂等重试。
  • 谨慎降隔离级别:把 RR 降到 RC 可消除大部分间隙锁死锁,但要评估幻读风险。

七、MySQL 与 PostgreSQL 的死锁差异

换数据库,死锁的”脾气”也不同:

维度MySQL (InnoDB)PostgreSQL
死锁检测内置等待图检测默认也检测
错误码121340P01
定位手段SHOW ENGINE INNODB STATUSpg_locks + log_lock_waits
常见诱因间隙锁 / 外键级联行锁 / 咨询锁

PostgreSQL 没有间隙锁概念,但行级 FOR UPDATE 与咨询锁(advisory lock)搭配不当时同样会死锁,可通过 log_lock_waits=on 与查询 pg_locks 定位(延伸:《PostgreSQL 慢查询优化:执行计划解读与索引调优实战》、《PostgreSQL vs SQL Server:语法差异与迁移避坑指南》、《PostgreSQL 流复制实战:用 Patroni 实现高可用主从集群》)。

八、监控与兜底设计

再好的预防也挡不住偶发。真正稳健的系统会在应用层兜底:捕获死锁异常后,用幂等键做有限次重试。

import functools, time, pymysql

DEADLOCK_CODES = (1213, '40P01')

def retry_on_deadlock(tries=3, delay=0.2):
    def deco(fn):
        @functools.wraps(fn)
        def wrapper(*a, **k):
            for i in range(tries):
                try:
                    return fn(*a, **k)
                except pymysql.MySQLError as e:
                    if e.args[0] in DEADLOCK_CODES and i < tries - 1:
                        time.sleep(delay * (i + 1))
                        continue
                    raise
        return wrapper
    return deco

关键是重试必须幂等——用唯一业务单号保证"转两次账不会扣两次钱"。把重试封装成装饰器,业务代码就能无感知地获得死锁免疫力。

九、线上排查清单

  • 拉取 LATEST DETECTED DEADLOCK,确认双方 SQL 与锁类型。
  • 确认是否缺少索引导致锁范围扩大。
  • 检查两个事务的加锁顺序是否一致。
  • 评估事务是否过长、是否可拆分。
  • 临时开启全量死锁日志,观察是否高频发生。
  • 应用层补齐 1213 / 40P01 捕获与幂等重试。

结语

死锁不可怕,可怕的是"看不见"。把复现、日志、军规、兜底四件套配齐,它就会从"线上事故"变成"可观测的小插曲"。如果你想把缓存层的并发也一并理顺,可以继续读 《Redis 缓存设计:穿透、击穿、雪崩与最佳实践》、《Redis 读写分离实战:主从架构与高可用扩展》 与 《PostgreSQL 分区表实战:性能优化与自动维护》。

上一篇 LocalStack 实战:用本地 AWS 云仿真做无成本集成测试
下一篇 前端性能优化实战:Core Web Vitals 与首屏提速
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]