Windows 反复卡死排查实录:一个 pytest mock 陷阱如何吃光 78GB 内存
> 一次 "系统无征兆冻结、只能硬重启" 的完整排障复盘。从 Windows 事件日志出发,层层缩小,最终定位到一个 > > `unittest.mock` > > 的经典陷阱,并沉淀出一套可复用的排查与防御方法。
Windows 反复卡死排查实录:一个 pytest mock 陷阱如何吃光 78GB 内存
一次 “系统无征兆冻结、只能硬重启” 的完整排障复盘。从 Windows 事件日志出发,层层缩小,最终定位到一个
unittest.mock的经典陷阱,并沉淀出一套可复用的排查与防御方法。
一、现象:一上午 6 次硬重启
某天早上 8 点开始,Windows 机器反复出现 “桌面完全冻结、鼠标键盘无响应、只能按电源键硬重启” 的故障。一上午发生了 6 次。每次都发生在启动后 5~12 分钟内,且没有蓝屏,没有报错码,仿佛系统 “睡着” 了。
伴随现象:进程会弹出 “应用程序错误” 对话框 ——TRAE SOLO CN.exe、NVIDIA Overlay.exe 都报告 “未知的软件异常 (0xe0000008)”,且崩溃地址完全相同(0x00007FF8480F187A)。两个不同应用在同一地址崩溃,强烈暗示是共享系统组件在资源枯竭下的连锁崩溃。
故障复现规律:每次硬重启后,5~12 分钟内系统内存被某个 python.exe 吃光,随后系统死亡。
二、第一步:事件日志定位(5 分钟)
排障不能靠猜。Windows 自带的事件查看器(Event Viewer)是第一个工具:
| 事件源 | 事件 ID | 含义 |
|---|---|---|
| Kernel-Power | 41 | 系统未正常关机(硬重启铁证) |
| EventLog | 6008 | 记录 “上次意外关机时间” |
| Resource-Exhaustion-Detector | 2004 | 系统资源耗尽告警 —— 带进程名、PID、内存数值 |
| EventLog | 0x800705AF | 页面文件不足 |
关键动作:筛选 Event ID 2004,它直接告诉你谁在耗尽内存:
进程名: python.exe
PID: 560
提交内存: 73,051,095,040 字节(≈68GB)
配合 31.7GB 物理内存、12.8GB 页面文件、系统提交上限 ≈91GB 的事实,结论已经清晰:
一个 python.exe 进程把提交内存顶到 65~78GB,触顶后系统假死。
同时排除了硬件嫌疑:WHEA 硬件错误 0 条、3 块磁盘 SMART 全部 Healthy、无蓝屏 minidump、显卡驱动无 TDR 超时。这不是硬件问题,是软件内存泄漏。
💡 经验:硬重启(Kernel-Power 41 + BugcheckCode=0)≠ 蓝屏。先看 2004 资源耗尽事件,谁内存大谁是嫌疑,别急着查驱动。
三、第二步:是哪个 python?(30 分钟)
这台机器上 python.exe 有 7 处:erp 项目的 venv、系统 Python 3.12、python-sdk、uv 缓存、两个 AI IDE 沙箱…… 装了多个 IDE(Trae、CodeBuddy、Qoder、Doubao、VS Code 等),每个 IDE 都会拉起自己的 Python 扩展进程。
事件日志 2004 不提供进程路径,只给 PID 和启动时间。所以锁定身份要靠旁证链:
-
排除业务服务:各项目日志、缓存目录在卡死时段零更新 → 不是 runserver、不是定时任务、不是 mypy/ruff;
-
排除业务数据:数据库 388 张表全部 0 行 → 不是 “数据量爆炸”;
-
锁定 AI IDE:腾讯云代码助手(CodeBuddy)的日志显示,每次卡死前 1~12 分钟,它都在执行同一条命令:
uv run pytest apps/subscriptions/tests/test\_tenant\_addon\_permission\_gate.py -q
- 秒级铁证:把系统 2004 事件里的 PID 启动时间,与 CodeBuddy 日志里的命令执行时间对齐:
| 2004 事件 python PID | 启动时间 | CodeBuddy 命令执行时间 | 结果 |
|---|---|---|---|
| 20852 | 10:57:42 | 10:57:41 | 吻合 |
| 1308 | 11:07:06 | 11:07:06 | 吻合 |
| 560 | 13:08:44 | 13:08:43 | 吻合 |
💡 经验:Windows 事件日志给 PID 不给路径。要 “验明正身”,把进程启动时间与各应用日志的命令执行时间做对齐 ——
时间戳对齐是跨系统定位进程身份最有效的旁证
。
四、第三步:深入代码,却先排除了自己(1 小时)
在锁定 pytest 之前,先对 erp 项目代码做了四组受控复现(全部带 12GB 内存自动终止保护):
| 实验 | 行为 | 峰值内存 |
|---|---|---|
manage.py check |
加载 40+ 应用 + 全部信号 + 连库 | 0.15GB,零增长 |
mypy apps |
2731 个源文件全量类型检查 | 0.86GB,正常 |
runserver idle 6 分钟 |
开发服务器空转 | 0.17GB,零增长 |
| 权限 SSOT 脚本 | 2700+ 文件 AST 遍历 | 0.01GB |
结论:业务代码本身没有 69GB 的内存路径。代码侧的 “常规嫌疑”(线程堆积、全表加载、缓存重建、OTLP 上报)全部排除。
这非常关键 —— 它把排查方向逼向测试运行环境,而不是业务逻辑。
五、复现:21 秒,内存从 0.17GB 飙到 12GB+
用同样的命令复现这个 pytest 测试,监控内存:
0.17 → 0.39 → 1.65 → 3.85 → 5.86 → 7.65 → 9.84 → 12.49 GB
每秒翻倍,指数增长。前 11 个用例全过,第 14 个用例(test_assign_allows_member_of_tenant)开始爆炸。
用二分法缩小范围:单独跑第 13 个用例(test_assign_rejects_cross_tenant_user)→ 正常,0.17GB。单独跑第 14 个用例 → 爆炸。差异只有两行代码:
role = RoleFactory.create(tenant\_id=tenant.id)
UserRoleFactory.create(user=member, role=role, tenant\_id=tenant.id)
…… 以及一个被 patch 的 SeatService。
六、根因:unittest.mock 的经典陷阱
用 pytest-timeout 插件(thread 模式)在超时时 dump 当前执行堆栈,一锤定音:
File ".../rest\_framework/utils/encoders.py", line 63, in default
  return obj.tolist()
