接口被刷到宕机后,我把分布式限流搬进了API网关:Redisson实战全记录
周五晚上八点,促销秒杀开始,10万请求在十秒内涌进网关。订单服务CPU飙到100%,数据库连接池被打穿,工单群里一片哀嚎。事后复盘发现,真正的问题是分布式限流没有做——而它本该用一行代码挡住这场风暴。这篇文章就带你走一遍我用 Redisson 在网关层落地分布式限流的完整过程,看完你就能直接抄作业。
一场"促销事故"教会我的事
先还原一下当时的现场。
我们的网关是 Spring Cloud Gateway,后端挂了六个订单服务实例。一开始大家以为"集群嘛,扛得住",结果流量一进来,六个实例同时被击穿——因为没人做任何限流,请求全量打到数据库。
更扎心的是,事后检查日志,里面混着大量脚本刷单请求:同一个 IP、同一台设备,一秒钟发了上百次。接口防刷和限流降级这两件事,平时觉得"上线再加也来得及",真出事才发现根本没机会补。
所以先说结论:网关必须有一道"闸门",挡住超出下游承载能力的流量,而这道闸门,我最终选了 Redisson。
四套限流方案,我为什么最后选了Redisson
在动手之前,我把市面上的方案都过了一遍,各有各的坑。
单机限流(如 Guava RateLimiter)
- 优点:零依赖,写起来最快,本地缓存不占网络
- 缺点:每台机器各限各的。六台实例、每台限100,总量就是600,根本没达到保护效果;重启还丢状态
- 适合:单体应用、临时顶一下
Nginx / OpenResty 层限流
- 优点:在流量入口拦截,性能极好,静态配置简单
- 缺点:规则写死在配置里,想按用户维度、按接口维度动态调整很痛苦;集群多 Nginx 节点时同样要共享状态,得搭 Redis 配合
- 适合:只做粗粒度 IP/连接数限制
Redis + Lua 自研
- 优点:状态集中、原子性强,能精确控制
- 缺点:令牌桶、滑动窗口、GCRA……每种算法都要自己写脚本,还要自己处理过期、防并发、容灾。我见过团队自研半年,最后代码比业务还复杂
- 适合:公司有专门的中间件团队
Redisson 分布式限流
- 优点:开箱即用的
RRateLimiter,基于 Redis 的 Hash + ZSet + Lua 实现,原子性由脚本保证;支持全局/按客户端维度,还能动态调速率 - 缺点:依赖 Redis,Redis 挂了需要降级预案
- 适合:绝大多数分布式 Java 应用
我选 Redisson 的理由很朴素:它把最难的部分(原子计数、状态管理、多实例同步)都封装好了,我只需要关心"限多少"和"超限怎么办"。官方文档里有一章专门讲各种分布式对象,限流器在 docs/data-and-services/objects.md 里有完整用法,后面源码级的疑问我都是去那翻的。
5分钟跑起来的第一个限流器
按"先跑起来再深究"的原则,我们先写最小示例。项目里加依赖,用现成的 redisson-spring-boot-starter/,连 Redis 的事它都帮你办了:
RedissonClient redisson = Redisson.create(config);
// 每个限流器对应一个 Redis 键,名字就是业务语义
RRateLimiter limiter = redisson.getRateLimiter("api:order:submit");
// 每 1 秒最多放行 100 个请求,只看当前值,不覆盖已有配置
limiter.trySetRate(RateType.OVERALL, 100, Duration.ofSeconds(1));
// 试获取一个许可:拿得到就放行,拿不到立刻返回 false
if (limiter.tryAcquire()) {
return handleOrder(order);
} else {
return ResponseEntity.status(429).build(); // 超限,直接打回
}
这段代码跑在单机上和跑在六台实例上,效果是一样的:总量永远被压在 100/s。你不需要理解背后的实现也能用,但下面我劝你花三分钟看懂它,因为踩坑都藏在细节里。
令牌桶怎么在Redis里落地:RedissonRateLimiter原理拆解
用过之后你可能会好奇:tryAcquire() 到底在 Redis 里做了什么?我翻了实现类 RedissonRateLimiter.java,发现它的设计非常"令牌桶"。
它用了三类键。 限流器名字对应的 Hash 键存配置(rate、interval、type);一个 value 键存"当前桶里还剩多少令牌";一个 permits 键是个 ZSet,记录每个许可被拿走的时间戳。为什么用 ZSet?因为令牌过期要按时间清理——ZSet 的 score 就是毫秒时间戳,清理脚本直接 zremrangebyscore 把超期的记录删掉,同时把对应的令牌还回桶里。
原子性靠 Lua 脚本。 每次 tryAcquire 不是多条 Redis 命令,而是把"读配置→算过期令牌→判断够不够→扣减/记录"整段逻辑塞进一个 Lua 脚本一次性执行。Redis 保证脚本执行期间不会插入其他命令,所以多实例并发扣减不会超卖。脚本核心逻辑大概是:
local current = redis.call('get', valueName)
-- 把已经超过一个 interval 的许可记录清掉,还回令牌
local expired = redis.call('zrangebyscore', permitsName, 0, now - interval)
-- 桶里不够就返回需要等待的时长,够就 zadd + decrby 扣减
"桶"长什么样取决于 RateType。 OVERALL 是所有 Redisson 实例共用一个桶;PER_CLIENT 是每个实例一个桶,限流维度就变成"每个客户端节点各限各的"。很多团队在这个参数上踩过坑——想限总量却用了 PER_CLIENT,结果总量翻了实例数倍。
看完这个你就能理解一个常见疑问:tryAcquire 失败后那个返回值是什么?是下一个令牌可用前需要等待的毫秒数,acquire() 内部就是拿这个值去做阻塞等待的。这让批量请求、排队请求都有了理论依据。
Spring Cloud Gateway接入:从依赖到降级闭环
理论落地,我们把限流器挂到网关过滤链上。完整流程分五步。
第一步,加依赖。 网关项目引入 Redisson starter,配置好单机或集群连接,网关和业务共用同一个 Redis 就行。
第二步,配置连接。 用官方 starter 的话,spring.redis 配置写好后 RedissonClient 会自动注入,网关过滤器里直接构造注入,不需要自己管理连接生命周期。
第三步,写网关限流过滤器。 核心是按请求特征生成限流器名字,再决定放不放行:
@Component
public class RateLimitFilter implements GlobalFilter, Ordered {
private final RedissonClient redisson;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getPath().value();
// 按接口路径隔离限流,一个接口一个桶,互不干扰
RRateLimiter limiter = redisson.getRateLimiter("gw:rate:" + path);
if (limiter.tryAcquire()) {
return chain.filter(exchange); // 拿到许可,放行
}
// 超限:直接回 429,不再往后端转发
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
return exchange.getResponse().setComplete();
}
@Override
public int getOrder() { return -100; } // 越靠前越先执行
}
第四步,设计超限响应。 429 不能只给个状态码,客户端拿不到原因会一脸懵。建议在响应头里带 Retry-After,body 里说明"请求过于频繁",前端拿到后可以走降级提示、弹验证码或者直接重试。
第五步,和熔断降级配合。 限流是"入口闸门",熔断是"出口保险"。网关限流管住流量总量,下游仍可能偶发故障,所以还要配合 Sentinel 或 Resilience4j 做熔断:限流防的是"进太多",熔断防的是"出不去"。两个配合,才是一个完整的限流降级闭环。
网关踩坑清单(都是血泪)
- 过滤器顺序不对:限流过滤器没放在最前面,请求先过了鉴权、日志才被拦,等于白限
- 限流器名字没隔离:所有接口共用一个限流器,一个热点接口把别的接口也饿死了
trySetRate和setRate混用:前者只在没配置时生效,后者会重置状态。想改配置却调了trySetRate,会发现"改了没反应"- 网关超时配置太短:
acquire()阻塞等待时如果网关的读超时小于等待时间,会抛异常而不是返回 429,要统一用tryAcquire(timeout)并捕获超时
调优与FAQ:动态调速、批量/异步,还有三个经典坑
动态调整限流速率
秒杀开始前把速率调高,结束后调低,不用重启网关:
// 动态调速,会重置当前桶状态
limiter.setRate(RateLimiterArgs.of(RateType.OVERALL, 500, Duration.ofSeconds(1)));
配合配置中心(Nacos/Apollo)下发,就能实现"活动前自动扩容限流阈值"。我们当时就靠这个,把秒杀接口的速率从 100 临时提到 500,活动结束再调回来。
批量与异步获取许可
- 批量:订单提交接口一次消耗多个许可(比如一个订单算 2 个),用
tryAcquire(2),给重操作加权,防止批量调用钻空子 - 异步:高并发下别用同步阻塞版本,
tryAcquireAsync()返回RFuture<Boolean>,配合回调处理放行/拒绝,网关的线程池不会被占满
三个经典问题的解决办法
Q1:限流好像不生效? 先查两件事:一是确认所有网关实例连的是同一个 Redis(多环境 Redis 配串了很常见);二是确认用的 RateType.OVERALL 而不是 PER_CLIENT,后者是按实例限的。
Q2:误伤了正常用户? 常见原因是把"按 IP 限"和"按接口限"混在一个桶里。正确姿势是拆维度:全局总量一个桶、单用户一个桶(ratelimit:user:" + userId)、单 IP 一个桶。三者独立,各限各的,刷子被按 IP 挡住,正常用户不受影响。
Q3:集群 Redis 的时间问题? 脚本用的是 System.currentTimeMillis() 也就是客户端时间戳。如果各网关实例的时钟不同步,令牌过期判断就会错乱。NTP 对时是基本要求;实在没法对齐,就在 tryAcquire 前统一从 Redis 取一次时间,或者用 TIME 命令对齐,代价是多一次往返。
总结
回到开头那场事故:如果当时网关上有这道限流闸门,10 万请求最多只有 100 个能到达后端,剩下的全部 429 打回,数据库连一根头发都不会掉。
下一步建议你这样做:先抄第五节的最小过滤器,用压测工具打到超限,观察 429 和 Retry-After 是否正常;再把维度拆成"接口总量 + 单用户"两个桶;最后接上配置中心做动态调速。限流是保护系统的第一道防线,但它永远不能是唯一一道——熔断、降级、隔离,一个都不能少。🚀
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



