本地限流与 Redis 分布式限流的无缝切换 技术栈:Sentinel 线程池隔离 + Nginx + Kafka

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 恢复后避免"直接全量 "带来的二次冲击,采用小流量探测 → 逐步放量
    • 关键点:维护openRatio0/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(可用 INCRBYLua 批处理);后续可落库对账与清理。
    • 何时触发回补: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: "本地兜底限流拒绝率过高"
相关推荐
2602_959960922 小时前
电商大厂Java面试实录:Spring Boot/JVM/Redis/Kafka/微服务/安全/测试全解析
java·jvm·spring boot·redis·面试题
天涯明月19933 小时前
ray深度研究报告
大数据·人工智能·分布式·ray
小的~~4 小时前
面试被问懵了?为什么 Redis 单线程还能保证 Lua 脚本原子性,却偏偏选了 Lua?
redis·面试·lua
Rain的Java大神之路13 小时前
介绍一下分布式事务
java·分布式·后端·spring·spring cloud·架构·springcloud
做一个AK梦21 小时前
Redis 深入浅出
java·数据库·redis·缓存
ruleslol21 小时前
Redis 雪崩与服务降级
数据库·redis
Java牛马1 天前
Caffeine 缓存及其应用相关总结
java·redis·缓存·caffeine·数据一致性·本地缓存
李可以量化1 天前
Redis 从了解到精通(三)上:量化交易场景下的数据备份与安全配置
redis·python·安全·qmt·ptrade
Patrick在香港1 天前
Python asyncio + aiohttp 并发抓取实战:把同步爬虫改造成 3 倍吞吐量的并发管道
redis·爬虫·python
大菠萝爱上小西瓜1 天前
【大数据实战】Spark 2.2.2 完全分布式集群搭建
大数据·分布式·spark