凌晨两点零七分,我盯着监控大屏,CPU 曲线已经直直地顶在 100% 上不动了,Nginx 错误日志以每秒几百条的速度翻滚,全是 connect() failed (110: Connection timed out)。那一瞬间我脑子里只有一个念头:操,完了。事后复盘原因特别简单——服务里有个查询接口没加任何限流,被一个测试脚本用 for 循环在十分钟里调了八十万次。整个过程没有任何外挂、没有攻击者、没有恶意流量,就是一次典型的“没有限流的 API 在正常业务压力下的自我毁灭”。
这篇文章我就想跟你聊聊 PHP 应用里的 API 限流。不论你用的是 ThinkPHP、Laravel 还是原生框架,也不论你的服务是给 App 做后端、给前端做 BFF、还是给第三方开放平台做接口,只要你的 API 暴露在外网,这个问题早晚会找上你。我会用一次真实事故作为引子,把“为什么要限流”“限流的算法怎么选”“PHP 里怎么落地”以及“上线之后如何排查调优”整个链路讲透。尤其适合正在维护线上业务、被突发流量折腾过、或者准备给自己的接口加上保护层的开发者。
1. 没有限流的 API,是怎么一步步拖垮整个系统的
很多人对限流的认知停留在“限流就是拒绝请求”这个层面,觉得不加限流顶多就是响应慢一点。这是最大的误解。实际的故障链路远比你想的复杂,而且一旦进入恶性循环,恢复起来极其痛苦。
1.1 一次凌晨事故的完整复盘
那天的业务场景是这样的:我们做了一个面向内部运营的数据报表接口,给前端页面提供最近一个月的订单趋势数据。正常访问量一天也就两三千次,峰值 QPS 撑死五六。所以当初开发的时候根本没考虑保护措施,接口内部要查三张表做聚合,单次响应大概在 200 到 400 毫秒之间。
事故的导火索是运营那边写了个批量导出脚本,直接循环调这个接口,没有任何间隔,一次循环拉两千个日期维度的数据。脚本跑起来之后,PHP-FPM 的可用进程从 50 个迅速被占满。新进来的正常用户请求全部在队列里等着,数据库连接池被打满,慢查询堆积,MySQL 的 CPU 直接飙到 100%。然后更要命的事情发生了——因为请求全都堵在 PHP-FPM 里,Redis 连接也跟着超时,而订单服务的缓存读写刚好依赖 Redis,于是订单服务也开始报错。二十分钟内,原本只是一个查询接口过载,最后把整个后端服务体系全部拖垮了。
这就是没有限流时的典型雪崩路径:入口接口过载 → Web 服务器进程占满 → 数据库连接耗尽 → 依赖组件(Redis、下游服务)跟着故障 → 故障范围从单个接口扩散到整个服务。
1.2 限流带走的不只是稳定性,还是你的钱
有人说,我们小项目、流量低,根本没有被打爆的可能。这个想法在创业初期可以理解,但你得知道,API 没有限流时,被“薅”走的永远是真金白银。
我见过一个做短信验证码接口的兄弟项目,接口没限流,被羊毛党用脚本批量调用,一夜之间发出去五万多条短信。按照一条三分钱算,那就是一千五百多块的直接损失。这还没算短信服务商看到异常后直接把账号冻结,导致正常用户也收不到验证码,最后用户投诉、退款、口碑崩坏这些隐形成本。
还有一类常见场景是接第三方 API。你的业务方给你开放了每秒钟最多 20 次的调用额度,你这边把请求一股脑转发出去,结果直接触发对方的 429 限流错误,账号被临时封禁。等到封禁解除,业务的黄金时段早就过了。这种因为自己没做前置限流,导致把下游伙伴的配额打爆的事情,在对接支付、地图、天气、物流查询这些外部接口时太常见了。
1.3 有保护和没保护,业务韧性完全不在一个量级
咱们可以做个直观对比:一个加了限流的系统,面对突发流量时,它会先拦住超出的部分,保住核心链路,让一部分请求正常完成;而没有限流的系统,会把所有的请求都尝试处理一遍,结果一个都处理不好,全挂了。
code复制没有限流:10 个请求进来 → 10 个全部超时 → 前端重试 → 又进来 20 个 → 数据库扛不住 → 服务宕机
有限流: 10 个请求进来 → 只放行 5 个 → 5 个正常返回 → 另外 5 个快速拒绝 → 用户刷新重试 → 系统依旧稳定
注意一个关键点:被限流拒绝的请求,占用的资源是极小的。它可能在 Nginx 层就被拦掉了,也可能在 PHP 进程里做了个计数判断后立即返回,几乎不查数据库、不写日志、不做复杂计算。而没有被限流的请求,每一个都在实打实地消耗 CPU、内存、数据库连接。所以限流不是“减少服务能力”,恰恰相反,它是保住服务能力不被击穿的最后一道防线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 限流的底层原理与算法选型
聊完了“为什么”,咱们进入“怎么做”。限流这件事不是简单地在代码里写个 if ($count > 100) { exit; } 就完事了。根据不同的业务场景,你需要选择不同的算法,而算法选错了,要么挡不住突发流量,要么误伤了正常用户。
2.1 先想清楚你到底要保护什么资源
在选算法之前,必须想清楚限流的对象是什么。很多新手一上来就纠结“到底用固定窗口还是令牌桶”,其实先应该问自己:我这接口的瓶颈到底在哪?
需要保护的资源大致分三类:
- 服务器资源:PHP-FPM 进程数、CPU、内存,这种一般用 QPS 限流就能覆盖;
- 数据库资源:MySQL 连接数、慢查询,这种需要限制的是并发数,而不只是每秒请求数。因为一个请求可能查询 5 秒,QPS 只有 2,但并发连接已经攒了 10 个;
- 外部资源:短信通道、第三方 API 配额,这种按每日/每小时总量限流更有意义。
我见过不少人把 QPS 限流当成万能方案,结果接口 QPS 控制得很好,数据库连接还是被打满了。原因就是没分清“并发”和“吞吐”。
2.2 主流限流算法对比:从计数器到令牌桶
限流领域的算法主要有四种,我直接用一个生活场景来解释差别:想象一个奶茶店,一秒内来了 20 个顾客,但店员一秒只能做 4 杯。
| 算法 | 生活类比 | 优点 | 缺点 | 典型适用场景 |
|---|---|---|---|---|
| 固定窗口计数器 | 每秒钟只接待 4 人,多出的直接走人 | 实现最简单,内存或 Redis 一个计数即可 | 存在临界突发:59 秒时来了 4 人,下一秒又 4 人,两秒内实际放行 8 人 | 对突发不敏感的内部接口 |
| 滑动窗口 | 每秒钟只接待 4 人,但按毫秒级精确统计 | 解决了临界突发问题 | 需要存储每个请求的时间戳,内存占用高一些 | 需要精确控制流量的对外 API |
| 漏桶算法 | 不管顾客怎么涌进来,店员保持固定速度做奶茶 | 输出速率绝对恒定,对下游保护最好 | 无法应对突发流量,即使偶尔没人来也不能预支 | 保护数据库、下游弱依赖系统 |
| 令牌桶算法 | 每秒钟往桶里放 4 个令牌,有令牌才能买奶茶;桶里最多存 8 个令牌 | 允许一定程度的突发,又限制整体速率 | 实现比计数器和漏桶复杂一点 | 绝大多数 Web API,最推荐的方案 |
从表格能看出来,令牌桶是最平衡的方案。它允许你在空闲期攒下一些“令牌”,在突发流量到来时快速消耗,不至于把正常的秒杀、活动流量一刀切掉,同时又用一个固定的速率限制了长期的平均负载。
2.3 为什么在 PHP 项目里我特别推荐令牌桶
PHP 和其他常驻内存的语言不一样,它的请求生命周期是“来一个请求、启动一个进程、处理完就销毁”。这种模式下,你没法像 Go 或 Java 那样在内存里维护一个全局的令牌桶对象。所以 PHP 里做限流,核心就一个字:Redis。
用 Redis 实现的令牌桶,不光能跨进程共享状态,还能在 PHP-FPM 的多进程模式下做到精确统一。每一台服务器上的所有 PHP 进程共用一个 Redis 计数器,限流判断的准确性不会因为进程数增加而失真。而这也意味着,你写的限流代码必须足够高效——每次请求都走一次网络请求去 Redis 拿令牌,如果 Lua 脚本写得不好,反而会拖慢接口响应。这是后面落地部分我会重点展开的细节。
3. PHP 项目里的限流落地实现
理论聊透了,现在给你能直接抄到项目里的代码。我会从最简单的固定窗口计数器开始,一步步升级到基于 Redis + Lua 的令牌桶方案,然后再说怎么把限流逻辑和业务代码解耦。
3.1 基础版:固定窗口计数器
如果你只是临时要保护一个非核心接口,用最原始的 Redis 计数器就够了。逻辑很简单:以当前秒数作为 Redis key,每次请求 INCR,如果计数超过阈值就拒绝。
php复制<?php
/**
* 固定窗口限流
* @param Redis $redis Redis 实例
* @param string $key 限流标识,如 user:send_sms:10001
* @param int $limit 窗口内最大请求数
* @param int $window 窗口秒数
* @return bool true=放行 false=拒绝
*/
function fixedWindowRateLimit(Redis $redis, string $key, int $limit, int $window = 1): bool
{
$current = $redis->incr($key);
if ($current === 1) {
// 第一次请求,设置过期时间
$redis->expire($key, $window);
}
return $current <= $limit;
}
// 使用示例:短信接口限制每手机号每秒最多 1 条
$allowed = fixedWindowRateLimit($redis, 'sms:13800138000', 1, 1);
if (!$allowed) {
http_response_code(429);
echo json_encode(['error' => '请求过于频繁,请稍后再试']);
exit;
}
这个方案看似能用,但有个坑你必须知道:INCR 和 EXPIRE 不是原子的。如果进程在 INCR 之后、EXPIRE 之前崩溃,这个 key 就永远不过期了,你的接口会被永久限流。我建议用 INCR 后的返回值判断是否等于 1,再设置过期时间,这个方案能规避大部分场景,但极端情况下依然有隐患。要完全解决,需要引入 Lua 脚本。
3.2 推荐版:Redis Lua 脚本实现令牌桶
令牌桶的思路是:定义一个容量为 capacity 的桶,以 rate 的速度往里面添加令牌。每次请求时从桶里取一个令牌,有令牌就放行,没有就拒绝。
我用 Lua 脚本实现,核心是保证整个操作的原子性:
lua复制-- rate_limit_token_bucket.lua
-- KEYS[1]: 令牌桶的 key
-- ARGV[1]: 桶容量 capacity
-- ARGV[2]: 每秒补充速率 rate
-- ARGV[3]: 当前时间戳(毫秒)
-- ARGV[4]: 本次请求需要消耗的令牌数,一般传 1
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4]) or 1
-- 当前桶里剩余的令牌数和上次补充时间
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])
if tokens == nil then
-- 桶不存在,初始化
tokens = capacity
last_refill = now
end
-- 计算这段时间补充了多少令牌
local elapsed = math.max(0, now - last_refill)
local refill_amt = math.floor(elapsed * rate / 1000)
if refill_amt > 0 then
tokens = math.min(capacity, tokens + refill_amt)
last_refill = now
end
if tokens >= requested then
tokens = tokens - requested
redis.call('HSET', key, 'tokens', tokens, 'last_refill', last_refill)
redis.call('PEXPIRE', key, 5000)
return 1 -- 放行
else
-- 拒绝,也更新一下 last_refill,避免每次都走计算
redis.call('HSET', key, 'tokens', tokens, 'last_refill', last_refill)
redis.call('PEXPIRE', key, 5000)
return 0 -- 拒绝
end
对应的 PHP 调用代码:
php复制<?php
class TokenBucketLimiter
{
private Redis $redis;
private string $scriptHash;
public function __construct(Redis $redis)
{
$this->redis = $redis;
$this->scriptHash = $redis->script('load', file_get_contents(__DIR__ . '/rate_limit_token_bucket.lua'));
}
/**
* @param string $key 限流 key
* @param int $capacity 桶容量,允许的最大突发量
* @param int $rate 每秒补充令牌数
* @param int $requested 本次消耗令牌数
*/
public function allow(string $key, int $capacity, int $rate, int $requested = 1): bool
{
$now = intval(microtime(true) * 1000);
$result = $this->redis->evalSha(
$this->scriptHash,
[$key, $capacity, $rate, $now, $requested],
1
);
return (int)$result === 1;
}
}
// 使用示例
$limiter = new TokenBucketLimiter($redis);
// 对于查询接口:桶容量 100,每秒补充 20 个令牌
if (!$limiter->allow('api:report_query', 100, 20)) {
http_response_code(429);
header('Retry-After: 1');
echo json_encode(['error' => '请求量过大,请稍后重试']);
exit;
}
// 执行业务逻辑,查询数据库返回结果...
这里有几个参数的设计逻辑值得展开说:桶容量 capacity 决定了你的接口能承受多大的突发流量,比如容量 100,意味着即使平时流量很平稳,一旦秒杀开始,瞬间可以进来 100 个请求而全部放行。rate 决定了长期的平均速率,比如每秒补充 20 个令牌,那 5 秒内最多只能处理 100 个请求。这两个值需要根据你的接口实际处理能力来定,不是拍脑袋写的。
3.3 优雅集成:用中间件统一收口限流逻辑
实际生产项目里,不可能在每个控制器里手动调用上面这段代码。一是太啰嗦,二是容易出现漏网的接口。正确做法是放在中间件里统一处理。
以 Laravel 为例,你可以创建一个 ApiRateLimit 中间件:
php复制<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\Response;
class ApiRateLimit
{
public function handle(Request $request, Closure $next): Response
{
$route = $request->route();
$userId = $request->user()?->id ?? $request->ip();
$key = 'rate_limit:' . $route->getName() . ':' . $userId;
$limiter = new TokenBucketLimiter(Redis::connection()->client());
// 不同接口可以配置不同阈值,从路由参数里读取
$capacity = $route->getAction('limit_capacity') ?? 100;
$rate = $route->getAction('limit_rate') ?? 20;
if (!$limiter->allow($key, $capacity, $rate)) {
return response()->json([
'code' => 429,
'message' => '请求过于频繁,请稍后再试',
], 429, ['Retry-After' => 1]);
}
return $next($request);
}
}
然后在路由里针对特殊接口做差异化配置:
php复制Route::middleware('api.rate.limit')->group(function () {
// 普通查询接口,默认限流
Route::get('/report', [ReportController::class, 'index']);
// 导出接口比较耗资源,限流收紧
Route::get('/report/export', [ReportController::class, 'export'])
->defaults('limit_capacity', 10)
->defaults('limit_rate', 2);
});
如果你的项目用的是 ThinkPHP,同样有全局中间件的机制,思路完全一样。核心要点就是:让限流从“业务代码里的一个判断”变成“框架层的一道关卡”,这样才能保证每个接口都默认受到保护。
4. 限流参数怎么定?教你一套可复用的配置方法
到了这一步,代码层面已经通了,但真正的难题才刚刚开始:限流的阈值到底设多少?设太宽,起不到保护作用;设太紧,正常用户会被误伤。这一节我把我自己用的那套方法完整分享出来,你照着做就能得到一个比较合理的初始值。
4.1 从业务容量反推限流参数
限流的本质是“让请求量不超过服务的最大处理能力”。所以第一步是搞清楚你的服务到底能扛多少。
具体做法分三步:
- 用压测工具(我常用 ab 和 wrk)对接口做基准压测,找到 CPU 在 70% 左右时的最大 QPS。比如压测结果是 200 QPS,那你的系统瓶颈就在 200 左右;
- 给这个瓶颈值打个 7 折,得到 140 QPS。这 140 就是你这个接口的
rate上限; capacity的设置看业务特性。普通接口可以设成rate的 1 到 2 倍,让系统能接住小规模的突发;如果是秒杀类接口,可以放宽到 3 到 5 倍,但rate一定要压住。
举例来说,一个下单接口压测极限是 100 QPS,那么 rate = 70,capacity = 140。也就是说,系统在不崩的前提下,能瞬间承接 140 个请求,但连续 2 秒以上,速率会被平均压到 70 QPS 以下。
4.2 针对不同业务接口做分级限流
实际生产里不能所有接口用一个配置。我通常把接口分成三个等级:
| 接口等级 | 典型接口 | 限流策略 | 原因 |
|---|---|---|---|
| L1 高敏感 | 登录、注册、短信验证码、支付 | 按用户维度限流,速率很低(如 1 QPS) | 防刷最关键,误伤概率低,被拦截用户几乎不受影响 |
| L2 核心业务 | 下单、提交评论、修改资料 | 按用户+接口组合限流,速率适中(如 5 QPS) | 既要防刷,又要保证正常操作的连贯 |
| L3 查询类 | 列表、详情、报表 | 按 IP + 接口限流,速率较高(如 20 QPS) | 正常业务可能频繁调用,但不能允许脚本无限刷 |
还要注意一个细节:限流 KEY 的粒度选择。同一个接口,按用户限流和按 IP 限流,效果天差地别。对于登录接口,你应该按用户手机号/用户名限流,防止对单一账号的暴力破解;对于爬虫防护,你则需要按 IP 限流,防止脚本抓取全量数据。绝大多数场景,两者都要做,只是配置不同:
php复制// 用户维度的限流 key
$userKey = 'rate:login:user:' . $userName;
// IP 维度的限流 key
$ipKey = 'rate:login:ip:' . $request->ip();
4.3 正常业务被误伤之后的“逃生通道”
限流最常见的争议就是:我的用户真是活人,他真的一下子点了十次,凭啥给他 429?这里我给你四个“逃生通道”,按优先级排序:
第一,前端做防抖 + 重试。用户手抖连续点击时,前端截断重复请求;收到 429 时,用指数退避的方式隔几秒自动重试,用户根本感知不到。
第二,后端对 429 响应做特殊处理。返回时带上 Retry-After 头,告知客户端多少秒后再试。优秀的客户端会自动遵从这个参数,而不是立刻狂点。
第三,加白名单。内部人员、VIP 用户、特定 IP 段,可以绕过限流逻辑。白名单要放在限流判断最前面,但要注意做好日志记录,防止白名单被滥用。
第四,排队而不是拒绝。对于异步任务类接口,可以把超出的请求丢进消息队列,处理完成后通过回调通知客户端。这种方式体验最好,但实现复杂度也最高。
5. 上线之后的问题排查与调优实录
把限流代码上线,这不叫结束,只能叫开始。我在实际运维中发现,限流模块本身也经常成为故障源。这一节把这些年踩过的坑、排查过的奇怪问题整理成一份速查手册。
5.1 限流生效了,但接口还是挂了
这是最让人崩溃的场景:明明限流已经返回 429 了,服务器还是被打挂了。排查之后发现问题往往不在限流逻辑本身,而在限流判断的位置。
我犯过的错误是:在 PHP 中间件里做限流判断,但请求是先到 Nginx,再到 PHP-FPM,中间件判断时 PHP-FPM 的进程已经被占用了。因为每个请求都要经过“Nginx 接收 → PHP-FPM 分配进程 → 框架初始化 → 路由匹配 → 中间件执行”整个链路,限流判断太靠后,等于没保护。
正确的做法是多层限流:
- 第一层,Nginx 层:用
limit_req_zone对 IP 做粗粒度限流,挡住明显异常的流量; - 第二层,PHP 中间件层:做精细化的业务限流,比如按用户、按接口、按配额;
- 第三层,数据库层:通过连接池限制最大连接数,确保数据库不会被打穿。
Nginx 的配置很简单,加在 http 块里就行:
nginx复制# 定义限流区域,每 IP 每秒 5 个请求,突发允许 10 个
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=5r/s;
server {
location /api/ {
limit_req zone=api_limit burst=10 nodelay;
proxy_pass http://php_fpm_backend;
}
}
加了 Nginx 这层后,恶意流量在第一道关口就被挡掉了,PHP-FPM 的压力会大幅降低。
5.2 限流参数被外部因素“撞库”了
另一种诡异情况是:明明压测显示接口能扛 200 QPS,限流值设了 150,结果上线没多久数据库还是出了慢查询。后来一查,发现是 PHP-FPM 进程太少,只有 20 个。200 QPS 是“进程数足够多”时的数据,但我们的 FPM 配置只允许 20 个进程同时工作,每个请求响应要 400 毫秒,所以实际吞吐上限是 20 / 0.4 = 50 QPS。
你看,即使限流值设成 150,系统的真实容量也只有 50。这种情况下,要么调大 pm.max_children,要么下调限流值。限流参数的设定必须建立在真实容量评估之上,而真实容量受到进程数、数据库连接数、下游接口时延等多重因素影响。上线最初几天,建议把限流值设为预估容量的 50%,观察一段时间后再逐步上调。
5.3 限流误伤排查:用户反馈“操作着突然就 429 了”
这是最需要耐心的问题。用户确实在正常操作,但触发了限流。排查思路是看日志——你必须在限流拒绝时记录上下文信息。
我通常会在中间件里记录这么几条:限流 KEY、用户 ID、IP、接口名、当前令牌桶的剩余量、阈值配置:
php复制// 在限流拒绝日志里
Log::warning('api_rate_limited', [
'key' => $key,
'user_id' => $userId,
'ip' => $request->ip(),
'route' => $route->getName(),
'tokens_left' => $tokensLeft, // 如果脚本有返回剩余令牌数
'capacity' => $capacity,
'rate' => $rate,
'request_uri' => $request->getRequestUri(),
]);
拿到这些日志后,你会发现 90% 的误伤场景是同一个模式:某个页面的一次性并发请求超过了容量。比如前端页面加载时同时发出 6 个请求,而容量只设了 5。解决办法不是盲目调大容量,而是让前端合并请求,或者后端针对“并发请求数”而不是“单位时间请求数”做限流。
5.4 监控与告警:从“事后救火”到“事前预警”
最后说说监控。限流是保护系统的“免疫系统”,但免疫系统也会生病,所以你得观察它。我重点盯四个核心指标:
| 指标 | 含义 | 危险信号 |
|---|---|---|
| 限流拒绝率 | 被 429 请求数 / 总请求数 | 超过 5% 甚至 10% 时,说明阈值过紧或正在被刷 |
| 接口平均响应时间 | 放行请求的耗时 | 持续上升说明系统容量逼近极限 |
| FPM 进程占用率 | PHP-FPM 繁忙进程比例 | 超过 70% 要扩容或降级部分功能 |
| REDIS 慢查询 | Lua 脚本执行耗时 | 执行超过 10ms 说明脚本需要优化 |
告警别设得太灵敏,否则你会在半夜被无关紧要的告警吵醒,以后真出事反而不看了。我一般的做法是:限流拒绝率连续 3 分钟超过 5%,或者接口 P95 响应时间连续 5 分钟超过 1 秒,才触发送告警。
6. 限流之外,API 防护还应该做的三件事
限流只是 API 防护的一个维度。如果你正在搭建或维护一个对外的 PHP API 服务,下面这三件事也建议一起做了,能把系统的整体稳定性再抬高一个台阶。
6.1 超时设置是限流的好搭档
限流解决的是“请求太多”,超时解决的是“单次请求太慢”。很多线上故障的根源在于对下游系统的依赖没有超时时间,一个 Redis 超时会导致 PHP-FPM 进程挂起几十秒,大量进程被“僵尸”请求占满,最终表现为限流完全失效。
在 PHP 里用 Guzzle 做外部 HTTP 请求时,务必显式设置超时:
php复制$client = new \GuzzleHttp\Client([
'timeout' => 3.0, // 总体超时 3 秒
'connect_timeout' => 1.0, // 连接超时 1 秒
]);
同时也要在 php-fpm 的配置里设置 request_terminate_timeout,防止某个 PHP 进程被极端耗时的请求卡死。这个值我一般是 max_execution_time 的 1.5 倍左右。
6.2 熔断和降级,处理“下游故障”的手段
限流保护的是“入口流量”,但入口流量正常时,下游服务也可能出问题。比如订单服务调用了另一个团队的库存服务,库存服务挂了,订单接口每次都超时。这时候即使限流正常,服务质量也会断崖式下降。
我的做法是引入最简单的熔断逻辑:统计最近 30 秒内调用下游接口的失败率,如果超过 50%,就直接熔断,不再调用下游,而是返回一个缓存的降级数据或者快速失败。这个逻辑不需要额外的组件,用 Redis + 计数器就能实现,核心代码也就几十行。
这里要注意一个设计原则:熔断要快、要全局、要可恢复。熔断后要有定时探测机制,当下游恢复时自动关闭熔断,否则你就要半夜爬起来手动改配置了。
6.3 幂等性设计,让重试变得安全
有句话说得好:没有重试的系统是不完整的,但重试没有幂等保护的系统是危险的。
当 API 被限流后,客户端大概率会自动重试。如果重试请求和原请求作用在同一个资源上,就可能出现重复下单、重复扣款、重复发短信的问题。解决思路是要求客户端在请求头里带一个全局唯一的 Idempotency-Key,服务端在 Redis 里记录处理过的 key,重复请求直接返回上一次的结果。
这在 PHP 里实现起来也不复杂:
php复制$idempotencyKey = $request->header('Idempotency-Key');
if ($idempotencyKey) {
$cached = Redis::get('idempotent:' . $idempotencyKey);
if ($cached !== false) {
// 返回上次处理结果
return response()->json(json_decode($cached, true));
}
}
// 执行真正的业务逻辑...
// 处理完成后缓存结果,缓存时间不用太长,几分钟足够
if ($idempotencyKey) {
Redis::setex('idempotent:' . $idempotencyKey, 600, json_encode($result));
}
限流负责说“不”,幂等负责保证“重试不出错”,这两者配合起来,整个 API 的容错能力才算闭环。
说实话,回到那次凌晨事故,如果我在第一个版本就给所有 API 加上最少一层的限流,那场“俩小时全站不可用”的故障完全可以避免。很多人觉得限流是业务做大之后才需要考虑的事,但现实是,一个被搜索引擎爬虫多抓了几次、被某个测试脚本多刷了几轮的服务,就足够把你打到手忙脚乱。
我的建议很简单:别等出事。今天回去就给最核心的三五个接口加上 Redis 令牌桶限流,Nginx 那边顺手挂个粗粒度限制,日志里把限流拒绝信息打出来。算下来半天时间就能全部搞定,但换来的,是以后每一次流量尖峰出现时,你都能安心睡个好觉。
