java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot Redis限流

SpringBoot基于Redis和Lua构建分布式限流防御体系教学

作者:(farerboy)

本文手把手教你用Redis的SortedSet和Lua脚本实现原子化的滑动窗口限流,彻底解决固定窗口的临界突变问题,通过自定义注解和AOP切面,轻松给秒杀接口加上用户级、IP级和接口级的多维度防护,并附上压测数据证明性能损耗可忽略

1. 先聊聊限流算法那点事

限流这事,说穿了就是给系统装个闸门,流量大了就放行一部分,剩下的挡在外面。单机限流用 JVM 内存计数器就行,但一旦服务部署多份,计数器各数各的,总阈值根本控制不住。这时候就得找个公共的存储,Redis 顺手就干了。

算法本身不多,常用的就四种:固定窗口、滑动窗口、漏桶、令牌桶。先一个个捋清楚。

固定窗口最简单,按固定时间粒度(比如 1 分钟)划一个窗口,用 INCR 数请求,到阈值就拒,窗口重置后再来。优点是实现简单,内存开销小,适合对精度不太敏感的总量控制。缺点是有“临界突变”问题:阈值 100,第 59 秒来了 100 个请求,第 60 秒又来了 100 个,两个窗口 交界处 2 秒内就放过去了 200 个,系统可能直接被这一波顶垮。

滑动窗口把时间切成更小的格子(比如每秒一个),每个请求带着时间戳进来,统计当前窗口内的数量时,自动把窗口外的请求踢掉。这样就能平滑限制突发流量,不会出现固定窗口那种边界突刺。缺点是每个请求都得记时间戳,内存占用大一些,但如果用 Redis 的 ZSet,一个请求也就几十字节,扛得住。适合秒杀入口、接口防刷这种对突发敏感的场所。

漏桶这个思路更直观:请求先倒进桶里,桶底固定速率往外漏,桶满了就泼掉。输出速率恒定,下游系统最舒服,适合做流量整形,比如保护数据库、保护第三方调用。但你不能指望它应对突发,哪怕桶是空的,批量请求也只能按固定速度走,想爽快也是不行的。

令牌桶和漏桶相反,它以固定速率往桶里放令牌,桶满了令牌就丢弃。请求来了得从桶里拿一个令牌才能走,桶里攒下的令牌可以用来承受突发流量。Google 的 Guava RateLimiter 就是这路子。它适合 API 网关、服务间调用限流,既限制平均速率,又允许一定程度的突发。

算法没有绝对优劣,关键在于场景。网关层想保护下游就用令牌桶,秒杀接口想卡瞬时尖峰就用滑动窗口,数据库前想平滑流量就用漏桶。后面我们主要讲滑动窗口,因为秒杀场景下它最实用。

2. 滑动窗口用 Redis ZSet 实现?别忘了 Lua

滑动窗口用 Redis 通常就是拿 Sorted Set 搞:时间戳当 score,请求的唯一 ID 当 member。每次请求进来:

  1. ZREMRANGEBYSCORE 清掉窗口之外的数据;
  2. ZADD 写入当前请求;
  3. ZCARD 看看当前窗口内有多少个请求,超阈值就拒绝。

思路很直白,但有个坑:这三步不是原子的。假设两个请求同时查到剩余名额还有 1 个,然后两边都执行 ZADD,结果第 2 个请求也放行了,超限就这么发生了。

要解决就得把“检查 + 写入”打包成一个原子操作。Redis 的 Lua 脚本能在服务端单线程执行,天然串行化,正好干这个。

下面是限流脚本,支持一次检查多个 key,只有全部通过才写入,不然一个都不写:

