首页
›
Linux › Programming
›
生产环境死锁排查完全指南:从Python线程死锁到MySQL锁等待——一次凌晨故障的完整复盘(2026)
📝 600 字 · ☕ 2 分钟阅读
凌晨两点,一条告警把我从梦里薅了起来
消息是 Grafana 发的:order_processing_latency_p99 > 30s。
我揉了揉眼睛打开笔记本——订单处理服务的 P99 延迟从平时的 200ms 直接飙到了 38 秒。与此同时,线程池里的线程数在涨,但 CPU 使用率反而在降。你猜怎么着——典型的死锁症状。
这篇文章复盘一下那次故障的完整排查过程,从发现到定位到修复,顺便把死锁排查的方法论串一遍。涵盖 Python 线程死锁、文件锁死锁、以及 MySQL 死锁三种最常见的情况。
第一步:先确认是死锁,不是别的
死锁有一个非常明显的特征:线程数在涨,CPU 使用率在降。为什么?因为死锁的线程都在等待——它们不消耗 CPU,只是在阻塞系统调用上挂着。与此同时新的请求进来,线程池还在创建新线程,但老线程回不来。
先用最简单的方式看一眼线程状态:
# 找到 Python 进程 PID
ps aux | grep order_service
# 看线程数
cat /proc/PID/status | grep Threads
# Threads: 247
247 个线程,而线程池配的是 200。说明池已经满了,活儿干不完。再看一眼 CPU:
top -H -p PID
# 你会发现大部分线程状态是 S(sleeping),CPU 使用率接近 0%
到这里基本可以确定是阻塞问题了。但阻塞不等于死锁——可能只是某个外部服务响应慢。接下来要区分到底是「等 IO」还是「互相等」。
第二步:dump 线程栈,看谁在等谁
Python 有一个特别好用的内置模块叫 faulthandler,能在收到信号的时候 dump 所有线程的调用栈。这玩意在生产环境是救命神器。
# 在启动代码里加一行
import faulthandler
import signal
faulthandler.register(signal.SIGUSR1, all_threads=True)
加上之后就可以随时 dump:
kill -SIGUSR1 PID
# 输出会到 stderr,所以需要重定向
# 如果你是 systemd 管理的服务,用 journalctl 查看
导出来的栈大概长这样:
Thread 0x7f8a3c001700 (idle): "ThreadPoolExecutor-0_195"
File "/usr/lib/python3.11/threading.py", line 324, in wait
File "/usr/lib/python3.11/threading.py", line 607, in wait
File "order_service/lock_manager.py", line 42, in acquire_order_lock
self._order_locks[order_id].acquire()
...
Thread 0x7f8a3b800b00 (idle): "ThreadPoolExecutor-0_187"
File "/usr/lib/python3.11/threading.py", line 324, in wait
File "/usr/lib/python3.11/threading.py", line 607, in wait
File "order_service/lock_manager.py", line 42, in acquire_order_lock
self._order_locks[order_id].acquire()
...
几十个线程全部卡在 acquire_order_lock 的同一行。这已经不是「某个请求慢」了——这是系统性的锁等待。
进一步检查代码发现,acquire_order_lock 里是先锁订单 A、再锁订单 B(用于转账场景)。而另一个路径是先锁 B、再锁 A。经典的「锁顺序不一致导致死锁」——教科书级别的坑,但线上该踩还是踩。
# 问题代码(简化版)
def transfer(from_id, to_id):
lock_a = get_lock(min(from_id, to_id)) # ❌ 锁顺序不统一
lock_b = get_lock(max(from_id, to_id))
with lock_a:
with lock_b:
do_transfer(from_id, to_id)
看起来用了 min/max 统一了顺序,对吧?但问题出在 get_lock 内部:它在获取锁之前先做了一个数据库查询,查询期间锁的映射表可能被其他线程修改。导致两个线程拿到的 lock_a 和 lock_b 实际上是同一把锁的不同实例(threading.Lock() 不是可重入的),形成 AB-BA 死锁。
第三步:修死锁——不是加超时那么简单
很多人第一反应是「给锁加超时」:
# 看起来合理的修复
if lock.acquire(timeout=5):
try:
do_work()
finally:
lock.release()
else:
raise TimeoutError("acquire lock timeout")
这确实能防止服务彻底卡死,但超时后怎么办?请求失败,重试,又可能再次死锁。超时只是止血,不是根治。
真正的修复要解决锁顺序问题。我们用了一个简单粗暴但有效的方案:全局锁排序。
# 修复方案:所有需要多锁的操作统一走 LockManager
class OrderedLockManager:
def __init__(self):
self._locks: dict[str, threading.Lock] = {}
self._master_lock = threading.Lock() # 保护 _locks 字典
def acquire_multi(self, *resource_ids):
"""按字典序加锁,杜绝 AB-BA 死锁"""
sorted_ids = sorted(resource_ids)
acquired = []
try:
for rid in sorted_ids:
with self._master_lock:
if rid not in self._locks:
self._locks[rid] = threading.Lock()
lock = self._locks[rid]
lock.acquire()
acquired.append(rid)
return lambda: self._release(acquired)
except Exception:
self._release(acquired)
raise
def _release(self, resource_ids):
for rid in reversed(resource_ids): # 逆序释放
self._locks[rid].release()
核心就两件事:(1) 统一排序——所有需要多把锁的地方按相同顺序获取;(2) master lock 保护锁实例的创建,避免两个线程同时创建同一资源的 Lock 对象。
上线后线程数从 247 降回 15,P99 延迟回到 180ms。故障持续 47 分钟。
第四步:别以为只有 Python 有死锁——数据库也会
就在修完线程死锁的第二天,DBA 群里又炸了:
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction
MySQL 死锁。这次是什么情况?
-- 事务 A:先更新 order_items,再更新 orders
BEGIN;
UPDATE order_items SET status='shipped' WHERE order_id=1001;
-- 此时事务 B 开始
UPDATE orders SET updated_at=NOW() WHERE id=1001;
-- 事务 B:先更新 orders,再更新 order_items
BEGIN;
UPDATE orders SET status='completed' WHERE id=1001;
-- 等待事务 A 释放 orders 的行锁
UPDATE order_items SET shipped_at=NOW() WHERE order_id=1001;
-- 同时事务 A 也在等事务 B 释放 order_items 的行锁
-- 💥 DEADLOCK
MySQL 的死锁检测机制会自动回滚其中一个事务(通常是持锁较少的那个),然后客户端收到 ERROR 1213。关键是要能找到具体的死锁日志。
-- 查看最近一次死锁的完整信息
SHOW ENGINE INNODB STATUS\G
-- 或者用 performance_schema(MySQL 5.7+)
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
SHOW ENGINE INNODB STATUS 输出很长,但死锁部分非常清晰,会列出两个事务各自持有哪些锁、在等哪些锁,以及最后回滚了哪个。直接搜 LATEST DETECTED DEADLOCK 段落就行。
修复思路和线程死锁一样:统一访问顺序。把代码里所有涉及 orders 和 order_items 两表的地方统一为「先 orders 后 order_items」,死锁消失。
第五步:文件锁死锁——隐藏最深的一种
还有一种情况更隐蔽:文件锁导致的死锁。特别是当你用 fcntl.flock() 做进程间互斥的时候。
import fcntl
def process_file(path):
with open(path, 'r+') as f:
fcntl.flock(f, fcntl.LOCK_EX) # 获取排他锁
data = f.read()
# 处理数据...
# 如果这里调用了另一个也会 flock 同一个文件的函数
subprocess_that_also_locks(path) # 💥 死锁(同一进程内 flock 不可重入)
排查文件锁死锁有个很实用的文件:/proc/locks。它列出了系统里所有被 flock 或 fcntl 锁定的文件:
cat /proc/locks | grep order_data
# 1: FLOCK ADVISORY WRITE 27845 08:01:524289 0 EOF
# 2: FLOCK ADVISORY WRITE 27846 08:01:524289 0 EOF
# 看到了吗?两个进程各持有一把同文件的写锁——其中一个在死等
配合 lsof 找到是哪个进程:
lsof /data/order_data.lock
# 或者
fuser -v /data/order_data.lock
死锁排查速查表
总结一下,下次凌晨被叫起来,按这个顺序走:
步骤
做什么
关键命令
1. 确认症状
线程数↑ CPU↓ → 大概率死锁
cat /proc/PID/status | grep Threads
2. Dump 线程栈
看所有线程卡在哪一行
kill -SIGUSR1 PID(需预装 faulthandler)
3. 分析锁顺序
找 AB-BA 模式
grep acquire\|lock\|wait 线程栈
4. 修代码
统一锁顺序 / 用超时 + 重试
全局 LockManager 或 threading.RLock
5. 数据库死锁
看 InnoDB 死锁日志
SHOW ENGINE INNODB STATUS\G
6. 文件锁死锁
查系统锁表
cat /proc/locks + lsof
一个容易被忽略的点:Python 的 RLock 不是万能药
有人会问:直接用 threading.RLock() 不就行了?RLock 解决的是「同一线程重复获取同一把锁」的问题(可重入),但它解决不了「两个线程交叉等对方的锁」的问题。当你有多把不同的锁时,RLock 帮不了你——该排序还是得排序。
另外,RLock 有性能开销(每次 acquire 都要检查当前线程 ID),高频场景下比普通 Lock 慢 20-30%。不是不用,是别滥用。
# RLock 的正确使用场景
class RecursiveProcessor:
def __init__(self):
self._lock = threading.RLock()
def process(self, data):
with self._lock:
self._validate(data) # _validate 也 acquire 了一把锁 → OK,同线程可重入
def _validate(self, data):
with self._lock:
if not data:
raise ValueError
📎 相关阅读:
Python 并发编程深度实战:为什么你的多线程比单线程还慢 —— GIL 原理与最优并发策略选择 — 理解 Python 线程模型是排查死锁的前提
strace 生产环境调试完全指南:不重启不改代码定位疑难问题 — 如果 faulthandler 没装,strace 是备选方案
Redis 生产环境踩坑实录:缓存穿透、雪崩、热点 Key — 另一个凌晨告警的完整故事
常见问题
Q: faulthandler 在生产环境安全吗?会不会影响性能?
faulthandler 在注册信号处理函数时几乎没有性能开销(只是在信号表中注册了一个条目)。dump 线程栈的操作虽然会短暂暂停所有线程,但通常只持续几十毫秒。关键在于别在生产环境频繁 dump——只在排查问题时用一次。另外建议用 SIGUSR1 而不是 SIGUSR2,因为某些库(如 uWSGI)会占用 SIGUSR2。
Q: 死锁和活锁有什么区别?
死锁是两个线程互相等待对方释放锁,谁都不干活。活锁是两个线程都在不停尝试获取锁但总是失败(比如互相谦让),虽然线程没阻塞但实际也没进展。活锁在 CPU 监控上看起来是正常负载,更难发现——需要用分布式追踪(如 Jaeger/Zipkin)看请求链路才能定位。
Q: MySQL 死锁和锁等待超时是一回事吗?
不是。MySQL 死锁是 InnoDB 检测到循环等待后主动回滚一个事务(报 ERROR 1213),通常在亚秒级内触发。锁等待超时是单个事务等待行锁超过 innodb_lock_wait_timeout(默认 50 秒)后被回滚(报 ERROR 1205)。死锁是「互等」,锁等待超时是「单方面等太久」。别混淆——排查思路不一样。
总结
死锁排查其实有规律可循。核心就两步:先 dump 栈看卡在哪,再分析锁的获取顺序。Python 的 faulthandler、MySQL 的 InnoDB Status、Linux 的 /proc/locks——三件套在手,死锁跑不了。
那次故障之后我在团队里定了一条规矩:任何需要获取多把锁的代码,必须走统一的 LockManager,必须在 code review 里标注锁的获取顺序。半年了,再没出过同类故障。
你的生产环境装 faulthandler 了吗?没装的话去加一行吧——等你凌晨两点被叫起来的时候会感谢我的。
📤 分享这篇文章
微博
📖 你可能还喜欢
📄 C# 13 + .NET 9 新特性实战:集合表达式、params 增强、ref struct 接口 —— 让代码更简洁更快📄 Python asyncio 协程调度深度剖析:从 Event Loop 原理到生产环境假死排查(2026)📄 Python __slots__ 内存优化深度实战:让百万级对象的内存占用直降 60%(2026)📄 生产环境 MySQL 慢查询排查实战:从凌晨告警到根治的全链路复盘(2026)