Redis + Lua 实现高性能分布式限流

在高并发分布式系统中,限流是保障系统稳定性的最后一道防线。当电商秒杀、大促活动、API 网关等场景遭遇流量洪峰时,若没有合理的限流机制,系统会因 CPU、内存、数据库连接等资源耗尽而发生雪崩式崩溃。传统的单机限流方案在微服务架构下已显乏力,而基于 Redis+Lua 的分布式限流方案凭借其高性能、原子性和全局一致性,成为业界主流选择。本文将从原理出发,深入解析令牌桶算法的核心逻辑,并结合生产级代码实现,带你从零搭建一个支持多维度组合的分布式限流系统。

一、为什么必须用分布式限流?

1.1 单机限流的致命缺陷

传统的单机限流工具(如 Google Guava 的 RateLimiter)在单体应用时代表现尚可,但在微服务架构下存在无法克服的局限性:

  • 全局状态不一致:每个服务实例独立维护自己的限流计数器,无法跨节点共享状态。例如,若设置全局 QPS 为 1000,部署 10 个实例,理论上每个实例应限 100QPS,但实际中负载均衡可能导致某个实例被集中访问,提前触发限流,而其他实例仍处于空闲状态,整体系统的实际承载能力远低于预期。
  • 扩容困难:当业务增长需要新增服务实例时,必须手动重新计算每个实例的限流阈值,运维成本极高,且容易出现配置错误。
  • 无法应对分布式攻击:针对 IP、用户维度的恶意刷接口行为,单机限流无法识别跨节点的请求,导致攻击者可以通过分散请求到不同实例来绕过限流。

1.2 分布式限流的核心优势

分布式限流通过中心化存储(Redis)统一管理所有服务实例的限流状态,从根本上解决了单机限流的问题:

  • 全局一致性:所有服务实例共享同一份限流数据,无论部署多少个实例,都能严格遵守全局限流阈值。
  • 灵活扩展:支持动态调整限流策略,无需重启服务,可通过配置中心实时修改阈值、时间窗口等参数。
  • 多维度精细化控制:可以按接口、IP、用户 ID、设备号等任意维度进行限流,甚至支持多维度组合(如同时限制某个用户在某个 IP 上的请求频率)。
  • 高可用:Redis 本身支持主从复制、哨兵模式和集群部署,可保证限流服务的高可用性。

二、技术选型与核心算法详解

2.1 技术栈选型:AOP + Redisson + Lua

本方案采用 Spring AOP + Redisson + Lua 的技术组合,各组件的职责如下:

  • Spring AOP:通过切面拦截带自定义注解的方法,实现无侵入式限流,无需修改业务代码。
  • Redisson:Java 生态中最成熟的 Redis 客户端,原生支持 Redis 集群模式、Lua 脚本执行和分布式锁,比 Jedis 更适合复杂的分布式场景。
  • Lua 脚本:将限流逻辑封装在 Lua 脚本中,由 Redis 原子执行,避免多线程并发下的竞态条件,保证限流逻辑的正确性。

2.2 限流算法对比

常见的限流算法有四种:固定窗口、滑动窗口、漏桶和令牌桶。每种算法都有其适用场景,我们需要根据业务需求选择最合适的算法。

(1)固定窗口算法

原理:将时间划分为固定大小的窗口(如 1 分钟),每个窗口内维护一个计数器,请求到达时计数器加 1,当计数器达到阈值时,拒绝后续请求,窗口结束后计数器清零。

优点:实现简单,性能高。

缺点:存在严重的 "临界问题"。例如,设置 1 分钟限 100QPS,在第 59 秒发送 100 个请求,第 1 分 01 秒再发送 100 个请求,实际上在 2 秒内处理了 200 个请求,远超限流阈值。

适用场景:对限流精度要求不高的简单场景。

代码实现:

java 复制代码
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

/**
 * 固定窗口计数器限流算法
 * 优点:实现简单、性能极高
 * 缺点:存在"临界问题"(窗口交界处可能出现2倍阈值的突发流量)
 */
