Redis 故障下的本地与分布式限流平滑切换 代码分段讲解
一 组件与切换策略要点
- 双轨制:常态采用本地令牌桶 + Redis 分布式计数 ,先本地快速裁决,再 Redis 兜底全局一致;Redis 异常自动降级为仅本地限流 ,故障恢复后短时半开探测并回到全局限流。
- 健康探测:定时 PING Redis ,连续失败 ≥3 次 判定故障,设置故障标记键 (TTL 60s)防抖动。
- 降级动作:关闭 Redis 读写,启用本地限流与静态/排队页;网关层(如 Nginx limit_req_zone)作为最后防线。
- 半开回切:故障标记过期后先放行 1% 探测流量,观测 P99/错误率/连接成功率 ,稳定后再逐步放量至 10% → 50% → 100%。
- 一致性补偿:故障期本地计数写入本地日志/Kafka,Redis 恢复后异步回补/对账,以数据库为"唯一可信源"修正差异。
二 核心代码分段讲解
- 1 健康探测与故障标记
- 作用:维护全局"Redis 是否可用 "状态,避免频繁抖动;探测失败累计到阈值后设置故障标记键并进入降级。
- 关键点:探测使用 PING ;失败计数与阈值(如 3 次 )配合;故障键设置 TTL 60s 自动恢复;恢复时清除标记。
- 何时触发降级:标记存在即为"故障态",拒绝走 Redis 路径,仅本地裁决。
java
@Component
public class RedisHealth {
private final StringRedisTemplate redis;
private final AtomicBoolean broken = new AtomicBoolean(false);
private final AtomicInteger failures = new AtomicInteger(0);
private final long failThreshold = 3, ttlSeconds = 60;
public RedisHealth(StringRedisTemplate redis) { this.redis = redis; }
public boolean probe() {
try {
String pong = redis.execute((RedisCallback<String>) c -> c.ping());
if ("PONG".equals(pong)) {
if (broken.getAndSet(false)) redis.delete("rate_limit:health:failure");
failures.set(0);
return true;
}
} catch (Exception ignore) {}
if (failures.incrementAndGet() >= failThreshold) {
broken.set(true);
redis.opsForValue().set("rate_limit:health:failure", "1", ttlSeconds, TimeUnit.SECONDS);
}
return false;
}
public boolean isBroken() { return broken.get(); }
}
- 2 本地令牌桶限流器(进程内快速裁决)
- 作用:以低延迟 放行或拒绝请求,保护本机资源;作为"第一道闸门"。
- 关键点:按 key (如 userId/ip/api )隔离;使用 computeIfAbsent 懒加载;支持预热 与突发。
- 何时触发拒绝:本地令牌不足即快速失败,不依赖外部存储。
java
import com.google.common.util.concurrent.RateLimiter;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
public class LocalRateLimiter {
private final ConcurrentHashMap<String, RateLimiter> limiters = new ConcurrentHashMap<>();
private final double permitsPerSecond;
private final long warmupPeriodSeconds;
public LocalRateLimiter(double permitsPerSecond, long warmupPeriodSeconds) {
this.permitsPerSecond = permitsPerSecond;
this.warmupPeriodSeconds = warmupPeriodSeconds;
}
public boolean tryAcquire(String key, long timeout, TimeUnit unit) {
RateLimiter limiter = limiters.computeIfAbsent(key, k ->
RateLimiter.create(permitsPerSecond, warmupPeriodSeconds, TimeUnit.SECONDS)
);
return limiter.tryAcquire(timeout, unit);
}
}
- 3 Redis 分布式固定窗口限流(Lua 原子计数)
- 作用:以 Redis 保证全局一致的 QPS 控制,避免集群超卖。
- 关键点:使用 INCR + EXPIRE 原子计数;单条 Lua 保证"计数 + 过期"一致性;窗口过期自动清理。
- 何时触发拒绝:窗口内计数超过阈值即拒绝。
java
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
public class RedisFixedWindowLimiter {
private final StringRedisTemplate redis;
// KEYS[1]=key, ARGV[1]=limit, ARGV[2]=windowSeconds
private final String lua =
"local cur=redis.call('incr',KEYS[1]); " +
"if tonumber(cur)==1 then redis.call('expire',KEYS[1],ARGV[2]) end; " +
"return cur";
public RedisFixedWindowLimiter(StringRedisTemplate redis) { this.redis = redis; }
public boolean tryAcquire(String key, int limit, int windowSeconds) {
Long cur = redis.execute(
new DefaultRedisScript<>(lua, Long.class),
Collections.singletonList("rate_limit:" + key),
String.valueOf(limit), String.valueOf(windowSeconds)
);
return cur != null && cur <= limit;
}
}
- 4 Redis 滑动窗口限流(ZSet 原子清理与计数)
- 作用:在固定窗口基础上消除"跨窗口临界翻倍 ",更平滑地控制速率。
- 关键点:用 ZSET 存时间戳;每次请求先清理过期 再新增当前时间戳 ;用 ZCARD 获取窗口内数量;设置键 TTL。
- 何时触发拒绝:窗口内请求数超过阈值即拒绝。
java
// KEYS[1]=key, ARGV[1]=nowMs, ARGV[2]=windowMs, ARGV[3]=max
private final String slidingLua =
"local key=KEYS[1]; local now=tonumber(ARGV[1]); " +
"local window=tonumber(ARGV[2]); local max=tonumber(ARGV[3]); " +
"redis.call('zremrangebyscore',key,0,now-window); " +
"redis.call('zadd',key,now,tostring(now)); " +
"redis.call('expire',key,math.ceil(window/1000)); " +
"return redis.call('zcard',key)";
public boolean tryAcquireSlide(String key, long windowMs, int max, StringRedisTemplate redis) {
long now = System.currentTimeMillis();
Long count = redis.execute(
new DefaultRedisScript<>(slidingLua, Long.class),
Collections.singletonList("rate_limit:slide:" + key),
String.valueOf(now), String.valueOf(windowMs), String.valueOf(max)
);
return count != null && count <= max;
}
- 5 双轨裁决与平滑切换(AOP 切面)
- 作用:把"本地裁决 + Redis 兜底 "串成一条调用链,并在故障态 自动降级到仅本地。
- 关键点:先本地快速裁决(10ms 超时),再 Redis 全局裁决;Redis 异常或开关开启时走本地兜底 (50ms 超时);埋点记录拒绝来源。
- 何时触发拒绝:本地或 Redis 任一拒绝即返回 429;异常态下仅本地拒绝。
java
@Aspect
@Component
@ConditionalOnProperty(name = "rate.limit.enabled", havingValue = "true", matchIfMissing = true)
public class RateLimitAspect {
@Autowired private RedisHealth health;
@Autowired private LocalRateLimiter localLimiter;
@Autowired private RedisFixedWindowLimiter redisLimiter;
@Autowired private StringRedisTemplate redis;
@Value("${rate.limit.local.qps:5000}") double localQps;
@Value("${rate.limit.redis.window:1}") int windowSeconds;
@Value("${rate.limit.redis.limit:100}") int redisLimit;
@Value("${rate.limit.failover.local-only:false}") boolean failoverLocalOnly;
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
String biz = rateLimit.biz();
String key = biz + ":" + getPrincipal(pjp);
if (!health.isBroken() && !failoverLocalOnly) {
if (!localLimiter.tryAcquire("local:" + key, 10, TimeUnit.MILLISECONDS)) {
return tooManyRequests("local");
}
if (!redisLimiter.tryAcquire(key, redisLimit, windowSeconds)) {
return tooManyRequests("redis");
}
} else {
if (!localLimiter.tryAcquire("local:" + key, 50, TimeUnit.MILLISECONDS)) {
return tooManyRequests("local_fallback");
}
// 可选:故障期写入本地日志/Kafka,待恢复后补偿
// localLog.append(key, Instant.now());
}
return pjp.proceed();
}
private Object tooManyRequests(String source) {
Metrics.counter("rate_limit.blocked", "source", source).increment();
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, "请求过于频繁");
}
private String getPrincipal(JoinPoint pjp) { return "u123"; } // 示例:从上下文解析
}
三 半开回切与恢复补偿
- 1 半开探测与渐进放量
- 作用:Redis 恢复后避免"直接全量 "带来的二次冲击,采用小流量探测 → 逐步放量。
- 关键点:维护openRatio (0/1/10/50/100% );首次恢复放行 1% ,稳定后按 10% → 50% → 100% 递进;探测失败立即回退。
- 何时触发回退:探测阶段错误率/延迟异常即回到 0%,延长观测窗口。
java
@Component
public class RateLimitWarmUp {
@Autowired private RedisHealth health;
@Autowired private StringRedisTemplate redis;
private final AtomicInteger openRatio = new AtomicInteger(0); // 0, 1, 10, 50, 100(%)
@Scheduled(fixedDelay = 10_000)
public void warmUp() {
if (health.isBroken()) { openRatio.set(0); return; }
Long fail = redis.opsForValue().increment("rate_limit:health:failure");
if (fail == null) return;
if (fail == 1) {
openRatio.set(1);
redis.expire("rate_limit:health:failure", 300, TimeUnit.SECONDS);
} else if (fail >= 3) {
openRatio.set(0);
redis.expire("rate_limit:health:failure", 60, TimeUnit.SECONDS);
} else {
int cur = openRatio.get();
if (cur < 100) openRatio.set(Math.min(100, cur * 10));
}
}
public int getOpenRatio() { return openRatio.get(); }
}
- 2 故障期补偿(示例:消费本地日志并回补 Redis)
- 作用:收敛 Redis 故障期的计数缺口,尽量恢复全局一致性。
- 关键点:故障期将 key + 时间戳 写入内存队列 ;定时批量聚合(如 1000 条/批);回写 Redis(可用 INCRBY 或 Lua 批处理);后续可落库对账与清理。
- 何时触发回补:Redis 恢复且队列非空即触发;补偿任务失败需重试与告警。
java
@Component
public class LocalCountCompensator {
@Autowired private StringRedisTemplate redis;
private final Queue<LocalRecord> buffer = new ConcurrentLinkedQueue<>();
public void record(String key, Instant ts) { buffer.offer(new LocalRecord(key, ts)); }
@Scheduled(fixedDelay = 5_000)
public void flush() {
if (buffer.isEmpty()) return;
List<LocalRecord> batch = new ArrayList<>();
while (buffer.size() > 1000) batch.add(buffer.poll());
Map<String, Long> counts = batch.stream()
.collect(Collectors.groupingBy(r -> r.key, Collectors.counting()));
counts.forEach((k, v) -> redis.opsForValue().increment("rate_limit:comp:" + k, v));
// 可进一步落库对账与清理
}
record LocalRecord(String key, Instant ts) {}
}
四 监控指标与告警示例
- 建议暴露与告警的关键指标
- Redis:连接成功率、PING P50/P95/P99 、故障标记计数(rate_limit:health:failure)。
- 本地限流:本地命中率、拒绝率、令牌等待时长(按接口/用户/租户维度)。
- 全局一致性:分布式与本地放行差值、补偿任务延迟与成功率。
- 业务稳定性:429 比例、线程池拒绝数、下游错误率、MQ 堆积。
- Prometheus 示例
yaml
- name: rate_limit
rules:
- alert: RedisUnavailable
expr: rate(rate_limit_health_failure_total[1m]) > 0
for: 30s
labels: severity=p0
annotations:
summary: "Redis 连续探测失败,已触发限流降级"
- alert: LocalLimiterHighReject
expr: sum(rate(rate_limit_blocked_total{source="local_fallback"}[1m])) / sum(rate(rate_limit_total[1m])) > 0.05
for: 2m
labels: severity=p1
annotations:
summary: "本地兜底限流拒绝率过高"