限流、熔断与降级
流量超过系统处理能力时,什么都不做只会整体崩掉:慢请求堆满线程池,数据库连接被打满,连原本正常的接口也一起超时。限流、熔断、降级是三道防线——限流控制“进来多少”,熔断防止“被下游拖死”,降级保证“坏的时候还活着”。
限流算法对比
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 固定窗口计数 | 每单位时间一个计数器,超阈值拒绝 | 实现最简单,内存小 | 窗口临界处可能通过 2 倍流量 |
| 滑动窗口计数 | 按更细粒度分桶滚动统计 | 平滑,精度可调 | 实现稍复杂,需存桶数据 |
| 令牌桶 | 按速率生成令牌,请求取令牌 | 允许一定突发,最常用 | 需处理桶容量与预热 |
| 漏桶 | 请求先入桶,按固定速率流出 | 流出绝对平稳 | 不允许突发,延迟不可控 |
固定窗口的临界问题很典型:阈值 100/秒时,第 1 秒最后 10ms 通过 100 个、第 2 秒前 10ms 又通过 100 个,20ms 内实际放行 200 个。
令牌桶的最小实现:
// 以下片段需放进对应类中运行
public class TokenBucket {
private final double rate; // 每秒生成令牌数
private final double capacity; // 桶容量,即允许的突发量
private double tokens;
private long lastRefillNanos = System.nanoTime();
public TokenBucket(double rate, double capacity) {
this.rate = rate;
this.capacity = capacity;
this.tokens = capacity;
}
public synchronized boolean tryAcquire(int n) {
long now = System.nanoTime();
tokens = Math.min(capacity, tokens + (now - lastRefillNanos) / 1e9 * rate);
lastRefillNanos = now;
if (tokens >= n) {
tokens -= n;
return true; // 输出:拿到令牌,放行
}
return false; // 输出:令牌不足,拒绝或改为排队
}
}
单机限流
单机限流最简单:网关层用 Nginx,应用内用本地计数器或令牌桶。
# Nginx 单机限流配置示意,参数含义以官方文档为准
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s;
server {
location /api/ {
limit_req zone=api burst=200 nodelay; # 允许 200 的突发,nodelay 表示不排队直接处理
}
}
单机阈值有硬上限:集群里每台机器各自限 100/s,10 台机器整体就是 1000/s。若阈值要按“整个服务”算,就必须用分布式限流。
分布式限流
常见做法是用 Redis + Lua 做滑动窗口计数,脚本在 Redis 内原子执行:
-- Lua 脚本示意(由客户端 eval 调用,KEYS[1]=限流 key)
local now = tonumber(ARGV[1]) -- 当前毫秒时间戳
local window = tonumber(ARGV[2]) -- 窗口大小(毫秒)
local limit = tonumber(ARGV[3]) -- 窗口内允许的请求数
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window) -- 清掉过期记录
if redis.call('ZCARD', KEYS[1]) >= limit then
return 0 -- 超限,拒绝
end
redis.call('ZADD', KEYS[1], now, ARGV[4]) -- ARGV[4] 为请求唯一 ID
redis.call('PEXPIRE', KEYS[1], window)
return 1 -- 放行
分布式限流的注意事项:
- 每次请求都要访问 Redis,Redis 成了热依赖,要做降级(Redis 不可用时退化为单机限流,而不是全部拒绝)。
- 用 ZSET 存储每次请求代价较大,热点接口可改用“分桶计数”(把时间切成小格,每格一个计数器)。
- 另一种思路是额度租借:实例从 Redis 一次性领取一批额度(如 100 个),本地扣减,用完后异步续借。既减少 Redis 压力,又只能做到近似限流。
熔断状态机
熔断器监控调用的失败率,触发后直接拒绝请求,给下游恢复时间:
失败率超阈值
CLOSED ───────────────→ OPEN
↑ │ 等待熔断时长
│ 探测成功 ↓
└────────────────── HALF_OPEN(放行少量请求探测)
探测失败 → 回到 OPEN
| 参数 | 含义 | 常见起点 |
|---|---|---|
| 统计窗口 | 计算失败率的时间跨度 | 10s(滑动窗口) |
| 最小请求数 | 样本太少不熔断 | 20 次 |
| 失败率阈值 | 超过即熔断 | 50% |
| 慢调用阈值 | 慢调用占比过高也熔断 | P99 > 1s 占 50% |
| 熔断时长 | OPEN 持续多久 | 10s,可逐次递增 |
| 半开放行数 | 探测请求数 | 5 次 |
要点:熔断是按下游依赖粒度配置的(如“查用户”“查库存”各一个熔断器),不是整个服务一个;触发熔断要打日志和告警,否则故障会被静默掩盖。具体实现可参考 Sentinel、Resilience4j 等组件的官方文档。
降级策略
熔断和限流之后,还要回答“被拒绝的请求怎么办”:
| 策略 | 做法 | 例子 |
|---|---|---|
| 返回兜底数据 | 返回缓存快照、默认值、空列表 | 推荐位降级为默认商品 |
| 异步化 | 只写入队列/消息,稍后处理 | 下单送积分改为异步发放 |
| 关闭非核心 | 通过开关下线次要功能 | 关闭评论、足迹、广告 |
| 限流保护 | 对非核心接口优先限流 | 大促时优先保下单 |
降级开关要能在配置中心一键切换,并且定期演练——没有演练过的开关,真出事时往往是失效的。
配合超时与重试
- 超时必须设:连接超时、读超时都要显式配置,且调用链越上层超时越短,避免请求层层堆积。
- 重试要有前提:只重试幂等操作,只对网络抖动、限流类错误重试,参数错误重试没有意义。
- 重试要退避:固定间隔会形成重试风暴;用指数退避 + 随机抖动,并限制最大次数。
- 控制重试放大:三层调用各重试 3 次,最底层实际承受 27 倍流量;上游重试时下游最好不要再重试。
配置化与压测验证
- 阈值必须来自压测:先测出单机 QPS/P99 拐点,再按 70%~80% 定限流阈值。
- 阈值放配置中心,支持秒级调整与灰度,不用发版。
- 上线前做一次“限流熔断演练”:人为把下游打挂,确认熔断生效、降级生效、告警到达。
小结:限流用令牌桶或滑动窗口控制在途流量,熔断用状态机切断故障依赖,降级用兜底与开关保住核心链路;三者再加上合理的超时与退避重试,才是一套完整的过载保护。阈值不要拍脑袋,靠压测确定并做成动态配置。