
1. 限流这件事为什么值得单独写一篇实战先别急着看代码。限流Rate Limiting这个词你在简历上、项目里、技术方案中估计都见过不少次但真正能在生产环境里把限流做对、做稳、做到不出事故的其实不多。SpringBoot 作为目前 Java 后端最主流的开发框架提供了一堆现成的组件但恰恰是这种什么都有的生态环境让很多人对限流的理解停留在加个过滤器挡一下的层面。这篇文章我要和你聊的是在 SpringBoot 项目里如何从零设计并落地一套真正能扛住线上压力的 API 限流策略。核心关键词就是高效二字——不是指代码写得快而是指限流本身不能成为性能瓶颈不能误伤正常用户不能在被突发流量打爆的时候才想起来规则没配好。我默认看这篇文章的你已经会 SpringBoot 的基础开发能够写 Controller、配拦截器、用 Redis。如果你还停留在听说过限流但没亲手写过的阶段这篇文章会非常有价值如果你已经在项目里放过一些限流逻辑但总觉得用得别扭比如规则写死、性能差、误杀率高那这篇文章同样能帮你重新梳理一遍思路。先给结论SpringBoot 里做 API 限流常见的实现方案不外乎 Guava RateLimiter单机、Redis Lua分布式、以及 Sentinel / Resilience4j 这类重量级框架。但绝大多数项目真正需要的是那种看懂原理、能自己改、按需扩展的轻量级方案而不是动不动就引入一个全家桶。下面我会从设计思路开始逐步带你实现一套可复用、可配置、可观测的限流组件。2. 先想清楚你到底要限什么流很多人一说到限流脑袋里第一个念头就是限制每个用户每秒最多请求多少次。这当然是一种限流但只属于限流场景里最粗粒度的一种。在做技术选型和方案设计之前我建议你先回答下面几个问题。第一个问题你要保护的是什么资源是数据库、下游的第三方接口、还是一个核心业务接口不同的资源类型对限流策略的要求完全不同。比如说你保护的是一个查询商品的读接口突发流量顶多让数据库压力大一点响应变慢但不会产生数据一致性问题但如果你保护的是一个下单接口一旦超过阈值就可能导致库存超卖、订单重复、支付回调错乱那这时候不仅要做流量控制还得配合幂等设计。第二个问题你的系统是单机部署还是多机集群单机环境下JVM 内存里放一个计数器、一个令牌桶性能极高毫无网络开销。但一旦扩容到多台实例每台机器自己计数整体 QPS 就是单机阈值 × 实例数而你又没有办法精确控制总量。这时候就必须引入 Redis 这类中间件做分布式计数。请注意分布式限流不是把单机逻辑搬到 Redis 里跑这么简单它涉及原子操作、网络耗时、时钟漂移等问题后面我会详细展开。第三个问题你要限的是用户级别、IP 级别、接口级别还是全局限流这决定了你的限流 Key 怎么设计。比如按用户 ID 限流Key 就是rate:limit:user:{userId}按 IP 限流Key 就是rate:limit:ip:{ip}接口级别就是rate:limit:api:{uri}。很多项目会同时有多个维度比如全局限流 5000 QPS 单用户 10 QPS 单 IP 50 QPS这时候限流组件就不能只支持一种 Key 规则需要做成可配置的、多规则叠加的。我见过不少失败的限流设计共同点是都没想清楚以上三个问题直接在 Controller 里加了一段代码if (counter.incrementAndGet() MAX) { throw new RuntimeException(too many requests); }这段代码的问题不在写法粗糙而在于它根本没回答限流是想保护什么、粒度是什么、分布式环境怎么算。所以在你动手写任何代码之前先花 30 分钟把这三个问题想清楚这个时间绝对值回票价。2.1 选型之前先补一堂限流算法课谈实现方案绕不开四种经典限流算法固定窗口、滑动窗口、漏桶、令牌桶。这四种算法各有各的适用场景没有哪个是绝对最好关键看你的流量模型。固定窗口算法最简单把时间切成固定大小的窗口比如 1 秒每个窗口内累计请求数超过阈值就拒绝窗口结束清零。实现成本极低一个AtomicInteger加一个时间戳就能搞定。但它的致命缺点是窗口切换瞬间容易出现双倍流量假如阈值是 100前一个窗口的最后 0.5 秒打满了 100 个请求后一个窗口的最初 0.5 秒又打满了 100 个请求这 1 秒内实际通过了 200 个请求而你原本想限制的是每秒最多 100 个。这个缺陷在流量抖动明显的生产环境里是确实能感知到的。滑动窗口算法是对固定窗口的改进核心思想是按请求的时间戳往前推一个窗口长度统计这个时间段内的请求数。可以用多个子窗口加权求和来近似实现比如把 1 秒拆成 10 个 100ms 的格子每个格子独立计数滑动时按时间比例衰减。Redis 的 ZSET 方案就是这个思路用时间戳做 score 存储请求记录每次请求前删除窗口外的记录再统计剩余数量。准确度比固定窗口高不少但代价是存储开销和计算开销都上去了。漏桶算法想象一个底部带洞的水桶水请求以任意速率倒入但以固定速率从底部流出。如果桶满了新来的水就溢出拒绝。这个模型天生适合保护下游系统——无论上游流量怎么波动下游收到的请求速率恒定。实现上通常用一个队列模拟桶但队列会带来内存积压和排队延迟需要小心设置队列长度。nginx 的限流模块用的就是漏桶的变体。令牌桶算法则相反系统以固定速率往桶里放令牌桶最多存 N 个令牌每个请求必须拿到一个令牌才能通过。允许一定程度的突发流量——如果桶是满的瞬间可以放行 N 个请求然后速率回落到固定值。这非常适合大多数互联网 API 场景因为前端要面对用户的点击行为突发是常态而你的后端又不想被突发打垮。Guava 的 RateLimiter 和 Spring Cloud Gateway 的 RequestRateLimiter 都是令牌桶思路。拿生活化的例子来对比固定窗口就像每小时只能进 100 人的规则但大家都挤在整点前后来回横跳滑动窗口是给这个规则加了最近一小时的滚动视角漏桶就像高速公路匝道的定时放行每 5 秒放一辆车令牌桶则像游乐园的取票机每分钟出 100 张票但最多只能囤 500 张游客可以攒票一次性进园。2.2 分布式限流为什么绕不开 Lua 脚本前面提过单机限流用 JVM 内存即可性能极好。但真实生产环境尤其是微服务体系下几乎都是多实例部署。这时候你只有两个选择要么用外部存储做统一计数要么接受每台机器限各自的量。几乎所有人都会选前者而 Redis 是事实标准。但这里有个技术门槛计数器和时间戳的更新必须是原子操作。如果用 Java 代码先 GET 再 SET并发情况下必然出现竞态条件限流就形同虚设。要解决竞态可以用 Redis 的INCR和EXPIRE组合命令这两个命令本身是原子的但组合起来不是——可能出现 INCR 成功但 EXPIRE 失败的边界情况导致 key 永远不过期。真正干净的方案是写一段 Lua 脚本让 Redis 服务端原子地执行整个判断逻辑。Redis 从 2.6 版本开始内置 Lua 解释器并且保证同一时刻一个脚本的执行是原子的中间不会插入其他命令。Spring Data Redis 提供了DefaultRedisScript可以方便地把 Lua 脚本交给 Redis 执行。用 Lua 做固定窗口限流的脚本很简单大致逻辑是local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local current redis.call(INCR, key) if current 1 then redis.call(EXPIRE, key, window) end if current limit then return 0 end return 1这段脚本的精妙之处在于第一次 INCR 时设置过期时间后续请求直接累加。所有判断都在 Redis 内部完成不涉及网络回环所以即使 QPS 很高也不会有过多的网络往返。我见过很多把判断逻辑放在 Java 侧、只把 count 放 Redis 的实现那不仅慢而且在并发下基本不可用。3. 一步步搭出一个可用的限流组件有了上面的理论基础现在进入实操环节。我会带你写一个完整的、带注解的、可配置的 SpringBoot 限流组件。先明确需求清单支持接口级别的限流通过自定义注解标注在 Controller 方法上支持多维度 Key比如按用户、按 IP、按接口支持令牌桶和固定窗口两种算法当限流触发时返回统一的 JSON 响应而不是直接抛出异常让前端看到堆栈规则可以放在配置文件里不需要改代码就能调整这个需求列表看起来不大但覆盖了从算法到框架集成的所有核心点。3.1 自定义注解让限流声明式地生效SpringBoot 的 AOP面向切面编程非常适合做这件事。我用的方式是定义一个RateLimit注解标注在 Controller 方法上然后在切面里拦截所有带这个注解的方法执行限流逻辑。注解定义如下Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RateLimit { // 限流 Key 的维度表达式与 Spring EL 兼容如 #userId 或 #request.getRemoteAddr() String key() default ; // 时间窗口单位秒 int window() default 1; // 窗口内允许的最大请求数 int limit() default 100; // 限流算法fixed 固定窗口 / token 令牌桶 String algorithm() default fixed; // 被限流时的提示信息 String message() default 系统繁忙请稍后再试; }用的时候就是这样RateLimit(key #userId, window 1, limit 5, message 操作过于频繁请稍后再试) PostMapping(/order) public Result createOrder(RequestParam Long userId) { // 业务代码 }这里一个容易被忽略的细节是key的设计。如果你写死一个字符串常量那所有用户都共享同一个计数器根本做不到按用户隔离。所以我把 key 设计成 Spring EL 表达式#userId会自动解析成方法参数userId的值。这个能力其实来自 Spring 的SpelExpressionParser在切面里解析即可。3.2 切面里的核心逻辑切面是整个组件的核心枢纽负责解析注解、拼装 key、决定走单机还是走 Redis、处理熔断降级。Aspect Component public class RateLimitAspect { Autowired private RateLimitService rateLimitService; Around(annotation(rateLimit)) public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { // 解析 SpEL拼出完整的限流 key String limitKey buildKey(joinPoint, rateLimit.key()); // 是否放行 boolean allowed rateLimitService.tryAcquire( limitKey, rateLimit.limit(), rateLimit.window(), rateLimit.algorithm() ); if (!allowed) { // 返回统一响应这里用你项目里已有的 Result 类 return Result.fail(rateLimit.message()); } return joinPoint.proceed(); } }这段代码看着简单但有两个关键决策点。第一当限流触发时我选择返回Result.fail(...)而不是抛异常。为什么因为抛异常会走全局异常处理器多一轮处理链路而且如果全局异常处理器没兜底响应格式就不可控了。直接返回业务统一的失败结构前端不用额外处理体验最好。第二buildKey方法里我会把 Spring EL 表达式解析成实际值但解析失败时不能抛异常把正常请求也拦住。我的兜底策略是解析失败就用方法签名作为默认 key虽然会导致所有用户共享计数器但至少不会因为限流组件本身的问题而影响主流程。这个取舍很重要安全优先。3.3 固定窗口算法实现JVM 版和 Redis 版固定窗口算法适合用在阈值不高、流量均匀的场景。在单机模式下用 ConcurrentHashMap 加 AtomicInteger 就能实现代码非常直接Component public class FixedWindowRateLimiter { private final ConcurrentHashMapString, WindowCounter counters new ConcurrentHashMap(); public boolean tryAcquire(String key, int limit, int windowSeconds) { long now System.currentTimeMillis(); long windowId now / (windowSeconds * 1000L); WindowCounter counter counters.compute(key, (k, old) - { if (old null || old.windowId ! windowId) { return new WindowCounter(windowId, new AtomicInteger(0)); } return old; }); return counter.count.incrementAndGet() limit; } private static class WindowCounter { long windowId; AtomicInteger count; WindowCounter(long windowId, AtomicInteger count) { this.windowId windowId; this.count count; } } }注意看windowId的计算方式当前毫秒时间戳除以窗口毫秒数取整得到一个随窗口边界变化的整数。当前时间落在哪个窗口就用哪个计数器。当窗口切换时compute会发现旧的windowId和新的不一致于是创建新计数器旧的自然被垃圾回收。这里没有做主动清理因为compute只在请求到来时触发不会留下孤儿任务。这个实现有两个隐藏的优点一是concurrentMap.compute是原子操作并发下不会出现多个线程同时创建新窗口的问题二是性能极高由于没有锁竞争单机支撑上万 QPS 毫无压力。Redis 版的逻辑我在前面已经贴过 Lua 脚本直接拿去用就行。有一点需要特别提醒千万别在分布式环境里用setnxexpireincr这种多命令组合方案你以为代码上能保证顺序但在 Redis 集群模式下网络抖动可能导致 expire 和 incr 不是连续执行的。Lua 脚本一步到位才是干净利落的分布式解法。3.4 令牌桶算法用 Redis 哈希结构实现令牌桶在 Redis 里的实现相对复杂一些因为需要维护两个状态令牌数tokens和上次补充时间lastRefillTime。我用一个 Hash 来存储-- KEYS[1]: 限流 key -- ARGV[1]: 桶容量 -- ARGV[2]: 每秒补充速率 -- ARGV[3]: 当前时间戳单位秒 local tokens tonumber(redis.call(HGET, KEYS[1], tokens)) local lastRefill tonumber(redis.call(HGET, KEYS[1], lastRefill)) if tokens nil then tokens tonumber(ARGV[1]) lastRefill tonumber(ARGV[3]) end -- 计算需要补充的令牌数 local elapsed tonumber(ARGV[3]) - lastRefill local refill elapsed * tonumber(ARGV[2]) tokens math.min(tokens refill, tonumber(ARGV[1])) -- 在 Lua 里不能调用 redis.call(TIME) 来保证脚本的确定性 -- 所以建议 Java 侧传入当前时间戳保证多实例时钟一致 local allowed 0 if tokens 1 then tokens tokens - 1 allowed 1 end redis.call(HSET, KEYS[1], tokens, tokens) redis.call(HSET, KEYS[1], lastRefill, tonumber(ARGV[3])) redis.call(EXPIRE, KEYS[1], 2 * tonumber(ARGV[1])) return allowed这段脚本有几个细节值得展开。第一为什么用HSET而不是直接用 String因为令牌数和上次补充时间是一对不可分割的状态存在同一个 Hash 里能保证事务性避免两次SET之间出现中间态。第二math.min(tokens refill, capacity)这一行是令牌桶的灵魂。它既保证了不会无限累积令牌也允许了桶空后快速重新积满的突发能力。第三时间戳为什么要由调用方传入因为 Redis 的TIME命令在 Lua 脚本中使用时是非确定性的在 Redis Cluster 环境中会破坏脚本的复制一致性。从 Java 侧传入System.currentTimeMillis() / 1000虽然不同机器之间可能有几百毫秒的时钟误差但对于秒级甚至毫秒级的限流精度来说这点误差完全可以接受。这里要明确一点令牌桶是个允许突发的算法所以如果你的业务场景要求绝对平滑比如限制下游数据库的写入速率那令牌桶可能反而不合适漏桶更匹配。但绝大多数对外 API 场景令牌桶的体验是最好的。4. 从组件到生产配置化、预热与监控写完了核心逻辑接下来要解决的是怎么用和怎么管的问题。一个限流组件如果只能通过改代码调整参数那它就是不及格的。4.1 把限流规则挪到配置文件里我设计的方案是在application.yml里针对特定接口配置限流规则优先级高于注解上的默认值。这样运维同学不用懂 Java 代码也能在流量高峰来临前调整阈值。rate-limit: rules: - key-pattern: /api/order limit: 50 window: 1 - key-pattern: /api/search limit: 200 window: 1在切面里先查配置表命中则用配置值否则使用注解值。用 Spring 的ConfigurationProperties绑定配置项非常简单。这里我加了一个「配置热更新」的小技巧把配置封装成一个单独的 Bean并在切面里每次读取时都访问这个 Bean。如果配合配置中心比如 Nacos、Apollo修改配置后不用重启服务就能生效。4.2 避免冷启动毛刺令牌桶要预热吗令牌桶刚初始化时桶是满的这会让接口在启动后的第一秒内瞬间放行大量请求。如果你希望系统启动后慢热可以在一段预留时间后开始快速填充令牌。这个行为和 JVM 的-XX:CompileThreshold预热逻辑一样属于细节调优。RateLimiter 本身提供warmupPeriod参数来处理冷启动。如果不做预热也可以接受——大多数 API 网关场景下启动后的瞬间流量压力不会太大令牌桶的突发能力反而有助于快速消化积压。但如果你在维护一个关键的秒杀服务我强烈建议通过定时任务在启动后逐步增加阈值或者直接把初始 tokens 设置为较小值。4.3 日志与监控限流组件必须自带观测能力限流组件最容易出现的问题就是哑巴式拒绝——请求被拦了但没人知道为什么拦、拦了多少、哪个接口拦得最多。这不是技术问题而是可观测性问题。所以我在组件里加了两个基础能力。第一是日志记录每次拒绝请求时记录 key、method、uri、当前计数、阈值方便排查。注意日志要用 WARN 级别而不是 DEBUG否则会被日志框架过滤掉。第二是暴露指标用 Micrometer 把通过数/拒绝数/当前计数发布成指标接入 Prometheus 后可以做限流告警。下面这段代码示意了指标记录的思路MeterRegistry registry ...; Counter rejectedCounter Counter.builder(api.rate.limit.rejected) .tag(key, limitKey) .tag(uri, uri) .register(registry); rejectedCounter.increment();有了指标你就能在 Grafana 上直观看到限流触发的频率从而判断阈值设置是否合理。比如某个接口长期处于 30% 的拒绝率那阈值可能是压得太狠了反之某个接口几乎是零拒绝说明阈值设得过于宽松。5. 单机还是分布式这是个需要权衡的问题说了这么多实现细节最后必须把单机方案 vs 分布式方案的选择逻辑讲透。因为这是很多项目里最容易被拍脑袋决定的事情。5.1 单机方案的适用边界如果你的服务只部署一个实例或者单实例能扛住你 10 倍以上的峰值流量那我建议直接用 JVM 内存方案。原因很简单性能高、无网络依赖、代码简单、调试容易。用 Guava RateLimiter 或者自己写计数器几行代码搞定而且绝不会因为 Redis 抖动而影响主流程。别被分布式限流更高级的说法忽悠。限流本质上是个性能敏感组件能用内存解决的事情尽量不要牵扯外部存储。我见过一个项目单机 QPS 峰值才 800却硬要引入 Redis 限流结果 Redis 网络抖动导致部分请求拿不到令牌反而变成新的故障点。5.2 分布式方案到底解决了什么分布式限流的核心价值是精确控制集群维度上的流量总量。举个例子你有一个服务部署了 10 个实例每台机器面对 2 万 QPS 的流量单机限流上限设 5000那么整个集群理论上最多能消耗 5 万 QPS。但真实流量分布不可能完全均匀某台机器可能流量倾斜到 7 万 QPS这时候单机限流就失灵了。只有基于 Redis 的分布式计数才能保证整个集群 10 秒内最多接受 5 万个请求。代价也很明显每次请求都要经过一次 Redis 往返即使本地网络延迟只有 0.5ms在 10 万 QPS 的场景下带来的额外时间开销也很可观。而且 Redis 本身会成为新的瓶颈如果 Redis 挂了限流组件必须能快速降级——要么直接放行牺牲限流保可用性要么拒绝所有请求保安全但可能业务不可用。没有完美的答案需要结合业务的敏感性做决策。我的建议是追求精确控制的场景秒杀、支付、下单用分布式令牌桶加降级策略追求性能的场景普通查询接口、登录接口用单机令牌桶加合理超卖容忍。分组混合使用比一刀切更合理。5.3 降级策略Redis 宕机后限流怎么办这是所有分布式限流方案都躲不开的问题。我在生产环境中的做法是用try-catch包裹 Redis 调用捕获连接异常后设置一个标志位后续请求在 30 秒内直接放行同时开启一个后台线程定期探测 Redis 是否恢复。下面这段伪代码展示了核心思路public boolean tryAcquire(...) { if (!redisAvailable) { return true; // 降级放行 } try { Boolean allowed redisScript.execute(script, key, args...); if (allowed null) { return true; } return allowed; } catch (Exception e) { redisAvailable false; // 标记降级 scheduleProbe(); // 定期探测 return true; } }这个方案的本质是限流对业务不可用优先保证业务可用。如果是严格不能超量的场景降级策略应该反过来——失败即拒绝。但多数互联网 API 场景宁可让流量瞬间把系统打慢也不能因为限流组件故障导致大量请求直接被 500。这个取舍你要在评审会上提前和人达成一致。6. 常见坑位速查我踩过的那些坑任何限流组件落地过程中总会遇到几个让人抓狂的细节问题。我把实际项目中踩过的坑整理成一张速查表希望你能绕开。坑位现象原因解决方案注解失效加了RateLimit但限流不生效切面没被 Spring 扫描到或者方法内部自调用导致 AOP 失效确保切面类在组件扫描路径下内部方法间调用走AopContext.currentProxy或拆到另一个 Bean计数器溢出高并发下计数变为负数AtomicInteger在超过Integer.MAX_VALUE后溢出使用LongAdder或AtomicLong并设定合理阈值避免计数无限增长Redis Key 堆积限流 Key 过多导致 Redis 内存爆炸忘记设置EXPIRE或者EXPIRE只在第一次触发时设置每次请求时刷新过期时间或者定期清理无用 key后台定时任务时间窗口错位明明刚过整点请求却被拒绝服务端时间与客户端时间不一致或本地时钟跳变统一使用服务器时间不信任客户端时间关键场景建议使用 NTP 同步全局异常拦截限流异常被全局异常处理器当成业务异常返回RateLimitException未被单独处理在切面里直接返回 Result不走异常链路多级限流叠加网关限流 应用限流 数据库限流互相矛盾各层阈值独立设置没有统一规划明确每层职责例如网关限全局流量应用层限用户维度数据库层限连接数压测时误伤压测流量被认成正常流量导致真实用户被拒限流 Key 没有区分压测来源在压测流量中注入标识限流 Key 过滤掉压测标志除了表格里的这些我还有三个在实际项目里总结出的经验。第一限流组件一定要在压测阶段验证而不是上线后再调。压测时你会发现单机限流和分布式限流的行为差异非常大——单机实现的 QPS 曲线是标准锯齿形分布式实现则会因为 Redis 网络抖动出现毛刺。如果压测阶段不摸清这些特征上线后被流量一冲就会手忙脚乱。第二限流的拒绝响应必须包含可读信息。不要只返回一个 HTTP 403 或者 429 就完了最好在响应体里带上稍后重试的提示客户端可以根据响应报文友好地引导用户。我在项目里还做过一个增强在响应头Retry-After里写入预计等待秒数让调用方可以精确控制重试时间。这个细节对上游系统的体验提升非常明显。第三重要接口的限流阈值要配成参数化配置 分级灰度。什么意思呢就是不要对所有用户一视同仁。高级用户、内部系统、APP 端、Web 端可以分别设置不同的阈值。如果某个接口的访问量临时暴涨可以由运维在配置中心直接调整而不是紧急发版。7. 写在最后的一个实战思路文章到这里核心内容已经讲完了。你在写自己的限流组件时我建议第一步不要追求功能全而是先跑通一个最简单的固定窗口版本接入一个现有接口观察它对性能的影响。然后再逐步扩展令牌桶、加 Key 维度、上 Redis每走一步都做一次压测这样你对每个组件在系统中的真实表现会非常有数。最后分享一个我在项目中坚持的小习惯每次上线限流规则我都会在日志里打印一份限流规则快照包含所有接口的阈值、算法、生效时间。这个习惯帮我省了无数次排查事故的时间。因为限流这个组件平时不声不响但一旦出了问题影响面往往是灾难级的。提前把规则、原因、变更历史都记录清楚是对自己和团队最负责任的做法。