☰
Java接口限流实战:令牌桶+Redis+Lua从入门到排错
2026/10/10 9:35:02 网站建设 项目流程

做后端开发这些年,接口调用次数限制是需求文档里最常出现、也最容易被低估的一句话。刚开始我以为它就是一个简单的计数器,后来被线上事故反复教育,才明白这背后牵扯到算法选型、分布式一致性、Redis可靠性、压测调参一整套链路。这篇博文就围绕“Java接口调用次数限制”这个需求,从场景拆解、限流算法、实战落地到排错经验,一次讲透,顺手给出一套能直接抄进项目的代码方案。

这套内容不只适合被领导丢一句“这里加个限流”后无从下手的后端同学,也适合正在准备面试、想系统梳理限流原理的开发者,更欢迎那些已经被线上限流误杀、压测不过、Redis抖动搞到焦头烂额的朋友来对号入座。

1. 需求拆解:接口限流到底要解决什么问题

1.1 都是哪些场景在喊“要限流”

先说一个最容易忽略的问题:用户说“接口要有调用次数限制”,他心里的真实诉求往往不一样。不把这个需求背后的场景搞清楚,后面做出来的方案大概率会被推翻。

我遇到过的典型场景有这么几类。

第一类是无账号体系的对外接口被脚本刷。比如一个查询天气的接口,没有登录态,客户端直接请求,某天突然涌进来几万条来自同几个IP的请求,量不大但每个请求都触发一次分布式链路调用,数据库慢查询日志瞬间就炸了。这种场景要的是“防恶意调用”。

第二类是登录、注册、验证码这类敏感接口被撞库或者被刷短信。攻击者拿手机号字典批量试,或者批量触发验证码下发,每一条都可能产生真金白银的运营商费用。这种场景要的是“频率控制 + 安全防护”。

第三类是用户短时间内疯狂点击按钮,比如下单、支付回调、库存扣减,前端做了防抖还是会因为网络重试造成重复请求。这种场景要的是“并发去重 + 平滑限制”。

第四类是面向第三方的开放平台计费,比如开放API按调用次数收费,免费用户每天上限1000次。这种场景要的是“配额管理”,其实也是一种广义的限流。

把这四类场景翻译成技术语言,就是两类诉求:一类是保护系统资源,防止被拖垮;另一类是执行业务规则,控制成本或管理配额。第一类和第二类对实时性要求高,误杀容忍度低;第三类和第四类对准确性要求高,不能超发。

1.2 限流、熔断、降级不是一回事

需求评审会上,经常听到“这个接口要么加个限流,要么熔断算了”。限流和熔断、降级经常被混着说,但它们解决的问题完全不同,实现机制也不同。

限流解决的是“进来的请求太多,我控制入口速度”;熔断解决的是“下游已经故障,我主动断开调用链,避免故障扩散”;降级解决的是“核心功能保命,非核心功能牺牲”。用一个日常类比:限流是商场入口保安控制进场人数,熔断是电梯坏了直接暂停扶梯服务并及时维修,降级是平时开放的停车场临时关闭,只保证主楼通道的畅通。

这三者可以组合使用,但别互相替代。我见过一个项目把熔断器的超时时间调成4秒,结果下游慢SQL一发生,所有请求都在等,熔断根本没机会触发。后来改成限流先拦截突刺流量,熔断守住连续失败率,降级兜底数据返回默认值,系统才稳定下来。

机制核心目标触发条件典型动作
限流控制请求速率QPS或并发数超阈值拒绝请求、排队等待
熔断隔离下游故障连续失败率、超时比例过高快速失败,暂停调用
降级保核心业务资源不足或非核心链路故障返回兜底数据,关闭非核心功能

先分清这几个概念,再去看“接口调用次数限制”这个需求,你才知道自己到底要写什么代码。

2. 算法选型:四种限流算法的脾气和取舍

2.1 计数器与滑动窗口:简单直接但各有短板