public class FixedWindowRateLimiter {
    // 时间窗口大小(毫秒)
    private final long windowSizeMs;
    // 窗口内允许的最大请求数
    private final int maxRequests;
    // 当前窗口的请求计数器(原子类保证线程安全)
    private final AtomicInteger counter = new AtomicInteger(0);
    // 窗口开始时间(原子类保证线程安全)
    private final AtomicLong windowStartTime = new AtomicLong(System.currentTimeMillis());

    public FixedWindowRateLimiter(long windowSizeMs, int maxRequests) {
        this.windowSizeMs = windowSizeMs;
        this.maxRequests = maxRequests;
    }

    /**
     * 尝试获取令牌
     * @return true-获取成功(允许请求),false-获取失败(限流)
     */
    public boolean tryAcquire() {
        long currentTime = System.currentTimeMillis();
        // 1. 判断是否进入新窗口
        if (currentTime - windowStartTime.get() > windowSizeMs) {
            // 重置窗口:CAS操作保证只有一个线程能重置成功
            if (windowStartTime.compareAndSet(windowStartTime.get(), currentTime)) {
                counter.set(0);
            }
        }
        // 2. 原子性增加计数并判断是否超过阈值
        return counter.incrementAndGet() <= maxRequests;
    }

    // 测试用例
    public static void main(String[] args) throws InterruptedException {
        // 1秒内最多允许5个请求
        FixedWindowRateLimiter limiter = new FixedWindowRateLimiter(1000, 5);
        // 模拟10个并发请求
        for (int i = 0; i < 10; i++) {
            final int requestId = i;
            new Thread(() -> {
                if (limiter.tryAcquire()) {
                    System.out.println("请求" + requestId + ":成功");
                } else {
                    System.out.println("请求" + requestId + ":被限流");
                }
            }).start();
        }
        // 等待1秒后,窗口重置,再发5个请求
        Thread.sleep(1000);
        System.out.println("===== 窗口重置 =====");
        for (int i = 10; i < 15; i++) {
            final int requestId = i;
            new Thread(() -> {
                if (limiter.tryAcquire()) {
                    System.out.println("请求" + requestId + ":成功");
                } else {
                    System.out.println("请求" + requestId + ":被限流");
                }
            }).start();
        }
    }
}

(2)滑动窗口算法

原理:将固定窗口进一步划分为多个小的时间片(如将 1 分钟划分为 6 个 10 秒的时间片),每个时间片维护独立的计数器。当请求到达时,计算当前时间所在的窗口(包含最近 6 个时间片),将所有时间片的计数器相加,若超过阈值则拒绝请求。窗口会随着时间滑动,不断淘汰过期的时间片。

优点:解决了固定窗口的临界问题,限流精度更高。

缺点:实现复杂,需要维护多个时间片的计数器,性能稍差。

适用场景:对限流精度要求较高的场景。

代码实现:

java 复制代码
import java.util.concurrent.atomic.AtomicIntegerArray;

/**
 * 滑动窗口计数器限流算法
 * 优点:解决了固定窗口的"临界问题",限流精度更高
 * 缺点:实现稍复杂,需要维护多个时间片的计数器
 */
public class SlidingWindowRateLimiter {
    // 总窗口大小(毫秒)
    private final long windowSizeMs;
    // 每个小时间片的大小(毫秒)
    private final long sliceSizeMs;
    // 时间片数量
    private final int sliceCount;
    // 每个时间片的请求计数器(原子数组保证线程安全)
    private final AtomicIntegerArray counters;
    // 上一次请求的时间戳
    private volatile long lastRequestTime;
    // 上一次请求所在的时间片索引
    private volatile int lastSliceIndex;

    public SlidingWindowRateLimiter(long windowSizeMs, int sliceCount, int maxRequests) {
        this.windowSizeMs = windowSizeMs;
        this.sliceCount = sliceCount;
        this.sliceSizeMs = windowSizeMs / sliceCount;
        this.counters = new AtomicIntegerArray(sliceCount);
        this.lastRequestTime = System.currentTimeMillis();
        this.lastSliceIndex = getCurrentSliceIndex();
    }

