全量 21 个失败、单跑全绿:泄漏进连接池的 SQL 变量
这是一次让人怀疑人生的排查:全量跑测试,21 个用例失败;把失败的那几个文件单独挑出来跑,全部通过。反复跑几遍,失败的那批还会换人——上一次是级联删除的断言炸了,下一次是另一个文件里的计数对不上。
这种「单跑绿、全量红」的现象有个经典误判:以为是被测代码有并发问题。但这次不是。根因是一条 SQL 会话级变量,它跟着数据库连接一起被归还进了连接池,然后泄漏给了下一个完全无关的用例。
第一步:先用「单跑判定法」把问题分层
排查之前,得先确定这是「我的改动有问题」还是「环境有问题」。判据很便宜:
- 把失败的文件单独跑 → 如果全绿,说明代码逻辑本身没错,问题出在用例之间的相互影响。
- 把失败的文件按不同顺序再跑几遍 → 如果失败集合会变,说明是共享状态在传递,而不是某个用例本身坏。
这两步一做完,方向就锁定了:不是业务代码的问题,是测试之间的状态污染。
顺便说一个经验:在这个项目里我还遇到过另一类「单跑绿全量红」——测试缓存目录和工作量账本硬编码指向了生产目录,A 用例写进去的缓存被 B 用例命中,于是 B 那条「必须发生一次真实调用」的断言就假失败了。所以看到这个现象,优先怀疑共享的可变外部状态(缓存、账本、全局单例、连接池),而不是被测代码。
第二步:顺藤摸到「幽灵表」
顺着「状态污染」往下查,锁定了每个用例结束后的一段清理逻辑:遍历所有表、逐个 TRUNCATE 清空,保证下一个用例拿到干净的空库。
逻辑本身没问题。问题出在表名单的来源上。
for table in reversed(Base.metadata.sorted_tables):
conn.execute(text(f"TRUNCATE TABLE `{table.name}`"))
这里用的是 ORM 的元数据 Base.metadata。而它是动态的:只要有任何一个用例在运行期 import 了一个新模型,这张表就会被加进元数据里。但建表动作(create_all)只在测试会话开始时执行过一次。
于是出现了一个诡异的中间态:元数据里有这张表,数据库里却没有。我把它叫做「幽灵表」。
拿幽灵表去 TRUNCATE,MySQL 会返回 1146 Table doesn't exist。异常一抛,循环当场中断——排在幽灵表后面的那些真实表,一个都没清。
第三步:为什么失败是「零散且每次不同」的
到这里还只解释了一半:循环中断只会导致「数据没清干净」,为什么会出现级联删除断言炸掉这种八竿子打不着的失败?
关键在于清理逻辑的最后一行:
conn.execute(text("SET FOREIGN_KEY_CHECKS=0"))
for table in ...:
conn.execute(text(f"TRUNCATE TABLE `{table.name}`"))
conn.execute(text("SET FOREIGN_KEY_CHECKS=1")) # ← 循环中断,这行不执行
SET FOREIGN_KEY_CHECKS 是会话级变量,它绑定在数据库连接上,不是全局设置。看这条链:
幽灵表出现 → 循环在第 N 张表中断 → 末尾的
SET FOREIGN_KEY_CHECKS=1没执行 → 连接被归还给连接池时,它的状态仍然是「外键校验关闭」 → 下一个用例从池里复用到这条连接 → 它活在一个外键约束不生效的世界里
外键校验关掉之后会发生什么?级联删除不触发、引用完整性不校验。于是那些依赖外键行为的测试就开始莫名其妙地失败——而具体哪个测试会踩到这条脏连接,取决于连接的复用顺序,这正好解释了「失败零散且每次不同」。
第四步:修复,以及两个必须同时做的动作
修法有两个要点,缺一不可。
第一,从源头避免中断:只清「数据库里真实存在」的表。
with test_engine.begin() as conn:
existing = {
row[0]
for row in conn.exec_driver_sql(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = DATABASE()"
)
}
conn.execute(text("SET FOREIGN_KEY_CHECKS=0"))
for table in reversed(Base.metadata.sorted_tables):
if table.name in existing:
conn.execute(text(f"TRUNCATE TABLE `{table.name}`"))
conn.execute(text("SET FOREIGN_KEY_CHECKS=1"))
用 metadata 和 information_schema 取交集,幽灵表自然被过滤掉,循环不会再中断。
第二,异常路径必须丢弃整个连接池。
except Exception:
test_engine.dispose()
raise
这条比第一条更重要。只要还存在任何一条能让 FOREIGN_KEY_CHECKS=1 执行不到的路径,就有泄漏的可能。 与其逐个堵漏,不如加一条兜底:一旦清理过程出错,整个连接池全部作废重建。宁可多花几十毫秒重建连接,也绝不让一条「外键关着」的连接流出去。
修复后验证:那个此前反复翻车的级联删除测试文件,单跑 20 个用例全绿;再把它和其他几个文件串联跑两遍,两遍都是 62 passed。
第五步:承认一次方向错误的归因
这次排查里最该记下来的一点,不是技术细节,是上一轮的归因错了。
第一轮排查时,日志里有一批 1146 Table doesn't exist。当时全量测试同时还在被另一个问题干扰——宿主机内存溢出导致数据库进程周期性被杀,测试成片报连接错误。于是我把那批 1146 一并打包进了「环境噪声」,还写下了一个错误结论:「表在跑的过程中消失了」。
事实完全相反:表一直都在,是表名单里混进了数据库里根本没有的表。一个是「东西没了」,一个是「名字错了」,排查方向完全不同。
沉淀出一条判据:
1146 / 1142(对象不存在)是结构层错误,不是连接层错误。连接层错误是2003 / 2013 / 1205 / 1213。把「对象不存在」打包进「环境问题」,就会掩盖一个真实的测试隔离缺陷。
而且这条经验是通用的:噪声只是根因的遮盖物,不是根因的替代品。清掉噪声之后,必须回头把之前的归因重跑一遍。 否则你会带着一个错误的结论继续往前跑,而它迟早会在更贵的地方再暴露一次。
可带走的 4 条原则
-
「单跑绿、全量红」先怀疑共享可变状态。 顺序是:缓存目录 → 状态账本 → 全局单例 → 连接池。别一上来就怀疑被测代码。
-
连接池会继承连接的所有会话级状态。
SET FOREIGN_KEY_CHECKS、SET SESSION sql_mode、临时表、事务隔离级别——任何会话级设置都是「借来的东西」,用完必须还回去,还不了就把连接销毁。 -
清理逻辑的错误处理,优先「销毁」而不是「继续」。 清理中途失败时,最危险的选择就是「记个日志往下走」。把连接池整个丢弃是便宜的,脏连接污染出一次假绿测试是昂贵的。
-
只比对静态清单去操作数据库对象是脆的。 任何「按代码里声明的清单去操作实际存在的资源」的动作,都应该先用一次 introspection 求交集。代码里声明的东西和现实之间存在时间差,这个差值是 bug 的高发区。
做自动化这几年,最耗时间的从来不是写代码,是摸清每个平台的脾气。
这篇里提到的坑,都是真金白银踩出来的。
如果你手上也有重复度很高的活儿——批量发布、数据搬运、有固定规则的机械操作
——可以在评论区说说你的场景,我看看能不能自动化掉。

浙公网安备 33010602011771号