
1. 一次把线上服务拖到假死的线上事故从接口变慢到全面告警那是比较平常的一个工作日晚上我正准备收拾东西离开工位。口袋里的手机开始连续振动告警群在刷屏API 平均响应时间从 180ms 一路飙到 3s 以上错误率直接升到 30%紧接着是 PHP-FPM 进程耗尽、数据库 CPU 冲上 92%。我打开监控面板后说的第一句话是坏了肯定是有接口没做限流。先说结论如果你在一个 PHP 项目里问“API 不加限流会怎么样”我不会给你讲一堆理论我会给你看三样东西——凌晨的告警群截图、MySQL 里堆积成山的慢查询记录、以及云服务商发来的让人肉疼的账单。这些我都经历过。下面这篇内容适合每个写 PHP 接口的开发者尤其是那些项目里还没有任何限流机制、靠“应该没人会刷”活着的人。1.1 事故当晚的完整时间线把时间线拉出来你会发现故障从来不是突然发生的而是有一个积累过程20:14某个接口的响应时间开始出现异常波动监控图上刚冒出小尖刺当时没人注意20:25慢查询数量开始上涨数据库连接数逼近上限部分接口响应已经明显变慢20:40PHP-FPM 的所有 worker 进程被占满新请求全部排队等待20:52不只那个接口慢整个服务的所有接口都卡住连首页都打不开了21:00我确认是有人用脚本在刷一个短信验证码接口注意看这个顺序。最开始只是“某一个接口变慢”最后却变成了“整个服务假死”。这是无限流事故最典型的特征异常流量先吃掉服务本身的所有余量然后顺着连接链路拖垮其它接口。很多团队觉得限流是“大公司才需要做的事”直到某一天小流量脚本就把服务打挂了才会明白限流是每个接口的底线不是可选组件。1.2 排查链条从 Nginx 到 PHP-FPM 再到 MySQL我把当天的排查顺序整理成一套可复用的排错模板。第一步看 Nginx 的 access log按时间窗口统计每个 API 的请求量。日志是最好的第一现场我当时很快就发现那个短信验证码接口的 QPS 从平时 2~3 次直接涨到了 300 多次。第二步打开 PHP-FPM 的状态页active 进程数已经打满几乎所有 worker 都在等待外部响应要么是短信服务超时要么是数据库查询排队。第三步最有说服力——进 MySQL 跑一条 SHOW PROCESSLIST200 多个连接里绝大多数都在执行同一个表的查询和插入而且执行时间远超正常值。到这里其实已经不需要再往下查了请求量把连接池、worker 和数据库的余量全部吃光整个服务自然就卡死了。如果你遇到类似的故障特征也可以按这个链路从外到内一层层剥先确认是不是真有异常流量再去 PHP-FPM 看进程状态最后去数据库看连接和慢查询。每一步都会把问题范围缩小一截。1.3 根因很简单接口开放、无频率控制、无计数事故根因不需要复杂的解释这个短信验证码接口完全对外开放没有做任何频率控制。它每次请求都要调用第三方短信服务还要把生成的验证码写入数据库链路长、耗时长。被脚本批量调用之后每个 PHP-FPM worker 都被这个接口占住其它接口的请求只能排队。说白了不是写代码的人水平不够而是压根没把“接口会被刷”这件事当成一种必然风险。如果你的项目里连“按 IP 计数”这种最基础的防护都没有我建议你把它当作必修项。不要想着“我们接口有鉴权”“我们有验证码”“我们的调用方都是内网”这些在真实的爆破脚本面前都脆得很。对验证码接口、短信接口、登录接口来说限流不是锦上添花而是和参数校验一样正常的基础配置。2. 没有限流时压力到底是怎么一步步压垮 PHP 应用的看懂事故还不够你还要理解故障传导的机制。这一节我用“请求量上升时每个环节如何被击穿”的角度来拆解这样下次你在设计接口时心里会有一张完整的风险地图。2.1 连接数耗尽请求排队的恶性循环PHP-FPM 的 worker 数量是有限资源。如果你在 php-fpm.conf 里设置了 pm.max_children 50那这台机器同一时间最多只能处理 50 个请求。限流的核心目的之一就是让你宝贵的 worker 不被无意义的请求占满。如果一个接口本身处理得很快比如 10ms 返回那 50 个 worker 轻松撑住几千 QPS但如果被刷的接口是那种要查数据库、写日志、再调外部服务的慢接口每个 worker 会被占住好几秒甚至十几秒。外部请求还在源源不断地进来新请求到不了 PHP-FPM 就被 Nginx 挂起排队最终连接数、内存、带宽一起耗尽对外表现为大面积超时和 502。你可能会困惑“服务器资源明明没打满为什么服务挂了”瓶颈往往就出在进程数和连接数上而不是 CPU。2.2 PHP-FPM 阻塞CPU 与内存被持续消耗请求并没有因为系统慢下来就停止恰恰相反越慢越积累。每个请求都要读取数据、执行代码、写访问日志如果日志框架还要做格式化、上下文抓取那每多一个请求就多一份 CPU 和 I/O 的消耗。内存被大量进程占满之后操作系统开始使用交换分区磁盘 I/O 又变成新的瓶颈服务从“慢”变成“卡死”往往就在这一两分钟内。很多人只盯着 QPS 的数字觉得“不就多了几百个请求吗”却完全忽略了请求处理链路有多长。一个限流器存在的主要价值就是让服务在异常流量面前保持“可控的慢”而不是直接“不可用的死”。你可以接受接口响应从 200ms 变成 1s但没办法接受服务彻底无响应宕机这是本质区别。2.3 数据库与下游服务故障的连锁传播数据库通常是最先垮掉的一环。PHP-FPM 打满后数据库连接也被占满慢查询增多锁等待开始出现。更糟糕的是缓存——一旦大量请求同时打到同一个热点数据缓存在同一时刻失效请求会直接穿透到数据库形成缓存雪崩。很多团队只给数据库配置了 200 个连接上限结果异常流量一来连接瞬间被全部占住正常业务请求也排不上队。如果这个 API 还依赖下游服务比如短信通道、支付接口、第三方 AI 平台那你不光把自己的服务拖下水还把外部服务也卷了进来。下游服务的容量往往按正常业务估算一旦收到超量请求可能出现超时重试。客户端发现超时后会自动重试重试请求又到你的 PHP你再去调下游流量被成倍放大。这就是典型的“重试风暴”。不限流相当于把故障传染给整条依赖链而且越传越大。3. 被刷接口的代价不只是宕机账单、爬虫与安全隐患如果把“没做限流”的后果只理解成“服务变慢”那你就低估了问题。这一节我讲三个更容易被忽视、但在真实项目里伤得更狠的方面。3.1 云资源账单被刷爆每一次调用都是钱很多接口背后接的是付费服务。短信验证码接口每条短信都有成本对象存储的每次上传都会产生流量费用如果你把第三方 AI 对话能力封装成接口直接暴露给前端那么每多一次调用就多一笔 token 费用。我见过一个比较典型的案例某个企业应用的短信验证码接口被脚本刷了一晚上网关侧统计出几千条计费短信当月短信账单直接翻了十倍。所以接口在暴露外部能力时限流不只是在“保护服务稳定”更是在“保护钱包”。尤其是当你封装了大模型 API 或者高单价数据服务时调用量上去之后账单数字会非常吓人。一个深夜的脚本刷量可能抵得上你一个月的云资源预算。3.2 数据污染与爬虫拖库接口被当成免费数据通道不做限流很多业务接口会变成爬虫的免费数据通道。注册接口、评论接口被批量调用后数据库里塞满了垃圾数据。你以为自己在做正经运营活动其实是在帮别人刷量。再比如列表类的数据接口如果只做了鉴权但没做频率控制一个正规的 B2B 数据服务就相当于把数据库大门敞开了一条缝。从接口层面看限流是一种“获取成本约束”——让数据获取有成本让批量抓取变得不值得。这也是为什么很多数据类 API 的设计里限流和配额、计费是同一件事的两个侧面。你不做限流爬虫拿你的数据比拿自己数据库还方便这已经不是性能问题了是商业模式问题。3.3 登录与验证接口被暴力攻击认证类接口不加限流事情的性质就变了。登录接口可以被暴力破解验证码接口会被用来做短信轰炸找回密码接口可以被用来做账号枚举。这种“安全问题”一旦被利用就不是回滚代码能解决的你需要紧急处理用户数据、发公告、甚至面对投诉严重情况下还要处理数据泄露。所以做接口设计时至少要把几类接口单独拎出来做严格限流登录、注册、验证码、支付下单。这些接口的失败请求也要记录完整日志方便事后分析来源和模式。给业务接口的限流可以宽松些但给认证和支付相关接口的限流必须严格这是底线。4. 限流算法选型固定窗口、滑动窗口、漏桶与令牌桶到了动手解决的环节。先讲算法再给能直接落地的代码。我尽量用你记得住的比喻和场景来区分不堆术语。4.1 固定窗口代码最少但注意边界漏洞固定窗口的做法是把时间切成固定长度的小段比如 1 分钟一个窗口每段窗口内维护一个计数器达到阈值就拒绝窗口结束就归零。在 Redis 里实现非常直接INCR EXPIRE 两条命令就够。它的经典毛病是临界双倍流量假设规则是每分钟最多 100 次你在 59.9 秒时用完了这 100 次下一秒是新窗口你又能用 100 次相当于两秒内通过了 200 次。如果你的服务对突发流量敏感这个漏洞可能让压力在窗口边界突然翻倍。我一般建议先用固定窗口简单、好理解、易排查等监控数据真的出现了“边界毛刺”再考虑升级。4.2 滑动窗口修掉边界问题代价是内存和计算滑动窗口的原理是把大窗口切成多个小格时间轴每滑动一格就统计最近 N 格的总请求数。它解决固定窗口的边界问题但实现需要存储每个请求的时间戳Redis 里可能需要用 Sorted Set 存每次请求的时间消耗的内存比固定窗口高不少。对大多数业务 API 来说固定窗口已经够用。什么时候才需要滑动窗口比如你是一个开放平台对外提供 API 给开发者调用方会精确卡着每分钟的配额打满而且特别在意计数准确性——这时候再上滑动窗口体验和口碑都会更好。4.3 漏桶把突发流量压成匀速漏桶可以理解成一个漏斗你往里面倒水无论你倒得多猛桶底的水管总是按固定速率出水。对应到接口就是无论进来多少请求处理速率是恒定的多余的请求排队或者直接丢弃。漏桶特别适合保护下游脆弱服务比如短信通道、邮件服务、老旧的第三方系统。它们对突发流量非常敏感匀速反而让它们工作得最稳定。代价是请求响应时间会变长流量峰值时排队问题明显甚至会丢掉部分请求。所以在用户体验要求高的场景里漏桶不一定是最优选。4.4 令牌桶允许突发又约束总量API 场景首选令牌桶是我个人最推荐当默认方案的算法。它像收费站管理员按固定速率往桶里放令牌桶容量有限请求要进来就必须拿走一个令牌桶空了就拒绝。好处是允许一定程度的突发——如果你平时流量平稳桶里会慢慢攒下令牌活动流量高峰来的时候可以一次性把攒的令牌用完。从体感上看令牌桶比漏桶更合理平时接口响应很快偶尔小突发也能扛住只是不允许长期超速。很多网关和云服务的默认限流策略就是令牌桶。我用一个表格把这四种算法放在一起对比方便你直接做取舍算法实现成本是否允许突发典型场景主要缺陷固定窗口最低会简单调用量控制窗口边界可能双倍放行滑动窗口中会开放平台精确配额内存和计算成本更高漏桶中不允许短信、邮件等下游限速排队导致响应变慢令牌桶中高允许绝大多数 API 接口需要维护令牌状态5. PHP 项目落地限流的三种方案从 Nginx 到 Redis Lua选好算法之后关键问题是放在哪一层做。我按从外到内的顺序讲你可以根据项目实际情况选一两个方案落地不用三层全上。5.1 Nginx 层 limit_req先挡住最粗暴的流量如果服务前面有 Nginx第一层限流建议就直接放在这里。Nginx 的 limit_req 模块自带令牌桶处理能力对 PHP-FPM 完全无侵入也不需要改业务代码。配置文件长得像这样limit_req_zone $binary_remote_addr zoneapi_limit:10m rate10r/s; server { listen 80; server_name api.example.com; location /api/ { limit_req zoneapi_limit burst20 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:9000; } }其中 rate10r/s 表示每秒放行 10 个请求burst20 表示允许瞬间多排 20 个请求nodelay 表示队列中的突发请求不用额外延迟等待直接放行超出 burst 部分直接返回 429。limit_req_status 这条很关键默认是 503我习惯改成 429跟 HTTP 语义保持一致。这一层的价值在于请求根本到不了 PHP-FPM连进程都不需要启动就能挡住大流量扫描、暴力爬虫和低频攻击。zone 大小 10m 大约能存 16 万个 IP日常够用如果你面对的是大流量 CDN 回源场景可以把 zone 调到 20m 以上。要注意的是 Nginx 的 limit_req 是按 IP 维度计数没有办法区分同一个 IP 后面的不同账号所以它只能算第一道防线。5.2 应用层 Redis Lua 计数精确到业务维度的限流Nginx 的局限是只能按 IP 做没法做到“同一个账号 60 秒只能调用 10 次”“A 接口和 B 接口的规则不同”。所以业务级限流还要在 PHP 应用内做最稳妥的方式是借助 Redis 的 Lua 原子脚本。为什么要用 Lua因为计数和设置过期时间必须是原子操作。用纯粹的 INCR EXPIRE 两条命令并发执行时可能出现多个请求同时执行 INCR 后都满足 current 1 的情况导致过期时间被反复设置或漏掉。放到 Lua 脚本里交给 Redis 原子执行就彻底避免了并发下的状态错乱。local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local current redis.call(INCR, key) if current 1 then redis.call(EXPIRE, key, window) end if current limit then return 0 end return 1PHP 侧封装成一个可复用的方法public function allowRequest(string $key, int $limit, int $window): bool { $redis $this-redis; // 注意配置好连接超时和重试策略 $script $this-getScript(); // 缓存 Lua 脚本内容避免每次读文件 $result $redis-eval($script, [$key, $limit, $window], 1); return (int) $result 1; }在中间件里按业务维度拼一个 key再调用上面的方法public function handle($request, Closure $next) { $requestId $request-user()?-id ?? $request-ip(); $key rate: . date(YmdH) . : . $requestId . : . $request-path(); if (!$this-allowRequest($key, 60, 60)) { return response()-json([ code 429, message 请求频率过高请稍后再试, ], 429)-header(Retry-After, 60); } return $next($request); }这个方案的关键在于 key 的粒度设计。你可以按接口路径、账号 ID、IP 三个维度自由组合。比如验证码接口按 IP 限流登录接口按账号限流数据导出接口按账号 接口双重限流。粒度越细控制越精准但也要注意不要让 Redis 里的 key 数量膨胀建议给所有 key 设置过期时间比如窗口 60 秒、过期时间 120 秒。如果你对突发流量有要求可以把计数脚本换成令牌桶版本。思路是维护当前令牌数和上次补充时间每次请求先按时间差补充令牌再判断是否有令牌可用local key KEYS[1] local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local now tonumber(ARGV[3]) local tokens tonumber(redis.call(GET, key) or capacity) local lastTime tonumber(redis.call(GET, key .. :ts) or now) tokens math.min(capacity, tokens (now - lastTime) * rate) if tokens 1 then redis.call(SET, key, tokens - 1, EX, 3600) redis.call(SET, key .. :ts, now, EX, 3600) return 1 end return 0这是一个简化版实现生产环境建议在并发更高时配合 Redis 事务或成熟限流库做细化。核心思路是“按过去的时长补令牌、但不超过桶容量”这样既有匀速填充的稳定性又能允许短时间突发。5.3 网关层限流微服务或多实例时把规则前移当你有多台 PHP 实例或者前面已经挂了 Kong、APISIX 这类网关时可以在网关层直接配置限流插件。好处是规则统一、管理集中、不用改业务代码网关天然支持集群级统计。我通常在两种场景用网关限流一种是团队内多个服务共用一个入口需要统一安全策略另一种是接口要开放给外部开发者需要在发布层面按 App、按 key 分配配额。但这不意味着应用层就可以不做了。网关层再强也无法理解“登录会员可以放宽、游客应该收紧”这类业务语义。比较稳妥的分工是网关挡全局和粗粒度应用层做业务精确限流Nginx 再兜一层最硬的物理防护。三层各管一件事互不冲突。6. 阈值怎么定、触发反馈与配置实战线上调参的真实经验最后一章讲参数和运维这是很多教程不写、但真正决定限流效果的部分。定一个拍脑袋的阈值还不如不做因为错误限流会把正常用户挡在门外。6.1 阈值从哪里来压测、监控与缓冲系数限流阈值不能拍脑袋。我的做法分三步先用压测工具ab、wrk、JMeter 都可以测出单机单接口的峰值 TPS然后从监控平台拉出最近三个月的历史流量峰值最后在两者之间取一个偏保守的值——压测峰值乘以 0.7 左右再留出 20% 的告警水位。举个例子一个查询接口在压测下单机能到 300 TPS线上日常峰值是 80 TPS那我会把限流值设在 180~200 TPS 左右。设太低正常的活动流量也会被误伤设太高等于没设。同时要分接口对待登录、短信这类高危接口阈值尽量紧普通 GET 接口可以宽松一些。限流规则建议做成配置项放到配置中心或者数据库里不要写死在代码中因为线上调参会比发布代码频繁得多。6.2 触发限流后的响应设计429 与 Retry-After限流不是静默拒绝你要让调用方知道该怎么做。HTTP 语义里对应的是 429 Too Many Requests同时建议返回 Retry-After 响应头告诉客户端多少秒后再试。配合下面三个自定义头会更好用X-RateLimit-Limit 表示上限X-RateLimit-Remaining 表示剩余额度X-RateLimit-Reset 表示窗口重置时间。客户端那边最好做指数退避重试第一次等 1 秒第二次等 2 秒第三次等 4 秒到上限后停止重试并提示用户。如果遇到 429 反而继续快速重试等于自己把自己的服务又打了一轮。你可以看到很多开放平台的 API 错误码文档里429 后面永远跟着一张“退避策略表”原因就在这里。6.3 限流器自身不可用时怎么办降级策略与常见误区限流器依赖 Redis一旦 Redis 不可用限流策略也就失效了。这时候你必须提前做一个决策fail-open 还是 fail-closed。我的建议是区分接口等级登录、支付、修改密码这类高风险接口宁可拒绝请求也不能放行损失一点可用性换安全普通查询接口可以暂时放行以保障服务可用。这个策略要写清楚并且做成开关方便运维人员在故障时快速切换。再列几个我实测中遇到的典型坑你注意绕开忘记给 Redis key 设置过期时间导致 key 永久残留明明窗口已经过去计数却一直累计限流过度多台 PHP 实例各自独立计数没有把 key 统一到 Redis 中心结果被刷时每台机器的限流值叠加总上限虚高好几倍把限流和熔断混为一谈。限流管的是“请求太多”熔断管的是“下游已经故障直接短路”。两者应该配合使用比如连续调用短信服务失败 10 次后在应用内直接熔断 30 秒不再发起新请求对定时任务和后台队列不做限流结果高峰期定时任务和用户请求抢同一批数据库连接两边一起变慢最后说点实在的。很多团队在限流这件事上容易走两个极端小项目什么都不做出事故当天才开始补大项目一上来就堆网关、中间件结果没人会正确调参规则形同虚设。我建议从最小的方案起步——Nginx 挡住最粗暴的流量Redis 计数器保护关键业务接口再加上监控告警先跑一个月看流量曲线再逐步升级到网关限流或者独立的限流层。限流不是为了把所有请求挡在外面而是让服务在异常流量面前依然可控让真正该进来的请求能够顺畅通过。这个平衡点需要你在自己的项目里慢慢调出来。