    /**
     * 获取当前时间对应的时间片索引
     */
    private int getCurrentSliceIndex() {
        return (int) ((System.currentTimeMillis() / sliceSizeMs) % sliceCount);
    }

    /**
     * 尝试获取令牌
     * @return true-获取成功,false-被限流
     */
    public synchronized boolean tryAcquire() {
        long currentTime = System.currentTimeMillis();
        int currentSliceIndex = getCurrentSliceIndex();

        // 1. 清除所有过期的时间片(滑动窗口)
        long timePassed = currentTime - lastRequestTime;
        if (timePassed > windowSizeMs) {
            // 超过整个窗口大小,全部清零
            for (int i = 0; i < sliceCount; i++) {
                counters.set(i, 0);
            }
        } else {
            // 清除从上次请求到现在之间过期的时间片
            int slicesToClear = (int) (timePassed / sliceSizeMs);
            for (int i = 1; i <= slicesToClear; i++) {
                int index = (lastSliceIndex + i) % sliceCount;
                counters.set(index, 0);
            }
        }

        // 2. 计算当前窗口内的总请求数
        int totalRequests = 0;
        for (int i = 0; i < sliceCount; i++) {
            totalRequests += counters.get(i);
        }

        // 3. 判断是否超过阈值
        if (totalRequests >= maxRequests) {
            return false;
        }

        // 4. 当前时间片计数加1
        counters.incrementAndGet(currentSliceIndex);
        lastRequestTime = currentTime;
        lastSliceIndex = currentSliceIndex;
        return true;
    }

    // 测试用例
    public static void main(String[] args) throws InterruptedException {
        // 1秒窗口,划分为10个100毫秒的时间片,最多允许5个请求
        SlidingWindowRateLimiter limiter = new SlidingWindowRateLimiter(1000, 10, 5);
        // 模拟10个请求,间隔100毫秒
        for (int i = 0; i < 10; i++) {
            final int requestId = i;
            new Thread(() -> {
                if (limiter.tryAcquire()) {
                    System.out.println("请求" + requestId + ":成功");
                } else {
                    System.out.println("请求" + requestId + ":被限流");
                }
            }).start();
            Thread.sleep(100);
        }
    }
}

(3)漏桶算法

原理:将请求比作水,漏桶比作队列,水以恒定的速度从漏桶流出。当水流入速度超过流出速度时,多余的水会溢出(拒绝请求)。

优点:可以强制限制请求的处理速度,实现削峰填谷,使系统输出流量保持平稳。

缺点:不允许突发流量,即使系统资源空闲,也只能以固定速度处理请求,无法充分利用系统资源。

适用场景:需要严格控制请求处理速度的场景,如消息队列消费。

代码实现:

java 复制代码
/**
 * 漏桶限流算法
 * 优点:可以强制限制请求的处理速度,输出流量非常平稳
 * 缺点:不允许突发流量,即使系统资源空闲也只能以固定速度处理
 */
public class LeakyBucketRateLimiter {
    // 桶的容量(最大排队请求数)
    private final int capacity;
    // 漏水速度(每秒处理的请求数)
    private final double leakRate;
    // 当前桶中的水量(排队的请求数)
    private double currentWater;
    // 上次漏水的时间戳
    private long lastLeakTime;

    public LeakyBucketRateLimiter(int capacity, double leakRate) {
        this.capacity = capacity;
        this.leakRate = leakRate;
        this.currentWater = 0;
        this.lastLeakTime = System.currentTimeMillis();
    }

    /**
     * 尝试获取令牌
     * @return true-获取成功(请求进入桶中等待处理),false-被限流(桶满)
     */
    public synchronized boolean tryAcquire() {
        long currentTime = System.currentTimeMillis();
        // 1. 计算从上次漏水到现在应该漏出的水量
        double leakedWater = (currentTime - lastLeakTime) / 1000.0 * leakRate;
        // 2. 更新当前水量(不能小于0)
        currentWater = Math.max(0, currentWater - leakedWater);
        lastLeakTime = currentTime;

        // 3. 判断桶是否已满
        if (currentWater < capacity) {
            currentWater += 1;
            return true;
        }
        return false;
    }