最容易实现的方案是固定窗口计数器。给一个key设置过期时间,比如一分钟过期,key的value记录次数,每次请求做一次累加,超过阈值就拒绝。Redis的INCR加EXPIRE两个命令加在一起就够了,代码量非常少。

但固定窗口有个著名的临界问题。假设阈值是每分钟10次,用户在09:59:59请求了10次,10:00:00又请求了10次,从窗口维度看每一分钟都没超,但前后一秒内实际通过了20次。如果这20次请求都落在同一台数据库分片上,瞬时的压力可能直接把它打挂。

滑动窗口就是来修这个问题的。把时间桶拆细,比如每分钟拆成10个格子,每格6秒,记录每个格子的请求数。判断的时候不是看整个固定分钟,而是看当前时间往前推一分钟这个滑动的区间内,累计请求数是否超标。这样就把“59分59秒 + 00分00秒”这种突刺平滑掉了。

滑动窗口的代价是存储量变大,每个key要维护多个小格子的计数,不过可以结合Redis的hash和过期时间控制成本。对于登录防刷、验证码这些“短时间次数敏感”的场景,滑动窗口是最稳的选择。

2.2 漏桶与令牌桶:经典的平滑限流方案

漏桶算法很好理解,就像进水的漏斗,水以恒定速率从底部漏出,不管上面倒多猛,下面出水速度始终不变。对应到HTTP服务上,请求先进桶排队,处理线程按照固定速率消费队列里的请求,桶满了直接丢弃。漏桶最大的特点是强制平滑,不管上游流量什么样,下游看到的永远是恒定的速率。这适合保护数据库、第三方支付接口这类脆弱的依赖。

令牌桶算法则反过来,它是一个桶,桶里以固定速率生成令牌,请求来了必须拿到令牌才能通过,但桶的容量决定了可以累积一定数量的令牌,所以它允许短时间的突发流量被快速处理。打个比方:你的服务每秒处理100个请求,平时没人用,令牌在桶里攒着,突然来一波200的突发流量,如果桶容量是200,那么这个突发直接被消化掉,不会丢掉太多请求。

对于大部分业务接口,令牌桶是四者里最均衡的选择,能够兼顾“平滑”和“利用率”。

2.3 为什么我推荐令牌桶做业务接口限流

这里给出一张对照表,平时做技术方案评审可以直接抄:

算法实现成本允许突发平滑度典型场景
固定窗口极低一般差配额统计、管理后台接口
滑动窗口中一般中登录防刷、验证码
漏桶中否最好保护数据库、保护下游HTTP服务
令牌桶中是好业务API、网关、通用后端接口

我个人的习惯是:业务系统对外提供的REST API,优先令牌桶;安全相关接口比如注册登录,优先滑动窗口;系统内部调用第三方弱依赖,优先漏桶。原因很简单——业务API天然有波峰和波谷,令牌桶允许你消耗积攒的令牌渡过瞬时波峰,而漏桶会把所有突发全部削平,用户感知上会多出等待,体验不好。

3. 落地实战:Spring Boot + Redis + Lua 实现一套限流

3.1 为什么不用Guava,而选Redis

很多教程上来就让你用Guava的RateLimiter,两行代码搞定,单机测试也看不出什么问题。但一旦服务多实例部署,问题立刻暴露:请求打到三台机器,每台各自维护自己的令牌桶,每台都允许100QPS,整体实际放过了300QPS,与限额模型完全脱节。

所以分布式环境下,必须把限流状态放到所有实例都能访问的地方,Redis是最常见的选择。方案是在Redis里保存令牌桶的状态,一个key对应一个桶,每次请求通过Lua脚本原子地获取令牌。Redis单条命令是原子的,但“查令牌数、判断够不够、扣减令牌”这个逻辑靠多条命令组合就不安全了,必须用Lua脚本让它在Redis服务端一次性执行,避免并发请求同时读到同一个剩余令牌数。

