Shuoxun Wen GitHub

文章 SecureView

100 个用户,3% 的 CPU,大部分上传失败

100 人同时上传时最多 78% 失败,CPU 却只有 3%。怎么找到原因,第一次修复为什么更糟,最后怎么把失败率降到 0。

我们的毕业设计 SecureView 有一个压测工具,在 loadtest/ 里。它同时登录很多用户,上传、下载、分享文件,并用 SHA-256 把每个下载回来的文件和原文件对比。50 个并发用户以内,服务器没有报错。到 100 个,65% 到 78% 的上传返回 HTTP 500。

服务器 CPU 平均 3%,内存也没变化。所以服务器并不忙,它是卡在等什么东西。

报错

每次失败,日志里都是同一句:

QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30.00

API 只跑了一个 uvicorn 进程,用的是 SQLAlchemy 默认连接池:5 个连接加 10 个溢出,同时最多 15 个数据库事务。请求等连接超过 30 秒就返回 500。但如果每个请求只占连接几毫秒,15 个连接给 100 个用户用绰绰有余。所以一定有东西占着连接很久。

第一个猜测是错的

测试报告猜的是:文件传输期间一直占着数据库连接。听起来很合理,但我先验证再改。我一边盯着 MySQL,一边故意慢速下载了三次 30 MB 的文件。传输过程中没有任何未提交的事务。下载在开始传输前就提交了,连接已经还回去了。

所以下载没问题。我在笔记本上复现了 100 人的失败(80% 上传失败,吞吐量掉到每秒 0.33 次操作),这样就不用占着共享的服务器排查了。日志显示,每次超时都发生在身份验证里。

原因:两个池子互相等

上传接口是 async 的。一次上传是这样的:

  1. 身份验证取出一个数据库连接,用完没有还。
  2. 上传在事件循环上执行数据库调用。
  3. 把上传的文件写到磁盘时,每写一块都要等一次线程池,而连接一直拿在手里。

线程池有 40 个线程。100 个用户时,15 个上传占着全部 15 个连接,在等空闲线程;40 个线程全被别的请求占着,在等连接。两边互相等,谁也动不了,直到连接池 30 秒超时,请求失败。

这就解释了 3% 的 CPU。没有任何东西在计算,所有东西都在等。

第一次修复反而更糟

最直接的改法是身份验证之后、写文件之前先把连接还掉。我只改了这一处,结果 50 个用户也变慢了:下载 p95 到了 30 秒。

上传剩下的数据库调用还在事件循环上跑。现在它们是在事件循环上等连接,这会卡住整个进程里的所有请求,不只是上传。这个改法没有合并。

真正有效的修复

把上传拆成三段,保证事件循环上没有数据库调用,等线程的时候也不拿着连接:

async def upload(self, *, actor, metadata, ciphertext, context):
    # 1. 在线程池里检查:密钥、额度。检查完连接就释放。
    owner_key, recovery_key = await run_in_threadpool(self._check_upload, actor, metadata)

    # 2. 把加密文件写到暂存区。不占连接。
    staged = await self.storage.stage_upload(ciphertext, max_size_bytes=...)

    # 3. 回到线程池:写记录、审计事件、发布、提交。
    return await run_in_threadpool(
        self._record_upload, actor, metadata, context, staged, owner_key, recovery_key
    )

身份验证现在也会结束自己的只读事务,没有请求会把连接从身份验证一路带进接口。(上面省略了错误处理。第 2 步失败时仍然会回滚并删掉暂存文件。)

结果

同一台笔记本、同一个工具、同一批账号,每档 30 秒,文件 1 MiB:

并发用户 修复前失败率 修复前 ops/s 修复后失败率 修复后 ops/s
20 0% 53.6 0% 58.4
50 0% 54.5 0% 56.1
100 80% 0.33 0% 56.1

修复后每一档吞吐量都稳定在每秒 56 次左右,延迟随排队增加,这才是机器本身的上限。笔记本上的数字是本地复现。真实服务器上,前一晚 100 个用户时 103 次上传只成功 23 次(78% 失败);当天晚上重跑,102 次上传全部成功,每个下载文件的哈希都和原文件一致。

守住修复的测试

  • 一个单元测试检查暂存开始前连接已经释放。
  • 故障注入测试在写入文件记录的那次提交时杀掉数据库连接,并检查清理任务会删掉孤立的文件。
  • 改完后整套后端测试在 MySQL 上通过:633 个测试,覆盖率 85%。

小结

  • CPU 很低但请求失败,说明有东西在等。去找连接池、锁和超时,不要去找慢代码。
  • 先验证猜测再动手。这里第一个猜测听起来对,其实是错的,一次测量就看出来了。
  • 在 async Python 里,事件循环上的阻塞数据库调用会卡住所有请求。要么改成异步调用,要么放进线程池,而且不要拿着连接跨过 await。
  • 修复前后的数字用同一个工具测。这样修复是测出来的,而不是说出来的。

完整记录和原始数据在 docs/performance/pool-fix-2026-10-04/,代码在 file_service.py。