    // 测试用例
    public static void main(String[] args) throws InterruptedException {
        // 桶容量10,每秒漏水5个(即每秒处理5个请求)
        LeakyBucketRateLimiter limiter = new LeakyBucketRateLimiter(10, 5);
        // 模拟15个突发请求
        for (int i = 0; i < 15; i++) {
            final int requestId = i;
            new Thread(() -> {
                if (limiter.tryAcquire()) {
                    System.out.println("请求" + requestId + ":进入桶中");
                } else {
                    System.out.println("请求" + requestId + ":被限流(桶满)");
                }
            }).start();
        }
        // 等待2秒,观察漏水效果
        Thread.sleep(2000);
        System.out.println("===== 2秒后 =====");
        // 再发5个请求
        for (int i = 15; i < 20; i++) {
            final int requestId = i;
            new Thread(() -> {
                if (limiter.tryAcquire()) {
                    System.out.println("请求" + requestId + ":进入桶中");
                } else {
                    System.out.println("请求" + requestId + ":被限流(桶满)");
                }
            }).start();
        }
    }
}

(4)令牌桶算法(本方案选择)

原理:系统以恒定的速度向令牌桶中放入令牌,当请求到达时,需要从桶中获取一个令牌才能被处理。如果桶中没有令牌,则拒绝请求。令牌桶的容量是固定的,当令牌放满时,多余的令牌会被丢弃。

核心优势

  • 允许突发流量:令牌桶可以积累令牌,当系统空闲时,令牌会逐渐填满桶,此时如果有突发流量到来,可以一次性获取多个令牌进行处理,充分利用系统资源。
  • 平滑限流:令牌以恒定速度放入桶中,避免了固定窗口的临界问题,使请求处理速度更加平滑。
  • 易于实现多维度限流:每个限流维度(如接口、IP、用户)可以维护独立的令牌桶,互不干扰。

令牌桶算法的数学模型

  • 设令牌桶容量为max_tokens(即最大突发请求数)
  • 令牌生成速率为rate(即每秒生成的令牌数,等于限流 QPS)
  • 当前令牌数为current_tokens
  • 当请求到达时,若current_tokens >= 1,则current_tokens -= 1,请求被处理;否则拒绝请求。
  • 每隔 1/rate 秒,current_tokens += 1,但不超过max_tokens

代码实现:

java 复制代码
/**
 * 令牌桶限流算法(工业界标准)
 * 优点:允许突发流量,同时能平滑限流,兼顾性能和灵活性
 * 缺点:实现稍复杂
 */
public class TokenBucketRateLimiter {
    // 令牌桶容量(最大突发请求数)
    private final int capacity;
    // 令牌生成速度(每秒生成的令牌数,即限流QPS)
    private final double tokenRate;
    // 当前令牌数
    private double currentTokens;
    // 上次生成令牌的时间戳
    private long lastTokenTime;

    public TokenBucketRateLimiter(int capacity, double tokenRate) {
        this.capacity = capacity;
        this.tokenRate = tokenRate;
        // 初始时桶是满的
        this.currentTokens = capacity;
        this.lastTokenTime = System.currentTimeMillis();
    }

    /**
     * 尝试获取令牌
     * @return true-获取成功(允许请求),false-获取失败(限流)
     */
    public synchronized boolean tryAcquire() {
        return tryAcquire(1);
    }

    /**
     * 尝试获取指定数量的令牌
     * @param permits 需要获取的令牌数
     * @return true-获取成功,false-获取失败
     */
    public synchronized boolean tryAcquire(int permits) {
        if (permits <= 0 || permits > capacity) {
            return false;
        }
        long currentTime = System.currentTimeMillis();
        // 1. 计算从上次生成令牌到现在应该生成的令牌数
        double generatedTokens = (currentTime - lastTokenTime) / 1000.0 * tokenRate;
        // 2. 更新当前令牌数(不能超过桶的容量)
        currentTokens = Math.min(capacity, currentTokens + generatedTokens);
        lastTokenTime = currentTime;

        // 3. 判断是否有足够的令牌
        if (currentTokens >= permits) {
            currentTokens -= permits;
            return true;
        }
        return false;
    }

