写给每个担心”流量一上来接口就挂”的 Java 开发者。这篇不堆公式,先用大白话把限流的思想讲明白,再落地到可以直接抄走用的代码。
一、为什么你的接口需要限流
1.1 从景区限流说起
九寨沟每天只卖四万张票,不是为了难为游客,而是因为景区的承载力是有限的——步道就那么宽,索道运力就那么大。人再多也得控制在承受范围内,否则体验崩坏,甚至出安全事故。
服务器就是景区:
- CPU、内存、线程池、数据库连接池,就是步道和索道;
- 每个接口的承载力都有上限,比如单机实测只能扛 500 QPS;
- 流量一旦超过上限,响应变慢 → 请求堆积 → 彻底崩溃。
限流(Rate Limiting)就是门口的检票员:单位时间内只放固定数量的请求进来,多出来的礼貌拒绝。
1.2 不限流会发生什么:雪崩
来看一条真实的事故链路,每一步都在拖垮下一步:
- 秒杀开始,瞬时 10 万请求涌入;
- Tomcat 线程池 200 个线程全部打满,后续请求只能排队;
- 排队的请求占着数据库连接不放,连接池也被耗尽;
- 接口响应从 50ms 恶化到 30s;
- 上游调用方等不及,超时重试,流量翻倍——火上浇油;
- 整个服务雪崩,连带依赖它的服务一起挂。
限流的意义一句话:牺牲一部分请求,保住整个系统。 宁可让一部分用户看到”稍后再试”,也不能让所有用户看到 502。
1.3 限流、熔断、降级,一句话分清
面试和设计时经常把这三个词混在一起,其实各管一摊:
| 概念 | 类比 | 干什么 |
|---|---|---|
| 限流 | 门口检票 | 人多了不放进来,保护自己 |
| 熔断 | 保险丝 | 发现下游已经挂了,暂时不调用,保护自己不被下游拖死 |
| 降级 | 保底方案 | 忙不过来时返回个兜底结果,牺牲体验保可用 |
三者常常配合使用,本文专注限流。
二、动手之前,先想清楚三个问题
2.1 在哪里限?
Java 项目的限流通常分三层,各司其职、层层设防:
| 层级 | 常用工具 | 特点 |
|---|---|---|
| 接入层 | Nginx、网关 | 全局第一道闸门,粗粒度,先挡一层洪峰 |
| 应用层 | Guava RateLimiter、Sentinel | 单机精细控制,能精确到某个方法,无需中间件 |
| 分布式层 | Redis + Lua | 多实例共享同一个阈值,集群统一计数 |
小项目用应用层就够了;集群部署、阈值要全局统一时,就必须上分布式层。后文第四、五、六节分别展开。
2.2 限多少?
阈值不是拍脑袋定的:先压测出单机极限(比如 800 QPS),乘以安全系数(比如 70%)得到单机阈值,再结合机器数量得出全局阈值。限流阈值是运维指标,要跟着容量变化调整,不是写完就一劳永逸。
2.3 超限的请求怎么办?
三种态度:直接拒绝(返回 429)、排队等待(削峰填谷)、降级兜底(返回缓存或默认值)。第七节展开。
三、四种经典限流算法
老规矩,先看全景图,再逐个拆解:
3.1 固定窗口计数:最简单的”按分钟数数”
规则:把时间切成固定窗口(比如每 1 分钟一个),窗口内计数,超过阈值就拒绝;窗口一切换,计数归零。就像景区”每小时最多放 5000 人进园,整点重新计数”。
用 Redis 实现只需要两条命令:INCR 计数 + EXPIRE 设置过期,简单到不行。
致命缺陷——临界问题:假设阈值是每分钟 100 次。
- 第 1 分钟的第 59 秒,涌进来 100 个请求——没超,放行;
- 第 2 分钟的第 1 秒,又涌进来 100 个请求——新窗口计数,也没超,放行。
两个窗口各自都合规,但从 59 秒到 61 秒这短短两秒里,实际放过了 200 个请求——瞬时压力是阈值的两倍。流量恰恰喜欢卡着边界冲(秒杀整点开抢就是典型),这就是图 3 面板 A 里标红的”边界突刺”。
3.2 滑动窗口:把”数数”变准
思路:不再以自然分钟为边界,而是”任意时刻,只统计最近 60 秒”。窗口像一条随时间连续右移的传送带,老的请求滚出去,新的滚进来,边界突刺自然消失。
实现上通常把 60 秒切成 6 个 10 秒的小格子,每个格子独立计数,统计时只累加还没过期的格子(Sentinel 内部就是这么做的)。代价是要记住每个小格子的计数,内存和计算都比固定窗口多一点。
3.3 漏桶:流出速率绝对恒定
想象一个漏水的桶:请求像水一样以任意速度倒进来,桶以恒定速率往外漏。无论进得多猛,出去的永远是匀速的——下游得到完美平滑的流量。
- 优点:绝对平滑,下游压力曲线像尺子画的一样直;
- 缺点一:桶容量有限,倒得太猛桶满了就溢出(拒绝);
- 缺点二:太”死板”——就算系统有富余,也无法利用突发,秒杀开始的瞬间本来允许冲一下,它也给你压平了。
Nginx 的 limit_req 指令就是漏桶思想。
3.4 令牌桶:最常用的”投币闸机”
地铁站的投币闸机:后台匀速生成令牌(硬币)扔进桶里,桶有容量上限,装满就不再投;每个请求必须先从桶里取走一个令牌才能通过,取不到就拒绝或排队。
两个关键参数,务必记牢:
- 速率 r:每秒生成多少个令牌——决定长期平均速率;
- 容量 b:桶最多攒多少个令牌——决定允许的突发量。
为什么它最常用?因为它既保住了漏桶的平滑(平均速率受控),又允许突发:闲时攒下的令牌,可以在忙时一次性消耗掉。Guava 的 RateLimiter、Nginx 的 burst 参数、Redisson 的 RRateLimiter,背后都是令牌桶。
3.5 四种算法对比总结
| 算法 | 平滑性 | 允许突发 | 实现难度 | 典型实现 |
|---|---|---|---|---|
| 固定窗口 | 差(边界突刺 2 倍) | 否 | ★ | Redis INCR + EXPIRE |
| 滑动窗口 | 好 | 否 | ★★ | Sentinel、Redis ZSET |
| 漏桶 | 最好(绝对恒定) | 否 | ★★ | Nginx limit_req |
| 令牌桶 | 好(平均恒定) | 是 | ★★ | Guava、Redisson |
拿不准就选令牌桶,它在绝大多数场景都不出错。
四、单机实战:Guava RateLimiter + 注解封装
4.1 引入依赖
1 | <dependency> |
4.2 最小可用示例
1 | // 每秒生成 10 个令牌的令牌桶 |
注意:Web 接口一定要用 tryAcquire。acquire 会让请求线程原地阻塞排队,流量大时 Tomcat 线程池会被排队请求占满,限流反而成了压垮自己的最后一根稻草。
4.3 注解 + AOP:一行注解限流任意接口
每个业务方法手写 tryAcquire 太啰嗦,仿照 @Transactional 的思路封装成注解。
第一步,定义注解:
1 |
|
第二步,编写切面:
1 |
|
第三步,业务代码里一行搞定:
1 |
|
实际项目里把 BusinessException 接到全局异常处理器上,统一转成 HTTP 429 返回即可。
但要清醒地看到它的局限:RateLimiter 只存在于当前 JVM 里。部署 3 台机器、每台限 100 QPS,集群整体就是 300 QPS——想全局限 100,就得靠下一节。
4.4 补充:用 Semaphore 限”并发数”
RateLimiter 限的是速率(每秒多少个),Semaphore 限的是瞬时并发数(同一时刻多少个在跑),两者互补:
1 | private final Semaphore semaphore = new Semaphore(20); // 最多 20 个并发 |
典型场景:某个接口单次要跑 2 秒,QPS 限 100 没意义(瞬时并发可能堆到 200),限并发 20 才保护得住。
五、分布式实战:Redis + Lua
5.1 为什么单机限流在集群下会失真
目标:全局限流 100 QPS,部署 3 台机器。每台限 100?总体 300,超了。每台限 34?加减机器就失效,某台宕机阈值也对不上。
正确姿势:把计数放到所有实例都看得到的地方——Redis。
5.2 固定窗口版:INCR + EXPIRE
1 |
|
这段代码能用,但埋着两个问题:
increment和expire是两条命令。第一条执行完后实例恰好宕机,这个 key 就永不过期,计数一路涨,所有请求永久被拒;- 固定窗口的临界问题(3.1 节)依然存在。
5.3 用 Lua 解决原子性
Redis 执行 Lua 脚本是原子的:脚本里的多条命令会被当成一个整体执行,中间不会被其他请求插队。
1 | private static final DefaultRedisScript<Long> RATE_LIMIT_SCRIPT = new DefaultRedisScript<>( |
计数 + 过期一步到位,竞态问题消失。key 的设计决定了限流的维度:
rate_limit:order:create:{userId}—— 按用户限流,防单用户刷接口;rate_limit:ip:{clientIp}—— 按 IP 限流,防爬虫;rate_limit:api:order-create—— 按接口全局限流。
5.4 更进一步
- 滑动窗口版:用 ZSET 记录窗口内每个请求的时间戳,每次先
ZREMRANGEBYSCORE清掉过期成员,再ZCARD计数判断——彻底消除临界问题; - 令牌桶版:把”上次取令牌时间、剩余令牌数”存进 Redis,每次请求按流逝的时间补发令牌再判断,Redisson 的
RRateLimiter内部就是这个思路。
生产环境不想手写脚本,直接用 Redisson:
1 | RRateLimiter limiter = redissonClient.getRateLimiter("order:create"); |
六、接入层:Nginx 配置挡住第一波洪峰
应用层限流再好,请求也已经打到了 Tomcat。流量洪峰最好在 Nginx 就挡掉一部分:
1 | # http 块:定义限流区——按客户端 IP,速率 10 请求/秒(漏桶) |
注意 burst + nodelay 这个组合:桶里有余额(burst)时请求直接通过、不等待——这正是把漏桶升级成了令牌桶。你看,思想是相通的。
七、被限流的请求,何去何从
拒绝不是终点,善后体验才是产品力:
- 返回 429 而不是 500,并带上
Retry-After响应头,明确告知客户端多久后再试; - 调用方指数退避重试(1s、2s、4s……),切忌立即重试——那等于自己给自己发起重试风暴;
- 能降级就降级:推荐接口限流后返回默认榜单,搜索接口限流后返回热搜词,别让用户看白屏;
- 限流日志 + 告警:限流比率突然升高,往往是上游异常、正在被攻击、或即将开始大促的信号。
八、选型速查与面试要点
8.1 场景速查
| 场景 | 推荐方案 |
|---|---|
| 单机小服务 | Guava RateLimiter + 注解 AOP |
| 集群统一阈值 | Redis + Lua(或直接 Redisson) |
| 网关统一防护 | Nginx / Spring Cloud Gateway 限流 |
| 限流 + 熔断 + 降级全家桶 | Sentinel |
| 保护慢接口 | Semaphore 限并发数 |
8.2 面试高频四连
- 四种算法的区别? 固定窗口简单但有边界突刺;滑动窗口消除突刺;漏桶绝对平滑但不允许突发;令牌桶平滑且允许突发,最常用。
- 固定窗口的临界问题怎么解决? 换滑动窗口——任意时刻只统计最近 N 秒。
- 令牌桶为什么允许突发? 闲时攒令牌(容量 b),忙时可一次性消耗。
- 分布式限流为什么用 Lua? 把”读计数、判断、写回、设过期”合并成原子操作,避免多实例并发下的竞态。
总结
- 限流 = 景区检票:牺牲一部分请求,保住整个系统;
- 算法拿不准就选令牌桶:平均速率受控 + 允许突发,Guava、Redisson 都有现成实现;
- 集群限流把计数放 Redis,用 Lua 保证原子性;
- 拒绝请求时返回 429 + Retry-After,有降级方案就别让用户看报错页。
下一篇打算聊聊熔断与降级的实战(Sentinel 规则配置),和这篇凑成高可用三件套。