资讯详情

SpringBoot+Redis+Lua:分布式接口限流实战与原理剖析

📅 2026/10/14 4:13:39 | 华诺云谱 👁 阅读
SpringBoot+Redis+Lua:分布式接口限流实战与原理剖析
1. 这次限流方案解决的到底是什么问题先说结论SpringBootRedisLua这套组合核心解决的是分布式环境下接口流量不可控的问题。单体应用时代限流可以用Guava的RateLimiter、Semaphore甚至一个AtomicLong计数器就能扛住因为所有请求都落在同一台机器上状态天然共享。但一旦服务拆成多个实例、部署在多台机器上本地限流就失效了——每个实例各自计数总量可能被放大N倍。举个例子某接口设计容量是每秒1000请求部署了5个实例。每个实例用Guava限流到每秒200看起来5x2001000刚好。但真实流量不是均匀分布的某个实例可能瞬间打满500另一个只有50整体就突破了1000。更麻烦的是如果其中一个实例扩容到10个你又得重新算每台机器的配额。这种各自为政的限流方式在微服务架构里根本没法用。RedisLua方案的做法很直接把限流判断集中到Redis这一台或一个集群上所有实例共享同一份计数器总量精确可控。再用Lua脚本保证检查计数这两个动作的原子性避免并发下超卖。这套方案适合API网关层限流、核心接口保护、秒杀场景防刷也适合作为Sentinel这类框架的底层补充。后台管理、内部报表这类低流量接口用不上这么重的方案杀鸡不用牛刀。这里要提醒一句限流不是安全防护的全部它和鉴权、风控是三个维度的事。限流只管速率不管你是谁鉴权管你能不能进风控管你是不是恶意。三者叠加才能形成完整防护。2. 为什么必须是RedisLua不能只用Redis2.1 分布式限流的技术选型对比先看一张选型对比表把常见方案摆在一起看更清楚方案是否分布式精度控制原子性复杂度适用场景单机RateLimiter否高由JVM保证低单体应用、单实例Redis事务MULTI/EXEC是中弱需配合WATCH重试中不推荐用于高频限流RedisLua是高强脚本整体原子执行中分布式限流首选消息队列削峰是低依赖MQ语义高异步场景非实时拦截Redis的MULTI事务乍一看也能做检查写入两步操作但它有个问题事务中的命令是排队执行的执行期间其他客户端的命令插不进来但如果你要在事务里先读后写比如先GET当前计数再INCR就需要WATCH配合一旦发现被修改就重试。在高并发下重试意味着大量请求浪费在自旋上限流本身的延迟就被拉高了。Lua脚本解决这个问题的思路很漂亮把当前计数是否超限和计数1这两步逻辑写进一个脚本Redis服务器端整体执行执行期间不会被其他命令打断。要么全部成功要么全部失败不存在中间状态。这就是原子性的价值。2.2 Lua脚本的原子性与性能边界Lua在Redis里的执行模型是服务器加载脚本到Lua虚拟机执行完毕后返回结果。整个过程是单线程的所以天然串行。这里要澄清一个常见误解Redis是单线程不是说Lua脚本单线程而是整个Redis命令处理是单线程的Lua脚本同样在这个线程里跑。这意味着你的脚本里不能有耗时操作比如循环一万次、调用外部API、写日志文件——这些都会阻塞整个Redis实例所有其他客户端全部卡住。在实际项目中我见过有人把复杂的业务校验逻辑写进Lua循环几百次做字符串拼接结果Redis CPU直接飙到90%以上线上业务全部受影响。限流Lua脚本必须保持短小精悍最多几十行只做计数、过期时间设置、返回结果这几个动作其余逻辑一律放在Java层。性能边界方面我自己压测过单条Lua脚本执行基本在微秒级纯Redis操作每秒可以支撑十万级以上的QPS瓶颈通常不在Redis而在网络往返和Java客户端的连接池配置。所以如果你的限流遇到性能瓶颈优先排查连接池、序列化方式而不是质疑Lua本身。2.3 为什么用固定窗口计数器算法而非令牌桶限流算法常见的三种计数器固定窗口、滑动窗口、令牌桶/漏桶。令牌桶在本地限流场景很常用因为Guava封装好了语义也很直观——桶里有令牌就能放行没有就拒绝。但在分布式场景令牌桶需要额外维护令牌生成速率和桶内令牌数两个状态逻辑更复杂Lua脚本也更大。计数器算法简单直接以固定时间窗口比如1秒为单位窗口内累计请求数超过阈值就拒绝窗口结束自动重置。实现下来Lua脚本只有十几行性能更好逻辑也容易验证。代价是临界问题比如窗口是0到1秒1秒内通过100个请求1到2秒又通过100个但1.5秒这个瞬间可能同时有边界请求造成突发实际通过量略超阈值。举一个生活化的例子计数器限流像每天限买100个名额0点清零重新算令牌桶像匀速补货的自动售货机补货速度恒定买的人多就排队等。前者简单粗暴后者平滑但复杂。对于大多数接口场景计数器方案的临界突发是可以接受的毕竟你限的是一秒1000的接口瞬间超个几十下游通常扛得住。如果要求更平滑可以升级到滑动窗口但那是另一个话题了。最终选型建议第一版上线用固定窗口计数器代码简单、容易排查问题等真的出现临界问题再升级滑动窗口或令牌桶。技术选型切忌一步到位追求完美先跑起来比什么都重要。3. 核心实现步骤从零搭一个可运行的限流模块3.1 项目依赖与基础配置我们用Maven管理依赖SpringBoot版本建议2.7以上Redis客户端用LettuceSpringBoot默认。pom里加这些dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency为什么需要AOP因为限流最好是横切逻辑不侵入业务代码。你肯定不想在每个接口方法里手动写一遍限流判断那太丑陋了。用自定义注解AOP切面只要在接口方法上标注注解切面自动拦截业务代码保持干净。application.yml里配置Redis连接spring: redis: host: 127.0.0.1 port: 6379 password: timeout: 3000ms lettuce: pool: max-active: 50 max-idle: 10 min-idle: 5连接池参数值得多说一句max-active不是越大越好连接数过多会占用Redis端的文件描述符反而增加GC压力和上下文切换开销。对限流这种高频短操作50个连接完全够用除非你的服务实例数特别多再适当上调。timeout设置3秒避免Redis抖动时请求无限等待把故障传导到业务线程。3.2 核心Lua脚本设计与原理解读这是整套方案的心脏。先看脚本内容local key KEYS[1] local limit tonumber(ARGV[1]) local current redis.call(incr, key) if current 1 then redis.call(expire, key, ARGV[2]) end if current limit then return 0 end return 1拆解一下每一行的作用逻辑KEYS[1]存的是限流key格式类似rate:limit:user:123由Java层拼接好传进来。把key放在KEYS里而不是ARGV里是因为Redis Cluster模式下Lua脚本需要保证所有key在同一个slot只有通过KEYS传入才能让集群正确路由。limit是我们设定的阈值用tonumber强转因为从Java传过来的ARGV是字符串。redis.call(incr, key)对key做自增。第一次请求时Redis没有这个keyincr会创建并返回1所以用if current 1判断这是窗口内第一个请求需要设置过期时间。expire设置key的过期时间对应窗口大小。比如窗口是1秒就设expire为1秒这一秒内的所有请求共用同一个计数器一秒后key自动消失重新计数。最后判断current limit说明超了返回0代表拒绝否则返回1放行。这里有个非常关键的设计细节为什么不用ttl先判断再决定是否设置过期时间因为incr和expire之间无法保证原子性——如果incr执行后、expire执行前有另一个请求也incr了那么第一个请求设置的expire可能把第二个请求覆盖掉导致窗口重置错乱。在Lua脚本里这两步是连续执行的Redis单线程模型保证中间不会有别的命令插入所以可以放心先incr后expire。另一种写法是先判断key是否存在local current redis.call(exists, key) if current 0 then redis.call(set, key, 1, EX, ARGV[2]) return 1 end local count redis.call(incr, key) if count tonumber(ARGV[1]) then return 0 end return 1两种写法效果等价但第一种更简洁incr一条命令同时完成计数创建和1性能略优。我习惯用第一种。3.3 自定义注解与AOP切面配置先定义注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RateLimit { // 限流key的前缀 String key() default rate:limit; // 限流阈值 long count() default 100; // 窗口时间单位秒 long time() default 1; }这个注解带三个参数这样每个接口可以灵活配置。比如登录接口限每秒50次下单接口限每秒200次直接标注解传参就行不用为每个接口写一套逻辑。AOP切面实现Aspect Component public class RateLimitAspect { private static final String RATE_LIMIT_SCRIPT local key KEYS[1] local limit tonumber(ARGV[1]) local current redis.call(incr, key) if current 1 then redis.call(expire, key, ARGV[2]) end if current limit then return 0 end return 1; Autowired private StringRedisTemplate redisTemplate; Around(annotation(rateLimit)) public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable { String key buildKey(rateLimit, pjp); ListString keys Collections.singletonList(key); Long result redisTemplate.execute( new DefaultRedisScript(RATE_LIMIT_SCRIPT, Long.class), keys, String.valueOf(rateLimit.count()), String.valueOf(rateLimit.time()) ); if (result null || result 0) { throw new RuntimeException(请求过于频繁请稍后再试); } return pjp.proceed(); } private String buildKey(RateLimit rateLimit, ProceedingJoinPoint pjp) { MethodSignature signature (MethodSignature) pjp.getSignature(); String className signature.getDeclaringTypeName(); String methodName signature.getName(); StringBuilder sb new StringBuilder(rateLimit.key()); sb.append(:).append(className).append(:).append(methodName); // 可以把用户ID拼进key实现用户维度限流 // RequestContextHolder获取当前请求 return sb.toString(); } }有几个细节值得深究第一用DefaultRedisScript的时候返回类型要明确指定Long.class。Lua脚本返回0或1Redis执行后返回到Java层如果不指定类型可能被反序列化成Integer后续判空逻辑就要跟着改容易踩坑。第二keys列表只放一个key。我刚才说的那个示例key是固定方法的维度如果要做用户维度限流需要在buildKey里从RequestContextHolder拿到当前请求的用户ID拼进去。例如rate:limit:order:userId:123。这样就是每个用户每秒最多下N单。第三抛RuntimeException不是唯一选择。实际项目中你应该定义一个专门的限流异常比如RateLimitException然后在全局异常处理器里统一返回HTTP 429状态码和友好提示。这样客户端能明确感知我被限流了而不是笼统的500。3.4 单元测试与并发验证代码写完了怎么证明它真的能限住写个简单Controller加上注解RestController public class DemoController { GetMapping(/api/order) RateLimit(key rate:limit:order, count 5, time 1) public String createOrder() { return success; } }然后用并发工具压测。我自己习惯用jmeter或者直接写一个Java多线程测试类。简单起见写一个main方法public class RateLimitTest { public static void main(String[] args) throws InterruptedException { ExecutorService pool Executors.newFixedThreadPool(20); CountDownLatch latch new CountDownLatch(20); for (int i 0; i 20; i) { pool.submit(() - { // 模拟调用接口 latch.countDown(); }); } latch.await(); pool.shutdown(); } }真正上线前压测至少能看到这几个结果20个并发请求只有5个成功因为limit515个被拒。然后等1秒窗口过去再发5个请求全部成功。确认限流窗口确实会自动重置。这里提醒一个坑本地测试时如果Redis和SpringBoot都在本机时间差基本没有但在分布式部署时不同服务器之间可能存在时钟漂移。计数器限流依赖Redis的过期时间来重置窗口Redis服务器时间不准确会影响窗口切换点。建议保持一致的时间同步方案否则高峰期可能看到限流提前或延后几百毫秒。4. 进阶优化从能用到好用4.1 key设计策略与热点问题key的设计影响两个维度限流精度和Redis内存。如果key太粗比如只到方法维度所有用户共享一个窗口一个恶意用户打满后正常用户全部被误伤。如果key太细比如到用户IP方法Redis需要存储的key数量会急剧膨胀而且可能产生热点key。实际项目中我的做法是分级限流先做接口总维度限流比如每秒1000保护下游再做用户维度限流比如每秒5次防止单人刷接口。接口总维度和用户维度是两个独立的key分别判断任何一个超限都拒绝。这种组合在秒杀、抢购场景中非常有用。热点key问题当某个key被超高并发访问比如一个热门商品的限流key每秒几万次请求打在同一个key上Redis单线程处理会放大延迟。Lua脚本执行本身很快但如果所有流量集中到一个keyCPU会集中在某个分片。解法有两种一是把限流key加随机后缀拆分比如rate:limit:order:1、rate:limit:order:2但这样会降低精度二是用Redis Cluster分散分片。大多数场景第一种方案适度拆分就行了不需要过度设计。4.2 限流阈值动态配置与监控硬编码count在注解里改阈值就要改代码重新发布这在线上不好使。我在项目里做了一个改进把阈值放到配置中心比如Nacos或Appllo用RefreshScope支持动态刷新。注解里不写死count而是写一个配置项名称AOP切面运行时从配置中心取值。配置方式RateLimit(key rate:limit:order, configKey rate.limit.order.count, time 1)切面里String configValue configService.get(rateLimit.configKey()); long count Long.parseLong(configValue);这样运维同学可以在配置中心直接调整限流阈值不用改代码、不用重启。遇到流量突增直接在配置台改一下从1000调到500秒级生效。监控方面限流的拒绝数、放行数必须暴露到监控系统。我在切面里用Metrics比如Micrometer记录两个指标rate_limit_total总请求和rate_limit_rejected被拒请求。通过监控面板可以看到每个接口的限流触发频率如果某个接口频繁达到上限说明可能需要扩容或者优化逻辑。4.3 Lua脚本性能优化与网络IO开销Lua脚本方案里有一个隐藏的性能瓶颈Redis执行脚本快但Java客户端到Redis的网络往返不可忽视。每次限流判断都发起一次Redis调用在超高频接口上每秒十万请求网络IO会成为瓶颈。优化手段是pipeline或提升连接复用但限流判断本身是串行依赖的没法批量。另一种思路是把限流判断前置到网关层比如在Nginx或SpringCloud Gateway里做粗粒度限流到达业务层的流量已经减过一轮业务层再做精细化限流。这样分层限流每层的负担都小。Lua脚本本身也可以复用Redis有SCRIPT LOAD和EVALSHA机制将脚本预先加载到Redis后续通过SHA调用减少脚本传输开销。Spring的DefaultRedisScript在初始化时已经做了加载所以后续调用会自动走EVALSHA我们不需要手动处理。5. 常见问题与排查技巧实录5.1 Redis连接超时导致限流失效现象压测时发现偶尔有请求直接通过限流查看日志发现Redis连接超时异常。原因连接池的max-idle太小突发流量把连接池打满后新请求等待获取连接超过timeout直接报错。异常在切面里没有被捕获业务代码catch不到请求就正常往下走了。解决切面里增加异常兜底逻辑。限流模块要遵循降级不阻塞原则Redis挂了不能把业务也拖死应该放行请求同时记录告警。很多团队用fail-open策略Redis不可用时放行保障业务可用性如果要求严格限流可以使用fail-closedRedis挂了直接拒绝但这样对业务影响太大。我建议核心交易链路用fail-closed非核心链路用fail-open。AOP切面优化try { result redisTemplate.execute(...); } catch (Exception e) { log.error(Redis限流异常执行降级放行, e); // 或者根据配置决定是否拒绝 return pjp.proceed(); }5.2 Lua脚本语法错误与ClassCastException现象上线后接口直接报错错误信息是Error executing script或者ClassCastException: java.lang.Long cannot be cast to java.lang.Integer。原因DefaultRedisScript指定的返回类型和Lua脚本实际返回类型不一致。Lua脚本返回1Redis会转成Long如果你写的是Integer.class就会类型转换异常。排查方式先用redis-cli手执行脚本测试redis-cli --eval /tmp/rate_limit.lua rate:limit:test , 10 60注意eval语法key和arg参数之间要用逗号分隔而且逗号两边必须有空格。如果脚本能正常运行说明问题在Java端类型或序列化配置。我遇到的另一个坑是redis.call(incr, key)如果key已存在但不是数字会报错value is not an integer or out of range。出现这种情况往往是key被复用了比如业务代码里用了和限流同名的key存储了字符串。所以key前缀设计时一定要有业务隔离比如统一加rate:limit:前缀。5.3 限流窗口边界导致突发流量压垮下游现象监控显示限流生效但下游数据库偶尔还是在临界点被打到高负载。原因固定窗口的临界问题。比如阈值1000每秒一个窗口每秒的最后1ms和下一秒的前1ms可能各通过1000个请求瞬间2000请求打到下游。解决思路第一把窗口切细比如改成1秒窗口但内部按10ms切片滑动窗口思路不过这会增加脚本复杂度。第二接受固定窗口的近似性但把限流阈值设置成实际容量的80%左右留出20%的缓冲。这是成本和收益平衡后的实际做法。第三在Lua脚本里同时使用漏桶算法或者引入Redisson的RRateLimiter实现了令牌桶。如果项目里已经用了Redisson直接在切面换成RRateLimiter代码更简洁平滑性也更好。Redisson内部同样基于Lua实现不过封装好了省得自己维护脚本。我这里给一个简单的滑动窗口Lua脚本参考local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local currentTime redis.call(TIME)[1] * 1000 local clearBefore currentTime - window redis.call(ZREMRANGEBYSCORE, key, 0, clearBefore) local count redis.call(ZCARD, key) if count limit then return 0 end redis.call(ZADD, key, currentTime, currentTime .. - .. math.random(100000)) redis.call(PEXPIRE, key, window) return 1这个脚本用ZSET记录每次请求的时间戳每次请求先移除窗口外的旧记录再统计当前窗口内的数量。比计数器精确但每个请求都会增加一个ZSET成员在超高QPS下内存占用较大。实际使用中要评估好成本。5.4 Redis Cluster模式下Lua脚本报错现象从单机Redis迁移到Cluster后执行Lua脚本报错Lua script attempted to access a non local key in a cluster node。原因Cluster模式下Redis要求一个Lua脚本中所有访问的key必须位于相同slot。我们的脚本只访问一个key按理说没问题但不同请求的key如果散落在不同节点脚本加载和执行的节点路由会不一致。解决尽量让限流key带相同的哈希标签比如{rate:limit}:order:123花括号内的部分会被用来计算slot这样所有限流key都落到同一个slot。但要注意如果所有限流key都落到同一个节点会加剧热点需要在分散和slot一致之间找平衡。我的做法是Cluster模式下限流key按业务线区分哈希标签比如{rate:order}:xxx、{rate:login}:xxx不同业务线分散到不同节点同时保证同一业务线的key在相同slot。6. 实际项目中的落地经验总结这套方案我在某电商项目里实际落地过说几个最有参考价值的体会。第一限流一定要和熔断、降级配套使用。限流是防御前置但如果下游已经出现故障光限流是不够的需要配合Sentinel做熔断快速失败避免请求堆积。我在项目中把限流放在Controller层熔断放在Feign调用层两层配合效果明显。第二配置中心动态调整阈值是必选项不是可选项。线上流量特征每天都在变活动期间流量是平时的几十倍如果限流阈值写死在代码里每次调整都要发版运营等不了那么久。第三压测不要太早结束。我见过很多团队限流压测只跑一分钟看起来没问题就上线了。实际上Redis内存增长、连接池耗尽、GC停顿这些问题是几分钟压测暴露不出来的至少要压20分钟以上观察趋势。另外日志打印要克制。限流拒绝日志如果每一条都打WARN每秒拒绝上千次日志文件直接爆掉还会拖慢应用。我的做法是只记录被拒总数和抽样日志if (rejected) { // 通过计数器聚合每100次打印一条日志 long count rejectedCounter.incrementAndGet(); if (count % 100 0) { log.warn(限流拒绝累计次数: {}, count); } }最后说一个原则问题限流方案只是工具关键在于你清楚自己的业务边界在哪里。阈值设多少窗口选多大都需要基于真实的压测数据和下游容量评估来确定不要拍脑袋。先把这套方案跑起来拿到数据再不断调优这才是正确的工作方式。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