    // 测试用例
    public static void main(String[] args) throws InterruptedException {
        // 令牌桶容量10,每秒生成5个令牌(即限流QPS=5,最大突发10个请求)
        TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(10, 5);
        // 模拟15个突发请求
        System.out.println("===== 突发15个请求 =====");
        for (int i = 0; i < 15; i++) {
            final int requestId = i;
            new Thread(() -> {
                if (limiter.tryAcquire()) {
                    System.out.println("请求" + requestId + ":成功");
                } else {
                    System.out.println("请求" + requestId + ":被限流");
                }
            }).start();
        }
        // 等待1秒,令牌桶会补充5个令牌
        Thread.sleep(1000);
        System.out.println("===== 1秒后 =====");
        // 再发10个请求
        for (int i = 15; i < 25; i++) {
            final int requestId = i;
            new Thread(() -> {
                if (limiter.tryAcquire()) {
                    System.out.println("请求" + requestId + ":成功");
                } else {
                    System.out.println("请求" + requestId + ":被限流");
                }
            }).start();
        }
    }
}

三、生产级分布式限流系统实现

3.1 自定义限流注解:灵活配置多维度策略

首先定义一个 @RateLimit 注解,用于标记需要限流的方法,并配置限流参数。注解支持多维度组合限流、可配置的时间窗口和降级方法,满足不同业务场景的需求。

java 复制代码
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
    /**
     * 限流维度枚举
     */
    enum Dimension {
        GLOBAL,  // 全局限流(所有请求共享一个令牌桶)
        IP,      // IP维度限流(每个IP一个令牌桶)
        USER     // 用户维度限流(每个用户一个令牌桶)
    }

    /**
     * 限流维度(支持组合,如同时限制全局和用户)
     */
    Dimension[] dimensions() default {
        Dimension.GLOBAL
};

    /**
     * 时间窗口内允许的最大请求数(即令牌桶容量)
     */
    double count();

    /**
     * 时间窗口大小
     */
    long interval() default 1;

    /**
     * 时间单位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 降级方法名(限流时调用该方法返回结果)
     */
    String fallback() default "";
}
  • 支持多维度组合,例如 @RateLimit(dimensions = {Dimension.GLOBAL, Dimension.USER}, count = 1000, interval = 1)表示同时限制全局 QPS 为 1000,且每个用户的 QPS 不超过 1000。
  • 可配置降级方法,限流时优雅返回自定义结果,而非直接抛出异常,提升用户体验。
  • 时间单位灵活,支持秒、分钟、小时等多种时间窗口。

3.2 AOP 切面:无侵入式拦截与逻辑处理

通过 Spring AOP 拦截所有标记了 @RateLimit 注解的方法,在方法执行前执行限流逻辑。切面负责生成限流 Key、调用 Lua 脚本执行限流判断、处理限流结果和降级逻辑。

java 复制代码
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RScript;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.StringCodec;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

@Aspect
@Component
public class RateLimitAspect {
    @Autowired
    private RedissonClient redissonClient;

    private String luaScriptSha;

    // Lua脚本内容(见下文)
    private static final String LUA_SCRIPT = "..."

    @PostConstruct
    public void init() {
        // 预加载 Lua 脚本到 Redis,获取 SHA 1值,减少网络传输开销
        this.luaScriptSha = 
  redissonClient.getScript(StringCodec.INSTANCE).scriptLoad(LUA_SCRIPT);
    }

