一个测试用例把连接池卡死 30 秒:SQLite 连接池 max_size=1 下的重入自锁复盘

上个月我们的桌面客服工具在 CI 上跑集成测试,套件末尾平白多出 30 秒的空转,本地复现时整个测试进程直接挂起,既不报错也不退出。排查了两天,最后发现问题不出在数据库本身,而出在我们自己封装的那个小小的连接池上:测试用例先从池里攥走一条连接做断言,然后在持锁期间调用了一个"内部还要再取连接"的公共函数。连接池上限是 1,同线程重入,第二次取连接等满 30 秒超时。这篇复盘把完整过程写下来,包括两次误判、三处修复和一条附带踩到的并发测试污染坑。## 事故现场:CI 日志尾部多出来的 30 秒### 现象:套件尾部凭空出现的停顿 CI 上的表现很温和:测试全部通过,只是总耗时从 41 秒涨到 71 秒。日志里没有任何异常,新增的那 30 秒被摊在一个不起眼的用例上,时间戳显示它一个人就跑了一分多钟。textPASSED test_ticket_service::test_close_ticket_with_attachment ... 63.4sPASSED test_ticket_service::test_reopen_ticket ................. 0.2s======== 214 passed in 71.03s ========如果只看 CI,这只是一次"性能劣化"。真正的问题在本地:开发者改完代码跑同一个用例,pytest 卡在那里 5 分钟不动,键盘中断都只能杀进程。### 30 秒这个数字本身就是线索挂起的用例每次都在同一位置停 30 秒然后继续跑通。30 秒不是人类随手写的常量,它一定来自某个超时配置。我们 grep 了一遍代码库,只有一处:连接池的 acquire_timeout 默认值。也就是说,有代码在等一条永远不会到来的连接,等满超时后不知为何又"好了"。## 环境背景:桌面工具的数据层为什么长这样### 为什么是 SQLite这套工具是单机桌面程序,工单、会话、客户备注全部落在本地一个文件里。选 SQLite 的理由很常规:零部署、进程内嵌、单文件方便随安装包一起分发和备份。数据层的结构是一个自研的轻量连接池,上面挂着仓储层和若干领域服务,TicketService 这类公共函数都自己负责取连接、提交事务。### 为什么 max_size 设成 1连接池上限设 1 是当初的性能同学拍板的:SQLite 写操作会串行化,多开连接只会在写锁上排队,徒增 database is locked 的报错概率;而且工具的 UI 线程会高频读取本地库,连接数压到 1 可以让"读不阻塞写"的行为在 WAL 模式下变得可预测。这个决定本身没错,错的是我们后来写测试时,心里默认它是"无限"的。py# data/pool.py(节选)class SqlitePool: def __init__(self, path: str, max_size: int = 1): self._sem = threading.Semaphore(max_size) self._idle: list[sqlite3.Connection] = [] self.acquire_timeout = 30.0 # 就是它,后面反复出现的 30 秒## 排查第一步:以为是死锁,栈里什么都没有### 用 faulthandler 抓挂起线程本地复现挂起后,我们第一件事是给 pytest 开 faulthandler_timeout,让它在超时时把所有线程的 Python 栈打出来。按经验,如果是经典的"持锁再等同一把锁",栈应该停在 Lock.acquire 上,而且能看到两帧都落在同一个函数附近。textThread 0x... (active): "MainThread" ... 没有 acquire 帧,没有 lock 帧 wait (threading.py:320) get (data/pool.py:41) _conn (services/ticket_service.py:18) close_ticket (services/ticket_service.py:88) test_close_ticket_with_attachment (tests/test_ticket_service.py:201)等等,栈里确实有 pool.get,但它在挂起的头 30 秒里几乎每帧都在移动------测试断言部分的代码还在跑。真正的"死锁感"来自第二次进入 get 之后。第一版抓到的栈让我们误以为问题是数据库内部的锁,方向从一开始就歪了。### 为什么"死锁"的判断会落空教科书死锁的前提是两个等待方互相持有对方需要的资源,可以永远等下去。而这里只有一个等待方:同一个线程既占着连接、又在向一个上限为 1 的信号量要第二份许可。它不是环,是自环------不满足"永远等待"的定义,因为池的等待带了超时,30 秒后抛 PoolTimeout,测试代码把它吞了,于是表现为"卡 30 秒然后继续"。没有环,自然也就没有我们熟悉的那种两线程对咬的栈形态。## 排查第二步:以为是文件锁,WAL 模式排除了它### 怀疑操作系统层的锁竞争既然怀疑数据库层,下一个自然的对象是文件锁:CI 的 runner 磁盘偶发慢,两个进程同时开 SQLite 文件,拿 fcntl 锁排队,不正好就是"卡住然后自己好了"?我们写了个最小脚本在同一个环境里反复开合连接,同时用 strace 观察 flock 调用,结论是完全干净:测试进程从头到尾只有它自己持有这个库文件,没有第二个进程参与。### WAL 模式直接证伪更要命的是,我们的库跑在 WAL 模式下。WAL 的设计初衷就是读不挡写、写不挡读,读者看到的是快照,写者追加日志文件。就算真有两个连接,在 WAL 下互相卡 30 秒文件锁也是不可能事件------SQLite 的 busy_timeout 我们设的是 5 秒,轮不到 30 秒出场。两次数字都对不上,文件锁这条线就此排除。pyconn.execute("PRAGMA journal_mode=WAL;") # 读不阻塞写conn.execute("PRAGMA busy_timeout=5000;") # 文件级忙等最多 5 秒## 转折点:在 pool.get() 前后加日志### 日志打在四个位置排除法走到头,我们回到那个 30 秒。在 SqlitePool.get() 的入口、信号量等待前、拿到许可后、归还连接后各加一行带线程号和池状态的日志,重跑挂起的用例。textT1 12:03:01.220 pool.get ENTER tid=main idle=1 busy=0T2 12:03:01.220 pool.grant tid=main idle=0 busy=1 # 测试用例拿走连接T3 12:03:01.231 release tid=main idle=1 busy=0 # 断言中手工 return_conn 一次T4 12:03:01.240 pool.get ENTER tid=main idle=1 busy=0 # close_ticket 内部又进来了? ... 中间 30 秒一行都没有 ...T5 12:03:31.244 pool.timeout tid=main waited=30.00 idle=0 busy=1### 真凶浮出水面T2 之后明明有 T3 归还,为什么 T5 还显示 busy=1?翻测试代码才看到:用例在断言"附件记录已落库"时拿了一条连接,with 块里中途调用了 service.close_ticket(...);而 close_ticket 内部第 88 行又调用 self._conn() 向池要连接。用例确实归还过,但它归还的是它打算稍后再要回去的那一条------with 块退出时的隐式归还和显式归还叠加,计数错乱了一次,真正的持有人在 close_ticket 调用发生时仍然攥着连接没撒手。池上限 1,自己等自己,一等 30 秒。## 根因:重入 + 上限 1 = 自锁把整个时序排成表:| 时刻 | 事件 | idle | busy || --- | --- | --- | --- || t0 | 测试用例从池取连接 c1,开始断言 | 0 | 1 || t1 | 断言中途调用 close_ticket | 0 | 1 || t2 | close_ticket 内部 pool.get,等信号量 | 0 | 1 || t3 | 等满 30 秒,抛 PoolTimeout | 0 | 1 || t4 | 用例 with 块退出,归还 c1 | 1 | 0 |问题代码压缩后就是这么几行:pydef test_close_ticket_with_attachment(pool): with pool.get() as conn: # 拿走池中仅有一条连接 att = conn.execute("SELECT ...").fetchone() assert att is not None svc.close_ticket(att.id) # 内部:pool.get() -> 自锁 30s log = conn.execute("SELECT ...").fetchone() assert log is not None``````py# services/ticket_service.py(节选)def close_ticket(self, ticket_id: int): with self._pool.get() as conn: # 上限 1:外层攥着,这里永远等 conn.execute("UPDATE tickets SET state='closed' ...")同一线程、同一个池、上限 1、无重入计数------这四个条件凑齐,自锁就是必然。之所以在本地比 CI 更严重,是因为 CI 上超时后代码路径恰好把异常吞了继续跑,而本地有人把这个用例放进循环里反复触发,30 秒乘以次数,看起来就像整套挂起。## 修复:让连接生命周期显式化### 方案一:公共函数调用前先释放手里的连接改动量小的方式是修测试:凡是中途要调"内部自己会取连接"的公共服务,先把断言用的连接归还,调用完再取一条新的。pydef test_close_ticket_with_attachment(pool): with pool.get() as conn: att = conn.execute("SELECT ...").fetchone() assert att is not None svc.close_ticket(att.id) with pool.get() as conn: log = conn.execute("SELECT ...").fetchone() assert log is not None这个方案能跑,但它把正确性寄托在"写测试的人记得每次先撒手"上,规则藏在人脑里,迟早有人忘。所以我们把它当作过渡,继续往下。### 方案二:提供 with_connection 回调式 API真正治本的是让"连接从哪来"变成调用方无法忽略的参数。给服务层加一个显式入口:如果调用方已经有连接,就把自己的连接传进来,服务函数在给定连接上干活,绝不碰池。pydef close_ticket(self, ticket_id: int, *, conn=None): if conn is not None: return self._close_on(conn, ticket_id) # 用调用方的连接 with self._pool.get() as c: # 没人给,才向池要 return self._close_on(c, ticket_id)再配一个回调式糖,把生命周期锁进语法结构里:pydef with_connection(self, fn): with self._pool.get() as conn: return fn(conn)# 测试里log = svc.with_connection(lambda c: svc.close_ticket(tid, conn=c) or c.execute("SELECT ...").fetchone())回调返回前连接必然归还,想重入都写不出语法。### 方案三:对 max_size=1 的池禁止可重入假设团队约定随后落到 lint 规则:凡函数签名内部出现 pool.get(),一律视为"不可在持锁上下文调用",公共写函数必须走 conn=None 的注入风格。要么显式把连接传下去,要么把池加大------两者必选其一,不允许"看起来能跑"的重入。py# 反模式,评审直接打回def close_ticket(self, ticket_id: int): conn = self._pool.get().__enter__() # 内部隐式取,调用方无从得知## 防复发:把 30 秒降到可诊断的小值### 超时值不是越长越稳30 秒的静默等待是这次事故难排查的直接原因。我们把测试环境的 acquire_timeout 降到 2 秒:宁可让 CI 红得早,不要让 CI 绿得慢。生产路径保留较长超时,但配置文件里两者分开,测试配置强制小值。### 超时错误信息带上池快照超时时抛出的异常信息里直接嵌入池状态:idle 计数、busy 计数、每个持有者的线程号、以及该线程当前栈顶三帧。下次再有人自锁,报错 10 秒内就能定位,不用加日志重跑。pyclass PoolTimeout(RuntimeError): passdef get(self): if not self._sem.acquire(timeout=self.acquire_timeout): snapshot = ", ".join( f"tid={t.name} at {frame}" for t, frame in self._holders.items()) raise PoolTimeout( f"pool exhausted: idle={len(self._idle)} busy={self._busy} " f"holders=[{snapshot}]")``````textdata.pool.PoolTimeout: pool exhausted: idle=0 busy=1holders=[tid=MainThread at tests/test_ticket_service.py:201]一眼就能看出:占着连接的人,和正在等连接的人,是同一个线程。## 关联坑:并发测试互相污染### 全局状态让并行 worker 打架修完自锁,CI 又冒出另一类问题:为了跑得快,我们把集成测试从单进程改成 pytest-xdist 双 worker,结果一半用例随机失败。原因是集成测试依赖模块级的"当前会话上下文"单例,以及同一个本地库文件------两个 worker 并行时,A 用例刚写入的工单状态被 B 用例的重置逻辑抹掉,断言随执行顺序漂移。这类失败和真实代码毫无关系,纯粹是测试互相踩。### 结论:共享数据的套件必须单线程最终的划分规则:凡碰共享可变状态(同一个库文件、同一个单例)的集成测试,固定跑在单 worker;只有纯函数、纯计算的单元测试才允许并行。这条规则写进了 CI 配置,并在 conftest 里加了断言防呆。ini# pytest.ini(节选)[pytest]addopts = -p no:cacheprovidermarkers = integration: 依赖共享数据库与单例,禁止并行``````py# conftest.py(节选)def pytest_configure(config): if worker_id(config) != "main" and has_marker("integration"): raise RuntimeError("integration tests must run with -p no:xdist")## 复盘清单:三条守则与一个教训这次事故的完整链条:一个合理的架构决定(池上限 1)遇上一次无意识的重入调用,再被一个过长的静默超时放大成"看似数据库、实为自锁"的悬案。沉淀下来的守则:1. 公共函数内部取连接的,测试与调用方必须先释放手里的连接再调用,或改走显式传入连接的签名;2. max_size=1 的池禁止可重入假设,要么显式传连接,要么加大池,评审时按 lint 规则拦截;3. 超时配置宁可短不要长,超时异常必须携带池快照与持有者栈,拒绝静默等待。教训层面,我的体会是:任何"资源上限恰好为 1"的设计都自带重入陷阱,而 30 秒的默认超时把一次本该当场报错的编程错误,伪装成了一起数据库性能疑案。如果当初超时是 2 秒且带快照,这个 bug 从出现到定位不会超过一顿午饭的时间。## 相关实现本文方案来自一套真实运行的桌面客服工具的数据层:本地 SQLite、WAL 模式、单连接池加显式连接注入的测试守则,连同上面这些超时快照与并行隔离规则,都在其中持续生效。形态与实现细节见 dingdang.asia/。

相关推荐
Quz14 小时前
QML TableView:可编辑表格与 SQLite 数据持久化
qt·sqlite
imDwAaY14 小时前
Java中垃圾回收器 G1 和 CMS 有什么区别?
jvm·后端
xiaoqiMikko14 小时前
JVM 线上排查实战(五):升 JDK 17 后进程起不来,十几个老 GC 参数挨个实测(完结)
java·jvm
Flynt14 小时前
Java 27 悄悄改了 3 个默认值,我在 1 核小机器上逐个验证了一遍
java·jvm·性能优化
小林ixn14 小时前
用 SQLite + 大模型做一个 Text2SQL 小助手:从建表到自然语言查询的完整实战
数据库·sqlite
xiaoqiMikko15 小时前
JVM 线上排查实战(三):grep BLOCKED 找不到的死锁,和 jstack 根本不报的死锁
java·jvm
此时不提桶,更待何时6 天前
01-06-A-JVM排查实战详解
java·jvm
wuminyu6 天前
HotSpot的轻量锁与膨胀后的ObjectMonitor状态重建原理
java·linux·c语言·jvm·c++
需要8266 天前
JVM 内存区域与对象创建:一次 GC 从哪来
java·jvm·spring boot·spring·servlet·tomcat