9人参与 • 2026-09-17 • Java
限流这事,说穿了就是给系统装个闸门,流量大了就放行一部分,剩下的挡在外面。单机限流用 jvm 内存计数器就行,但一旦服务部署多份,计数器各数各的,总阈值根本控制不住。这时候就得找个公共的存储,redis 顺手就干了。
算法本身不多,常用的就四种:固定窗口、滑动窗口、漏桶、令牌桶。先一个个捋清楚。
固定窗口最简单,按固定时间粒度(比如 1 分钟)划一个窗口,用 incr 数请求,到阈值就拒,窗口重置后再来。优点是实现简单,内存开销小,适合对精度不太敏感的总量控制。缺点是有“临界突变”问题:阈值 100,第 59 秒来了 100 个请求,第 60 秒又来了 100 个,两个窗口 交界处 2 秒内就放过去了 200 个,系统可能直接被这一波顶垮。
滑动窗口把时间切成更小的格子(比如每秒一个),每个请求带着时间戳进来,统计当前窗口内的数量时,自动把窗口外的请求踢掉。这样就能平滑限制突发流量,不会出现固定窗口那种边界突刺。缺点是每个请求都得记时间戳,内存占用大一些,但如果用 redis 的 zset,一个请求也就几十字节,扛得住。适合秒杀入口、接口防刷这种对突发敏感的场所。
漏桶这个思路更直观:请求先倒进桶里,桶底固定速率往外漏,桶满了就泼掉。输出速率恒定,下游系统最舒服,适合做流量整形,比如保护数据库、保护第三方调用。但你不能指望它应对突发,哪怕桶是空的,批量请求也只能按固定速度走,想爽快也是不行的。
令牌桶和漏桶相反,它以固定速率往桶里放令牌,桶满了令牌就丢弃。请求来了得从桶里拿一个令牌才能走,桶里攒下的令牌可以用来承受突发流量。google 的 guava ratelimiter 就是这路子。它适合 api 网关、服务间调用限流,既限制平均速率,又允许一定程度的突发。
算法没有绝对优劣,关键在于场景。网关层想保护下游就用令牌桶,秒杀接口想卡瞬时尖峰就用滑动窗口,数据库前想平滑流量就用漏桶。后面我们主要讲滑动窗口,因为秒杀场景下它最实用。
滑动窗口用 redis 通常就是拿 sorted set 搞:时间戳当 score,请求的唯一 id 当 member。每次请求进来:
zremrangebyscore 清掉窗口之外的数据;zadd 写入当前请求;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,多请求时间戳相同也不会冲突。
手写 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());
}
}
注意两点:
evalkey 里用了 defaultparameternamediscoverer,这样不需要强制要求编译时加 -parameters 参数(加了更好)。getdeclaredmethod 而不是 getmethod,因为降级方法可能是 private 或包私有,getmethod 只能拿 public 的。自动装配很简单,配置类里声明切面 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 字面量处理。
秒杀这个场景,瞬时流量高,恶意刷 单多,光靠一个维度不行。一般至少三层:
接口级限流控制整体 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 一起判断 → 任一超限就返回降级提示,不碰业务逻辑 → 全部通过才执行秒杀事务 → 库存扣减、订单创建 → 异步发通知。通过这种分层防护,恶意请求基本挡在最外圈。
限流不能黑盒操作,得知道拦截了多少、放行了多少。用 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 脚本影响性能”这种说法吓到,只要不是无脑在脚本里写重循环,影响基本可以忽略。
限流不是银弹,得和熔断、降级、隔离配合着用。算法选型也要灵活:网关层用令牌桶,保证整体吞吐;秒杀接口用滑动窗口,卡住瞬时尖峰;数据库前用漏桶,平滑请求速率。这不是非黑即白的事,混着用才是常态。
做这套东西的时候,最好把阈值做成动态配置,改配置不用发版;限流 key 记得设置过期时间,免得 redis 里堆一堆垃圾数据;降级逻辑也要设计好,别直接把异常抛给前端,给个友好点的提示。
工程化方面,后面可以加上限流日志的链路追踪、异常分类、多渠道告警等功能。代码写出来容易,跑得稳才见功夫。
以上就是springboot基于redis和lua构建分布式限流防御体系教学的详细内容,更多关于springboot redis限流的资料请关注代码网其它相关文章!
您想发表意见!!点此发布评论
版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。
发表评论