本地限流与 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: "本地兜底限流拒绝率过高"
相关推荐
云技纵横2 小时前
Redis 数据结构底层与 Hash 优于 JSON 的工程实践
数据结构·redis·哈希算法
源代码•宸2 小时前
goframe框架签到系统项目开发(分布式 ID 生成器、雪花算法、抽离业务逻辑到service层)
经验分享·分布式·mysql·算法·golang·雪花算法·goframe
-Xie-2 小时前
Redis(十八)——底层数据结构(三)
数据库·redis·缓存
无盐海2 小时前
Redis 集群模式Redis Cluster
数据库·redis·缓存
刘个Java2 小时前
手搓遥控器通过上云api执行航线
java·redis·spring cloud·docker
好大哥呀2 小时前
Redis解析
数据库·redis·缓存
User_芊芊君子2 小时前
GLM-4.7 与 MiniMax M2.1 实测上线免费使用:国产大模型的 “工程化 + 长周期” 双赛道落地
数据库·redis·缓存
初级炼丹师(爱说实话版)2 小时前
ROS分布式通信和Socket.io通信的区别
分布式
阿方索2 小时前
Ceph 分布式存储
分布式·ceph