    @Around("@annotation(rateLimit)")
    public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
        // 1. 计算时间窗口(转换为毫秒)
        long intervalMs = rateLimit.timeUnit().toMillis(rateLimit.interval());
        // 2. 获取目标类和方法名
        String className = joinPoint.getTarget().getClass().getName();
        String methodName = joinPoint.getSignature().getName();
        // 3. 生成限流Key(使用Hash Tag适配Redis集群)
        List<String> keys = generateKeys(className, methodName, rateLimit.dimensions());
        // 4. 构造Lua脚本参数
        List<Object> args = new ArrayList<>();
        args.add(rateLimit.count()); // 最大令牌数
        args.add(intervalMs); // 时间窗口(毫秒)
        args.add(System.currentTimeMillis()); // 当前时间戳
        args.add(1); // 每次请求消耗的令牌数
        args.add(intervalMs * 2 / 1000); // Key过期时间(窗口的2倍)

        // 5. 执行Lua脚本
        RScript script = redissonClient.getScript(StringCodec.INSTANCE);
        Long result = script.evalSha(RScript.Mode.READ_WRITE, luaScriptSha,
                RScript.ReturnType.VALUE, keys, args.toArray());

        // 6. 处理限流结果
        if (result == null || result == 0) {
            // 触发限流,执行降级方法
            return handleFallback(joinPoint, rateLimit);
        }

        // 7. 执行业务方法
        return joinPoint.proceed();
    }


    /**
     * 生成限流Key,使用Hash Tag确保同一方法的所有Key落在同一个Redis Slot
     */
    private List<String> generateKeys(String className, String methodName, RateLimit.Dimension[] dimensions) {
        List<String> keys = new ArrayList<>();
        // Hash Tag:用{}包裹类名和方法名,确保所有Key落在同一个Slot
        String hashTag = "{" + className + ":" + methodName + "}";
        String keyPrefix = "ratelimit:" + hashTag;

        for (RateLimit.Dimension dimension : dimensions) {
            switch (dimension) {
                case GLOBAL:
                    keys.add(keyPrefix + ":global");
                    break;
                case IP:
                    String ip = getClientIp();
                    keys.add(keyPrefix + ":ip:" + ip);
                    break;
                case USER:
                    // 从请求上下文获取当前用户ID(需根据实际项目调整)
                    String userId = getCurrentUserId();
                    keys.add(keyPrefix + ":user:" + userId);
                    break;
            }
        }
        return keys;
    }


    /**
     * 执行降级方法
     */
    private Object handleFallback(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
        String fallbackName = rateLimit.fallback();
        if (fallbackName.isEmpty()) {
            // 未配置降级方法,抛出默认异常
            throw new RuntimeException("请求过于频繁,请稍后再试");
        }

        // 查找降级方法(优先匹配同参数列表,其次匹配无参方法)
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Class<?> targetClass = joinPoint.getTarget().getClass();
        Class<?>[] parameterTypes = signature.getParameterTypes();
        Method fallbackMethod;
        try {
            fallbackMethod = targetClass.getDeclaredMethod(fallbackName, parameterTypes);
        } catch (NoSuchMethodException e) {
            fallbackMethod = targetClass.getDeclaredMethod(fallbackName);
        }
        fallbackMethod.setAccessible(true);

        // 调用降级方法
        return fallbackMethod.invoke(joinPoint.getTarget(), joinPoint.getArgs());
    }


    // 辅助方法:获取客户端IP
    private String getClientIp() {
        HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }


    // 辅助方法:获取当前用户ID(需根据实际项目实现)
    private String getCurrentUserId() {
        // 示例:从Token中解析用户ID
        return "123456";
    }
}
  • Redis Cluster 兼容性 :使用 Hash Tag({className:methodName} )将同一方法的所有限流 Key 映射到同一个 Redis Slot。在 Redis Cluster 模式下,Lua 脚本只能操作同一个 Slot 内的 Key,否则会报错。Hash Tag 通过将 Key 中{}内的部分作为分片依据,确保相关 Key 落在同一个节点。
  • Lua 脚本预加载 :在 @PostConstruct 方法中将 Lua 脚本加载到 Redis 并获取 SHA1 值,后续通过 **evalSha**调用脚本,避免每次传输完整的脚本内容,大幅减少网络开销。
  • 智能降级处理:支持两种降级方法签名 ------ 与原方法同参数列表的方法和无参方法,提升了降级逻辑的灵活性。

