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 ConcurrentHashMap<String, 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 脚本,直接拿去用就行。有一点需要特别提醒:千万别在分布式环境里用setnx+expire+incr这种多命令组合方案,你以为代码上能保证顺序,但在 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,每走一步都做一次压测,这样你对每个组件在系统中的真实表现会非常有数。
最后分享一个我在项目中坚持的小习惯:每次上线限流规则,我都会在日志里打印一份"限流规则快照",包含所有接口的阈值、算法、生效时间。这个习惯帮我省了无数次排查事故的时间。因为限流这个组件平时不声不响,但一旦出了问题,影响面往往是灾难级的。提前把规则、原因、变更历史都记录清楚,是对自己和团队最负责任的做法。