生产环境死锁排查完全指南:从Python线程死锁到MySQL锁等待——一次凌晨故障的完整复盘(2026)

📝 626 字 · ☕ 2 分钟阅读

📎 相关推荐:Python 上下文管理器深度实战:不只是 with open——6 个生产级场景从数据库事务到 ExitStack

📌 推荐阅读:Linux /proc 文件系统深度实战——最小依赖排查法(2026) — 生产环境最小容器里连 htop 都没有?只靠 cat /proc 就能完成 CPU/内存/IO/网络全栈诊断。

凌晨两点,一条告警把我从梦里薅了起来

消息是 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_alock_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 段落就行。

修复思路和线程死锁一样:统一访问顺序。把代码里所有涉及 ordersorder_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。它列出了系统里所有被 flockfcntl 锁定的文件:

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

📎 相关阅读:

常见问题

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# Span<T> & Memory<T> 高性能内存操作实战:零分配字符串解析——从2GB到8MB的优化复盘

📎 相关阅读:Python 内存泄漏排查实战:tracemalloc + objgraph 从症状到根因的完整复盘(2026)

📤 分享这篇文章