it编程 > 编程语言 > Java

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

9人参与 2026-09-17 Java

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限流的资料请关注代码网其它相关文章!

(0)

您想发表意见!!点此发布评论

推荐阅读

SpringBoot项目中导入外部JAR包的完整指南

09-17

在Spring中注入Set集合的方法详解

09-17

Java后端最优PDF导出方案

09-17

Java基于EasyExcel实现动态表头Excel导出工具类的完整示例代码

09-17

Python PySide6入门之快速编写桌面GUI程序的实践教学

09-17

JVM性能调优之GC日志分析与参数优化法则详解

09-17

猜你喜欢

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论