服务端限流:算法、层级与降级预案
本文目录
限流的本质不是"拒绝用户",而是在流量超过容量时,由系统主动选择拒绝谁,而不是被雪崩随机选择。没有限流的系统过载时会发生什么:请求排队 → 延迟上升 → 上游超时 → 客户端重试 → 流量被重试放大 → 彻底瘫掉。这篇文章把限流当作稳定性工程来讲:算法只是工具,层级划分、降级预案、熔断配合和压测验证才是主体。
分层防护:网关、应用、数据库
只在某一层做限流是常见误区。每一层挡的东西不一样:
| 层级 | 限什么 | 手段 | 这层失守的后果 |
|---|---|---|---|
| 网关层 | 粗粒度总量:IP、连接数、全入口 QPS | Nginx limit_req / 网关插件 | 大流量直接穿透到应用 |
| 应用层 | 业务维度:用户、接口、动作 | 代码内限流器(本文重点) | 单账号刷接口、慢接口占满线程 |
| 下游/存储层 | 自我保护 | 连接池上限、信号量、DB 侧配额 | 应用没挂,数据库先挂 |
三个实践要点:网关限流挡不住业务维度攻击(一个恶意账号在网关阈值之内刷爆你的写接口);应用限流挡不住单请求的资源消耗(一个深分页查询能吃掉一秒的数据库时间),所以下游还要自保;每层配额要独立评估,应用层每实例 100 QPS × 8 实例,网关全局配额就要覆盖这个量级并留出余量。
四大算法:各自的缺陷与适用
| 算法 | 原理 | 临界缺陷 | 适用 |
|---|---|---|---|
| 固定窗口 | 每 N 秒一个计数桶,超阈值拒绝 | 窗口边界突刺:两倍流量可在 1 秒内穿过 | 阈值粗、允许毛刺的场景 |
| 滑动窗口 | 窗口随时间平移,统计最近 N 秒 | 精确实现要记时间戳(内存大),工程上多用分格近似 | 需要平滑边界的中等精度场景 |
| 漏桶 | 请求进队列,恒定速率流出 | 不允许任何突发;队列本身会积压 | 保护脆弱下游,强制匀速 |
| 令牌桶 | 桶里按速率生成令牌,容量限突发 | 参数两个(速率、容量),调参要懂业务突发模型 | 大多数场景的默认选择 |
固定窗口的突刺问题值得用代码说清楚——两个相邻窗口的交界处,各来满额的一半就是双倍速率:
import { createClient } from 'redis';
const redis = createClient({ url: 'redis://127.0.0.1:6379' });
await redis.connect();
// 固定窗口:INCR 首次触发 EXPIRE(Lua 里做才是原子的,这里示意逻辑)
async function fixedWindowAllow(key: string, limit: number, windowSec: number) {
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, windowSec);
return count <= limit;
}
// 问题:incr=5、expire 前进程崩溃 → key 永不过期;两个命令间也有并发缝隙
滑动窗口用 zset 把"最近 60 秒的请求时间戳"记下来,精度换内存:
async function slidingWindowAllow(userKey: string, limit: number, windowMs: number) {
const now = Date.now();
const key = `rl:${userKey}`;
const pipeline = redis.multi();
pipeline.zRemRangeByScore(key, 0, now - windowMs); // 清掉窗口外的旧时间戳
pipeline.zAdd(key, [{ score: now, value: crypto.randomUUID() }]);
pipeline.zCard(key); // 窗口内请求数
pipeline.pExpire(key, windowMs); // 给 key 兜底过期
const [,, count] = await pipeline.exec() as [unknown, unknown, number];
return count <= limit;
}
注意上面的 multi 仍然不是严格原子的(EXEC 只是排队执行,不隔离其他客户端),并发极高时会轻微超发;要严格精确就上 Lua(下一节的方案直接解决)。
单机与分布式:Redis + Lua 的原子实现
单机(进程内)限流只适合单体或边缘场景。多实例部署下有两个结构性问题:每实例配额 N/m 时总量不精确;按实例均分时新扩容实例会稀释配额。一旦系统有多个实例,限流必须走集中计数。
集中计数的 check-then-act(读剩余令牌 → 判断 → 扣减)在 Redis 单线程上会交错,必须交给 Lua:
-- 令牌桶:KEYS[1] 桶键 ARGV: 速率(个/秒) 容量 当前时间戳(ms)
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local bucket = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(bucket[1]) or capacity
local ts = tonumber(bucket[2]) or now
-- 按流逝时间补令牌,封顶在容量
tokens = math.min(capacity, tokens + (now - ts) / 1000 * rate)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, 120000)
return allowed
export async function allowRequest(bizKey: string, rate = 10, capacity = 20): Promise<boolean> {
const allowed = await redis.eval(tokenBucketLua, {
keys: [`rl:bucket:${bizKey}`],
arguments: [String(rate), String(capacity), String(Date.now())],
});
return allowed === 1;
}
这个实现的参数含义:rate 是稳态速率,capacity 是允许的突发额度。比如"平均 10 次/秒、允许瞬时 20 次"就是 rate=10, capacity=20。速率要按下游容量反推,不是按"感觉"。
集中式限流的可用性问题必须回答:Redis 故障时 fail-open 还是 fail-close。核心交易接口建议 fail-open(放行)并启用本地兜底令牌桶——用放宽阈值(全局配额 ÷ 实例数 × 2)扛住故障窗口;反爬、营销类限流可以 fail-close。这个选择要写进预案,不能等故障时临场争论。
限流维度:IP、用户、接口、全局
| 维度 | 键的构成 | 主要风险 |
|---|---|---|
| IP | rl:ip:{addr} | NAT 后大量正常用户共享出口 IP,互相误伤 |
| 用户 | rl:user:{uid} | 匿名场景没有 uid;接口被单账号脚本刷 |
| 接口 | rl:api:{route}:{uid} | 路由参数要归一化(/order/5001 与 /order/5002 是同一个接口) |
| 全局 | rl:global | 兜底保护,阈值即系统容量 |
function buildRateLimitKey(req: Request): string {
const uid = req.session?.userId ?? `ip:${trustedClientIp(req)}`;
const route = req.route?.path ?? req.path; // 用注册路由,不要用原始 URL
return `rl:api:${route}:${uid}`;
}
IP 维度两个坑:公司/校园 NAT 出口是一两个人为几十万用户挡限流,阈值按 IP 设太低会大面积误伤,IP 限流只适合拦明显异常(单 IP 每秒几百次);X-Forwarded-For 可被伪造,只有来自你信任的网关/代理写入的跳才可信——取哪个跳取决于你的部署拓扑,站在不可信来源直接读 X-Forwarded-For 的第一项等于把限流键交给攻击者指定。用户维度 + 接口维度是业务限流的主力,全局维度是最后兜底,三层配额同时生效,取最严格结果。
429 与响应头:把拒绝说清楚
限流触发时的响应是协议问题,不是随手 500:
function respondRateLimited(res: Response, retryAfterSec: number, remaining: number): void {
res.status(429); // Too Many Requests,表示"稍后重试",区别于 503 的"服务不可用"
res.setHeader('Retry-After', String(retryAfterSec)); // 秒数;也可用 HTTP-date
res.setHeader('X-RateLimit-Limit', '100');
res.setHeader('X-RateLimit-Remaining', String(remaining));
res.setHeader('X-RateLimit-Reset', String(Math.floor(Date.now() / 1000) + retryAfterSec));
res.json({ code: 'RATE_LIMITED', message: '请求过于频繁,请稍后重试' });
}
要点:Retry-After 的值要和限流窗口对得上(固定窗口给窗口剩余秒数,令牌桶给 1/rate 的整数上取整),给一个让人等 60 秒但 1 秒就恢复的值,等于引导客户端在 1 秒后回来撞墙;X-RateLimit-* 是给守规矩的客户端做退避的,公开 API 建议提供,内部接口可省;错误体不要带内部细节(限流器类型、键名)。
反过来,客户端的责任也要有人写:收到 429 必须尊重 Retry-After 且带随机抖动重试。同步立即重试会把限流器变成放大器——10 个客户端各 1 秒重试 3 次,实际流量是正常时的 4 倍。团队里服务端与客户端的两篇规范要配套。
降级预案:限流之后的第二道防线
限流是"拒绝新增",降级是"减少存量消耗"。预案按流量砍什么来分层:
| 优先级 | 动作 | 示例 |
|---|---|---|
| P0 保命 | 核心链路资源预留,非核心先砍 | 支付、下单保留,推荐、榜单直接关 |
| P1 读降级 | 返回缓存快照/默认值/裁剪数据 | 列表页砍到 10 条,实时统计换成 5 分钟前的快照 |
| P2 写降级 | 写操作队列化异步化 | 评论先入队返回"已提交",审核服务慢慢消化 |
写降级要向用户说真话——响应是"已接收、异步处理",并在队列消化后可查询结果,而不是假装同步成功。所有降级动作做成配置开关而不是代码分支,且必须在演练环境真练过:降级开关从没演练过的,事故时第一次执行就是在生产上做实验。
async function submitComment(req: Request, res: Response) {
if (await isDegraded('comment-sync-write')) {
await mq.publish('comment.async', req.body); // 写降级:进队列异步落库
return res.json({ code: 'ACCEPTED_ASYNC', message: '提交成功,稍后展示' });
}
await db.insert('comments', req.body);
res.json({ code: 'OK' });
}
熔断:三态机与半开探测
限流保护"自己不被流量打死",熔断保护"自己不被下游拖死"。当某个下游持续超时/报错,继续调用只会让请求在它身上排队,把本服务线程/连接占满。熔断器是三态机:
CLOSED(正常放行)
│ 错误率/慢调用比例超过阈值(如 60s 窗口内 >50%)
▼
OPEN(快速失败,不再调用下游)
│ 冷却时间到(如 30s)
▼
HALF-OPEN(放极少量探测请求)
│ 成功 → CLOSED 失败 → OPEN
class CircuitBreaker {
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
private openedAt = 0;
private readonly failureThreshold = 0.5; // 错误率阈值
private readonly cooldownMs = 30_000;
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'OPEN') {
if (Date.now() - this.openedAt < this.cooldownMs) throw new Error('CIRCUIT_OPEN');
this.state = 'HALF_OPEN'; // 冷却期到,转半开
}
try {
const result = await fn(); // HALF_OPEN 状态下只应有极少量请求进来
if (this.state === 'HALF_OPEN') this.state = 'CLOSED';
return result;
} catch (error) {
if (this.state === 'HALF_OPEN') {
this.state = 'OPEN';
this.openedAt = Date.now();
}
throw error;
}
}
}
半开的细节决定成败:半开只放一个探测请求(生产实现用原子标志或限流到 1),全量放行会把刚恢复的下游再次打挂;失败统计要有基数下限(10 个请求里错 6 个不算 60%),小流量接口要按绝对次数触发;被熔断的调用要配降级结果(缓存、默认值),不能把错误直接抛给用户。
限流配置的压测验证
限流阈值不能拍脑袋,要用压测把容量测出来:
# autocannon 压测单接口,阶梯升压观察 P99 与拒绝率
npx autocannon -d 60 --rate 500 -w 8 http://127.0.0.1:3000/api/orders
npx autocannon -d 60 --rate 1000 -w 8 http://127.0.0.1:3000/api/orders
压测要验证的是一整组结论,不只是"能抗多少":
- 容量基线:P99 延迟、数据库 CPU、连接池水位在哪个流量档位开始劣化——限流阈值设在劣化点之前,留 20–30% 余量。
- 限流器本身生效:超阈值后拒绝率符合配置、429 响应头正确、被拒流量没有穿透。
- 误伤率:正常用户模型(而不是全打同一接口的机器人模型)被拒绝的比例。
- 降级联动:限流触发与降级开关的联动是否符合预案,队列降级后消息不丢。
- 多实例公平性:8 个实例压测时集中限流计数是否精确(用 Lua 脚本的没问题,客户端自己拼命令的要重点查)。
上线后用拒绝率做持续校验:常态拒绝率长期高于 1% 说明阈值配错(容量被低估或阈值不该这么低),而不是"限流在起作用"。限流器的存在感越低越健康,高拒绝率意味着每次发布都在丢真实用户。
注意事项与边界
- 限流键的生成与校验都在热路径上:每个请求一次 Redis 往返在高 QPS 下成本可观,可叠加本地预检(令牌桶本地缓存剩余量、同步周期性回源),但要评估精度损失。
- 服务器要跑 NTP:基于时间戳的算法在时钟跳变时会出现窗口计算异常,尤其是自己算
now传进 Lua 的实现。 - 重试风暴是限流最大的反作用力:服务端必须给准确的 Retry-After,客户端 SDK 必须带抖动退避,两者缺一都会在限流生效时放大流量。
- 别忘了服务器自身也有极限:限流器部署在应用里时,超过应用进程承载能力的流量(连接洪水)要先靠网关与负载均衡挡,应用层限流不是第一道闸。
- 配置变更要有审计与回滚:阈值调整、开关切换都过配置中心留痕,出问题时先回滚再排查。
总结
限流是稳定性体系里最"防守"的一环:分层配置额(网关拦总量、应用管业务维度、下游自我保护),选对算法(大多数场景令牌桶,要求严格匀速用漏桶),原子实现(分布式限流必须 Redis + Lua),把拒绝协议化(429 + Retry-After + 客户端抖动退避),配齐降级与熔断(限流砍流量、降级砍消耗、熔断断依赖),最后用压测和拒绝率校准阈值。这套东西每次大促前都要演练,没被真实流量打过脸的限流配置不能叫预案。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。