1. 项目背景:一次“529”事故引发的反思
大概两个月前,我们线上一个面向合作方开放的数据查询接口突然开始大面积报错,错误码是 529 overloaded,后面跟着一句 this is a server-side issue, usually temporary。一开始我以为是机房网络抖动或者数据库连接池满了,翻了一圈监控发现都在正常范围内,真正的问题出在应用层的请求量上——有几个合作方因为业务推广,把请求频率从每秒几十次直接拉到了每秒几千次。
当时这个接口是用 PHP 写的,跑在 ThinkPHP 3.2.3 框架上,代码里完全没有做任何限流保护。结果就是:应用服务器 CPU 被打到 95% 以上,数据库连接数被占满,其他正常业务接口也跟着响应变慢,最终导致所有调用方一起遭殃。事后复盘时我意识到,这不是某一家调用方的问题,而是我们作为 API 提供方,缺少了最基础的一道防线——限流。
有很多 PHP 开发者会在技术群里面讨论接口怎么设计、参数怎么校验、返回格式怎么统一,但聊到限流这个话题时,常常觉得那是大厂才需要考虑的事,自己的接口并发量低,加不加限流无所谓。但实际上,你永远不知道一个看起来不起眼的接口,会在什么时间点被什么业务触发到超出预期的高频调用。等事故真的发生了,再回来补限流,代价往往已经翻了十几倍。
这篇文章我会把这次事故之后我做的限流方案完整整理出来,包括算法选型、Redis 落地实现、ThinkPHP 3.2.3 框架下的适配代码、还有我在实际排查中踩过的坑。如果你是 PHP 开发者,正在维护对外开放的 API,或者只是想在自己项目里建立一套可靠的接口保护机制,这篇文章应该能给你一个可以直接抄作业的参考方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. API 限流保护的整体设计思路
2.1 限流到底要解决什么问题
先聊清楚限流的本质。API 限流(Rate Limiting)的核心目标很简单:控制一个接口在单位时间内能够被访问的最大次数,超出的请求要么直接拒绝,要么排队等待,要么降级返回。它的存在不是为了限制正常用户的访问,而是为了在流量尖峰来袭时保护后端服务不被压垮。
在日常开发中,接口缺少限流保护会引发几类很典型的问题。第一类是外部调用方的异常行为,比如某个合作方的系统出现 Bug,循环调用接口停不下来;第二类是恶意请求,比如爬虫、刷单脚本、撞库攻击,这些请求往往频率极高且没有业务价值;第三类是突发的业务热点,比如上了一波营销活动,接口流量在几分钟内翻了十倍。这三种情况里,任意一种都足以让一个没有限流的 PHP 服务直接瘫痪。
我在这次事故里遇到的就是第一种情况的变种——不是恶意,而是合作方做活动时预估流量失误。但结果和恶意攻击几乎是一样的,都在短短几分钟内耗尽了服务资源。所以限流保护不是防君子不防小人,而是给系统上一道保险丝,不管来的是谁,超了就熔断。
需要特别说明的是,限流和另外两个常被混淆的概念不同:限流是控制请求的速率上限,目标是保护系统稳定性;降级是在系统压力过大时主动牺牲部分功能保住核心链路;熔断是在下游依赖出现故障时快速切断调用,防止故障扩散。三者经常配合使用,但各自解决的问题和触发的条件都有明确区别。
2.2 选择限流方案前的三个关键约束
在设计限流方案之前,我先梳理了手头的技术约束,这些约束决定了最终方案的方向。
第一个约束是技术栈。我们的核心服务是 ThinkPHP 3.2.3,这个框架本身没有任何内置的限流组件,也没有中间件机制(3.2.3 版本还没有规范的中间件概念),所以所有限流逻辑都得自己写。同时,PHP 是短生命周期模型,一个请求处理完就释放所有内存和进程资源,无法像 Java 或 Go 那样在进程内维护状态。这意味着所有限流计数器必须放到外部存储中,最自然的选择就是 Redis。
第二个约束是性能开销。限流逻辑本身不能成为系统的性能瓶颈。每一次接口请求都要经过限流检查,如果检查过程需要几十毫秒,那即便挡住了超量请求,正常请求的延迟也受不了。所以限流算法既要准确,又要尽量轻量,最好是一条 Redis 指令就能完成的判断。
第三个约束是分布式支持。线上服务虽然是 PHP 写的,但将来扩展时很可能多实例部署。如果限流计数存在单台服务器的内存中,那么在多实例部署时每个实例的计数是独立的,整体限流效果就会失效。基于 Redis 的实现天然支持多实例共享,这也是我坚持用 Redis 而不是本地文件或进程内缓存的原因。
理清这三个约束之后,方案选型就变得很清晰了:以 Redis 为存储,选择适合业务场景的限流算法,提供尽可能简单、低侵入的接入方式。
2.3 常见限流算法的选型对比
限流算法有几种经典方案,我分别梳理了它们的特点和适用场景,也做了对比实验。下面这个表格是我自己整理的选型参考。
| 算法 | 实现复杂度 | 准确性 | 突发流量处理 | 适合场景 |
|---|---|---|---|---|
| 固定窗口计数 | 低 | 一般 | 支持突发,但临界问题明显 | 简单场景、管理后台、非核心接口 |
| 滑动窗口计数 | 中 | 较好 | 支持突发,临界问题缓解 | 绝大多数对外API |
| 漏桶算法 | 中 | 好 | 不支持突发,流速恒定 | 保护下游数据库、第三方接口 |
| 令牌桶算法 | 中高 | 好 | 支持一定突发 | 网关、对外核心接口 |
固定窗口计数是最容易理解的实现:把时间划分成固定长度的窗口,比如每 1 秒一个窗口,统计这个窗口内有多少请求,超出阈值的直接拒绝。问题在于临界点,假设限制每秒 100 次,在第 0.9 秒来了 100 次,第 1.0 秒又来了 100 次,这两个窗口各自都只有 100 次,但实际在 0.1 秒内的请求量是 200 次,系统可能瞬间被打爆。
滑动窗口计数对固定窗口做了优化,把窗口切得更细,比如 1 秒分成 10 个格子,每次判断时统计当前格子往前推 1 秒的总请求数,大大缓解了临界问题。这是我在生产环境中最终选择的方案,后面会详细展开实现。
漏桶和令牌桶都需要在内存里维护一个带状态的对象,用 PHP 做纯内存实现不太现实,但如果配合 Redis 脚本和定时任务,也能实现出不错的效果。考虑到我们当前的业务场景对突发流量有一定容忍度,滑动窗口计数已经够用,我就没有把方案复杂化。如果你的接口上游是数据库写入操作,想限制写入速率,那漏桶算法会更合适。
3. 核心实现:基于 Redis 的滑动窗口限流
3.1 Redis 数据结构和指令选型
实现滑动窗口限流的核心思路是:以请求的时间戳作为 Key,用 Redis 的有序集合(Sorted Set)存储每个时间窗口内的请求记录。每次有新请求进来时,先把当前时间加到集合里,然后删除窗口之外的历史记录,最后统计集合内的总数,超出阈值就拒绝。
这里选择 ZSET 而不是简单的 String 计数,原因在于 ZSET 能够存储每个请求的具体时间戳,可以精确地把窗口向前滑动,而不是像固定窗口那样每隔一个固定周期就"咔嚓"一下清零。
具体的 Redis 指令组合是 ZADD 加 ZREMRANGEBYSCORE 加 ZCARD,逻辑如下:
php复制// 伪代码示例,展示ZSET滑动窗口的核心指令
$redis->multi();
$redis->zAdd($key, $currentTime, $uniqueReqId);
$redis->zRemRangeByScore($key, 0, $currentTime - $windowSize);
$redis->zCard($key);
$count = $redis->exec();
这条指令组合用 Redis 事务保证原子性,避免在高并发下出现竞态条件。后面我会再讲这个实现里的坑——在 PHP 的某些 Redis 扩展版本中,事务加管道执行返回格式不一致,导致取不到结果。
可能有人会问:为什么不直接用 INCR 加 EXPIRE 的固定窗口方案?这个方案确实更简单,一条指令加一个过期时间就搞定了。我之前的测试数据显示,在低并发场景下两者差异不大,但一旦业务量上来,固定窗口的临界问题就很致命。我们其中一个合作方在每小时整点后的 10 秒内会爆发一次请求洪峰,固定窗口方案几乎每次都在这个点触发告警。所以最终我还是选择了稍微复杂一点但是更平滑的滑动窗口方案。
3.2 完整的 PHP 限流类实现(适配 ThinkPHP 3.2.3)
下面直接贴出我在 ThinkPHP 3.2.3 中使用的完整限流类。这段代码处理了参数配置、Redis 连接获取、检测逻辑、异常降级等全部环节,你基本可以复制到自己的项目里改改就能用。
php复制<?php
/**
* Class RateLimiter
* 基于Redis Sorted Set实现滑动窗口限流
*/
class RateLimiter {
protected $redis = null;
protected $windowSize = 60; // 滑动窗口大小,单位秒
protected $maxRequests = 100; // 窗口内允许的最大请求数
protected $prefix = 'rate_limit:';
public function __construct($config = array()) {
// 兼容不同Redis连接方式
if (isset($config['redis'])) {
$this->redis = $config['redis'];
} else {
// ThinkPHP 3.2.3中常见的Redis连接方式
$this->redis = new Redis();
$this->redis->connect(
C('REDIS_HOST') ?: '127.0.0.1',
C('REDIS_PORT') ?: 6379
);
if (C('REDIS_AUTH')) {
$this->redis->auth(C('REDIS_AUTH'));
}
}
if (isset($config['window_size'])) {
$this->windowSize = intval($config['window_size']);
}
if (isset($config['max_requests'])) {
$this->maxRequests = intval($config['max_requests']);
}
if (isset($config['prefix'])) {
$this->prefix = $config['prefix'];
}
}
/**
* 检测请求是否被允许
* @param string $identifier 限流标识,如用户ID、IP、接口名
* @return bool true=允许请求,false=拒绝请求
*/
public function allow($identifier) {
$key = $this->prefix . $identifier;
$currentTime = microtime(true);
$uniqueId = uniqid('', true);
// 使用Lua脚本保证原子性
$script = <<<LUA
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max = tonumber(ARGV[3])
local reqId = ARGV[4]
redis.call('ZADD', key, now, reqId)
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
redis.call('EXPIRE', key, window + 1)
return count
LUA;
try {
$count = $this->redis->eval($script, array($key, $currentTime, $this->windowSize, $this->maxRequests, $uniqueId), 1);
if ($count !== false && intval($count) <= $this->maxRequests) {
return true;
}
return false;
} catch (Exception $e) {
// Redis异常时放行,保证业务可用性(降级策略)
// 记录日志方便排查
trace('RateLimiter Redis exception: ' . $e->getMessage(), 'error');
return true;
}
}
/**
* 获取当前窗口内的请求数
*/
public function getCurrentCount($identifier) {
$key = $this->prefix . $identifier;
$count = $this->redis->zCard($key);
return $count ? intval($count) : 0;
}
/**
* 清除限流记录
*/
public function clear($identifier) {
$key = $this->prefix . $identifier;
$this->redis->del($key);
}
}
代码里面我用 Lua 脚本替换了之前的 multi() 事务,原因后面会讲。这里先解释一下这个类的几个设计要点。
$identifier 参数是限流的粒度控制关键。它可以是一个用户 ID、一个 IP 地址,也可以是一个接口名加用户 ID 的组合,比如 user:12345、ip:127.0.0.1、article_detail:12345。不同的标识方式决定了限流的维度。我们的接口调用方都有固定的 app_id,所以我常用的标识是 appid:9527,这样每个合作方有独立的限流水位,互不影响。如果直接用接口名做标识,那么所有用户共享一个窗口,偶尔一个大户就能把其他用户的请求全部挤掉,不太合理。
3.3 在接口中的接入方式和调用步骤
限流类的接入方式有两种:一种是每个接口方法里手动调用,另一种是写一个统一的基类或钩子自动校验。在 ThinkPHP 3.2.3 中没有中间件机制,最省事的方式是在入口控制器里手动调用。
假设原来有这样一个接口:
php复制<?php
class DataController extends Controller {
public function getList() {
$appId = I('get.app_id');
$type = I('get.type');
$page = I('get.page', 1);
// 业务逻辑
$data = M('data_list')->where(array('type' => $type))->page($page, 20)->select();
$this->ajaxReturn(array('code' => 0, 'data' => $data));
}
}
接入限流后变成这样:
php复制<?php
class DataController extends Controller {
public function getList() {
$appId = I('get.app_id');
if (!$appId) {
$this->ajaxReturn(array('code' => 400, 'msg' => '缺少app_id参数'));
}
// 限流检测:每个app_id在60秒内最多请求100次
$limiter = new RateLimiter(array(
'window_size' => 60,
'max_requests' => 100,
));
if (!$limiter->allow('appid:' . $appId)) {
$this->ajaxReturn(array(
'code' => 429,
'msg' => '请求太频繁,请稍后重试',
'retry_after' => 60
));
}
$type = I('get.type');
$page = I('get.page', 1);
// 业务逻辑
$data = M('data_list')->where(array('type' => $type))->page($page, 20)->select();
$this->ajaxReturn(array('code' => 0, 'data' => $data));
}
}
当调用频率超过限制时,返回 HTTP 429(Too Many Requests)状态码,并且在响应体里带上 retry_after 字段,告诉调用方过多久再试。这个字段非常重要,很多调用方的重试逻辑会依赖它来设计退避策略。
这里我建议把限流器初始化放在控制器构造函数中,或者封装成一个统一方法,避免每个接口重复写初始化代码。但如果要区分不同接口的不同阈值,那就得在方法体内单独配置。我的做法是在项目里维护一张接口限流配置表,里面存接口名、窗口大小、最大请求数、限流粒度,然后写一个公共方法读取配置并自动执行限流。这样新增接口时只需要加一条配置,不用再改代码。
3.4 限流参数的设定方法:不是拍脑袋决定的
限流阈值应该设定多少?这是很多人拿到代码后的第一个疑问。我的经验是:不要拍脑袋拍一个数字,而是通过数据和业务预期来推导。
推导过程分三步。第一步,估算单个消费者正常场景下的请求量峰值。假设你的合作方在推广活动时,单个用户平均浏览 5 个页面,每个页面会触发 2 次 API 调用,那么单个用户在一次会话里会产生 10 次请求。如果预估同时在线用户量是 100 人,业务高峰期集中在 10 分钟内,那么单消费者在 60 秒内的请求量大约是 (100 * 10 / 600) * 60 = 100 次。这个值就是限流阈值的一个参考下限。
第二步,结合服务器的实际承载能力算上限。用压测工具(我用的 Apache Bench 和 wrk 都试过)对你的 PHP 接口做不同并发量下的压测,找到响应时间开始明显恶化那个点。比如压测结果是:并发 100 时平均响应 80ms,并发 200 时平均响应 450ms,那么 200 就是你的服务承载红线,限流上限不能超过单消费者在这个红线下的速率叠加。
第三步,在参考下限和承载上限之间取一个合适值,再留出余量。我通常取承载能力的 60%-70% 作为限流阈值,因为线上环境有太多不确定性,网络抖动、慢查询、垃圾回收都可能让实际承载能力低于压测值。
这个计算过程看起来繁琐,但非常必要。我见过不少团队上线限流时随便写个数字,阈值设得太大等于没限流,设得太小又频繁误伤正常用户,最后被业务部门投诉。限流阈值是持续运营出来的,不是一次性定死的,上线后要观察正常流量和限流触发次数的比例,动态调整。
4. 进阶优化:令牌桶实现与容灾设计
4.1 为什么我的场景还需要令牌桶
滑动窗口方案上线后,大部分问题都解决了,但有一个场景让我觉得还不够:我们的数据分析接口提供给合作方后,合作方有时会有一个"拉取历史数据"的批量任务,需要在短时间内快速拉到大量数据。这种突发流量是合理的业务需求,不应该被限流误杀。但滑动窗口如果窗口设得比较短,比如 1 秒 10 次,批量任务根本跑不动;如果窗口设得长,比如 1 分钟 600 次,那么运营商可能在 1 秒内把 600 次全部打过来,服务还是会被瞬时打垮。
这时候就需要令牌桶算法了。令牌桶的特点是:系统以恒定的速率向桶里放令牌,每个请求需要消耗一个令牌才能被处理,桶有一个容量上限,表示允许的最大突发量。如果令牌桶空了,请求就会被拒绝。翻译成人话就是:它把"平均速率限制"和"突发容量限制"分开管理,长期看请求速率是恒定的,但在瞬间允许一定程度的突发。
我们最后为批量拉取接口单独启用了令牌桶方案,把平均速率设为每秒 10 个请求,桶容量设为 50。也就是说,合作方可以在第一秒内突发 50 个请求,但之后必须以每秒 10 个的速率持续消耗,整个批量任务既不会瞬间打死服务,又能在合理时间内完成。
4.2 PHP + Redis 令牌桶的实现细节
在 PHP 环境下实现令牌桶,最简单的做法是把令牌数量存到 Redis 中,用一个定时任务周期性补充令牌。但这种做法有延迟,秒级补充的精度很难保证。更好的做法是懒补充:在每次请求进来时,先计算从上次补充到现在这段时间应该新增多少令牌,一次性补充到位,再尝试消费一个令牌。
实现如下:
php复制<?php
class TokenBucket {
protected $redis = null;
protected $capacity; // 桶容量,最大允许的突发请求数
protected $rate; // 补充速率,每秒生成多少个令牌
protected $prefix = 'token_bucket:';
public function __construct($config = array()) {
$this->redis = $redis; // 省略Redis连接初始化代码
$this->capacity = isset($config['capacity']) ? intval($config['capacity']) : 50;
$this->rate = isset($config['rate']) ? floatval($config['rate']) : 10.0;
}
public function acquire($identifier) {
$key = $this->prefix . $identifier;
$now = microtime(true);
$script = <<<LUA
local key = KEYS[1]
local now = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local lastRefill = tonumber(bucket[2])
if tokens == nil then
tokens = capacity
lastRefill = now
end
local delta = math.max(0, now - lastRefill)
local newTokens = math.min(capacity, tokens + delta * rate)
if newTokens >= requested then
newTokens = newTokens - requested
redis.call('HMSET', key, 'tokens', newTokens, 'last_refill', now)
redis.call('EXPIRE', key, 60)
return 1
else
redis.call('HMSET', key, 'tokens', newTokens, 'last_refill', now)
redis.call('EXPIRE', key, 60)
return 0
end
LUA;
$result = $this->redis->eval($script, array($key, $now, $this->rate, $this->capacity, 1), 1);
return $result == 1;
}
}
令牌桶的核心状态是两个字段:tokens 表示当前桶里剩余令牌数,last_refill 表示上次补充令牌的时间。每次请求进来时,先按时间差把令牌补充到桶里(但不能超过桶容量),然后再判断能不能消费一个令牌。这整个逻辑都在 Lua 脚本里完成,保证并发环境下不会出现两个请求同时读到相同的令牌数然后都消费成功的情况。
有一点要注意:如果桶长期空闲,last_refill 会是很久之前的时间戳,那么首次请求时会一次性把桶填满。这是符合预期的行为——长时间没用,令牌桶当然应该恢复到满状态,这样才能支持突发请求。但如果你的业务不希望冷启动时接受突发流量,可以初始化 last_refill 为当前时间,而不是从零开始。
4.3 Redis 故障时的降级策略:放行还是拒绝
线上系统最怕的不是限流本身出问题,而是限流组件依赖的 Redis 挂了,导致所有请求都被拦截或者全部放行,引发更大的故障。我在压测阶段专门做过一次 Redis 宕机演练,验证了限流系统的故障表现。
测试结果让我意识到,限流的降级策略必须写清楚。
方案一:Redis 故障时直接放行。好处是业务不受影响,坏处是如果此时恰好遇到流量攻击,系统没有任何保护,限流形同虚设。适合内部接口、低风险接口。
方案二:Redis 故障时拒绝所有请求。好处是安全,但代价是 Redis 抖动一次,整个接口就对外不可用了,等于是把可用性风险从后端转移到了限流组件上。适合安全要求极高的接口,比如支付、修改密码。
我的建议是折中处理:如果是对外开放的低风险查询接口,选择放行并记录告警日志;如果是涉及资金、隐私的高风险接口,选择拒绝并快速失败(Fail Fast),同时通知运维排查 Redis。在实际操作中,我还在代码里加了本地文件缓存的兜底逻辑——当 Redis 连接失败时,读取本地缓存中的限流信息做近似判断,虽然精确度差一些,但至少不会完全没有保护。
4.4 多实例部署下的限流一致性
PHP 应用通常是无状态的,可以横向扩展多台服务器。如果你把限流计数存在单台服务器的内存或本地文件里,那么每台服务器的计数是独立的,总请求量会变成 单机限流阈值 × 实例数,限流就失效了。
这个问题用 Redis 方案天然就解决了,因为所有实例共享同一个 Redis 中的计数。但随之而来一个新的风险点:Redis 单点故障。如果只有一台 Redis,那么它一旦宕机,所有实例的限流功能同时失效。
我在生产环境中是给 Redis 配置了主从模式,Master 负责写入限流状态,Slave 负责备份。即使 Master 宕机,Redis Sentinel 会在几十秒内完成故障切换,新的 Master 顶上,限流功能短暂中断但不会完全瘫痪。如果你的项目预算和运维能力有限,至少要给 Redis 开启 AOF 持久化,这样即使机器重启,限流数据也能恢复,不会出现重启后计数器清零、大量请求短时间内涌进来的情况。
5. 常见问题与排查技巧实录
5.1 Redis 事务和 Lua 脚本的选型坑
我最早写的滑动窗口限流用的是 multi() / exec() 事务组合,在低并发压测下没有异常,但在并发 500 的情况下就出现了一个很隐蔽的问题:部分请求明明没有超限,却被误判拒绝了。
排查后发现,PHP Redis 扩展在开启管道复用后,multi() 的返回值处理方式和预期不一致。我的代码是:
php复制$redis->multi();
$redis->zAdd(...);
$redis->zRemRangeByScore(...);
$result = $redis->exec(); // 返回数组,第三个元素才是ZCARD的结果
$count = $result[2];
问题出在并发量大时,事务中的命令被重排或者部分命令执行顺序不一致,导致读取到的 ZCARD 结果不是本次请求写入后的准确值。另外,multi() 事务在 Redis 中是不能保证所有命令都带条件执行的,它只是把多条命令打包串行执行,中间的读取和判断并不能完全杜绝竞态。
换成 Lua 脚本后问题就消失了。Lua 脚本在 Redis 中是原子执行的,整个脚本内部的命令顺序是确定的,不存在部分执行的问题。这也是业界实现限流时推荐用 Lua 脚本的根本原因。如果你在用 Redis 扩展做限流,直接用 Lua,不要用 multi() 事务,这是第一次踩坑后我最想说的结论。
5.2 时钟跳变对限流判定的影响
还有一个细思极恐的问题:Redis 服务器和 PHP 应用服务器的系统时钟如果不一致,限流判定会出现偏差。之前排查一个问题时发现,某个调用方的请求时而被限流、时而完全正常,后来对比了两台服务器的系统时间,发现应用服务器比 Redis 服务器快了将近 3 秒。
因为限流脚本里用的是 microtime(true) 作为请求时间戳,如果应用服务器时间超前,那么 now - window 这个窗口起点就会比真实时间大 3 秒,导致新写入的时间戳立即落在窗口外,或者窗口内的历史数据被提前清掉,结果就是限流实际生效的时间窗口被压缩了,本来 60 秒窗口变成了 57 秒,请求频率限制自然就不准了。
解决方案有两个。最稳妥的做法是在生产环境中统一配置 NTP 时间同步,让所有服务器的系统时间保持一致。如果你的环境无法做到这一点,可以在代码里把时间获取改成从 Redis 服务器读取(用 TIME 命令),但这样每次限流都要额外请求一次 Redis,性能会有一定损耗。我的做法是两台服务器都配置了 NTP,并且在部署脚本里增加了一个时间差检查告警,超出 500 毫秒就触发通知。
5.3 Redis 内存暴涨的防范措施
滑动窗口方案中,每来一个请求都会往 ZSET 中写入一个成员,如果窗口设置得比较大(比如 1 小时)而阈值又比较高(比如 1 万次),那么一个活跃调用方就能产生大量 ZSET 成员,多个调用方叠加起来,Redis 内存消耗会飙升。
我在压测一个每小时限流 5 万次的接口时,发现单个 Key 的 ZSET 成员数在高峰时接近 5 万个,如果同时有 20 个调用方在活跃,就是 100 万个成员,每个成员的时间戳和唯一 ID 加起来大约 40 字节,仅这一个限流 Key 就消耗了将近 40MB 内存。虽然单看不算大,但如果每个接口都这样做,内存耗散就非常可观了。
优化手段有几个方向。第一是刻意设置 EXPIRE 时间,让空置的 Key 自动过期清理;第二是尽快删除窗口外的成员,不把历史记录在集合中堆积,我在 Lua 脚本里已经做了 ZREMRANGEBYSCORE,关键是每次请求都要执行;第三是如果对精度要求不那么苛刻,可以改用 Hash + 固定数量的分片格子的实现方式,把滑动窗口离散成固定数量的子窗口,内存消耗大幅降低,代价是精度会有一点损失。我最终为了简单可靠选择了 ZSET 方案,但在内存监控上加了告警。
5.4 限流误伤正常用户时的排查思路
限流上线后最怕收到业务方的投诉:用户的正常操作被拦截了。这时候排查方向通常集中在几个地方。
先确认限流标识字段是否合理。我之前遇到过一个案例,某个接口用 IP 作为限流标识,结果一个公司出口 IP 下几十个员工共用,导致前几个人请求完之后,后面所有人都被限流了。这种情况下,如果接口有明确的用户登录态,应该优先使用用户 ID 作为标识,IP 只作为兜底。
再确认限流阈值是否和目标场景匹配。有些接口的调用频率天然就高,比如轮询类接口,客户端每 5 秒轮询一次,如果限流阈值为每 30 秒 10 次,一个客户端刚好踩在边缘,偶尔一两次超时就会引发用户重试,重试又加剧了限流,形成恶性循环。对于这类接口,建议和前端约定一致的轮询频率和重试策略,或将阈值放宽到轮询频率的两倍以上。
最后检查是否有异常的高频请求源。通过限流日志可以看到哪些标识被拦截次数最多,对比业务数据判断是正常的大客户还是异常的循环调用。这一条需要完整的日志体系支撑,建议在限流拒绝时记录请求来源、请求参数、限流标识和当前计数,方便事后分析。
5.5 限流日志与监控告警的经验
限流功能上线后,必须同时把监控体系建立起来,否则发生限流时你根本感知不到。我的做法是记录两类日志:一类是被限流的请求日志,包含时间、IP、app_id、接口名、当时窗口内的请求数、限流阈值,写入独立的日志文件;另一类是限流器本身的运行日志,记录 Redis 异常、脚本执行失败等情况。
监控告警方面,设置三个关键指标:一是限流触发次数,单位时间内触发次数突然翻倍,说明可能有异常流量;二是限流器异常次数,比如 Redis 连接失败;三是正常请求的成功率,这个指标反映限流是否误伤了正常用户。
在实际操作中,我用监控脚本定期扫描日志文件,统计限流触发次数和异常次数,超过阈值就在工作群推送告警。上线第一周几乎每天都能看到几个异常的限流触发,后来通过调整阈值和标识字段才逐步稳定下来。
6. 限流方案的扩展与后续完善方向
限流上线稳定运行之后,我一直没有停止对这套机制的优化。有几个方向是后续迭代中值得投入的。
第一个方向是把限流配置中心化。现在限流参数散落在代码里,每次调整都要改代码重新发布,效率很低。更好的做法是做一个简单的配置后台,让运营人员可以随时调整某个接口的限流阈值、窗口大小、限流粒度,配置存储在配置表或配置中心里,Redis 定期刷新。我在测试阶段用 ThinkPHP 的缓存机制做了一版配置表的实时读取,虽然每次请求都会多查一次缓存,但配合 Redis 缓存可以把性能损耗控制在几毫秒内,效果不错。
第二个方向是增加更细粒度的分级限流。目前我们做到了按 app_id 限流,但同一个 app_id 下的不同用户其实需要不同的限流策略。比如一个合作方的高权重客户,可以放宽限流阈值,而普通用户保持默认值。这就需要限流标识支持多重维度组合,并且要有相应的用户等级系统支撑。我建议在设计限流 Key 的时候就预留维度字段,比如 appid:9527:user:1001,后续扩展会容易很多。
第三个方向是把限流策略做成可编程的。不同接口面对不同的流量特征,有的适合滑动窗口,有的适合令牌桶,有的需要按 IP 限流,有的需要按用户限流。把这些策略抽象成可配置的规则,通过规则引擎动态选择算法和参数,是一个非常值得投入的方向。我在当前项目中只是用配置表区分了两种算法,但已经感受到这种灵活设计带来的好处了。
7. 写在最后的个人实践体会
从那次 529 事故到现在,我把限流方案从无到有地搭起来,在线上稳定运行了一段时间,回看整个过程有几点体会很深。
第一点,限流方案不是越高级越好,而是越匹配自己的业务场景越好。我们最开始考虑过引入网关层限流,比如用 Nginx 的 limit_req 模块,但最终放弃了,因为我们的接口在应用层有自己复杂的鉴权和频控逻辑,网关层做限流虽然性能好,但无法感知业务状态,粒度不够精细。在应用层用 Redis 做限流,虽然每多一次 Redis 网络开销,但对业务是可控的,后续调整策略也非常灵活。
第二点,限流的本质是取舍。你是在用请求拒绝率换系统稳定性,这个交换是否划算,取决于你如何设定阈值、如何设计标识、如何反馈拒绝原因。别把这个机制做成一堵冰冷的墙,给调用方清晰的错误码和重试建议,远比默默丢弃请求要好。
第三点,技术方案之外,还有一个很重要的收获是推动了和调用方的沟通机制。过去合作方接入接口时,我们只给一份 API 文档,告诉他们有哪些参数、返回什么格式。现在接入流程里多了一份限流规范,明确告诉对方每个接口的调用上限是多少、超出后会得到什么错误码、正确的重试策略是什么。事实证明,提前沟通清楚限流规则,比事后等对方触发限流再解释要省心得多。
如果你也有一个正在服务线上业务的 PHP 接口,不管当前流量多小,我都建议尽早把限流保护加上去。等流量真正大的那一天,系统已经有了一层保护,你可以在被流量冲击之前从容地调整参数,而不是在一个没穿盔甲的状态下迎接第一波攻击。