-- KEYS[1..N]          N 个限流 key
-- ARGV[1..N]          N 个窗口大小(秒)
-- ARGV[N+1..2N]       N 个阈值
-- ARGV[2N+1]          当前时间戳(毫秒)
-- ARGV[2N+2]          请求唯一前缀(用于生成 member)
for i=1, #KEYS do
    local window = tonumber(ARGV[i])
    local threshold = tonumber(ARGV[i + #KEYS])
    local current = tonumber(ARGV[2 * #KEYS + 1])
    local expired = current - window * 1000
    redis.call('ZREMRANGEBYSCORE', KEYS[i], 0, expired)
    if redis.call('ZCARD', KEYS[i]) >= threshold then
        return 0
    end
end
for i=1, #KEYS do
    local window = tonumber(ARGV[i])
    local current = tonumber(ARGV[2 * #KEYS + 1])
    local member = ARGV[2 * #KEYS + 2] .. '-' .. i
    redis.call('ZADD', KEYS[i], current, member)
    redis.call('EXPIRE', KEYS[i], window)
end
return 1

先说下参数怎么对上的:ARGV[1..N] 是 window,ARGV[N+1..2N] 是 threshold,ARGV[2N+1] 是当前毫秒时间戳,ARGV[2N+2] 是个随机前缀,用来拼 member 保证唯一。

脚本里循环两遍,第一遍只做“清理+检查”,只要有一个 key 超了就直接返回 0,不会动第二个 key 的写操作。第二遍才把所有 key 都写入。这样确保“要么全通过,要么啥也不写”,避免部分写入造成统计混乱。

在 Redis 里执行这个脚本,多个并发请求会排队,后到的请求看到的就是前一个请求写完后的数据,从根上杜绝了超限。

这里为什么非要用 Sorted Set? ZREMRANGEBYSCORE 按 score 范围删,天生就是给时间窗口准备的;ZADD 以时间戳排序,后续想按时间查也方便;ZCARD 直接拿窗口内元素个数,时间复杂度 O(1)。member 用 UUID,多请求时间戳相同也不会冲突。

3. 封装成注解,像调用本地方法一样用

手写 Lua 脚本执行器太原始了,不如搞个注解,往方法上一挂,限流就生效。考虑到秒杀场景往往要多维度限流,注解得支持重复标注。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(RateLimits.class)
public @interface RateLimit {
    /** 限流 key,支持 SpEL 表达式 */
    String key();

    /** 窗口大小(秒) */
    long window() default 60;

    /** 窗口内最大请求数 */
    long threshold() default 100;

    /** 降级方法名,必须在同类中定义,返回值与原方法一致 */
    String fallback() default "";
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimits {
    RateLimit[] value();
}

Lua 脚本加载成 Spring Bean:

@Bean
public DefaultRedisScript<Long> rateLimitScript() {
    DefaultRedisScript<Long> script = new DefaultRedisScript<>();
    script.setScriptSource(new ResourceScriptSource(
        new ClassPathResource("scripts/sliding_window.lua")));
    script.setResultType(Long.class);
    return script;
}

接下来是重头戏:AOP 切面。这里有个大坑——切点不能只写 @annotation(rateLimits)。如果方法上只写了一个 @RateLimit,Spring 不会自动把单个注解包装成 @RateLimits,结果切面压根不触发。稳妥的做法:切点匹配单个 @RateLimit,再在方法体内用 AnnotatedElementUtils 把方法上所有重复注解都取出来。

@Aspect
@Component
public class RateLimitAspect {

    private final StringRedisTemplate redisTemplate;
    private final DefaultRedisScript<Long> rateLimitScript;
    private final SpelExpressionParser parser = new SpelExpressionParser();
    private final DefaultParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();

    public RateLimitAspect(StringRedisTemplate redisTemplate,
                           DefaultRedisScript<Long> rateLimitScript) {
        this.redisTemplate = redisTemplate;
        this.rateLimitScript = rateLimitScript;
    }

    @Around("@annotation(io.ratelimit.RateLimit)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();

        RateLimit[] limits = AnnotatedElementUtils.getMergedRepeatableAnnotations(
                method, RateLimit.class, RateLimits.class);
        if (limits.length == 0) {
            return joinPoint.proceed();
        }

        List<String> keys = new ArrayList<>();
        List<Long> windows = new ArrayList<>();
        List<Long> thresholds = new ArrayList<>();

        for (RateLimit limit : limits) {
            keys.add("rate:limit:" + evalKey(limit.key(), joinPoint));
            windows.add(limit.window());
            thresholds.add(limit.threshold());
        }

        List<String> args = new ArrayList<>();
        windows.forEach(w -> args.add(String.valueOf(w)));
        thresholds.forEach(t -> args.add(String.valueOf(t)));
        args.add(String.valueOf(System.currentTimeMillis()));
        args.add(UUID.randomUUID().toString());

        Long result = redisTemplate.execute(
            rateLimitScript,
            keys,
            args.toArray(new String[0])
        );

        if (result != null && result == 1L) {
            return joinPoint.proceed();
        }

        RateLimit failed = limits[0];
        if (!failed.fallback().isEmpty()) {
            return invokeFallback(joinPoint, failed.fallback());
        }
        throw new RateLimitException("Too many requests");
    }

    private String evalKey(String expression, ProceedingJoinPoint joinPoint) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Object[] args = joinPoint.getArgs();
        String[] paramNames = parameterNameDiscoverer.getParameterNames(signature.getMethod());

        StandardEvaluationContext context = new StandardEvaluationContext();
        for (int i = 0; i < paramNames.length; i++) {
            context.setVariable(paramNames[i], args[i]);
        }
        return parser.parseExpression(expression).getValue(context, String.class);
    }

    private Object invokeFallback(ProceedingJoinPoint joinPoint, String fallback) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method targetMethod = joinPoint.getTarget().getClass()
                .getDeclaredMethod(fallback, signature.getParameterTypes());
        targetMethod.setAccessible(true);
        return targetMethod.invoke(joinPoint.getTarget(), joinPoint.getArgs());
    }
}

注意两点:

自动装配很简单,配置类里声明切面 Bean,然后在 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 里加上配置类全限定名(如果你用的是 Spring Boot 2.7 之前的老项目,那就写在 spring.factories 里)。具体细节就不展开了。

用法就像这样:

@RestController
public class SeckillController {

    @RateLimit(key = "'seckill:api'", window = 1, threshold = 1000, fallback = "apiFallback")
    @RateLimit(key = "'seckill:user:' + #userId", window = 60, threshold = 3, fallback = "userFallback")
    @RateLimit(key = "'seckill:ip:' + #ip", window = 60, threshold = 5, fallback = "ipFallback")
    @PostMapping("/seckill")
    public Order doSeckill(Long userId, String ip) {
        // 秒杀业务逻辑
        return new Order();
    }

    public Order apiFallback(Long userId, String ip) {
        return Order.failed("系统繁忙");
    }

    public Order userFallback(Long userId, String ip) {
        return Order.failed("请勿重复提交");
    }

    public Order ipFallback(Long userId, String ip) {
        return Order.failed("请求过于频繁");
    }
}

SpEL 表达式里的 #userId#ip 会自动去方法参数表里找同名变量。key 里用单引号包裹字符串,Spring 会把它当成 String 字面量处理。

4. 秒杀场景下的多维度限流

秒杀这个场景,瞬时流量高,恶意刷 单多,光靠一个维度不行。一般至少三层:

接口级限流控制整体 QPS。比如 1 秒最多 1000 个请求进业务逻辑,超出的直接丢弃或者返回“系统繁忙”。这样后端数据库不会被一波大流量打死。

用户级限流限制单个用户的操作频率。一个用户 60 秒内只能提交 3 次秒杀请求,多了就是他拿脚本在重复刷,虽然不一定能成功,但也不能让他无限打。

IP 级限流对付一波机器脚本。同一 IP 60 秒内最多 5 次,超过就拒绝。别指望 IP 能完美分辨人,但至少能挡掉一大部分低成本攻击。

把三个注解摞在一个方法上,切面会把三个 key 放到一个 Lua 脚本里执行,一次网络往返完成所有检查,而且脚本是原子的,不会出现 A 维度通过了 B 维度还没写但并发绕过的情况。这比发三条独立命令靠谱得多。

限流只是第一道门,秒杀业务里还有库存预扣、订单创建等一系列操作。这些操作不要求强原子性,可以考虑用 Pipeline 减少网络往返:

public void afterSeckillSuccess(Long userId, Long goodsId) {
    stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
        connection.incr("seckill:success:count".getBytes());
        connection.setEx("seckill:success:" + userId, 300, "1".getBytes());
        connection.hSet("seckill:order".getBytes(), userId.toString().getBytes(),
                goodsId.toString().getBytes());
        return null;
    });
}

Pipeline 把多条命令打包一次性发给 Redis,在数据库之前尽量缩短路径。

完整流程基本上是这样:用户点“秒杀”→ 请求到接口 → 切面执行 Lua 脚本,三个 key 一起判断 → 任一超限就返回降级提示,不碰业务逻辑 → 全部通过才执行秒杀事务 → 库存扣减、订单创建 → 异步发通知。通过这种分层防护,恶意请求基本挡在最外圈。

5. 监控和压测,看看效果如何

限流不能黑盒操作,得知道拦截了多少、放行了多少。用 Redis 计数器在脚本里 INCR 也行,但会额外增加几次 Redis 调用,高并发下不划算。更推荐在 AOP 切面里直接埋 Micrometer 计数器,放行和拒绝分别统计,然后接 Prometheus + Grafana 做可视化。这样业务代码无侵入,也不会增加 Redis 开销。

压测数据是我在本地环境跑的:单台 Redis 5.0,4 核 8G 虚拟机;Spring Boot 2.7,Tomcat 最大线程 200;JMeter 500 线程持续压 1 分钟。不限制流量时平均响应 15ms,加上单维度限流后平均 16ms,三维度组合限流平均 17ms。多出来的 1~2ms 主要是执行 Lua 脚本本身,在 Redis 内部走一圈,比一次网络 RTT 小得多。三维度和单维度性能几乎一样,因为 Lua 脚本是逻辑复用,只多几个 key 而已。整个压测过程中没有发现任何一次超限请求,限流准确率 100%。

所以别被“Lua 脚本影响性能”这种说法吓到,只要不是无脑在脚本里写重循环,影响基本可以忽略。

6. 最后说点实际的

限流不是银弹,得和熔断、降级、隔离配合着用。算法选型也要灵活:网关层用令牌桶,保证整体吞吐;秒杀接口用滑动窗口,卡住瞬时尖峰;数据库前用漏桶,平滑请求速率。这不是非黑即白的事,混着用才是常态。

做这套东西的时候,最好把阈值做成动态配置,改配置不用发版;限流 key 记得设置过期时间,免得 Redis 里堆一堆垃圾数据;降级逻辑也要设计好,别直接把异常抛给前端,给个友好点的提示。

工程化方面,后面可以加上限流日志的链路追踪、异常分类、多渠道告警等功能。代码写出来容易,跑得稳才见功夫。

以上就是SpringBoot基于Redis和Lua构建分布式限流防御体系教学的详细内容,更多关于SpringBoot Redis限流的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文