性能调优踩坑记录¶
记录乐问学术搜索 API 在压力测试中暴露的性能问题、排查过程和最终方案。
问题背景¶
数据规模:266 万篇论文,15GB SQLite 数据库,4 个 uvicorn worker 进程。
使用 10 并发、每端点 200 请求的压力测试时,观察到两类严重问题:
- 部分请求超时(30s 504):citations 端点 8/200 失败,p99 延迟达 30s
- 一次压测后服务完全卡死:第二次压测卡在第一个请求上,必须重启才能恢复
坑 1:FTS5 Batcher 单线程串行成为瓶颈¶
症状¶
- 所有涉及 FTS5 搜索的请求(sparse、hybrid、title)在高并发下延迟飙升
- QPS 只有 10 左右,远低于预期
原架构¶
FTS5SearchBatcher 使用一个后台线程从队列中取请求,批量执行 FTS5 查询。设计初衷是减少 SQLite 连接开销。
问题本质¶
FTS5 不像 GPU 推理那样需要 batch 来摊薄开销。单线程串行反而把 并发能力降为 1。10 个并发请求全部排队等待一个线程处理,吞吐量被严重限制。
修复¶
废弃 FTS5SearchBatcher 的后台线程设计,改为连接池 + 直接并发查询:
请求线程 A ──→ [连接池: conn 1] ──→ SQLite FTS5
请求线程 B ──→ [连接池: conn 2] ──→ SQLite FTS5 (并发执行)
请求线程 C ──→ [连接池: conn 3] ──→ SQLite FTS5
教训¶
只有当操作本身需要 batch 才能高效时(如 GPU 推理),才应该用 batcher 模式。对于 SQLite 这类支持多读者并发的系统,串行化反而是反优化。
坑 2:done.wait() 无超时导致线程池永久耗尽¶
症状¶
- 第一次压测完成后,服务对所有新请求无响应
- 必须重启服务才能恢复
- 从日志看 batcher 线程 是活着的(
batcher_alive: true),但不再处理任何请求
根因¶
Batcher 的 search() 方法使用 done.wait() 无超时等待:
# 有问题的代码
def search(self, query, ...):
done = threading.Event()
self._queue.put(request)
done.wait() # ← 永远阻塞,没有 timeout
return result
当 asyncio.wait_for(future, timeout=30) 超时后,asyncio 取消了 Future,但底层线程无法被取消。线程仍然卡在 done.wait() 上,永远不会释放回线程池。
时间线:
t=0s 请求提交到线程池,线程执行 batcher.search() → done.wait()
t=30s asyncio timeout,向客户端返回 504
但线程仍然在 done.wait(),永远不释放
t=∞ 线程池 100 个线程逐渐被耗尽 → 服务卡死
修复¶
给 done.wait() 加上超时:
教训¶
在线程池中执行的阻塞操作必须有超时机制。
asyncio.wait_for的 timeout 只能取消 Future,无法杀死底层线程。线程内部必须自己实现超时保护。
坑 3:SQLite WAL Checkpoint 阻塞读操作 14 秒¶
症状¶
从日志中看到 FTS5 batch 处理时间出现极端值:
根因¶
SQLite WAL 模式下,自动 checkpoint(wal_autocheckpoint 默认 1000 页)会将 WAL 文件合并回主数据库。这个过程需要 exclusive lock,会阻塞所有读操作。
对于 15GB 的数据库,一次 checkpoint 可能持续数十秒。
修复¶
我们的数据库是离线构建、在线只读的,不需要 checkpoint。自动 checkpoint 在这个场景下纯属有害。
教训¶
WAL 模式不等于"读写不冲突"。checkpoint 操作仍然会阻塞读者。对于只读数据库,应当禁用自动 checkpoint。
坑 4:每次请求新建 SQLite 连接开销大¶
症状¶
即使没有 WAL checkpoint,单个 FTS5 查询也需要 100-200ms(对于应该几十 ms 完成的查询来说偏慢)。
根因¶
原代码每次查询都 sqlite3.connect() 新建连接:
def fts5_search(query, top_k):
conn = sqlite3.connect(uri, uri=True) # ← 每次都重新打开 15GB 文件
conn.execute("PRAGMA cache_size = ...")
conn.execute("PRAGMA mmap_size = ...")
try:
# 查询
finally:
conn.close() # ← 用完就关,缓存丢失
SQLite 连接初始化(打开文件、读取 schema、设置 mmap)对大文件有不可忽略的开销。而且每次关闭连接都丢失了内存中的 page cache。
修复¶
创建全局连接池,预开 32 个连接,复用而非每次新建:
class _ConnectionPool:
def connection(self):
conn = self._pool.get() # 复用已有连接
yield conn
self._pool.put(conn) # 归还,不关闭
连接初始化时统一设置所有 PRAGMA(cache_size、mmap_size、busy_timeout、wal_autocheckpoint=0)。
教训¶
对于大型 SQLite 数据库,连接创建和关闭不是免费的。高并发场景下必须使用连接池。
check_same_thread=False允许跨线程复用连接。
坑 5:Qdrant client.search_batch 不存在¶
症状¶
日志中出现大量错误:
根因¶
DenseSearchBatcher 在 batch_size > 1 时调用 client.search_batch(),但安装的 qdrant-client 版本不支持这个方法。虽然 exception handler 捕获了错误并返回空结果,但这意味着所有多请求 batch 的向量搜索都返回空,结果质量受损。
修复¶
直接移除 search_batch 调用,vector_search_batch() 改为循环调用单次 vector_search():
def vector_search_batch(query_vectors, top_k=100):
return [vector_search(vec, top_k=top_k) for vec in query_vectors] if query_vectors else []
教训¶
依赖第三方 API 时要做兼容性检查和 fallback。沉默吞掉异常(catch + return empty)是非常危险的——功能看似正常但结果是错的。
修复前后对比¶
修复前¶
Total: 3000
Success: 2968 (98.9%)
QPS: 10.7
Latency: p50=0.165s p95=0.589s p99=30.003s
一次压测后服务卡死,必须重启
修复后¶
Total: 3000
Success: 3000 (100.0%)
QPS: 50.8
Latency: p50=0.214s p95=0.401s p99=0.501s
多次压测连续运行,服务稳定
| 指标 | 修复前 | 修复后 | 变化 |
|---|---|---|---|
| 成功率 | 98.9% | 100% | 消除超时 |
| QPS | 10.7 | 50.8 | 4.7x |
| p99 延迟 | 30.003s | 0.501s | 60x |
| 连续压测 | 卡死 | 稳定 | 根治 |
关键改动清单¶
| 文件 | 改动 | 解决的坑 |
|---|---|---|
core/db_pool.py(新) |
32 连接只读连接池 | 坑 3、4 |
core/retrieve/sparse.py |
用连接池替代新建连接 | 坑 4 |
core/retrieve/fts5_search_batcher.py |
直接并发查询,去掉后台线程 | 坑 1 |
core/citation/lookup.py |
用连接池替代新建连接 | 坑 4 |
core/citation/database.py |
读操作用连接池 | 坑 4 |
core/paper_id_resolver.py |
用连接池替代新建连接 | 坑 4 |
core/retrieve/dense.py |
vector_search_batch fallback |
坑 5 |
core/retrieve/dense_search_batcher.py |
done.wait(timeout=25) |
坑 2 |
通用总结¶
- Batcher 不是万能的:只有 GPU 推理等 batch 能提升吞吐的场景才需要。数据库查询应该直接并发。
- 线程池 + 无超时阻塞 = 定时炸弹:asyncio 的 timeout 不能杀线程,线程内部必须自保。
- SQLite WAL ≠ 无锁读:checkpoint 会阻塞,只读场景要关掉
wal_autocheckpoint。 - 连接池是高并发的基础设施:对大型数据库,每次新建连接的开销足以拖垮 QPS。
- 异常处理不能沉默吞掉:catch + return empty 让 bug 变成了"功能降级",更难发现。