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

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

首页

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 了吗?没装的话去加一行吧——等你凌晨两点被叫起来的时候会感谢我的。

📤 分享这篇文章

微博

Twitter

LinkedIn

📖 你可能还喜欢

📄 C# 13 + .NET 9 新特性实战:集合表达式、params 增强、ref struct 接口 —— 让代码更简洁更快📄 Python asyncio 协程调度深度剖析:从 Event Loop 原理到生产环境假死排查(2026)📄 Python __slots__ 内存优化深度实战:让百万级对象的内存占用直降 60%(2026)📄 生产环境 MySQL 慢查询排查实战:从凌晨告警到根治的全链路复盘(2026)

相关任务

365速发app下载平台注册 dnf决斗场哪个装备好

dnf决斗场哪个装备好

📅 10-27 👁️ 2984
盒子365app下载 道地道地尚品解.茶茶饮料

道地道地尚品解.茶茶饮料

📅 12-30 👁️ 9662
365速发app下载平台注册 《侠盗猎车手5》飞机在哪里找 gta5飞机位置大全汇总