线上流量突刺这事,早晚会落到每个做服务端的人头上。我见过太多白天秒杀、晚上报警、凌晨拉群修数据的场景——接口层面没有任何保护,流量一来直接被打穿,数据库连接池耗尽,紧接着故障顺着调用链蔓延到下游服务。如果你正在用SpringBoot做微服务,多个实例同时暴露同一个接口,想给这个接口加一道全局限流阀门,那么SpringBoot+Redis+Lua这套组合,是我实践下来成本最低、最可控的方案。这篇文章我会把为什么选它、底层原理、完整代码、压测效果和踩坑经验一次性拆干净,适合那些已经决定上限流、但还没找到一套可靠落地方案的开发者。
1. 分布式限流的核心问题:单机限流为什么失效
1.1 从一次线上事故说起:5个实例带来的“限流缺口”
先讲一个我实际遇到的案例。某业务接口平时峰值大概800 QPS,压测显示每个实例最多扛400 QPS,于是我们当时很自然地做了两件事:一是部署3个实例,二是给每个实例里的限流器配了单机400的阈值。看起来9个实例总容量是1200,绰绰有余。
结果大促当天接口还是被干趴了。原因说出来很简单:负载均衡策略是加权轮询,但流量从来不是均匀分配的。实际跑起来一台实例分到了将近70%的流量,另一台只分到了15%。那台扛大头流量的实例,还没到限流阈值就已经先被打崩;另外两台却只有少量请求在跑,预留的容量完全没发挥出来。
这个问题的本质在于,单机限流统计的是“当前进程”的调用量,而分布式系统需要的却是“整个集群”的维度。只要流量分配不均,单机限流就是失效的——它保护不了实例自己,更保护不了整个服务。更麻烦的场景是内部服务间调用。网关限流只覆盖入口流量,订单系统调用积分系统、消息回调触发发券接口这些内部链路完全不会经过网关,突发流量照样可以从旁路打进来。
1.2 分布式限流的三条硬需求
既然单机限流不行,那分布式限流要满足什么条件?我在实施时归纳了三条,缺一不可。
第一条是共享存储。所有实例必须看到同一个计数状态,而不是各自维护一份。这就意味着限流状态要放在一个所有实例都能访问的存储里,Redis、数据库、独立限流中间件都可以,但性能和可靠性要求会淘汰掉大部分选项。
第二条是原子操作。限流的判断逻辑通常包含“先查当前计数,再决定是否放行,最后更新计数”这么几步。在并发场景下,如果这几步不是原子的,两个请求可能同时读到同一个计数,同时判断自己没超限,然后同时放行,限流就形同虚设。这在Java代码里用Get-then-Set要加分布式锁才能解决,但锁又带来性能损耗和死锁风险。
第三条是低延迟和高可用。限流逻辑本身是业务请求链路里新增的一环,不能成为新的瓶颈。如果一次限流判断要额外消耗10ms,那对核心接口来说是完全不能接受的。理想情况是限流判断尽量接近“一次内存操作”的延迟。
1.3 三大方案对比:为什么我选Redis+Lua
市面上成熟的分布式限流方案大概可以归成三类。
第一类是网关层限流。在流量入口的统一网关里做全局限流,配置起来直观,运维也简单。缺点是流量必须全部经过网关,内部微服务之间的调用覆盖不到,网关本身也可能成为新的单点。另外业务方想按用户维度、按接口维度精细限流时,网关的配置往往会变得特别繁琐。
第二类是独立限流中间件,比如各类带控制台的限流熔断框架。功能确实强大,有可视化控制台、支持动态规则、还能做熔断降级。但代价是要引入一个新组件,研发要学习和对接,运维要部署和监控一套新系统,对中小团队来说成本不低。
第三类就是Redis+Lua。它最大的优势是,大多数SpringBoot项目本来就有Redis,不需要额外引入任何中间件。限流逻辑全部写在Lua脚本里,由Redis服务端原子执行,一次网络往返就能完成判断和计数,延迟通常不到一毫秒。算法自己想怎么调整就怎么调整,今天固定窗口、明天令牌桶,改一个脚本就行,完全不依赖某个框架的规则引擎。
我最终选择了第三类。表格对比一下几种方案,结论就非常清楚了。
| 方案 | 实现成本 | 运维成本 | 灵活性 | 性能 | 覆盖范围 |
|---|---|---|---|---|---|
| 网关层限流 | 中 | 低 | 低 | 高 | 仅入口流量 |
| 独立限流中间件 | 高 | 高 | 高 | 高 | 全链路 |
| Redis+Lua | 低 | 低 | 高 | 高 | 全链路 |
2. 为什么是Redis+Lua:原子性与算法选型
2.1 Redis能提供什么:共享计数与单线程执行模型
Redis是分布式限流的天然候选存储,因为它本身是内存数据库,读写极快。但这只是表面,真正关键的是它的单线程执行模型。Redis所有命令都是排队在同一个线程里顺序执行的,所以单个命令天然具备原子性,比如INCR、DECR、EXPIRE这些操作不存在并发覆盖的问题。
这意味着多个应用实例同时往同一个Redis key发INCR命令时,Redis会把这些命令排成一条队列依次执行,谁先谁后由Redis内部保证,不会出现两个实例同时读到同一个旧值然后各自加一的情况。对比一下,如果用MySQL先SELECT再UPDATE实现计数,并发下就很容易出现超卖式幻读;如果用分布式锁保护,锁获取和释放的开销又不划算。
不过,Redis的原子性只覆盖“单个命令”。限流场景里的“查当前计数、判断是否超限、决定放行、更新计数”实际上是一个复合操作,必须用Lua脚本把这些操作打包,让Redis将它作为一个整体来执行。这就是下面的重点。
2.2 Lua脚本的原子性:把“判断+扣减”打成一步
Redis从2.6版本开始内置了Lua解释器,支持通过EVAL命令执行Lua脚本。Lua脚本在Redis里运行时,整个脚本的执行过程是原子的,脚本执行期间不会有其他命令插入,也不会有其他客户端请求被处理。
打个生活化的比方:银行柜台的取款流程本来是“查验余额、核对身份、扣款、出钞”四步,如果这四步之间能被打断,两个窗口可能同时给同一个账户取钱,余额就变负数了。Redis执行Lua脚本就相当于给这四个动作加了一把“流程锁”,整个过程一气呵成,中间绝不允许别人插进来。
这个特性对限流的意义极其重要。用Java代码写“先查询计数再更新”需要自己加锁,用Lua脚本则完全不需要,因为Redis保证脚本执行期间没有其他并发操作。而且脚本是在Redis服务端执行的,客户端只需要发送一次EVAL请求,不用多次往返,网络开销也低。
还有一个容易被忽视的好处:Lua脚本里的逻辑统一放在服务端,业务代码只需要调用execute方法,传入key和参数。不同微服务之间不会各自写一套判断逻辑导致行为不一致,限流口径天然统一。
2.3 三种限流算法怎么选:固定窗口、滑动窗口、令牌桶
分布式限流不是只有一种算法,关键是你想达到什么样的限流效果。
固定窗口是最简单的实现。以分钟为单位,为这个key记录一个计数器,窗口内计数超过阈值就拒绝,窗口结束后计数器重置。它的优点是实现代码最少、Redis里只需要一个key和一个过期时间。缺点是有临界突刺问题:假设每60秒内允许100次请求,第59秒和第60秒交界处,两个窗口各发100次请求,实际在2秒内就打进去了200次,单看任何一个窗口又不超限。
滑动窗口解决了固定窗口的临界问题。它把时间切成更细的格子,用ZSET记录窗口内的每个请求时间戳,每次先删除窗口之前的记录,再统计当前窗口内的请求数。它能精确限制“任意时间段内”的流量,但每个请求都要往ZSET里写入一个成员的记录,阈值设置得高的时候内存占用会明显上涨。
令牌桶是另一种思路。它用一个桶来装令牌,以固定速率往桶里放令牌,桶满则停止放入;每个请求到来时要从桶里取一个令牌,能取到就放行,取不到就拒绝。它最大的特点是可以容忍突发流量——桶里积攒的令牌允许瞬间打一波,但整体速率又被补充速率约束住,不会无限穿越。
怎么选?我的习惯是这样的:
- 内部接口、定时任务、日志上报这类不涉及核心交易的场景,固定窗口足够,代码最省。
- 需要精确控制任意时间窗口、但QPS不高的场景,用滑动窗口,通常配合用户维度或IP维度。
- 核心交易链路、秒杀、支付回调这类既要保护系统、又希望瞬时能扛一点峰值的场景,用令牌桶。
三种算法我都写了Lua脚本,在下一节直接给出完整实现。
3. SpringBoot集成Redis+Lua:代码级实现
3.1 工程依赖与Redis连接配置
在SpringBoot工程里集成,首先在pom里加几个依赖。核心是spring-boot-starter-data-redis,还需要AOP相关依赖,因为限流注解要依托切面实现。我用的是Maven,坐标如下。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>然后在application.yml里配置Redis连接。注意这里如果用了Redis Cluster,要配置cluster.nodes而不是host和port,后面讲限流key的hash tag时会专门提到。
spring: redis: host: 127.0.0.1 port: 6379 password: timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2连接池的大小值得说两句。限流脚本本身执行很快,单个操作微秒级,但每次执行都要从连接池取一个连接。如果并发量高而连接池太小,获取连接也会排队,反而拖慢接口。16个连接基本能满足中等流量场景,压测显示连接池不是瓶颈时再增加。
3.2 三个Lua脚本的完整实现与注释
我在项目的resources/lua目录下放了三个脚本,分别命名为fixed_window.lua、sliding_window.lua和token_bucket.lua。下面逐个讲解。
固定窗口脚本:
local key = KEYS[1] local limit = tonumber(ARGV[1]) local windowSeconds = tonumber(ARGV[2]) local count = redis.call('INCR', key) if count == 1 then redis.call('EXPIRE', key, windowSeconds) end if count > limit then return 0 end return 1这个脚本里,INCR是原子命令,第一次INCR之后会返回1,这时候给key设置过期时间,后续请求就刷新窗口。返回值约定:1代表放行,0代表拒绝。
滑动窗口脚本:
local key = KEYS[1] local now = tonumber(ARGV[1]) local windowMillis = tonumber(ARGV[2]) local limit = tonumber(ARGV[3]) redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMillis) local count = redis.call('ZCARD', key) if count >= limit then return 0 end redis.call('ZADD', key, now, now .. ':' .. redis.call('INCR', key .. ':seq')) redis.call('PEXPIRE', key, windowMillis) return 1滑动窗口脚本用ZSET来保存请求时间戳,score就是当前毫秒时间戳,member用“时间戳加自增序号”来保证唯一性。每次请求进来先移除窗口之前的记录,然后统计当前窗口的请求数。注意这里PEXPIRE用的是毫秒,过期时间设置为一个窗口长度,避免key常驻内存。
令牌桶脚本:
local key = KEYS[1] local capacity = tonumber(ARGV[1]) local refillRate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local tokensKey = key .. ':tokens' local tsKey = key .. ':ts' local tokens = tonumber(redis.call('GET', tokensKey) or capacity) local lastRefill = tonumber(redis.call('GET', tsKey) or now) local elapsedMillis = math.max(0, now - lastRefill) local refillTokens = math.floor(elapsedMillis / 1000 * refillRate) if refillTokens > 0 then tokens = math.min(tokens + refillTokens, capacity) redis.call('SET', tsKey, now) end if tokens >= 1 then redis.call('SET', tokensKey, tokens - 1) return 1 end redis.call('SET', tokensKey, tokens) return 0令牌桶脚本用两个key分别存储当前令牌数和上次补充时间。重点说一下refillTokens的计算:传入的now是毫秒时间戳,补充速率refillRate的单位是“每秒多少个令牌”,所以用elapsedMillis / 1000 * refillRate得到这段时间内应该补充的令牌数,再向下取整。细节是只有当refillTokens大于0时才更新时间戳,避免频繁SET。
3.3 限流注解与AOP切面:一行注解接入
为了让业务代码只加一个注解就接入限流,我先定义一个注解类RateLimit。
@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { String key() default ""; RateLimitAlgorithm algorithm() default RateLimitAlgorithm.TOKEN_BUCKET; long capacity() default 100; long refillRate() default 10; long windowSeconds() default 60; }枚举RateLimitAlgorithm包含三种算法:
public enum RateLimitAlgorithm { FIXED_WINDOW, SLIDING_WINDOW, TOKEN_BUCKET }脚本加载放进配置类,用DefaultRedisScript把Lua文件加载进来,并声明返回类型是Long。
@Configuration public class RateLimitScriptConfig { @Bean public DefaultRedisScript<Long> fixedWindowScript() { DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("lua/fixed_window.lua")); script.setResultType(Long.class); return script; } @Bean public DefaultRedisScript<Long> slidingWindowScript() { DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("lua/sliding_window.lua")); script.setResultType(Long.class); return script; } @Bean public DefaultRedisScript<Long> tokenBucketScript() { DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("lua/token_bucket.lua")); script.setResultType(Long.class); return script; } }最关键的是AOP切面。核心逻辑是把注解参数转成Lua脚本需要的参数,调用RedisTemplate执行脚本,根据返回值决定是放行还是拒绝。
@Aspect @Component public class RateLimitAspect { private static final Logger log = LoggerFactory.getLogger(RateLimitAspect.class); private static final String RATE_LIMIT_PREFIX = "rate:limit:"; private final StringRedisTemplate stringRedisTemplate; private final DefaultRedisScript<Long> fixedWindowScript; private final DefaultRedisScript<Long> slidingWindowScript; private final DefaultRedisScript<Long> tokenBucketScript; public RateLimitAspect(StringRedisTemplate stringRedisTemplate, @Qualifier("fixedWindowScript") DefaultRedisScript<Long> fixedWindowScript, @Qualifier("slidingWindowScript") DefaultRedisScript<Long> slidingWindowScript, @Qualifier("tokenBucketScript") DefaultRedisScript<Long> tokenBucketScript) { this.stringRedisTemplate = stringRedisTemplate; this.fixedWindowScript = fixedWindowScript; this.slidingWindowScript = slidingWindowScript; this.tokenBucketScript = tokenBucketScript; } @Around("@annotation(rateLimit)") public Object doAround(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { String baseKey = resolveKey(joinPoint, rateLimit.key()); String fullKey = RATE_LIMIT_PREFIX + baseKey; String now = String.valueOf(System.currentTimeMillis()); Long canPass; switch (rateLimit.algorithm()) { case FIXED_WINDOW: canPass = stringRedisTemplate.execute(fixedWindowScript, Collections.singletonList(fullKey), String.valueOf(rateLimit.capacity()), String.valueOf(rateLimit.windowSeconds())); break; case SLIDING_WINDOW: canPass = stringRedisTemplate.execute(slidingWindowScript, Collections.singletonList(fullKey), now, String.valueOf(rateLimit.windowSeconds() * 1000L), String.valueOf(rateLimit.capacity())); break; case TOKEN_BUCKET: default: canPass = stringRedisTemplate.execute(tokenBucketScript, Collections.singletonList(fullKey), String.valueOf(rateLimit.capacity()), String.valueOf(rateLimit.refillRate()), now); break; } if (canPass != null && canPass == 1L) { return joinPoint.proceed(); } log.warn("RateLimit triggered, key={}, algorithm={}", fullKey, rateLimit.algorithm()); throw new RateLimitException("请求过于频繁,请稍后重试"); } }这里有一个很值得说的点:now这个参数不是在Lua脚本里调用的TIME命令,而是在Java侧生成后传进去的。原因在于,脚本里如果用Redis的TIME来取当前时间,Redis服务器时钟和业务服务器时钟可能存在偏差,而且TIME命令在脚本里返回的是数组类型,处理起来更绕。统一由调用方传入毫秒时间戳,整个集群的限流时间口径一致,调试也好定位。
3.4 动态限流维度:用SpEL表达式定制key
resolveKey这部分影响的是限流维度:是全局维度、用户维度、还是IP维度。我通过Spring的SpEL表达式解析,让key可以动态取参数里的某个字段。
private String resolveKey(ProceedingJoinPoint joinPoint, String keyExpr) { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); Object[] args = joinPoint.getArgs(); if (!StringUtils.hasText(keyExpr)) { return method.getName(); } StandardEvaluationContext context = new StandardEvaluationContext(); DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer(); String[] paramNames = discoverer.getParameterNames(method); if (paramNames != null) { for (int i = 0; i < paramNames.length; i++) { context.setVariable(paramNames[i], args[i]); } } context.setVariable("args", args); try { ExpressionParser parser = new SpelExpressionParser(); Object value = parser.parseExpression(keyExpr).getValue(context); return value == null ? method.getName() : value.toString(); } catch (Exception e) { log.error("RateLimit key expression parse error, expr={}", keyExpr, e); return method.getName(); } }这样业务代码里就能这样用:
@RateLimit(key = "#userId", capacity = 20, refillRate = 5) @PostMapping("/sendSms") public Result sendSms(@RequestBody SmsRequest request) { // 每个userId单独限流,每5秒最多补充5个令牌 } @RateLimit(key = "'sendSms:' + #phone", capacity = 1, windowSeconds = 60, algorithm = RateLimitAlgorithm.FIXED_WINDOW) @PostMapping("/verifyCode") public Result verifyCode(@RequestParam String phone) { // 每个手机号60秒内最多1次 }key简单时可以写#userId,复杂一点的表达式写成'sendSms:' + #phone,单引号里的字符串会被SpEL当成常量,后面加号拼接变量。如果注解不写key,就默认用方法名作为全局维度限流key。
4. 压测验证:限流到底稳不稳
4.1 压测场景设计与工具选择
写代码只是第一步,限流配置得对不对、效果符不符合预期,必须靠压测说话。我平时用JMeter或wrk,这次用JMeter做的是接口层压测。
压测前先把规则明确好。目标接口配置了令牌桶限流:capacity=50、refillRate=20。意思是桶里最多积攒50个令牌,每秒补充20个,第一秒突发最多能放50个请求,之后每秒钟最多放20个。线程组设置为100并发,持续压10秒,总请求量大概会在3000左右。
为什么要设计成100并发去压一个每秒只有20的限流接口?因为只有在严重超载的情况下,才能看清限流是不是真的把流量拦住了。压测时关注的指标有三个:放行请求数是否符合预期、被拒绝请求是否返回异常、Redis的内存和CPU涨幅是否可控。
4.2 测试结果怎么解读:令牌桶的突发特性
压测数据有很强的规律性,我贴一组有代表性的结果。
| 时间窗口 | 请求总数 | 通过数 | 拒绝数 | 通过率 |
|---|---|---|---|---|
| 0-1s | 312 | 50 | 262 | 16.0% |
| 1-2s | 288 | 20 | 268 | 6.9% |
| 2-3s | 295 | 20 | 275 | 6.8% |
| 3-4s | 286 | 20 | 266 | 7.0% |
| 4-5s | 291 | 20 | 271 | 6.9% |
第一秒的通过数正好等于桶容量50,因为压测开始前桶是满的,50个令牌可以被瞬间消耗。从第二秒开始,每秒通过数稳定在20附近,和refillRate一致。这个结果是令牌桶的正确行为,不是bug。如果希望一开始就不允许突发,可以每次启动时先把桶里的令牌清空,或者在业务侧额外加一个预热接口。
我压测时还额外看了Redis的指标:限流脚本的耗时稳定在0.5ms到1ms之间,Redis CPU没有明显波动。整套逻辑跑下来,对业务接口的额外延迟完全可以忽略。
5. 生产环境踩坑实录与避坑清单
5.1 高频问题排查:限流不生效、跨slot、时间与单位
先说一个我踩过最隐蔽的坑:限流偶尔失效,但又不是完全不生效。排查后发现,当服务接入Redis Cluster时,Lua脚本里如果涉及多个key,Redis会要求这些key必须落在同一个哈希槽内,否则会报CROSSSLOT错误。我们的令牌桶脚本在逻辑上使用了两个key:rate:limit:xxx和rate:limit:xxx:tokens、rate:limit:xxx:ts。普通key名在集群模式下可能被哈希到不同的槽位,脚本就会报错。
解决办法是给key加上hash tag,让Redis只对花括号里的部分做哈希。把脚本里的key都改成rate:limit:{userId}:tokens和rate:limit:{userId}:ts这样的形式,花括号里的userId决定了槽位,所有带同样userId的key就会落在同一个槽。Java侧组装key时也要保持这个习惯。
另一个高频问题很简单但特别容易忽略:限流key没设过期时间。固定窗口脚本第一次INCR后设置了EXPIRE,但有些脚本路径如果走了逻辑分支提前返回,可能就漏掉EXPIRE,长期下来Redis里堆了一堆限流key,内存被吃光。我在脚本里每条返回路径后面都审视了一遍,确保每个key都有过期设置。
还有时间单位问题。滑动窗口脚本里传入的windowMillis是毫秒,而固定窗口脚本用的是秒。如果代码里不小心把秒当成毫秒传进滑动窗口脚本,窗口会膨胀1000倍,限流等于没限。我在切面里统一在注解上用秒单位,传给脚本时乘1000,内部逻辑全按毫秒算,这样外部接口不容易出错。
5.2 从“能跑”到“好用”:五个避坑要点
结合这些年的经验,我整理了几个从“能跑”到“好用”必须注意的点。
第一个是要处理Redis异常时的降级策略。限流依赖Redis,如果Redis抖动或不可用,业务请求不能跟着一起挂。我的做法是给切面加了一个开关配置,默认Redis异常时打印告警日志并放行请求,保证业务可用性优先;但对资金类、支付类接口,可以配置成fail-closed,宁可拒绝也不放行。这个开关在不同团队可能有不同偏好,但绝不能不做处理,否则Redis一抖动,线上请求全被限流拦住,会比流量超载还严重。
第二个是连接池的容量监控。限流脚本每次执行都占用一个Redis连接,如果业务并发高,连接池打满会出现获取连接等待。压测时一定要把连接池参数放在监控范围里,通过增加max-active或者引入重用策略来缓解。
第三个是Lua脚本的预加载。Redis执行EVAL会先传脚本内容,脚本较长时有一定网络开销。生产环境可以用SCRIPT LOAD预加载脚本拿到sha值,然后通过EVALSHA执行。SpringBoot的DefaultRedisScript会自动做这件事,前提是使用同一个Redis连接,注意连接池不要乱切实例。
第四个是切面只对代理方法生效。在同一个类内部调用一个被@RateLimit标注的方法时,AOP切面不会被触发,限流直接绕过。这是一个非常经典的Spring AOP陷阱。团队里如果有人写内部调用时没有注意这一点,限流就会莫名其妙失效。我的经验是限流注解尽量加在Controller入口或者外部调用的Service方法上,并且明确禁止同类内部调用绕过。
第五个是监控要跟上。限流拦截次数、放行次数、触发的key分布,这些指标要打进日志或者监控平台。出了问题不是看配置,而是看监控数据快速定位是哪个key在挡流量、哪个接口经常被拦。没有监控的限流等于半裸奔。
最后再说一个个人体会。这套SpringBoot+Redis+Lua的限流方案,真正落地需要想清楚的其实不是Java代码,而是限流维度。维度定错了,再好的脚本也救不回来。接口维度适合保护后端资源,用户维度适合防刷单和恶意攻击,IP维度适合防止爬虫。多数场景需要组合使用,接口维度打底,业务维度精修。压测验证通过只是起点,生产环境观察一段时间的拦截日志和数据表现,再持续调整阈值,才是这部分工作真正开始的地方。