KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
04 · 失败模式排查手册:从报错反推根因 — keel 龙骨
前三章是「从原因到结果」,这一章反过来:给你真实故障的症状,训练你反推根因的能力。四个症状按出现频率排序,每个都给出排查路径和确定性的验证方法。读法建议:先只看症状栏,自己推一遍再看答案。
前三章是「从原因到结果」,这一章反过来:给你真实故障的症状,训练你反推根因的能力。四个症状按出现频率排序,每个都给出排查路径和确定性的验证方法。读法建议:先只看症状栏,自己推一遍再看答案。
一、总判定图
flowchart TD
S{"现在的症状?"} --> S1["随机协议错误 /<br/>terminating connection<br/>due to protocol error"]
S --> S2["Event loop is closed /<br/>Future attached to a different loop"]
S --> S3["连接数涨满 /<br/>Too many open files"]
S --> S4["偶发事务错乱 /<br/>数据偶尔不对"]
S1 --> Q1{"服务有多进程 /<br/>fork 型拉起?"}
Q1 -- "是" --> A1["进程边界:共享连接<br/>→ 子进程 dispose + 懒加载(02 章)"]
Q1 -- "否" --> A1x["查驱动版本 / 代理层<br/>(先排除归属问题再查别处)"]
S2 --> Q2{"池在别的 loop 创建?<br/>(同步路径 / 新线程)"}
Q2 -- "是" --> A2["loop 边界:自建独立池<br/>用完即关(03 章第三节)"]
S3 --> Q3{"连接数按『副本数 × 池大小』<br/>核对过吗?"}
Q3 -- "没算过" --> A3["容量边界:乘积重算 +<br/>fd 泄漏点排查(02/03 章)"]
Q3 -- "算过" --> A3x["查泄漏:连接是否 checkout 后<br/>没有归还(异常路径漏 finally)"]
S4 --> Q4{"事务边界上有没有<br/>共享连接的可能?"}
Q4 -- "有" --> A4["同 S1 的根因:<br/>事务串味(02 章第三节)"]
先说排查纪律:归属类问题(左三条路径)的共同特征是本地复现不了——Windows 没有 fork、本地单进程没有拓扑。所以「本地好好的」永远不构成排除项。归属排查的第一步永远是回答「这个连接由谁创建、现在谁在用」,而不是改数据库参数。
二、症状一:随机协议错误
临床表现:terminating connection due to protocol error、unexpected message from server,偶发,压测或高峰期更容易出现,重启后消失一段时间。
排查路径:
- 确认服务拓扑:有没有多进程?进程怎么拉起(
multiprocessing/ gunicorn / 容器里 fork)?拉起前父进程建过池吗? - 若为 fork 型:检查每个子进程入口有没有
dispose。没有——先在测试环境复现(第 2 章第 6 节的破坏实验),加上 dispose 后观察错误消失。 - 若为 exec 型拉起:归属嫌疑降低,转向查代理层(PgBouncer / ProxySQL 的协议兼容性)和驱动版本。
验证方法:在子进程入口打印 len(os.listdir("/proc/self/fd")) 并列出指向 socket 的 fd;对照「dispose 前 / 后」的差异,让继承从猜测变成观测。
三、症状二:Event loop is closed
临床表现:RuntimeError: Event loop is closed;或协程永远不返回、Future attached to a different loop;定时任务第一次跑正常、第二次开始炸。
排查路径:
- 找到报错的池是谁、在哪里、用哪个 loop 创建的——这是唯一重要的问题。全局搜索池的初始化点。
- 找到使用点所在的 loop:主服务的 loop?
asyncio.run新开的?另一个线程的? - 两者不同即确诊。修复按使用频率选型:低频同步路径 → 自建独立池用完即关;高频调用 → 把逻辑挪回主 loop(
run_coroutine_threadsafe或改成异步任务)。
为什么「第二次才开始炸」:常见变体是测试环境第一次通过——第一次恰好还连着旧 loop 的残留状态,第二次复用时 loop 已关。这提醒我们:loop 边界问题可能被「第一次成功」掩盖,不要用单次运行下结论。
四、症状三:连接数涨满 / Too many open files
临床表现:数据库侧 max_connections 打满、应用侧 fd 涨满、pymysql/asyncpg 报无法新建连接。
排查路径:
- 先算账再抓贼:总连接 = 副本数 × 每副本池大小(03 章第六节)。4 副本 × 20 池 = 80,若数据库上限 100,再叠加管理工具、迁移脚本,很容易顶满。这类「配置性打满」占大半,先排除。
- 账算不平 → 查泄漏:池的 checkout 有没有对应的 checkin?重点看异常路径——
async with pool.acquire()用了没有?finally里有归还吗?事务对象在异常时 close 了吗? - 再查 fd 泄漏(02 章):fork 型拉起是否让每个子进程额外背了一份父进程的 fd。
监控建议:按「池的 checkout 数 / checkin 数 / 等待者数」三个指标监控,而不是只看数据库侧总数——前者能定位到泄漏进程,后者只知道「满了」。
五、症状四:偶发数据错乱(最阴的一种)
临床表现:没有报错,但数据偶尔不对:一条记录的状态跳变、本该回滚的数据被提交、两条消息的内容互相穿插。
排查路径:
- 这个症状是症状一的无声版本——同一根因(共享连接),只是协议错位恰好没触发解析错误,而是把回包送错了事务。排查顺序同症状一,但优先级更高:数据错了没有报错可依赖。
- 补充检查:有没有「同一条连接上并发跑事务」的代码路径——手动
acquire后在多个协程间传递连接对象?连接对象的归属必须唯一(03 章判定图)。 - 验证方法:给事务打标(例如会话变量设置事务 ID),在疑似的串味场景下对比「事务 A 的标记是否出现在 B 的连接日志里」。
六、排查 checklist(收口)
遇到任何连接类故障,按顺序过一遍:
- 服务拓扑:几个进程?怎么拉起?每个进程几个 loop?
- 每个池的创建点在哪个进程、哪个 loop?
- 有没有 fork 型拉起?子进程入口 dispose 了吗?
- 同步路径(定时 / CLI / 信号)用的池是自建的吗?
- 连接总数算过乘积吗(副本数 × 池大小)?
- 异常路径的 acquire / release 配对检查过吗?
- 监控里有没有池级指标(checkout / checkin / 等待)?
七、练习
- 把第 1 节判定图从记忆里画出来(只画分支问题,不画答案),对照原文检查。
- 构造一个「第一次成功、第二次 Event loop is closed」的最小复现,解释为什么第一次能成功。(参考:第一次使用时旧 loop 与池的绑定尚未被破坏的时序窗口。)
- 你的服务即将从「单进程」改成「uvicorn --workers 4」,列出连接治理上的三处必改点。(参考:容量乘积、fork 泄漏检查、监控指标按进程拆分。)
现在你能拿到症状就反推根因,也能在架构评审时指出「这个拓扑下连接池的归属边界在哪」。四 章 合起来,这门课回答的就是导读里那个问题:并发单元和有状态资源,怎么划分归属。