狛荷屋主站后端基于 FastAPI + SQLAlchemy 构建,之前的项目使用 SQLite 作为数据库、单 Worker 运行。主站评论功能引入递归 CTE 查询,考虑到后期的可扩展性, 构建时选用了PostgreSQL。本文记录了从 SQLite 迁移到 PostgreSQL,并发优化的完整过程。
起点:SQLite + 单 Worker
最初的架构非常简单:
- 数据库:SQLite(文件型数据库)
- 运行方式:
uvicorn main:app,单 Worker 单进程 - 压测结果:
wrk -t2 -c32 -d10sRequests/sec: 48SQLite 在并发写入时有全局锁,且不支持异步连接池,成为最大瓶颈。
第一步:迁移到 PostgreSQL
将数据库从 SQLite 替换为 PostgreSQL,使用 asyncpg 驱动 + SQLAlchemy 异步引擎:
engine = create_async_engine( "postgresql+psycopg://user:pass@localhost:5432/blog", pool_size=10, max_overflow=20, pool_timeout=30, pool_pre_ping=True,)迁移后,单 Worker 压测:
wrk -t2 -c32 -d10sRequests/sec: ~255提升约 5.3 倍。PostgreSQL 原生支持并发连接和异步查询,递归 CTE 的执行效率也远优于 SQLite。
第二步:部署 Gunicorn 多 Worker
生产环境使用 Gunicorn 启动 4 个 Uvicorn Worker:
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker4 个 Worker = 4 个独立 Python 进程,每个进程有自己的事件循环,可以充分利用多核 CPU。
第三步:发现高并发瓶颈
部署后用 wrk 进行压力测试,发现问题:
wrk -t4 -c2048 -d10s Latency 1.49s 302.09ms 2.00s Socket errors: timeout 481Requests/sec: 285.63低并发时响应约 10ms,高并发时飙升到 200ms+,还出现大量超时。于是开始排查。
瓶颈分析
瓶颈一:日志线程池串行化(最致命)
原始的日志函数:
log_executor = ThreadPoolExecutor(max_workers=1) # 只有 1 个线程!
async def async_log(logger_obj, level, message): def _log(): getattr(logger_obj, level)(message) loop = asyncio.get_event_loop() await loop.run_in_executor(log_executor, _log) # await = 等日志写完每个请求在中间件中调用 2 次 await async_log。await 意味着协程会挂起并排队等待日志写完。线程池只有 1 个线程时,2048 个请求的日志调用排成长队,所有协程都卡在 await 上。
这是延迟飙升的最主要原因。
瓶颈二:数据库连接池不足
pool_size=10, max_overflow=20 # 最多 30 个并发 DB 连接当 2048 个请求同时到达,大量请求在等待连接池释放,pool_timeout=30 导致超时前长时间挂起。
瓶颈三:请求日志中间件开销
中间件对每个请求都执行:
- 读取并解析请求 body
- 序列化完整 headers + body 为 JSON
- 2 次阻塞式日志写入
高并发下叠加效果显著。
优化措施
优化一:日志改为 Fire-and-Forget
核心思路:日志提交到线程池后不等待完成,请求立即继续处理。
log_executor = ThreadPoolExecutor(max_workers=4) # 1 → 4
def async_log(logger_obj, level, message): def _log(): getattr(logger_obj, level)(message) try: loop = asyncio.get_running_loop() loop.run_in_executor(log_executor, _log) # 不 await! except RuntimeError: _log()关键区别:
- 之前:
await run_in_executor→ 协程挂起等日志写完 → 高并发下排队 - 之后:
run_in_executor不await→ 日志任务扔进线程池,请求立刻返回
优化二:增大数据库连接池
engine = create_async_engine( DATABASE_URL, pool_size=20, # 10 → 20 max_overflow=40, # 20 → 40 pool_timeout=10, # 30 → 10,避免长时间挂起 pool_pre_ping=True,)4 个 Worker 进程各自有连接池,总计可用 个数据库连接。
优化三:中间件日志不阻塞
所有 await async_log(...) 改为 async_log(...),中间件中的日志写入不再阻塞请求处理。
优化结果
本地测试(单 Worker)
| 并发数 | 优化前延迟 | 优化后延迟 | 吞吐量 |
|---|---|---|---|
| 16 | - | 62ms | 255 req/s |
| 32 | - | 125ms | 255 req/s |
| 100 | - | 406ms | 250 req/s |
吞吐量稳定在 ~255 req/s,延迟随并发线性增长符合 Little’s Law:
生产环境(4 Workers)
| 并发数 | 延迟 | 吞吐量 | 超时 |
|---|---|---|---|
| 16 | 96ms | 159 req/s | 0 |
| 32 | 103ms | 296 req/s | 0 |
| 100 | 296ms | 323 req/s | 2 |
| 256 | 804ms | 278 req/s | 84 |
其中生产环境的延迟包含约 34ms 的网络 RTT(客户端 → CDN → 服务器),实际服务器处理时间约 。
性能提升总结
| 阶段 | 吞吐量 | 延迟(32并发) |
|---|---|---|
| SQLite + 单 Worker | 48 req/s | - |
| PostgreSQL + 单 Worker | 255 req/s | 125ms |
| PostgreSQL + 4 Workers + 优化 | 323 req/s | 103ms(含网络) |
SQLite → PostgreSQL + 全部优化后,吞吐量提升约 6.7 倍。
为什么吞吐没有 4x?
4 个 Worker 理论上应该有 4 倍吞吐,但实际只提升了约 27%。原因是:
瓶颈已经从 Python 转移到了 PostgreSQL。 4 个 Worker 共享同一个数据库实例,评论查询使用递归 CTE,数据库 CPU 成为新的天花板。进一步优化需要在 DB 层下功夫(缓存、物化视图等)。
关于合理的压测方式
在排查过程中还踩了一个坑:一开始用 2048 并发压测单 Worker,看到大量超时就以为有问题——其实这完全在预期范围内。
合理的压测并发量应匹配 Worker 数量:
| Worker 数 | 合理并发 | 压力上限 |
|---|---|---|
| 1 | 16-32 | 100 |
| 4 | 32-100 | 500 |
超过处理能力的并发只会产生排队,不会暴露真实性能问题。
总结
这次优化最大的收获是:性能瓶颈往往不在你以为的地方。 直觉上会以为”数据库慢”、“Python 慢”,但实际上最致命的瓶颈是一个 max_workers=1 的日志线程池配合 await——一行 await 就让所有高并发请求排成了单行队列。
对于博客这种读多写少的场景,当前性能已经完全足够。如果后续需要进一步提升,方向是在 DB 层做缓存(Redis 或内存 TTL 缓存),可以将评论查询延迟从 ~60ms 降到 <5ms。
以下是可爱的评论们:

输入用户名和邮箱后自动检查登录状态。登录后用户名和邮箱将被绑定, 只可以修改头像和主页链接。