1334 字
7 分钟
博客后端并发优化实录:从 SQLite 48 req/s 到 PostgreSQL 323 req/s

狛荷屋主站后端基于 FastAPI + SQLAlchemy 构建,之前的项目使用 SQLite 作为数据库、单 Worker 运行。主站评论功能引入递归 CTE 查询,考虑到后期的可扩展性, 构建时选用了PostgreSQL。本文记录了从 SQLite 迁移到 PostgreSQL,并发优化的完整过程。

起点:SQLite + 单 Worker#

最初的架构非常简单:

  • 数据库:SQLite(文件型数据库)
  • 运行方式uvicorn main:app,单 Worker 单进程
  • 压测结果
wrk -t2 -c32 -d10s
Requests/sec: 48

SQLite 在并发写入时有全局锁,且不支持异步连接池,成为最大瓶颈。

第一步:迁移到 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 -d10s
Requests/sec: ~255

提升约 5.3 倍。PostgreSQL 原生支持并发连接和异步查询,递归 CTE 的执行效率也远优于 SQLite。

第二步:部署 Gunicorn 多 Worker#

生产环境使用 Gunicorn 启动 4 个 Uvicorn Worker:

Terminal window
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker

4 个 Worker = 4 个独立 Python 进程,每个进程有自己的事件循环,可以充分利用多核 CPU。

第三步:发现高并发瓶颈#

部署后用 wrk 进行压力测试,发现问题:

wrk -t4 -c2048 -d10s
Latency 1.49s 302.09ms 2.00s
Socket errors: timeout 481
Requests/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_logawait 意味着协程会挂起并排队等待日志写完。线程池只有 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_executorawait → 日志任务扔进线程池,请求立刻返回

优化二:增大数据库连接池#

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 进程各自有连接池,总计可用 4×(20+40)=2404 \times (20 + 40) = 240 个数据库连接。

优化三:中间件日志不阻塞#

所有 await async_log(...) 改为 async_log(...),中间件中的日志写入不再阻塞请求处理。

优化结果#

本地测试(单 Worker)#

并发数优化前延迟优化后延迟吞吐量
16-62ms255 req/s
32-125ms255 req/s
100-406ms250 req/s

吞吐量稳定在 ~255 req/s,延迟随并发线性增长符合 Little’s Law:

Latency=ConcurrencyThroughputLatency = \frac{Concurrency}{Throughput}

生产环境(4 Workers)#

并发数延迟吞吐量超时
1696ms159 req/s0
32103ms296 req/s0
100296ms323 req/s2
256804ms278 req/s84

其中生产环境的延迟包含约 34ms 的网络 RTT(客户端 → CDN → 服务器),实际服务器处理时间约 963462ms96 - 34 \approx 62ms

性能提升总结#

阶段吞吐量延迟(32并发)
SQLite + 单 Worker48 req/s-
PostgreSQL + 单 Worker255 req/s125ms
PostgreSQL + 4 Workers + 优化323 req/s103ms(含网络)

SQLite → PostgreSQL + 全部优化后,吞吐量提升约 6.7 倍。

为什么吞吐没有 4x?#

4 个 Worker 理论上应该有 4 倍吞吐,但实际只提升了约 27%。原因是:

瓶颈已经从 Python 转移到了 PostgreSQL。 4 个 Worker 共享同一个数据库实例,评论查询使用递归 CTE,数据库 CPU 成为新的天花板。进一步优化需要在 DB 层下功夫(缓存、物化视图等)。

关于合理的压测方式#

在排查过程中还踩了一个坑:一开始用 2048 并发压测单 Worker,看到大量超时就以为有问题——其实这完全在预期范围内。

合理的压测并发量应匹配 Worker 数量:

Worker 数合理并发压力上限
116-32100
432-100500

超过处理能力的并发只会产生排队,不会暴露真实性能问题。

总结#

这次优化最大的收获是:性能瓶颈往往不在你以为的地方。 直觉上会以为”数据库慢”、“Python 慢”,但实际上最致命的瓶颈是一个 max_workers=1 的日志线程池配合 await——一行 await 就让所有高并发请求排成了单行队列。

对于博客这种读多写少的场景,当前性能已经完全足够。如果后续需要进一步提升,方向是在 DB 层做缓存(Redis 或内存 TTL 缓存),可以将评论查询延迟从 ~60ms 降到 <5ms。

博客后端并发优化实录:从 SQLite 48 req/s 到 PostgreSQL 323 req/s
https://www.mintlab.top/posts/tries/backend-optimization/
作者
Mint
发布于
2026-04-19
许可协议
CC BY-NC-SA 4.0
发表评论

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

未登录
昵称
邮箱
填写头像链接与主页链接

头像链接为空默认使用gravatar头像

头像
主页
人机验证
评论列表

以下是可爱的评论们:

暂无评论, 呜呜, 快来评论喵!