FastAPI 注册/登录接口 QPS 压测实录:SQLite 到底是不是瓶颈?

服务:FastAPI + SQLite(WAL 模式)+ aiosqlite 连接池,Docker 容器内 4 个 uvicorn worker 接口:/health/register/login 压测:自研 aiohttp 异步脚本(scripts/qps_test.py),无需 wrk/ab 安全:PBKDF2-SHA256 密码哈希、HMAC 无状态令牌

1. 压测结论

简单总结如下:
密码哈希计算是登录接口的最大开销,SQLite 的写路径受单写者限制,而真正的排队点其实在连接池。

整体架构长这样:

flowchart LR
    C[压测客户端 aiohttp] -->|HTTP :8000| U[uvicorn x4 workers]
    U --> F[FastAPI 路由]
    F --> A[/register 写/]
    F --> L[/login 读/]
    A --> P[连接池 asyncio.Queue x10]
    L --> P
    P --> S[(SQLite WAL)]

2. 基准数据:QPS 实测结果

场景 配置 并发 QPS avg 延迟 p99 成功率
login PBKDF2=100k 100 233.6 434.9ms 940.8ms 100%
login PBKDF2=10k 100 1708.5 58.7ms 146.8ms 100%
login PBKDF2=10k 200 1821.5 110.1ms 163.9ms 100%
register PBKDF2=10k 300 799.0 385.2ms 1263.1ms 100%
register PBKDF2=10k 3000 1018.4 3503.6ms 7813.1ms 100%

所有场景成功率都是 100%,说明在基线并发下这套架构是稳的。差异体现在吞吐和延迟上。

3. 关键发现

3.1 安全性和并发性的权衡

PBKDF2 迭代次数从 100k 降到 10k,login 的 QPS 直接提升了 7.3 倍(233 → 1709),平均延迟从 435ms 降到 59ms。

这很好理解:PBKDF2 是故意设计成「慢」的哈希算法,迭代次数越高,单次哈希耗时越长,CPU 每秒能处理的请求就越少。100k 迭代对安全更友好,但对吞吐是实打实的拖累。安全强度与性能之间需要权衡——生产环境要根据服务器算力和安全要求选一个合适的迭代次数,而不是一味拉满一个方面。

3.2 SQLite 写路径受单写者限制

register(写)的 QPS 明显低于 login(读)。SQLite 同一时刻只允许一个写者,写请求需要排队抢写锁,这是它的天然天花板。

3.3 写锁等待被 busy_timeout 吸收

busy_timeout=10000 让写请求在抢不到锁时最多等待 10 秒。整个压测期间 0 条锁错误,说明请求都在十秒排队内得到了锁。

4. 流量控制现状:无限流

服务目前没有设置限流措施,属于“尽力而为”模式——请求全部接收,超出 QPS 的部分排队而非拒绝。

为了看它在突发流量下的表现,做了两组瞬时请求远超 QPS 的测试:

场景 结果
login 2000 并发 QPS 持平 1585,延迟涨 22 倍(avg 59ms→1326ms),0 失败
register 5000 并发 QPS 封顶 943,avg 6.4s / max 20s,仍 0 失败

从中可以读出三条规律:

  • 吞吐封顶不崩溃:QPS 稳定在基线附近,属于优雅降级,服务不会被打挂
  • 延迟随队列深度线性恶化:5000 并发时平均延迟是正常水平的 100 倍
  • 持续加压后的失败模式busy_timeout(10s)超时 → database is locked 500;uvicorn TCP backlog(2048)溢出 → 连接被拒/重置;客户端超时

也就是说,无限流下系统不会瞬间崩掉,但延迟会一步步走向不可用。这也是后面建议里把「加限流」放在第一位的原因。

5. 排队点分析:不在 SQLite,而在连接池

为了确认瓶颈到底在哪,做了一组分离实验(3000 并发写压测中采样):

接口 是否碰 DB 过载时延迟
/health 0.7~2ms(几乎无感)
/register avg 3.6s / p99 7.8s

两个接口共享同一套 TCP 连接和事件循环,结果却天差地别。这说明瓶颈不在网络层,而在数据库连接层

一次请求从进入到返回,要经过这样几个关卡:

flowchart TD
    R[客户端请求] --> A[① TCP backlog<br/>uvicorn 默认2048<br/>溢出时拒连]
    A --> B[② 事件循环<br/>被同步代码阻塞时排队]
    B --> C[③ 连接池队列 ★ asyncio.Queue x10<br/>连接用完则 await 等待]
    C --> D[④ SQLite 写锁<br/>busy_timeout 10s 重试<br/>超时报 database is locked]
    D --> E[返回响应]

结论:SQLite 本身没有队列,只有“锁 + 等待”;真正的显式排队发生在 aiosqlite 连接池的 asyncio.Queue(容量 10),其次才是多连接抢写锁。所以后续的优化抓手应该是替换 SQLite(如换 PostgreSQL)或加限流,而不是在 SQLite 本身做文章。

6. 设计考虑

  • 加流量控制:内存令牌桶(slowapi,单机场景)或 Redis 滑动窗口(多 worker 全局限流)
  • 换并发数据库:PostgreSQL / MySQL 替代 SQLite,消除单写者瓶颈
  • 生产化:JWT 令牌、Redis 缓存、请求限流、独立数据库

7. 小结

这次压测最有价值的收获是:不要凭直觉猜瓶颈。一开始以为 SQLite 是主要瓶颈,实测却发现密码哈希和连接池排队才是更值得动手的地方。压测的意义正在于此——用数据说话,让优化有的放矢。

一句话总结:login 的吞吐被 PBKDF2 拖累,register 的吞吐被 SQLite 单写者限制,而真正的排队点藏在连接池里。 想要更高 QPS,优先考虑降哈希成本、换并发数据库、加限流这三件事。