1473 字
7 分钟
FastAPI单worker后端并发优化: 从持续瘫痪到高并发保持服务

突发奇想给自己的后端压测一下, 没想到30并发就会卡死超时, 重启也未恢复, 本文介绍了完整的排查流程和优化方案, 实现了在3000并发下有超过1/3的请求得到服务, 超出能力的请求全部拒绝, 仅有3个超时.

问题#

今天下午压测后端了
咱的后端连30并发都扛不住
那很坏了
测试了512并发,两分钟就能让我们后端瘫痪半小时

使用了systemctl --user restart fastapi.service, systemctl --user stop fastapi.servicesystemctl --user start fastapi.service都不能解决问题, 虽然成功重启, 但是服务访问仍然超时

排查#

查看数据库连接池#

SQLite在创建连接时会先进入连接池, 如果连接池已满, 则后续的连接请求会被阻塞, 直到有连接被释放或默认15秒超时后报错退出

但我们未显式设置连接池数量, 默认为128, 对于我们30并发就阻塞卡死的情况, 显然不合理, 并且我们SQLite是WAL模式, 写不阻塞读, 所以排除数据库的问题

TCP连接池满?#

我们使用ss -tan state close-wait -H | wc -l命令查询了系统未关闭的TCP连接数量,

Terminal window
mint@MintServer-WH:~$ ss -tan state close-wait -H | wc -l
468

结果非常惊人, 有接近500个连接未被关闭, 这非常符合我们刚刚进行的512并发测试, 并且重启服务这些连接也未被关闭.

反复刷新可以发现这些连接在缓慢减少, 说明后端正在缓慢处理这些堆积的请求, 可是这些请求对应的客户端早都超时退出了, 服务器正在做无用功.

大约半小时过去了, 我们还在寻找解决方案时, 后端服务突然恢复了, 重新查看发现未关闭的连接数果然下降到了1, 这也证实了我们的想法

修复思路#

清除大量未关闭的连接#

从网上查到了这行命令lsof -i:<端口号>, 可以查询正在使用端口的进程和状态, 运行显示

Terminal window
mint@MintServer-WH:~$ lsof -i:8080
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
nginx 3967538 mint 13u IPv4 50128379 0t0 TCP localhost:47238->localhost:http-alt (ESTABLISHED)
gunicorn 4062077 mint 3u IPv4 48464702 0t0 TCP localhost:http-alt (LISTEN)
gunicorn 4062083 mint 3u IPv4 48464702 0t0 TCP localhost:http-alt (LISTEN)
gunicorn 4062083 mint 28u IPv4 50129369 0t0 TCP localhost:http-alt->localhost:47238 (ESTABLISHED)

ttp-alt 是 /etc/services 中定义的 8080 端口 的服务别名,实际就是端口 8080。

我们是一个典型的 Nginx 反向代理 → Gunicorn 应用服务器 的本地回环通信架构:

  • Gunicorn127.0.0.1:8080 上监听 HTTP 请求(两个 LISTEN 是因为 master + worker 共享同一个监听套接字)。

  • Nginx 作为客户端主动向 localhost:8080 发起连接(从自己的临时端口 47238 连过去),用来转发外部 Web 请求。

  • 输出中的 ESTABLISHED 连接正是 Nginx 与 Gunicorn 之间当前活跃的一条请求通道。

之前使用systemctl --user restart fastapi.service属于优雅关闭, 保留了进程的TCP连接池, 以便重启后能够继续处理未完成的请求, 这就是我们重启也无效的原因 — 大量的请求还未被关闭, 服务上线后依然在按顺序处理这些过时的请求, 新的请求只能在后面排队, 等排到新请求也超时了.

使用kill -9 <进程PID>强制关闭进程, 这里注意要关闭Gunicorn的主进程, 根据上图也就是4062077, 强制关闭后重新检查未关闭连接数量

Terminal window
mint@MintServer-WH:~$ ss -tan state close-wait -H | wc -l
1

恢复正常, 使用systemctl --user start fastapi.service启动服务, 后端服务正常运行!

优化单线程逻辑#

之前的操作只是从瘫痪中临时恢复, 如果再来一次高并发, 依然会陷入相同的境地, 我可不想每次都上来杀进程:(

解决方案是在FastAPI http中间件里添加信号量, 试了试asyncio但是我好像不太会用(, 所以就自己写了一个

该设计限制了单worker的最大处理数, 在多worker时依然有效, 因为不同worker内存独立, gunicorn会将负载均衡分配到每一个worker上

# 请求限制信号量实现
class RequestLimiter:
def __init__(self, max_concurrent: int):
self._max = max_concurrent
self._current = 0
self._lock = asyncio.Lock()
async def acquire(self) -> bool:
async with self._lock:
if self._current >= self._max:
return False
self._current += 1
return True
async def release(self) -> None:
async with self._lock:
if self._current > 0:
self._current -= 1
request_limiter = RequestLimiter(5)
@app.middleware("http")
async def log_requests(request: Request, call_next):
if not await request_limiter.acquire():
return JSONResponse({"detail": "server busy"}, status_code=503)
try:
# 处理请求
response = await call_next(request) # T1. 先处理业务, 获取响应
return response # T2. 由于在try块中, python会先记住要返回的值
finally:
await request_limiter.release() # T3. finally执行, 释放信号量
# T4. 函数真正返回

ESA访问频次限制#

在阿里云ESA访问频次限制中, 限制了同一ip10秒内请求超过60次即触发拦截, 返回403

403_zako

来感受恶意()

最终效果#

1024并发-ESA防护 1024并发-ESA防护

3000并发-无ESA防护 3000并发-无ESA防护 1273+1724=2997

3000并发情况下,有超过1/3的请求完成服务,1724个拒绝,仅有3个超时, 完美解决!

谁说单线程不能打,单线程能服务1000多个请求,改成32线程,并发上3万(x)

多worker优化#

保持上述设计的同时可以在nginx限制全局并发

# Nginx 配置示例
http {
# 定义一个共享内存区域,用于存储连接数状态
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;
server {
location / {
# 每个IP的并发连接数限制
limit_conn perip 10;
# 所有IP对当前虚拟主机的总并发连接数限制
limit_conn perserver 50;
# 当请求超过限制时,返回503状态码
limit_conn_status 503;
proxy_pass http://backend;
}
}
}
FastAPI单worker后端并发优化: 从持续瘫痪到高并发保持服务
https://www.mintlab.top/posts/lark-solution/single-process-improve/
作者
Mint
发布于
2026-04-10
许可协议
CC BY-NC-SA 4.0
发表评论

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

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

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

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

以下是可爱的评论们:

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