3.3 Lua 脚本:原子性限流的核心

Lua 脚本是整个限流系统的灵魂,它将所有限流逻辑封装在一个脚本中,由 Redis 原子执行,确保在高并发下不会出现竞态条件。本方案采用两阶段提交的设计思想:先检查所有维度的令牌是否充足,全部通过后再统一扣减令牌,避免部分扣减导致的数据不一致。

Lua 复制代码
-- 令牌桶限流Lua脚本
-- KEYS: 限流Key列表(每个维度一个Key)
-- ARGV: [1]max_tokens(最大令牌数), [2]interval_ms(时间窗口毫秒), [3]now_ms(当前时间戳), [4]permits(每次请求消耗的令牌数), [5]expire_time(Key过期时间秒)

local max_tokens = tonumber(ARGV[1])
local interval_ms = tonumber(ARGV[2])
local now_ms = tonumber(ARGV[3])
local permits = tonumber(ARGV[4])
local expire_time = tonumber(ARGV[5])

-- 第一阶段:预检查所有维度的令牌是否充足
for i, key in ipairs(KEYS) do
    local value_key = key .. ":value"  -- 存储当前令牌数的Key
    local permits_key = key .. ":permits"  -- 存储请求记录的ZSet Key

    -- 初始化令牌桶(如果不存在)
    if redis.call("exists", value_key) == 0 then
        redis.call("set", value_key, max_tokens)
    end

    -- 回收过期令牌:删除interval_ms之前的请求记录,并将对应的令牌放回桶中
    local expired_values = redis.call("zrangebyscore", permits_key, 0, now_ms - interval_ms)
    if #expired_values > 0 then
        local expired_count = 0
        for _, v in ipairs(expired_values) do
            -- 解析请求记录中的令牌数(格式:request_id:permits)
            local _, p = string.match(v, "(.*):(.*)")
            expired_count = expired_count + tonumber(p)
        end
        -- 删除过期记录
        redis.call("zremrangebyscore", permits_key, 0, now_ms - interval_ms)
        -- 回收令牌
        local curr_v = tonumber(redis.call("get", value_key))
        redis.call("set", value_key, math.min(max_tokens, curr_v + expired_count))
    end

    -- 检查当前令牌是否足够
    local current_val = tonumber(redis.call("get", value_key))
    if current_val < permits then
        return 0  -- 任一维度令牌不足,直接返回失败
    end
end

-- 第二阶段:所有维度检查通过,统一扣减令牌
for i, key in ipairs(KEYS) do
    local value_key = key .. ":value"
    local permits_key = key .. ":permits"

    -- 生成唯一请求ID(时间戳+随机数)
    local request_id = now_ms .. ":" .. math.random(1000000)
    -- 记录本次请求到ZSet(分数为时间戳,值为request_id:permits)
    redis.call("zadd", permits_key, now_ms, request_id .. ":" .. permits)
    -- 扣减令牌
    local current_v = tonumber(redis.call("get", value_key))
    redis.call("set", value_key, current_v - permits)
    -- 设置Key过期时间,防止内存泄漏
    redis.call("expire", value_key, expire_time)
    redis.call("expire", permits_key, expire_time)
end

return 1  -- 限流通过
  • 两阶段提交:先检查所有维度的令牌,全部通过后再扣减,避免出现 "部分维度扣减成功,部分失败" 的不一致情况。
  • ZSET 记录请求历史 :使用有序集合(ZSET)存储每次请求的时间戳和消耗的令牌数,便于精确回收过期令牌。ZSET 的分数为请求时间戳,值为request_id:permits格式的字符串。
  • 自动内存管理 :通过 zremrangebyscore 定期清理过期的请求记录,并设置 Key 的过期时间为时间窗口的 2 倍,确保过期令牌能被正常回收,同时避免长期占用 Redis 内存。
  • 原子性保证:整个脚本在 Redis 中作为一个原子操作执行,即使有多个请求同时到达,也不会出现竞态条件。