这套方案的优点是:状态集中、天然支持多实例、性能也够。一个Lua脚本在Redis里做几次内存运算,P99耗时通常在毫秒以内,对业务RT几乎没影响。

3.2 自定义注解:把限流逻辑从业务代码里剥离

先定义一个注解,把它标记到需要限流的接口方法上。这一步的目的是让业务代码看起来干干净净,限流逻辑全部由AOP切面统一处理。

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { // 限流维度:接口全局限流 / 用户限流 / IP限流 String type() default "global"; // 限流key,可以用SpEL表达式从参数里取值,比如用户ID String key() default ""; // 令牌桶容量,允许突发的最大请求数 int capacity() default 5; // 令牌每秒恢复速率,也就是平均QPS上限 int refillPerSecond() default 1; // Redis key过期时间,秒;建议设个过期时间防止key堆积 long expire() default 5L; // 被限流后的提示语 String message() default "系统繁忙,请稍后再试"; }

字段解释一下:capacity是桶里最多能存的令牌数,代表服务能承受的最大瞬时并发;refillPerSecond是每秒往桶里放的令牌数,代表长期平均速率;expire是安全阀,如果某个key被限流后长期没人访问,它能在指定时间后被Redis自动清理,避免内存泄漏。

然后写切面,拦截所有加了@RateLimit的方法。这里为了简化,只贴核心判断部分。

@Aspect @Component public class RateLimitAspect { private static final DefaultRedisScript<Long> SCRIPT = new DefaultRedisScript<>(); static { SCRIPT.setScriptText( "local key = KEYS[1] " + "local capacity = tonumber(ARGV[1]) " + "local refillPerSecond = tonumber(ARGV[2]) " + "local now = tonumber(ARGV[3]) " + "local plus = tonumber(ARGV[4]) " + "local tokens = redis.call('get', key) " + "local lastRefillTime = redis.call('get', key .. ':ts') " + "if tokens == false then tokens = capacity end " + "if lastRefillTime == false then lastRefillTime = now end " + "local delta = math.max(0, now - lastRefillTime) " + "local refill = math.floor(delta * refillPerSecond / 1000) " + "tokens = math.min(capacity, tokens + refill) " + "if tokens >= plus then " + " tokens = tokens - plus " + " redis.call('set', key, tokens, 'EX', ARGV[5]) " + " redis.call('set', key .. ':ts', now, 'EX', ARGV[5]) " + " return 1 " + "else " + " return 0 " + "end" ); SCRIPT.setResultType(Long.class); } @Around("@annotation(rateLimit)") public Object around(ProceedingJoinPoint point, RateLimit rateLimit) throws Throwable { // 这里用SpEL解析rateLimit.key(),拼接最终key,比如 user:123 String redisKey = buildRedisKey(point, rateLimit); Long result = redisTemplate.execute( SCRIPT, List.of(redisKey), String.valueOf(rateLimit.capacity()), String.valueOf(rateLimit.refillPerSecond()), String.valueOf(System.currentTimeMillis()), "1", String.valueOf(rateLimit.expire()) ); if (Long.valueOf(1).equals(result)) { return point.proceed(); } throw new RateLimitException(rateLimit.message()); } }

脚本里的时间单位要仔细处理。我在脚本里要求传入的now是毫秒时间戳,delta也是毫秒,所以refill计算时除以1000换算成秒,否则速率会偏差百倍。这是我调试过程中最容易出错的地方。

3.3 Redis key怎么设计最合理

很多新手会把限流key写成一个固定的字符串,比如"limit:order",结果变成全接口共享一个桶,所有用户互相挤兑。正确做法是限定维度和业务标识分开。

接口全局限流:rate:order:global用户维度限流:rate:order:user:888IP维度限流:rate:order:ip:1.2.3.4

比如“每个用户每分钟最多下单5次”和“整个下单接口最多1000QPS”是两个完全不同的限流条件,需要分别建key、分别设注解。吃透这一点,线上“自己写的限流把正常用户误杀了”的锅就能少背一半。

3.4 被限流后返回什么:HTTP 429才是标准答案

被限流的请求不要直接返回500,从语义上应该用HTTP 429 Too Many Requests。我见过项目里被限流返回200和空数据的,前端拿到空数据以为是Bug,反而触发重试,加重系统负担。

推荐统一返回结构:

{ "code": 42901, "message": "请求过于频繁,请稍后再试", "data": null, "traceId": "xxx" }

同时可以附带一个重试时间字段,比如retryAfterSeconds,方便客户端做定时重试。这样处理比前端盲等或者用户无感知重试要友好得多。

4. 踩坑实录:我在限流上踩过的几个坑

4.1 先查再扣的“经典超卖”问题

第一版限流代码我用的是最简单的逻辑:先从Redis里GET剩余次数,判断大于0再DECR。单线程测试没问题,压测一上,100个并发同时读到剩余次数是1,全部通过判断,然后一起DECR,结果变成负数。这就是典型的“先查再扣”不是原子操作。

解决方向只有两个:要么把整个逻辑封装成Redis Lua脚本由服务端原子执行,要么直接用现成的分布式限流组件。手动拼接多条Redis命令永远有竞态条件。

4.2 固定窗口的“整点分钟”突刺

有段时间做验证码下发限流,用了固定窗口:每分钟10条。上线后发现一到每分钟的第59秒和下一分钟的0秒,短信通道会忽然涌进一批请求,正好踩中临界问题。这导致短信供应商那边触发了风控,差点以为我们被刷了。

后来我把固定窗口换成了滑动窗口,每个key记录最近5秒和最近20秒的时间桶,通过两段来判断近一分钟总次数。这样虽然存储多了点,但彻底消除了窗口切换瞬间的流量突刺。

4.3 限流key维度搞反

另一次事故让我印象更深刻。需求是“每个IP每分钟最多查询100次”,但我在注解里把IP拼成了用户ID,代码逻辑本身没问题,可是取参写错了。

结果就是,某个正常用户短时间查了几十次之后,其他所有用户都跟着被限流,因为大家都在共享同一个误拼的用户key。这个问题的排查过程很痛苦,因为故障表现是全站查询接口大面积拒绝,看日志又找不到对应IP的异常记录,最后打印Redis key才发现维度对不上。

排查方法很简单:限流命中时,把实际拼接的key打出来看一眼。现在我都在切面里加了日志输出,key值一目了然,省掉很多猜谜时间。

4.4 Redis故障兜底策略

限流强依赖于Redis,那Redis挂了怎么办?有一种做法是请求直接放行,先把服务保下来,同时把限流降级为本地内存限流或者完全关闭限流并推送告警。另一种做法是快速失败,但这对业务影响太大,正常流量也会被卡住。

我的习惯是:限流Redis不可用的时候,放行请求并记录一个可观测指标,比如名为rate_limit_degraded_total的计数器,再交给监控告警。因为限流的本质是保护系统,既然Redis挂了,系统依然可能因为缺少限流而变慢,但至少不会因为限流组件自身故障而完全不可用。等Redis恢复后,再自动切回正常限流。

5. 常见问题排查速查表

5.1 限流完全不生效

先按顺序自查三处:注解位置是否正确、AOP切面是否被Spring容器管理、Redis key是否一致。

常见原因一:@RateLimit标注在了私有方法上。Spring AOP基于动态代理,私有方法不经过代理,注解根本不会触发切面逻辑。需要确保限流方法是被Spring代理调用的public方法。

常见原因二:调用发生在同类内部,比如Controller里的public方法调用了本类另一个带@RateLimit注解的方法,这种自调用绕过了代理,切面不会生效。解决办法是把限流方法抽到另一个Bean里,或者自己注入代理对象。

常见原因三:依赖缺失。spring-boot-starter-aop没引入,切面类没有生效。这点特别容易在模块化工程里翻车,建议启动时打一条日志“RateLimitAspect loaded”,确认切面初始化完成。

5.2 限流“误杀”正常用户

典型的误杀场景是“好几个接口共用一个key,或者共用一套阈值参数”,导致一个接口的突发流量把另一个接口的额度吃掉。

合理的做法是一个业务场景一个key,并且threshold参数要根据高峰期数据独立估算。比如查询接口压测能到2000QPS,但正常峰值才300QPS,不要把限流阈值定成300,而是留40%余量,比如500QPS,否则一个秒杀预热就把正常查询用户全挡了。

5.3 压测与验证方法

生产环境没法随意压测,但测试环境和预发环境可以用JMeter做一次快速验证。步骤很简单:创建线程组,线程数设为目标QPS的2倍,持续时间60秒,每个线程循环调用接口。观察:当请求速率超过限流阈值时,返回429的比例应逐渐上升,而成功响应的TPS会被压在那个阈值附近,不会显著超出。

如果成功TPS远远高出阈值,说明限流逻辑没拦截住,优先查key是否拼接了随机参数导致每个请求创建了新桶。如果成功TPS明显低于阈值,说明上游存在其他瓶颈,比如数据库或线程池先被打满了,这时要看的是整个链路的木桶效应。

6. 扩展思考:限流之外还要想什么

6.1 网关层限流和业务层限流怎么分工

业务层的限流适合做精细控制,比如“每用户每接口限频”,但只能拦住已经进到应用里的请求。在它之前,网关层还应该有一道粗粒度限流,比如对整个域名总QPS做限制,对特定IP做黑名单拦截。

我的经验是网关层用Lua限流,业务层用注解限流,两层配合。网关负责挡住绝大多数恶意流量,业务层负责精细配额,这样业务层承受的压力已经小了很多,Redis的读写压力也相对可控。

6.2 并发数限制和QPS限流是两件事

接口调用次数限制经常只考虑QPS,但别忘了另一个维度:并发数。QPS限流的是每秒能进来多少请求,并发数限制的是同时处理中、尚未返回的请求总数。一个接口RT是500ms,就算QPS限制是100,正在执行的请求也可能堆到50个。如果应用线程池核心线程20个,50个并发会直接把队列塞满。

实际项目中,我会对耗时高、慢SQL频发的接口同时做一个基于信号量的并发控制,比如并发数超过20立即拒绝并返回“系统繁忙”。严格来说这属于并发控制而不是限流,但它和限流是同一个库存里的两把锁,都需要单独设计。

6.3 开源组件能不能直接用

如果要快速落地,也可以考虑现成组件。简单列举一下当前Java生态里常见的几个方案,各有侧重。Guava的RateLimiter实现的是令牌桶,但只适合单机;Redisson自带RRateLimiter,基于Redis的令牌桶实现,API非常简单,适合中小项目;还有一类是专门做流量防护的开源框架,比如Sentinel,提供了Dashboard控制台和丰富的限流熔断规则,适合中大型微服务项目,学习成本相对高一些。社区还有Resilience4j这种更轻量级的容错库,也有RateLimiter模块,但不依赖Redis,同样更适合单体或单机容错。

组件可以帮你省掉很多底层代码,但原理仍然值得吃透。因为一旦线上阈值调不准,你要做的不是回去读框架文档,而是理解桶里令牌的积攒和消耗逻辑,才能判断该调容量还是调速率。

我个人在实际项目中形成的习惯是:先花半天时间用Lua脚本的通用版方案,把限流跑通、指标打出来、压测验证完,再在某些精细场景引入Dashboard做可视化配置。上线前一定先灰度,观察限流拒绝量的曲线和业务指标有没有异常相关。限流不是黑盒,也不是加上了就一劳永逸,你需要定期回看数据、调整容量和速率。尤其是大促、活动这类流量陡增的节点,阈值和分维度策略大概率需要临时调整,写死在代码里会非常被动。现在我会在管理后台留一个动态配置限流参数的接口,线上随时可以调,这比每次改代码发版要稳妥得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询