File ".../Lib/unittest/mock.py", line 1139, in \_\_call\_\_
  return self.\_mock\_call(\*args, \*\*kwargs)
File ".../Lib/unittest/mock.py", line 1167, in \_increment\_mock\_call
  while \_new\_parent is not None:
完整机制:
-
测试用
patch.object(SeatService, "assign_seat")只 patch 了方法,没有给返回值; -
请求打到视图后,视图执行
SeatAssignmentSerializer(seat).data——seat此时是 MagicMock; -
DRF 的 JSON 编码器对未知对象尝试
obj.tolist(); -
MagicMock 的特性:访问任意属性 / 调用任意方法都会 “自动创建” 一个新的子 mock 并返回;
-
于是
tolist()返回新 mock → 编码器又对它tolist()→ 又返回新 mock……无限递归,每轮新建对象; -
内存指数级暴涨,直到系统提交内存耗尽、桌面冻结。
这解释了所有现象:
-
为什么是 python.exe 吃内存 —— 递归发生在 pytest 进程里;
-
为什么反复发生 —— 用户在 CodeBuddy 里让 AI 反复跑同一个测试(该测试正在开发调试中);
-
为什么 TRAE SOLO / NVIDIA Overlay 也崩 —— 它们是系统内存耗尽后的受害者(
0xe0000008=STATUS_INVALID_HANDLE,共享系统 DLL 在资源枯竭下的连锁崩溃),不是元凶。
七、修复与验证:一行 return_value
修复极其简单 —— 让 mock 返回一个可序列化的真实模型实例:
from apps.subscriptions.models import SeatAssignment
assigned = SeatAssignment(
  tenant\_id=tenant.id,
  user=member,
  seat\_type=SeatAssignment.SeatType.FULL,
)
with (
  patch(CHECK, return\_value=True),
  patch.object(SeatService, "assign\_seat", return\_value=assigned) as assign\_seat,
):
  resp = client.post(f"{SEATS}/assign/", {"user\_id": str(member.id)}, format="json")
验证:
修复前:21 秒 12GB+(指数暴涨,被保护阈值终止)
修复后:14 passed in 5.53s,峰值内存 0.17GB,零增长
同时 grep 全项目,确认 patch.object(\w+Service, ...) 且返回值会被序列化的同类模式仅此一处。
八、方法论:四层防御,让同类问题 “想复发都难”
| 层级 | 手段 | 作用 |
|---|---|---|
| ① 代码规范 | patch Service 且返回值进 Serializer(...).data/ 响应体时,必须给 return_value 真实实例 |
消灭根因 |
| ② 运行护栏 | pytest.ini 加 timeout=120 + timeout_method=thread(pytest-timeout 插件) |
任何死循环 / 无限递归 120 秒自动中止并 dump 堆栈 |
| ③ 进程监控 | 常驻脚本每 5 秒记录所有 python.exe 内存,>5GB 告警、>12GB 自动 Kill | 即使泄漏复发,进程级止损 |
| ④ 系统兜底 | 页面文件调大(12.8GB → 32-48GB);避免多个 AI IDE 的 python 扩展同时常驻 | 单进程拖死整机的最后防线 |
配套的 AI IDE 工作流习惯:
-
新写的测试先
pytest -k 用例名单跑,全绿再跑全文件; -
大批量 / 全目录测试放 CI(有天然超时与内存隔离);
-
让 AI 跑测试前确认已
git pull拿到最新的护栏配置。
九、排查速查清单(下次直接用)
-
看 2004:事件查看器 → Windows 日志 → 系统 → 筛选
Event ID 2004,找内存最大的进程名 + PID; -
算账:物理内存 + 页面文件 vs 系统提交上限,确认是不是 “提交内存耗尽”;
-
排除硬件:Kernel-Power 41 + BugcheckCode=0 → 查 WHEA、SMART、minidump,没有即软件问题;
-
对时间戳:把 2004 的 PID 启动时间,与各应用(IDE / 任务调度 / 服务)日志的命令执行时间对齐;
-
受控复现:复现时务必带内存保护(>12GB 自动 Kill),别把调试变成又一次宕机;
-
抓堆栈:pytest-timeout(
--timeout=10 --timeout-method=thread)在超时时 dump 执行栈,直接看死循环在哪; -
修根因:mock 返回真实实例;修完重跑验证内存曲线(对比 “指数” 与 “平稳”)。
复盘要点:系统卡死不总是硬件问题,先看事件日志的资源耗尽告警;多 python 环境下先对时间戳定位身份;测试代码的 mock 陷阱是内存泄漏的隐藏源头;而 “调试用的复现实验” 本身也要带保护 —— 别让定位问题的过程,成为下一次宕机。