Nginx 反代之后,我的限流器把所有访客当成了同一个人
给博客 AI 助手上线那天,我一边验收一边疯狂提问——功能正常,心里美滋滋。然后怪事来了:有朋友反馈聊天窗提示「请求过于频繁,请稍后再试」,可他当天第一次打开这个页面。
我第一反应是前端状态机写错了。查了一圈才发现,锅在限流器上:它根本没分清谁是谁。
限流器是怎么实现的
后端给 /api/chat 加了一个内存滑动窗口限流:单 IP 每分钟 10 次、每天 200 次。核心逻辑很朴素:
# blog-agent/app/utils/rate_limit.py
def check(self, client_ip: str) -> tuple[bool, str, int]:
now = time.monotonic()
w = self._store[client_ip] # 按 IP 分桶
w.timestamps = [t for t in w.timestamps if now - t < MINUTE_WINDOW]
if len(w.timestamps) >= self._minute_limit:
retry = int(MINUTE_WINDOW - (now - w.timestamps[0])) + 1
return False, "请求过于频繁,请稍后再试", retry
w.timestamps.append(now)
w.day_count += 1
return True, "", 0
设计本身没毛病。问题出在调用处——client_ip 从哪来。
根因:反代之后,客户端 IP 的语义变了
原来的写法是直接用 FastAPI 的 request.client.host。本地开发时这确实是访客 IP,但线上架构是:
访客 ──> Nginx (80) ──反代──> FastAPI (127.0.0.1:8000)
FastAPI 看到的 TCP 连接全部来自本机的 Nginx,request.client.host 恒为 127.0.0.1。于是 _store 里永远只有一个桶,全站所有访客共享「10 次/分钟」。
那天我验收测试发了几十条提问,桶早就刷爆了。朋友随后打开页面,用的是同一个桶,直接 429。我测得越勤,真实用户越容易被误伤——这个 bug 的触发条件简直是反着长的。
修复:优先读 X-Real-IP
Nginx 反代配置里本来就有 proxy_set_header X-Real-IP $remote_addr;,真实 IP 一直在请求头里躺着,只是没人读。修复就一行:
# blog-agent/app/routes/chat.py
client_ip = request.headers.get("x-real-ip") or (
request.client.host if request.client else "unknown"
)
取不到头时回退到 client.host,本地直连开发依然正常。重启服务,朋友的账号立刻恢复。
X-Real-IP 和 X-Forwarded-For 怎么选
顺手把这两个头的区别理清了:
- X-Real-IP:非标准但约定俗成,Nginx 默认教程里最常见,值是「直连 Nginx 的那个客户端」的 IP,只有一个,简单可靠。
- X-Forwarded-For:事实标准,每经过一层代理就往后追加一个 IP,形如
访客, 第一层代理, 第二层代理。取真实访客要取第一个,但访客自己可以伪造一个假的 XFF 头发过来——如果直接信任第一个值,限流等于裸奔。
只有一层 Nginx 时,用 Nginx 自己覆写的 X-Real-IP 最省心:它由服务端写入,访客伪造不了。多层代理才需要认真处理 XFF 的信任链,原则是只信任你控制的那一层代理写入的内容。
内存限流器的两个天然短板
这次也顺带看清了这个方案的边界:
- 重启清零。计数在进程内存里,
systemctl restart之后所有桶重置。对「防滥用」来说可以接受,对「精确计费」就是事故。 - 多实例失效。将来如果 uvicorn 起多个 worker,每个进程一份
_store,10 次/分钟变成 10×N 次/分钟。跨进程限流必须上 Redis 一类的共享存储,或者用 Nginx 自带的limit_req_zone把限流前移。
目前单实例、纯防御性限流,内存版够用,但这两句话我写进了代码注释和部署文档,免得以后自己忘掉。
复盘
这个 bug 和之前那篇 SSE 换行符 是同一晚查出来的——当时它是「错误怀疑名单」上的一员,结果修出了一个真 bug。两个 bug 的症状在界面上长得一模一样(都是降级文案),所以我把它们修完之后才真正看清对方。
教训有两条。第一,反代会改写请求的语义,IP、协议、Host 都可能变,任何依赖「客户端直连」假设的代码过了反代都要重审一遍。第二,「我自己测不出来」不代表没问题——这个 bug 恰恰是我自己测出来的,只不过受害者的身份搞反了:我以为被限的是我,其实被限的是别人。