四、性能优化与生产环境注意事项

4.1 性能优化最佳实践

  1. 减少网络往返
    • 预加载 Lua 脚本:使用 scriptLoad+evalSha 替代 eval,每次调用仅传输 40 字节的 SHA1 值,而非完整的脚本内容。
    • 批量操作:在 Lua 脚本内部完成所有 Redis 命令,避免多次网络往返。
  2. 高效数据结构
    • 使用 String 存储当前令牌数,O (1) 复杂度的读取和更新。
    • 使用 ZSET 存储请求记录,支持按时间戳范围查询和删除,效率远高于 List 或 Hash。
  3. Redis 配置优化
    • 关闭不必要的持久化:限流数据属于临时数据,丢失后不会影响业务,可关闭 RDB 和 AOF 持久化,提高 Redis 性能。
    • 合理设置连接池:根据服务实例数量和并发量调整 Redisson 的连接池大小,避免连接瓶颈。
  4. 本地缓存兜底:当 Redis 出现故障时,使用本地 Guava RateLimiter 作为兜底方案,保证服务的可用性。

4.2 生产环境注意事项

  1. 限流阈值设置
    • 通过压测确定系统的最大承载能力,限流阈值应设置为最大承载能力的 80% 左右,预留一定的缓冲空间。
    • 不同接口的限流阈值应根据业务重要性和访问频率单独设置,核心接口可适当提高阈值。
  2. 监控与告警
    • 监控限流触发次数、Redis 的 QPS、内存使用率等指标,及时发现异常流量。
    • 当限流触发次数超过阈值时,发送告警通知,便于运维人员及时处理。
  3. 降级策略设计
    • 降级逻辑应尽可能简单,避免降级方法本身成为性能瓶颈。
    • 对于非核心接口,可直接返回默认值或缓存数据;对于核心接口,可采用排队等待或降级到备用服务的方式。
  4. 防刷机制
    • 对频繁触发限流的 IP 或用户进行临时拉黑,防止恶意攻击。
    • 结合验证码、滑块验证等手段,进一步提高系统的安全性。

五、总结与扩展

本文详细介绍了基于 Redis + Lua 的分布式限流方案,从原理到实现,深入解析了令牌桶算法的核心逻辑和生产级代码的设计要点。该方案具有高性能、原子性、全局一致性和多维度支持等优点,能够有效应对高并发场景下的流量冲击。

未来,我们可以在此基础上进行进一步扩展:

  • 支持动态调整限流策略:通过配置中心(如 Nacos、Apollo)实时修改限流阈值和时间窗口,无需重启服务。
  • 支持更复杂的限流算法:如基于令牌桶的动态限流,根据系统负载自动调整限流阈值。
  • 支持分布式限流集群:当单 Redis 实例性能不足时,可采用 Redis Cluster 或分片模式,将限流数据分散到多个节点。

分布式限流是高并发系统中不可或缺的组件,只有深入理解其原理并结合实际业务场景进行优化,才能构建出稳定可靠的系统。

相关推荐
衣舞晨风10 小时前
运行时行为盲区:API7 AI 网关CPU打满故障的AI辅助事后复盘
lua·openresty·apisix·coroutine·cpu-saturation·socket-buffer
月落归舟11 小时前
一篇文章了解Redis内存淘汰机制与过期Key清理
数据库·redis·mybatis
phltxy12 小时前
Redis 事务
数据库·redis·缓存
环流_13 小时前
redis核心数据类型在java中的操作
java·数据库·redis
接着奏乐接着舞18 小时前
java 数据结构
数据库·redis·缓存
许长安18 小时前
Redis 跳表实现详解
数据库·c++·经验分享·redis·笔记·缓存
難釋懷20 小时前
Redis网络模型-Redis是单线程的吗?为什么使用单线程
网络·数据库·redis
桂花很香,旭很美20 小时前
Redis-智能体开发中的大杀器
数据库·redis·缓存
OYangxf1 天前
对于AOF模块和命令层交互的理解
redis