突发奇想给自己的后端压测一下, 没想到30并发就会卡死超时, 重启也未恢复, 本文介绍了完整的排查流程和优化方案, 实现了在3000并发下有超过1/3的请求得到服务, 超出能力的请求全部拒绝, 仅有3个超时.
问题
今天下午压测后端了
咱的后端连30并发都扛不住
那很坏了
测试了512并发,两分钟就能让我们后端瘫痪半小时
使用了systemctl --user restart fastapi.service, systemctl --user stop fastapi.service和systemctl --user start fastapi.service都不能解决问题, 虽然成功重启, 但是服务访问仍然超时
排查
查看数据库连接池
SQLite在创建连接时会先进入连接池, 如果连接池已满, 则后续的连接请求会被阻塞, 直到有连接被释放或默认15秒超时后报错退出
但我们未显式设置连接池数量, 默认为128, 对于我们30并发就阻塞卡死的情况, 显然不合理, 并且我们SQLite是WAL模式, 写不阻塞读, 所以排除数据库的问题
TCP连接池满?
我们使用ss -tan state close-wait -H | wc -l命令查询了系统未关闭的TCP连接数量,
mint@MintServer-WH:~$ ss -tan state close-wait -H | wc -l468结果非常惊人, 有接近500个连接未被关闭, 这非常符合我们刚刚进行的512并发测试, 并且重启服务这些连接也未被关闭.
反复刷新可以发现这些连接在缓慢减少, 说明后端正在缓慢处理这些堆积的请求, 可是这些请求对应的客户端早都超时退出了, 服务器正在做无用功.
大约半小时过去了, 我们还在寻找解决方案时, 后端服务突然恢复了, 重新查看发现未关闭的连接数果然下降到了1, 这也证实了我们的想法
修复思路
清除大量未关闭的连接
从网上查到了这行命令lsof -i:<端口号>, 可以查询正在使用端口的进程和状态, 运行显示
mint@MintServer-WH:~$ lsof -i:8080COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAMEnginx 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 应用服务器 的本地回环通信架构:
-
Gunicorn 在
127.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, 强制关闭后重新检查未关闭连接数量
mint@MintServer-WH:~$ ss -tan state close-wait -H | wc -l1恢复正常, 使用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 -= 1request_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访问频次限制中, 限制了同一ip在10秒内请求超过60次即触发拦截, 返回403

来感受恶意()
最终效果
1024并发-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; } }}以下是可爱的评论们:

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