大前年双十一大促前压测,我们订单API的QPS刚到1200,Java后端服务就开始出现大量超时。我当时盯着监控,发现数据库的连接池直接被打满了,后来查日志才发现,80%的请求都是重复的查询,是前端的一个缓存失效策略出了问题,导致短时间内大量重试。那时候我才意识到,单纯在代码里做限流根本来不及,请求已经把线程池撑爆了。
我们线上跑的是 Nginx 1.26.3(也就是现在最新的稳定版,2024年10月刚更新的那个),我决定直接在网关层把水阀拧紧。Nginx的 limit_req 模块用的是漏桶算法(Leaky Bucket),这个算法和令牌桶最大的区别在于:令牌桶允许一定程度的突发(只要桶里有令牌),而漏桶是强行把请求处理速率平滑成恒定的,超出的请求要么排队,要么直接丢掉。
我当时面临一个选择:要不要加 burst?如果不加,用户稍微手快点多刷新几次就被503了,体验太差;如果加太大,后端还是扛不住。
我当时的配置是这样的,针对订单创建接口:
这里我得解释一下为什么我用了 nodelay。burst=5 的意思是,如果瞬间来了15个请求(超过了每秒10个的限制),我会把多出的5个请求放进队列里,慢慢处理。但如果我不加 nodelay,这5个请求会被延迟处理,也就是排队等下一秒。在那个场景下,用户下单等个1秒可能就以为卡死了,直接取消订单。所以我加了 nodelay,意思是这5个突发请求我立马处理,但是处理完这5个后,接下来的请求必须严格遵循每秒10个的速率。
不这么做会怎样? 我试过不加 nodelay,结果在压测时,虽然后端没崩,但接口响应时间从平均 120ms 飙升到了 800ms,因为请求都在Nginx里排队等令牌。加了 nodelay 后,除了被直接拒绝的,正常通过的请求响应时间依然保持在 150ms 以内。
还有一个细节,我之前用的是 $remote_addr 作为 key,后来发现很多用户都在 NAT 后面,导致整个公司的人都被限流了。后来改成了 $binary_remote_addr,它占用的内存更少(固定64字节),而且能更精准地识别单个客户端,10M 的内存空间在 1.26.3 版本下大概能存下 16 万个 IP 的状态,对于我们的业务量完全够用。
那是去年帮一个生鲜电商做秒杀活动,他们卖阳澄湖大闸蟹券,库存只有500份,结果预估流量有 5万 QPS。老板当时说怕服务器挂掉,让我想办法。我一看他们的架构,请求直接打到 Spring Boot 应用,应用里再查 Redis 减库存。这要是真有 5万 QPS,Redis 瞬间就被冲垮了,更别提数据库。
我当时给的方案是:Nginx 层必须做两层过滤。第一层是限流,第二层是并发连接数限制。
我们当时用的是 Nginx 1.27.4 (Mainline) 版本测试,因为当时需要用到 limit_req 配合 map 指令做更灵活的控制。核心思路是:既然库存只有500,那我就只允许前600个请求(留点余量)进入后端,剩下的直接返回“已售罄”。
我是这么配置的:
实际的血案排查过程: 第一次压测的时候,我设置了 limit_req 的 rate=5r/s,结果后端还是崩了。我查了 Nginx 的日志,发现 limit_req 确实拦截了大部分请求,但还是有大量请求进去了。后来我抓包分析,发现攻击者的脚本根本不走 HTTP 协议的标准流程,他们直接复用 TCP 连接发请求。这时候 limit_req 就不太够用了,因为它主要限制的是请求速率,而不是连接数。
于是我加上了 limit_conn。这一招下去,效果立竿见影。因为抢购脚本通常在一个 IP 下开几百个线程,加了 limit_conn 1 之后,每个 IP 同时只能有一个请求在处理,其他的连接直接被 Nginx 拒绝。
最终的数据是,活动开始后的前 3 秒,Nginx 处理了大约 2 万个连接,但只放行了 580 个请求到后端(因为有些请求是并发进来的,被 limit_conn 拦了)。后端服务全程 CPU 占用不到 30%,Redis 的 QPS 也只有几百。如果不做这个网关层拦截,按照之前的经验,这 5 万 QPS 直接穿透,后端服务重启都来不及。
做业务久了,你会发现单纯的限流有时候太死板。比如我们公司有个内部系统,只允许办公室的 IP 访问,但有时候出差的人需要访问,这时候如果写死 allow 和 deny,运维同学就得天天改配置。后来我研究了一下 njs 模块,配合 GeoIP,实现了动态黑白名单,这个方案在 1.26.3 版本里跑得特别稳。
我遇到过一个真实的攻击案例:攻击者通过代理池不断变换 IP,但他们的 User-Agent 都是一样的,或者他们的请求里不带特定的 Header。这种攻击用单纯的 IP 限流很难防,因为 IP 一直在变,共享内存很快就满了,而且误杀率极高。
我当时用 njs 写了一个简单的脚本,实现了一个逻辑:如果请求头里没有 X-Internal-Token,且 IP 不在白名单内,直接拒绝。
首先,你需要 Nginx 编译时带上 njs 模块(现在主流的包管理器安装时基本都带了,或者你可以去官网下 1.27.4 的包)。
这是我的 njs 脚本 (access.js):
然后在 Nginx 配置里调用它,同时配合 GeoIP 做静态匹配:
为什么这么做? 因为 GeoIP 的匹配是在 C 层做的,效率极高,适合做大规模的地域封锁(比如我们之前遇到过一波来自东南亚的攻击,直接通过 GeoIP 把那几个国家段封了,瞬间流量掉了 40%)。而 njs 适合做复杂的、需要逻辑判断的规则,比如检查 Header、Body 里的特定字段,或者根据时间判断(比如只允许工作时间访问)。
我之前试过用纯 if 指令在 Nginx 配置里写这种逻辑,那个嵌套层级简直是噩梦,而且 if 在 Nginx 里有很多坑(比如和 proxy_pass 一起用会有问题)。用了 njs 之后,逻辑清晰多了,而且我可以直接在 JS 里打日志,排查问题方便很多。
现在社区里也在讨论 AI 智能限流,我觉得这个方向挺好。现在的攻击很多都是模拟正常用户行为,慢速攻击,传统的阈值很难防。我现在的做法是,在 njs 里把请求的特征(IP、UA、请求频率)打出来,然后喂给后端的分析系统,如果发现异常,通过 API 动态更新 Nginx 的 map 或者 njs 里的白名单变量,实现半自动的防御。
去年双十一大促前压测,我们的商品详情页接口在 Nginx 层开启了 limit_req 做接口防护,QPS 刚到 2 万的时候,CPU 就飙到了 70%,而且 RT 从 30ms 涨到了 150ms。我当时第一反应是后端服务扛不住了,但看监控发现后端 Java 服务的 CPU 才 30%,这就很奇怪——流量还没到应用层,问题肯定出在 Nginx 的限流模块上。
后来翻 Nginx 1.26.3 的源码和官方文档才搞明白,limit_req 模块用的是共享内存(zone)来存储每个 IP 的请求计数,而高并发下多个 worker 进程读写同一块共享内存时,会产生锁竞争。我们当时配置的是 10MB 的共享内存区,存了大概 8 万个 IP 的状态,每个请求过来都要先加锁读计数、判断、再写回,QPS 高了之后锁等待时间直接占了 RT 的 60%。
我做了个对比测试:同样的 4 核 8G 服务器,Nginx 1.26.3,用 wrk 压测 /api/item 接口,不开限流时 QPS 能到 4.2 万,开启 limit_req zone=item_limit:10m rate=1000r/s 之后,QPS 掉到了 2.1 万,CPU 占用从 35% 涨到 72%。后来我把共享内存从 10MB 扩到 50MB,同时把 zone 的 key 从 $binary_remote_addr 改成了 $binary_remote_addr$request_uri(因为我们要按接口维度限流,之前是全局共享一个 zone),锁竞争反而降了——因为不同的接口请求会落到 zone 的不同哈希桶里,冲突概率变低了。优化后 QPS 回到了 3.8 万,CPU 降到 45%,RT 稳定在 40ms 左右。
这里有个细节要注意:zone 的大小不是越大越好。我试过把 10MB 扩到 100MB,QPS 反而降了 5%,因为共享内存的哈希表扩容和查找开销也会随 size 增大而上升。Nginx 官方建议 1MB 大概能存 1.6 万个 64 字节的 key(也就是 $binary_remote_addr 的长度),我们按接口+IP 维度限流的话,1MB 大概存 8000 个组合 key,所以 50MB 足够支撑 40 万个组合状态,完全够我们大促的流量规模。
还有个优化点是调整 worker 进程数和共享内存的亲和性。我们之前是 8 个 worker 进程,后来改成 4 个(和 CPU 核数一致),同时给 Nginx 配置 worker_cpu_affinity auto,让每个 worker 绑定一个 CPU 核,锁竞争又降了 10%。因为 worker 少了,共享内存的访问冲突自然就少了,而且绑定 CPU 后缓存命中率更高。
下面是我们优化后的生产配置,已经跑了 3 次大促,没再出现过限流导致的性能问题:
如果你的 QPS 超过了 5 万,还可以考虑用 split_clients 做流量分片,把不同 IP 段的请求分到不同的 zone 里,彻底避免锁竞争。我之前给一个直播平台做优化时,就把 /live/play 接口的流量按 $remote_addr 的前两段分成了 4 个 zone,每个 zone 对应一个 worker 进程,QPS 从 6 万涨到了 12 万,CPU 反而降了 15%。
去年我们公司做微服务改造,把原来的单体应用拆成了 20 多个服务,部署在 3 个机房的 15 台 Nginx 上做负载均衡。本来每个 Nginx 都配了 limit_req 做单机限流,结果有一次遇到 CC 攻击,攻击者用了 5000 个肉鸡 IP,每个 IP 每秒发 10 个请求,单台 Nginx 看每个 IP 都没超阈值(我们配的是单 IP 100r/s),但总流量到了 5 万 QPS,直接把后端支付服务打挂了。
这就是单机限流的硬伤:每个 Nginx 实例只认自己收到的流量,不关心全局总量。攻击者只要把请求分散到多台 Nginx 上,单机限流就完全失效。当时我们紧急把 15 台 Nginx 的限流阈值从 100r/s 降到 10r/s,虽然拦住了攻击,但也误杀了 30% 的正常用户——很多用户的请求被不同的 Nginx 转发,累计超过了 10r/s 就被拦了。
后来我们调研了 Envoy 的全局限流方案,对比下来发现两种架构的适用场景完全不一样:
| 维度 | Nginx 单机限流 | Envoy 全局限流 |
|------|----------------|----------------|
| 限流粒度 | 单机维度,基于本地共享内存 | 集群维度,基于独立的 Global Rate Limit 服务 |
| 一致性 | 最终一致,多实例间不共享状态 | 强一致,所有请求都去限流服务校验 |
| 性能损耗 | 低,本地内存操作,QPS 损失 <10% | 中,每次请求多一次 RPC 调用,QPS 损失 ~20% |
| 部署复杂度 | 低,改 Nginx 配置即可 | 高,需要额外部署限流服务、配置 Envoy 集群 |
| 适用场景 | 单实例/小集群、对一致性要求不高的场景 | 多机房/大集群、需要全局流量控制的场景 |
我们最后选了混合方案:边缘层的 3 台 Nginx 做单机限流,拦掉明显的单 IP 突发流量;后面的微服务网关用 Envoy 做全局限流,控制整个集群的总 QPS。Envoy 的全局限流服务我们用的是 Go 写的,基于 Redis 存储计数,支持按服务、按接口、按用户维度限流,配置如下:
首先是 Envoy 的限流过滤器配置,放在 envoy.yaml 里:
然后是全球限流服务的配置(基于 Envoy 官方的 ratelimit 项目,版本 1.4.0),存在 Redis 里的规则:
这个方案上线后,上次那种 5000 IP 的 CC 攻击直接被拦在全局限流层,总 QPS 超过 5000 就返回 429,正常用户只要总请求量没超就不会被误杀。不过要注意全局限流服务的可用性——我们之前有一次 Redis 主从切换,限流服务连不上 Redis,默认配置是“限流服务不可用就放行”,结果那 10 秒又被打超了。后来我们改了配置,限流服务超时或失败时直接拒绝请求,虽然会误杀一点,但比打垮后端强。
如果你的集群规模小于 5 台 Nginx,其实没必要上 Envoy 全局限流,用 Nginx 加一致性哈希也能解决大部分问题:把同一个 IP 的请求都路由到同一台 Nginx,这样单机限流就相当于“准全局限流”。我们之前有个小项目就是这么做的,配置如下:
这种方案的成本最低,QPS 损失也只有 5% 左右,适合中小规模的集群。
上个月我把我们边缘层的 Nginx 从 1.26.3 升级到了 1.27.4(2024 年 10 月刚发的主线版),主要是冲着两个新特性去的:一个是 njs 模块的正式稳定,另一个是 limit_req 支持动态阈值调整。之前我们用 njs 做 JWT 鉴权,还要自己写共享内存的逻辑,现在 1.27 里的 njs 可以直接调用 ngx.shared.DICT 的 API,实现动态限流方便多了。
举个真实的场景:我们之前有个面向企业的 API 服务,不同客户的调用频率不一样,白金客户每秒能调 1000 次,普通客户只有 100 次。之前用 Nginx 原生配置做不到按客户 ID 限流,只能把所有客户都限制成 100r/s,白金客户投诉了好几次。升级到 1.27.4 之后,我用 njs 写了个动态限流逻辑,从请求头里取 X-Client-ID,然后去 Redis 查这个客户的阈值,再动态判断要不要限流,代码如下:
然后是 rate_limit.js 的脚本,实现了按客户 ID 动态限流:
这个方案上线后,白金客户的调用量直接涨了 3 倍,也没再投诉限流的问题。而且 njs 的性能比我们之前用的 OpenResty 的 Lua 脚本还好,同样的逻辑,Lua 版本每次请求耗时 2.3ms,njs 版本只要 1.1ms,因为 njs 是 Nginx 原生支持的,不需要额外的 LuaJIT 运行时。
再说说未来的趋势,我最近看了 Nginx 官方的 2024-2026 路线图,还有社区的热门讨论,有几个方向我觉得会很快落地:
第一个是 AI 智能限流。现在的限流都是静态阈值,或者手动调整,攻击者很容易模拟正常用户的请求频率(比如每秒 5 个请求,持续 1 小时)来绕过限流。我之前遇到过一次慢速攻击,攻击者用 1000 个 IP,每个 IP 每秒发 3 个请求,持续了 2 小时,单机限流完全没触发,但后端服务的 CPU 慢慢涨到了 100%。后来我们接了 AI 模型,用过去 7 天的正常请求特征(请求频率、User-Agent、请求路径分布)训练了一个分类模型,实时判断请求是不是攻击,准确率达到 98%,那次之后类似的慢速攻击都被拦住了。Nginx 1.27 已经支持用 njs 调用外部 AI 服务的 API,我们下一步打算把模型推理放到 njs 里,直接在 Nginx 层做实时判断。
第二个是 QUIC/HTTP3 的适配。现在我们的 Nginx 已经开了 HTTP3 支持(基于 quiche 库),但 limit_conn 模块还是按 TCP 连接来计数的,QUIC 是基于 UDP 的,一个 UDP 连接可以对应多个 HTTP3 流,原来的 limit_conn 就失效了。Nginx 1.27.4 已经增加了 limit_conn_quic 指令,按 QUIC 连接计数,我们测试下来,限流准确率从原来的 60% 涨到了 95%,后续版本应该会完善更多 HTTP3 相关的限流特性。
第三个是 和 WAF 深度融合。我们之前用的是 ModSecurity 做 WAF,Nginx 做限流,两个是独立的模块,有时候 WAF 拦了攻击,但限流模块还是会把同一个 IP 的请求算进去,导致误杀。现在 Nginx 1.27 支持 WAF 规则触发后直接跳过限流,比如 ModSecurity 识别到 SQL 注入攻击,返回 403,Nginx 就不会再对这个 IP 做限流计数。我们测试下来,误杀率降了 40%,这个特性对于电商、金融这类对误杀敏感的业务很有用。
如果你的业务还在用 Nginx 1.24 以下的版本,我建议尽快升级到 1.26.3 稳定版,至少能拿到 limit_req 的性能优化和 njs 的稳定支持。如果是新业务,可以直接上 1.27.4 主线版,很多新特性已经可以在生产环境用了,只要做好回滚方案就行。毕竟限流和防攻击是业务的生命线,早用上新特性,就能少踩很多坑。
上周刚帮一个做在线教育的客户救了火,他们的直播课每逢名师开讲,Nginx 就直接被打挂。我上去一看配置,运维同事为了防止刷课,在 limit_req 里把 rate 设成了每秒 1 个请求,还没加 burst。结果正常用户在直播间里发个弹幕、点个赞,直接就被 503 拒之门外了。
我当时的排查思路很直接:先看 error.log,满屏的 limiting requests,说明限流规则生效了,但太“暴力”。这就像是门口保安为了不让闲人进,把门给焊死了。
我的处理方案其实没动大架构,只是微调了参数:
* 把 limit_req_zone 的 rate 调到了 10r/s,给正常流量留足空间。
* 加上了 burst=20 nodelay。这招很关键,让突发的弹幕流量能进到“等候区”,而不是当场拒绝。
* 针对播放器拉取视频流的接口,我单独拆了一条规则,因为长连接和短请求的限流逻辑本来就不该一样。
改完重启,那晚的课峰值在线 2 万人,Nginx 的负载稳稳的,再也没有出现大面积掉线。
关于这个技术,我得说句心里话。别迷信限流能解决所有攻击问题。如果你的业务是那种对延迟极度敏感的金融交易,或者你已经是多机房部署,单机 Nginx 限流其实挺鸡肋的,锁竞争带来的性能损耗在那种量级下会被放大,这时候不如直接上 Envoy 或者云厂商的 WAF。我见过有人在小博客上配了一堆复杂的 GeoIP 规则,结果内存没省下多少,维护起来倒是累得半死。
最后给个建议:别直接抄网上的“万能配置”。一定要先在你的测试环境用 ab 或者 wrk 压一压,看看你的 burst 阈值到底能扛住多少并发,再上生产。配置这东西,贴合业务场景的才是好配置。