写SpringBoot接口,最怕什么?不是业务逻辑复杂,也不是数据库慢查询,而是某个接口突然被刷爆,后面所有请求跟着一起死。我自己遇到过最典型的一次:一个内部数据查询接口平时只有几十QPS,结果某个合作方脚本跑错了循环,几秒钟打过来几千个请求,把下游数据库连接池直接占满,整个服务其他接口全部跟着超时。从那以后我就意识到,API限流不是“有空再补”的优化项,而是和参数校验、权限认证一样,属于每个对外接口的基础能力。
这篇文章不聊空泛的理论,就从SpringBoot项目实际落地角度,把手写限流、Redis分布式限流、Sentinel+Nacos动态限流这三条路都走一遍。你会看到每一种方案的适用场景、核心代码、常见坑以及限流参数怎么压测确定。适合正在维护SpringBoot项目的后端开发,尤其是刚接手线上服务、还没给接口加任何保护措施的同学。
1. 先想清楚:限流到底在解决什么?
1.1 业务痛点:三类场景最典型
限流解决的是“系统处理不过来时,如何让整体服务不被打垮”的问题。
第一类场景是突发流量。比如秒杀、抢券、热点事件,流量瞬间冲高,如果接口没有上限,后端服务会把压力传导给数据库、第三方接口、甚至下游系统,最后整条链路雪崩。限流不是提升系统能力,而是给系统一个缓冲垫,让超出的请求快速失败,而不是排队耗尽资源。
第二类场景是恶意请求或调用方Bug。就像我开篇说的循环刷接口,或者爬虫脚本高频访问,这类请求不会因为你带宽够就停止,必须用限流挡住。某种意义上,限流比鉴权还重要,因为鉴权只识别“你是谁”,限流还管“你一秒能干多少活”。
第三类场景是对接第三方付费API。现在很多业务会依赖短信、地图、大模型等外部接口,这些API往往有明确的调用额度限制。如果你不做本地限流,等第三方接口返回429或者额度耗尽时才处理,那体验就差得远了。更现实的是,有些外部API按调用量计费,一次代码Bug可能直接把月度预算烧光。
1.2 限流算法:选型前先搞懂四种方案
很多人一上来就写代码,结果选错了算法,上线后才发现问题。其实限流算法就四种,搞清它们的脾气,比背概念重要得多:
| 算法 | 核心逻辑 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|---|
| 固定窗口 | 统计单位时间内的请求数,超过阈值直接拒绝 | 实现简单,内存占用小 | 窗口临界处可能出现双倍突发 | 粗粒度接口保护 |
| 滑动窗口 | 把时间切成小块,统计滑动区间内的总数 | 比固定窗口平滑,突发控制更好 | 需要记录请求明细,内存更大 | 对突发敏感的业务 |
| 漏桶 | 请求先进桶里,按固定速率流出,桶满溢出 | 输出速率绝对恒定,对下游最友好 | 无法应对突发流量,请求被排队丢失 | 下游是慢接口或弱依赖 |
| 令牌桶 | 以固定速率生成令牌,请求拿到令牌才放行 | 允许一定突发,兼顾速率和弹性 | 实现相对复杂 | 绝大多数业务API |
如果让我在SpringBoot项目里做选择,我会优先考虑令牌桶,其次是滑动窗口。原因很简单:业务API通常会周期性调用,零突发反而少见,令牌桶可以允许短时间的流量高峰,比如秒杀前1秒用户疯狂点击,只要桶里有“攒下来”的令牌,就能对及时请求做出响应。而漏桶适合保护那些下游特别脆弱的接口,比如推送服务、短信网关这种你绝不希望它收到瞬时大流量的地方。
2. SpringBoot本地限流:拦截器 + 滑动窗口的实现与取舍
2.1 为什么不直接上Guava?手写一个窗口计数器更快
本地限流最简单的方案就是基于ConcurrentHashMap写计数器,连Guava都不用引。原因是什么?如果你的服务只有单实例部署,或者接口基本不参与多节点路由,那完全没有必要引入Redis和额外中间件,一个拦截器加一个Map就能解决。
滑动窗口的实现思路也不复杂。把一分钟拆成6个10秒的小窗口,每个小窗口计数器清零重置,判断当前整个滑动时间段内的累计请求数是否超过阈值。这样做的好处是可以完美规避开固定窗口“前58秒没请求,最后1秒来100个,下一窗口第1秒再来100个”这种临界突发。
从维护角度看,手写这种本地限流还有一个隐形成本非常低的好处:不依赖外部组件,单元测试好写,出了问题随便一个线程转储就能看明白,完全没有Redis超时、连接池泄漏这些额外的烦恼。
2.2 完整实现:拦截器、清理任务与线程安全
下面是滑动窗口限流的核心代码,我直接写一个可在SpringBoot里跑的版本。
首先定义一个窗口计数器管理类:
@Component public class SlidingWindowLimiter { private final ConcurrentHashMap<String, Deque<Long>> records = new ConcurrentHashMap<>(); private final int windowSizeSeconds = 60; private final int maxRequestsPerWindow = 200; public synchronized boolean tryAcquire(String key) { long currentTime = System.currentTimeMillis(); long windowStart = currentTime - windowSizeSeconds * 1000; Deque<Long> deque = records.computeIfAbsent(key, k -> new ConcurrentLinkedDeque<>()); // 清理掉已经滑出窗口的请求时间戳 while (!deque.isEmpty() && deque.peekFirst() < windowStart) { deque.pollFirst(); } if (deque.size() >= maxRequestsPerWindow) { return false; } deque.addLast(currentTime); return true; } }这段代码有几个细节要特别注意:
synchronized 是用在单实例内部的并发控制上,确保多个线程同时操作同一个key时不会出现统计错误。这里虽然损失一点并发度,但滑动窗口本身就要求时间戳入列是原子的,性能损失在这个场景下可以接受。
清理旧时间戳用 while + peekFirst + pollFirst,是因为队列里的时间戳是按照插入顺序排列的。请求时间一旦超过窗口范围就直接淘汰,否则无限增长。这里我故意选择 ConcurrentLinkedDeque 而不是普通ArrayList,就是为了让队头驱逐和队尾追加都变成常量时间操作。
接着把它接进HandlerInterceptor:
@Component public class RateLimitInterceptor implements HandlerInterceptor { private final SlidingWindowLimiter limiter; public RateLimitInterceptor(SlidingWindowLimiter limiter) { this.limiter = limiter; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String key = request.getRequestURI(); if (!limiter.tryAcquire(key)) { response.setStatus(429); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":429,\"msg\":\"请求太频繁,请稍后再试\"}"); return false; } return true; } }再到WebMvcConfigurer里注册:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(rateLimitInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns("/api/login"); } @Bean public RateLimitInterceptor rateLimitInterceptor() { return new RateLimitInterceptor(slidingWindowLimiter()); } @Bean public SlidingWindowLimiter slidingWindowLimiter() { return new SlidingWindowLimiter(); } }可能有人问,拦截器和Filter有什么区别?拦截器能拿到Handler对象,可以结合方法的自定义注解判断“这个接口是否参与限流”,而Filter在DispatcherServlet之前执行,适合做全局限流。你如果只想保护某一类接口,拦截器天然合适。
我习惯对外返回429状态码,因为按HTTP语义,429 Too Many Requests就是干这个用的,客户端也能识别和做退避。有一些团队统一返回200加业务码,也能用,但会损失一部分标准语义,我建议优先用429。
2.3 本地限流的边界:分布式一上就破功
上面的方案有一个很致命的边界:当你把SpringBoot服务水平扩展成多个实例,或者后端接口同时被多个网关转发时,每个实例维护的是各自的计数器,限流阈值就变成了“每实例单机阈值乘以实例数”。
举个例子,一台机器限流200次/分钟,部署五台机器后,实际放行可能达到1000次/分钟。这明显违背了限流的初衷。
更隐蔽的问题是进程重启丢失数据。本地内存里的时间戳记录在服务重启后全部清零,如果重启频繁,限流策略形同虚设。此外本地限流也没办法做集中式的实时监控,你很难知道全局一共放行了多少请求。
所以单机方案只适用于三种情况:其一,服务做不了水平扩展,只能单部署;其二,你限的其实是外部固定配额,比如对接第三方API的调用限制;其三,性能要求极度苛刻,不想引入额外网络IO。否则就要考虑下一节的分布式方案。
3. 注解 + Guava RateLimiter:把限流变成可复用的业务能力
3.1 令牌桶为什么更适合业务API?三个理由
Guava RateLimiter是Google Guava库里提供的限流工具,它实现的就是令牌桶算法。我把它定位成“本地限流场景里最高效的成品方案”,三个理由如下。
第一,它天然允许突发流量。令牌桶可以积攒令牌,在没有流量的时候,桶里的令牌数量最多能堆到burst容量。某个业务的瞬时访问冲到波峰时,RateLimiter能在不超限的前提下发放“旧令牌”,业务感受不到被人为削平。这是漏桶做不到的。
第二,它支持预热。RateLimiter.create(permitsPerSecond, warmupPeriod, unit) 可以设置冷启动预热时间,在服务刚启动时让流量从小到大地爬坡。这个设计很聪明,因为JIT编译、连接池初始化都发生在刚启动那段时期,新服务本来就不适合承担全部流量。
第三,它和AOP结合特别好。你可以基于自定义注解,把不同的限流策略像打标签一样贴在各个Controller方法上,不用写一堆重复的拦截逻辑。
3.2 自定义@RateLimit注解,代码贴出来直接抄
我推荐的做法是定义注解 + AOP切面,而不是每个接口里手动调tryAcquire。这样业务代码保持干净,限流逻辑统一收敛。
先看注解定义:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { // 每秒放行的请求数 double permitsPerSecond() default 10d; // 限流维度,支持用户ID、IP等 String key() default ""; }接着是切面:
@Aspect @Component public class RateLimitAspect { private final Map<String, RateLimiter> limiterMap = new ConcurrentHashMap<>(); @Around("@annotation(rateLimit)") public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { String key = buildKey(joinPoint, rateLimit); RateLimiter limiter = limiterMap.computeIfAbsent(key, k -> RateLimiter.create(rateLimit.permitsPerSecond())); if (!limiter.tryAcquire()) { throw new RateLimitException("请求过于频繁"); } return joinPoint.proceed(); } private String buildKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) { // 如果注解里指定了key就优先用,否则用全限定方法名 return rateLimit.key().isEmpty() ? joinPoint.getSignature().toString() : rateLimit.key(); } }使用时只需在Controller方法上标注:
@GetMapping("/order/query") @RateLimit(permitsPerSecond = 50, key = "orderQuery") public Result<OrderVO> queryOrder(@RequestParam String orderId) { // 业务逻辑 }这里有几个设计决策要讲明白。limiterMap用的是ConcurrentHashMap,因为每个接口对应一个独立的RateLimiter实例,不能让所有接口共享一个令牌桶,否则一个热点接口会把其他接口的令牌全部耗光。tryAcquire()是非阻塞的,拿不到令牌立刻返回false,适合HTTP接口这种需要快速响应的场景。拿不到令牌抛RateLimitException,再通过全局异常处理器转换成429响应,业务代码里不用写try-catch。
3.3 与此方案常伴的坑:AOP失效、透支预支与单机性
Guava RateLimiter看着简单,实际上有几个坑我踩过不止一次。
第一个坑是AOP不生效。自调用会导致切面失效,也就是同一个类内部调用带注解的方法时,Spring AOP代理不会介入。例如Controller里有一个方法调用了另一个带@RateLimit的方法,由于调用发生在内部,注解就完全失效。解决办法是注入self代理对象,或者把被限流方法拎到单独的Service里,让调用发生在代理边界之外。
第二个坑是透支预支。RateLimiter.tryAcquire()默认会尝试获取单个令牌,如果桶里没有令牌,它允许“预支”未来的令牌,导致刚启动时看似能放行几十个请求,然后后面一小段窗口内全部被拒绝。如果想严格限制速率到“绝不允许突刺”,需要调用tryAcquire(int permits)并控制可透支的令牌数量,或者干脆在限流前先sleep阻塞住(不推荐,会拖垮线程池)。
第三个坑是单机性。和上一节的本地限流一样,RateLimiter只在当前进程里有效,如果你部署了多实例,每个实例都会各自创建令牌桶。所以Guava方案始终定位是“进程级限流”,适合解决单个实例内部的资源保护,比如保护本机的线程池、连接池,不适合做全局的API限流。
4. Redis + Lua 分布式限流:生产环境的标准姿势
4.1 为什么必须用Lua:原子性、性能与网络开销
分布式限流绕不开Redis,因为多实例需要一个共享的计数器。但仅用RedisTemplate的incr和expire两个命令分别执行,会有原子性问题:可能incr成功,expire命令失败,导致某个key永远不过期,计数器永远累加。
Redis官方推荐的原子做法是Lua脚本。Redis脚本在执行时会阻塞其他命令,整个脚本一次性执行完,中间不会被其他客户端插入命令,天然保证原子性。同时把所有逻辑写在脚本里,一次网络请求完成判断、计数、过期所有操作,减少多次IO的耗时。
有人会担心:Lua脚本会不会把Redis拖垮?实际上脚本很短,执行时间微秒级,在正常的接口QPS下完全不是问题。需要警惕的是不要在Lua里写慢查询类操作,比如KEYS、HGETALL大哈希,那才是拖垮Redis的元凶。
4.2 SpringBoot集成Redis Lua限流:完整实现
首先准备Lua脚本,保存到classpath下的limit.lua:
local limit = tonumber(ARGV[1]) local windowMillis = tonumber(ARGV[2]) local current = redis.call('INCRBY', KEYS[1], 1) if current == 1 then redis.call('PEXPIRE', KEYS[1], windowMillis) end if current > limit then return 0 end return 1脚本的核心思想很简单:以计数器的value代表当前窗口内的请求数,第一次请求时设置窗口过期时间,后续请求直接判断计数是否超过阈值。
然后在Java侧封装执行:
@Component public class RedisSlidingWindowLimiter { private final StringRedisTemplate redisTemplate; private final DefaultRedisScript<Long> limitScript; public RedisSlidingWindowLimiter(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; this.limitScript = new DefaultRedisScript<>(); this.limitScript.setLocation(new ClassPathResource("limit.lua")); this.limitScript.setResultType(Long.class); } public boolean tryAcquire(String key, int limit, long windowMillis) { Long result = redisTemplate.execute( limitScript, Collections.singletonList(key), String.valueOf(limit), String.valueOf(windowMillis) ); return result != null && result == 1; } }业务调用时这么用:
if (!limiter.tryAcquire("api:order:query:" + userId, 60, 60000)) { throw new RateLimitException("请求过于频繁"); }key的设计也要讲一下。最好包含业务维度,比如userId、IP、接口路径。限流可以做成全局限流(所有用户共享60次/分钟),也可以做成单用户限流(每个用户60次/分钟)。如果你限的是“单用户调单接口次数”,key里必须带userId;如果你限的是“接口总量”,key只需要带接口路径。
4.3 分布式限流的参数细节与协议坑
用Redis+Lua方案时,有几个细节一定不能跳过。
第一个是过期时间窗口的选择。上面脚本用的是PEXPIRE(毫秒),避免单位换算错误。如果你用的窗口是1秒,windowMillis就是1000,别写成1,那样Redis会把你当成1毫秒处理,key很快就过期。
第二个是Redis连接问题。SpringBoot的StringRedisTemplate默认用连接池,如果并发高峰时连接池耗尽,限流判断本身可能变成瓶颈。我一般调大配置:
spring: data: redis: timeout: 3s lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5第三个是Redis集群模式。如果Redis部署为Cluster,Lua脚本内操作的多个key默认必须落在同一个哈希槽,否则会报CROSSSLOT错误。解决办法就是让限流Key带同一哈希标签,比如 userId = {user:123},确保所有限流Key都路由到同一个槽位。
第四个是时间一致性问题。脚本中只依赖Redis内部时间,不依赖应用服务器时间,这避免了多台机器时钟不一致导致的限流失真。但也要注意,Redis本身的命令执行延迟可能会让计数器略微不准确,好在限流本身是一个概率控制,极小的窗口误差不影响整体效果。
第五个是Redisson的替代方案。如果你的项目已经引入了Redisson,可以直接用RRateLimiter对象,它内部封装了Lua脚本,用法比手写简单得多。不过我对自定义Lua脚本更情有独钟,因为它逻辑透明、便于压测和逐行审查,线上出问题也好定位。
5. 接入Sentinel + Nacos:动态规则和持久化,摆脱“改代码重启”
5.1 Sentinel适合什么团队?对比手写方案
手写限流方案的痛点很直接:规则写在代码里,每次调整阈值都得改代码、重新打包、重启应用。线上环境想临时把某个接口的阈值从100调到50,你得走一次发布流程,等十几分钟才能生效。当限流接口越来越多时,这种硬编码的运维成本会显著上升。
Sentinel是阿里开源的服务治理组件,专门解决这类问题。它自带控制台,能看到每个接口的实时QPS、通过/拒绝数量,还能在页面上动态修改流控规则。如果你已经接入了Nacos做配置中心,还能把规则持久化到Nacos,达到“控制台改规则,Nacos自动下发,应用热加载”的效果。
但Sentinel也不是银弹。它需要额外的控制台部署,规则配置和文档资料相对多,对于一个小型单体应用来说可能显得重。我的判断标准是:如果接口数量少于十个、限流阈值基本固定,手写方案完全够用;一旦接口超过二十个、团队多人维护、阈值经常调整,那就值得接入Sentinel+Nacos。
5.2 快速接入:依赖、配置与流控规则
先在pom.xml引入依赖:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId> <version>2021.0.5.0</version> </dependency>在application.yml里配置:
spring: cloud: sentinel: transport: dashboard: localhost:8080 eager: true在控制台上找到指定服务,点击“流控规则”,新增规则:资源名填接口路径,比如 /order/query;阈值类型选择QPS;单机阈值填50;流控效果可以选择“快速失败”或“匀速排队”。配置完成后,Sentinel会自动把规则推送到应用。
如果你的业务代码需要针对某个方法限流,可以用@SentinelResource注解:
@SentinelResource(value = "createOrder", blockHandler = "handleBlocked") public Result<OrderVO> createOrder(OrderDTO dto) { return orderService.create(dto); } public Result<OrderVO> handleBlocked(OrderDTO dto, BlockException ex) { return Result.error(429, "系统繁忙,请稍后重试"); }blockHandler方法必须和原方法的参数以及返回值保持一致,还要额外加一个BlockException参数。我遇到过好多次因为blockHandler签名写错导致限流生效时直接报500,根本走不到兜底逻辑的情况,所以写完后一定要动手压一遍。
5.3 规则持久化到Nacos的关键步骤
Sentinel默认情况下,控制台配置的规则存在应用内存里,应用重启后规则就丢了。解决办法是接入Nacos作为规则数据源,把规则持久化到配置中心。
需要引入依赖:
<dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-datasource-nacos</artifactId> </dependency>在application.yml中配置数据源:
spring: cloud: sentinel: datasource: flow: nacos: server-addr: ${NACOS_ADDR:127.0.0.1:8848} dataId: ${spring.application.name}-flow-rules groupId: DEFAULT_GROUP rule-type: flow然后在Nacos里新增配置,dataId填上面对应的值,配置内容是JSON数组:
[ { "resource": "/order/query", "limitApp": "default", "grade": 1, "count": 50, "strategy": 0, "controlBehavior": 0 } ]字段含义如下:grade=1表示QPS维度,count是阈值,strategy=0代表单资源限流,controlBehavior=0表示快速失败。改完配置后Nacos会推送到应用,Sentinel自动热更新,不需要重启服务。
这条链路搭好之后,团队小伙伴想调阈值只需要打开Nacos配置中心改一个数字,应用侧完全无感知。因为配置有历史版本,就算调错了也能快速回滚,比改代码重启要安全得多。
5.4 SpringBoot 3.x 和版本兼容性
写到这里必须提醒一句:如果你用的是SpringBoot 3.x,需要注意依赖版本的兼容性。SpringBoot 3.0把Servlet API从javax迁移到了jakarta命名空间,部分早期版本的Sentinel starter基于javax编译,直接整合会抛ClassNotFoundException。
我在实际项目中见过几次SpringBoot版本太高导致的整合失败,解决方案基本都是升级到适配Spring Boot 3的Sentinel版本。如果你引入了Spring Cloud Alibaba组件,尽量查看官方版本说明,找匹配SpringBoot版本对应的release train。遇到报错不要先怀疑业务代码,优先核对启动日志里的类加载信息,确认是javax还是jakarta冲突。
这块经验也要带一句:从SpringBoot 2.x迁到3.x时,不只是Sentinel,其他很多第三方库都有同样的问题,比如自定义的Filter、拦截器如果手写了javax.servlet依赖,都会在升级后编译失败,一定要提前做全量扫描。
6. 限流参数怎么定?压测与故障排查实录
6.1 参数估算:没有压测数据别乱设阈值
很多团队把限流阈值拍脑袋设为100或500,结果上线后要么误伤正常用户,要么一点保护作用都没有。限流阈值不是凭空设定的,它取决于接口的容量上限以及业务可接受的排队时间。
我的经验是先压测拿到基础数,再定阈值。以一个调用订单查询接口为例,假设接口依赖数据库,压测结果显示单实例最大稳定吞吐是300QPS。在部署两台机器、希望保留30%余量的情况下,单机阈值可以设为300 * 0.7 = 210QPS。但这只是理论值,还要考虑接口延时的波动,如果P99延迟已经超过500ms,阈值就应该更保守一点。
外部接口限流又是另一套标准。比如对接短信网关,对方的配额是每分钟1000条,那我们本地限流到每分钟800条,留出配额给其他业务方。对于付费大模型API,我建议按成本反推上限,先算清楚每个请求的token消耗和月度预算,再换算成每分钟调用上限,别等账单出来才追悔莫及。
6.2 压测实操:wrk命令和关键指标解读
不需要重型压测工具,一个wrk就够用了。压测前先确认服务日志和监控面板已经打开,然后执行:
wrk -t4 -c100 -d30s --rate=1000 http://localhost:8080/order/query?orderId=123参数含义是4个线程、100个并发连接、持续30秒、总请求速率控制在1000RPS。可以通过调整--rate的值,观察接口在不同QPS下的表现。
判断限流是否有效,主要看三个数据:请求成功率、P99延迟、服务端CPU使用率。限流配置正确时,超过阈值的请求会以429状态返回,服务端CPU不会被打到满载,正常接口的P99延迟基本稳定。如果出现成功率掉到80%以下、延迟翻了几倍、CPU直接100%,你需要检查是不是阈值设置过高,或者限流根本没生效。
6.3 常见问题速查,全是我踩过的坑
我把平时排查限流失效的笔记整理成了一份速查表,基本覆盖SpringBoot项目里90%的限流问题:
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 限流没生效,请求全放行 | 拦截器注册路径错了,或者AOP切面没配 | 先看日志里有没有进入限流切面/拦截器 |
| 同一接口在5台机器上被放开5倍 | 本地限流挂了多实例,没走Redis | 换成Redis+Lua或Sentinel |
| 限流生效后出现大量500 | blockHandler或全局异常处理器签名错误 | 检查限流兜底方法参数,对照BlockException |
| Redis限流偶发失效 | Lua脚本没缓存,EVALSHA报NOCASING | 用DefaultRedisScript自动缓存,不要手写eval |
| Redis集群报CROSSSLOT | 多个Key哈希槽不一致 | 让限流Key统一加哈希标签 |
| 服务启动后限流特别松 | 工厂自动配置未加载,自定义Filter走默认顺序 | 排查依赖,确认限流组件Bean确实被注册 |
| 429请求依然拖垮数据库 | 限流在入口生效,但下游连接池仍被占满 | 在数据库连接池或线程池再加一层信号量 |
还有一个很多人没意识到的问题:限流组件一旦抛出异常被全局异常处理器捕获,你可能返回了200,导致前端把被限流的请求也当成正常请求。我建议用统一的响应体,把code字段设置为429,同时让HTTP状态码也是429,这样日志、网关、监控三方都方便处理。
部署在Docker或K8s环境时,限流日志频繁出现也不要慌。如果容器重启时某个限流组件连不上Redis,会看到类似connection refuse的错误,但应用还在跑,只是限流逻辑没有数据源,变成了空跑。这种半死不活的状态最坑人,一定要对限流组件的关键路径加上健康检查监控,比如定时探测Redis连通性,断开时立刻告警。
最后分享一个小技巧:在入口统一限流之外,给最核心的一两个业务接口再加一层更严格的二次限流,形成“粗粒度保护整体、细粒度保护核心”的结构。比如全局限流是1000QPS,下单接口单独限流到50QPS,这样即使别的接口流量冲高,也不会把下单这种关键路径拖死。我在实际环境中一直用这个思路,稳定性比单靠一道限流防线好很多。限流的本质不是拒绝用户,而是确保系统在最坏情况下仍然能保护住最重要的能力,明白这一点,你配置参数时就不会畏手畏脚,也不会把阈值设到形同虚设。