3.2 限流与熔断:Nginx 与 PHP 层面怎么做
场景:一个恶意脚本把整条链路打穿
CDN 挡住静态流量后,剩下的动态请求里混着大量脚本流量。有次活动监控显示一个 IP 在 3 秒内提交了 500 次下单请求,PHP 机器的限流当时完全没有,Redis 库存被脚本提前扣光,真实用户一件没抢到。第二天的复盘结论是:限流必须在多点分别落地,任何单点防御都会被绕过。于是我们在 Nginx 层、网关层、PHP 应用层各自加了限流,形成纵深防御。
方案:三层限流各管一段
限流不是越多越好,而是要在合适的层级做合适的事。我们的三层分工如下:
| 层级 | 限流维度 | 特点 | 响应 |
|---|---|---|---|
| Nginx | IP 与总并发 | 成本极低,请求还没进 PHP | 429 或 503 |
| PHP 中间件 | 用户与接口 | 精确到人与业务,基于 Redis 令牌桶 | JSON 错误码 |
| 服务内部 | 下游依赖 | 保护 Redis 与 MySQL,熔断降级 | 兜底返回 |
为什么不只在一个层面做?因为各层看到的请求粒度不同:Nginx 看到的是 TCP 连接,PHP 看到的是登录用户,内部熔断看到的是下游健康度。单看任何一层都有盲区,组合起来才没有死角。还有一个容易忽略的点:Nginx 限流是成本最低的防线,一个请求在这里被挡住,连 PHP 进程都不需要分配,对整个系统的保护效率最高。所以我们故意让 Nginx 层的阈值偏严格一些,宁可误杀少数高频真实用户,也不让洪峰压到后面。PHP 层则相反,粒度到人,可以放宽一点做精确补偿。两者的阈值需要联调才能找到平衡点,单看任何一层的监控数据都会得出错误结论。
限流算法我们对比过常用的三种,结论是令牌桶最适合秒杀:
| 算法 | 特点 | 我们的判断 |
|---|---|---|
| 计数器 | 简单但有临界突刺 | 只适合粗粒度 |
| 漏桶 | 出流平滑 | 不允许突发,不适合秒杀开场 |
| 令牌桶 | 允许突发且总量受控 | 秒杀首选 |
秒杀开场那几秒天然是突发流量,漏桶会把合法的洪峰削平导致用户体验差,令牌桶既允许开场突发又限制总量,刚好符合业务形态。熔断器的选择上我们用了成熟的滑动窗口实现而不是自己造轮子,状态分为关闭、打开、半开三种,恢复策略是先放一小部分流量试探,成功才完全恢复。配置虽简单但效果很好,有次 Redis 主从切换的三秒里,熔断器正确地打开了,用户只感觉到短暂的服务降级而不是整站超时报错。熔断与限流的配合关系就像刹车与油门:限流控制自己开多快,熔断是遇到障碍时的主动减速,两者缺一不可。
限流策略落地时还有一条原则我们特别强调:所有限流都必须有开关。线上出问题时能在十秒内关闭某个限流规则,而不是靠改配置重新发版。配置开关统一存在 Redis 里,应用进程每秒拉取一次,这是一个救命级别的设计。开关设计还有一个要点:开关的变更必须留痕。谁在什么时间开了或关了哪个限流规则,全部记录到 CLS 里,事后复盘时这是最重要的证据之一。我们甚至在告警系统里加了开关变更提醒,任何限流开关被改动都会在群里广播一条消息,值班同学立即知晓,避免有人调了阈值却忘记同步给团队。限流体系做到后期,其实做的是可观测性而不仅仅是拦截能力。
配置:Nginx 与 PHP 的两段配置
Nginx 层用 limit_req 做全局限流,秒杀场单独放宽到每秒每 IP 20 个请求,超出直接返回 429,连 PHP 都不进:
http {
limit_req_zone $binary_remote_addr zone=seckill:10m rate=20r/s;
server {
location /seckill/buy {
limit_req zone=seckill burst=40 nodelay;
limit_req_status 429;
limit_conn addr 10;
proxy_pass http://php_backend;
}
}
}
PHP 层的令牌桶基于 Redis 实现,限流维度是用户加接口,比 IP 精确,换 IP 的脚本党也能挡住:
class RateLimitMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$key = 'ratelimit:' . $request->user()->id . ':buy';
$max = 5; // 5 秒内最多 5 次
$ttl = 5;
$current = Redis::incr($key);
if ($current === 1) {
Redis::expire($key, $ttl);
}
if ($current > $max) {
return response()->json(['code' => 4291, 'msg' => '操作太频繁,请稍后再试'], 429);
}
return $next($request);
}
}
熔断侧我们保护的是对 Redis 与 MySQL 的调用。当下游错误率超过阈值,熔断器打开,直接走兜底逻辑返回友好提示,而不是让请求堆积等超时。实现是简单的滑动窗口计数,达到连续 10 次失败就熔断 30 秒,半开试探成功才恢复。熔断打开期间要有明确的服务降级行为:库存查询走本地缓存的模糊余量,下单直接返回活动太火爆的兜底文案。降级体验虽然不完美,但比整站超时白屏强一个量级。熔断恢复的策略同样关键:半开状态下放过的试探流量必须是从正常用户里取样的,如果试探流量恰好是异常流量,熔断器会错误地认为下游没恢复而重新打开,形成抖动。我们的做法是半开期间放行随机百分之一的正常用户请求,观察五秒成功率后再决定是否完全恢复。
踩坑:限流配置的三个坑
坑一:限流阈值拍脑袋。 早期 Nginx 限流设为每秒 5 个请求,结果把正常用户的连点操作都挡了,客诉反而上升。阈值必须基于压测数据定,先压测出系统真实容量,再反推每层阈值,这个流程在 5.1 节会展开。补充一个经验值:秒杀场景下 Nginx 限流阈值按单台 PHP 机器处理能力的总和来定,留出百分之三十的余量,这样即使有一台机器挂掉,系统也能靠余量撑住等扩容。
坑二:限流返回的错误信息太粗暴。 一开始直接返回 503,用户看到白页还以为网站挂了。后来统一返回 JSON 结构的错误码加文案,前端可以根据错误码展示排队中或售罄的友好界面。错误码体系在第四篇订单链路里还会持续演进。这里多说一句:错误码的设计要让前端一看就知道该展示什么,同一个 429 对用户而言至少有太快了、已售罄、排队中三种语义,分开编码后前端的处理逻辑清晰很多。
坑三:只做限流不做熔断。 限流是控制自己的流量,熔断是保护自己不被下游拖垮。有次云数据库 Redis 抖动,PHP 没有熔断,全部请求超时堆积,机器内存被撑爆。加上熔断后,下游抖动的 30 秒里系统柔性降级,用户看到的是部分功能暂不可用而不是整站白屏。
坑四:限流日志缺失导致无法复盘。 某次活动结束后运营反馈某批用户一直无法下单,但我们既不知道他们被哪层限流挡住,也不知道当时的阈值是否合理。后来给所有限流规则加了命中日志,每次限流触发都记录层级、用户、接口与阈值,日志统一进 CLS,复盘认责清晰多了。这段日志结构的具体设计在 5.3 节日志归集里展开。有了命中日志之后,每次大促前的阈值评审都有数据支撑,不再是几个人凭经验争论,讨论效率提升非常明显。
小结
限流与熔断是两件事:限流对外,熔断对内。Nginx 挡 IP 级洪峰,PHP 挡用户级刷子,熔断器挡下游抖动。三层各司其职,秒杀链路才有了纵深。配置阈值一律以压测数据为准,拍脑袋定的阈值最后都要用客诉来校准。给团队留一句话:任何一次被限流拦下的请求,都应该让用户看到明确的下一步动作,而不是一个错误页面,这是我们对系统质量的底线要求。再补充一条实战经验:限流配置一定要做成可热更新的,大促当天的流量形态往往与压测时不一样,临时调整阈值是常态,如果改个 limit 还要重启服务或走发版流程,应急窗口根本来不及。我们把所有阈值集中放在配置中心,Nginx 与 PHP 各自订阅,改动后十秒内全量生效,这个设计在多次活动中都救过我们。另外每次限流触发都要打一条结构化日志,包含用户标识、接口、命中阈值与具体规则编号,这些日志事后是调参的唯一依据,也是评估误伤率的数据来源,缺了它们,限流就只剩下一个谁也不敢动的黑盒。再强调一点:限流不是为了拒绝用户,而是为了让系统活着服务更多人,所以被限流的请求应该尽量排到下一波放行,而不是直接报错,这一点我们在产品层面也做了配套的排队提示。
