聊到架构,很多人第一反应是微服务、网关、分布式事务那些大词,但我做了这么多年系统以后越来越觉得,限流才是架构里最不能省的那张底牌。限流这个事,网上搜“架构”会蹦出一堆概念,什么限流电路、Redis实现令牌桶限流、AOP基于注解的接口限流、Sentinel集群限流,看起来散,其实都是一件事的不同侧面。这篇文章我打算把限流从原理到落地、从单机到分布式完整拆一遍,把我踩过的坑、压测时调过的参数、线上出过的诡异问题都写出来。适合正在做后端开发、准备面试系统设计、或者接手了一个没有限流的老系统正准备往里加保护的人。
1. 为什么“限流”是架构里的兜底牌——先搞懂它在防什么
1.1 架构书讲得再花哨,流量一冲就现原形
热搜词里有一堆“架构”,Transformer架构、微服务架构、六边形架构、CNN逻辑架构……这些当然重要,但它们解决的是“系统长什么样”的问题。限流解决的是“系统扛不住时怎么办”的问题。很多人设计架构时画了一张漂亮的拓扑图,服务怎么拆分、消息怎么流转、数据库怎么读写分离,全都安排得明明白白,结果上线第一天遇到一次热点流量,整个系统直接雪崩。为什么?因为节奏不对,上游的突发流量、下游的慢响应、某个接口被脚本刷爆,这些都不是靠调整服务划分能解决的。
我刚工作那会儿也天真地以为,限流嘛,就是计数器加个判断,无非是代码里写个if,统计一下请求数,超过了就拒绝。后来线上出了好几次事故才明白,限流真正的难点在于:你要在“用户体验”和“系统稳定性”之间找到那个平衡点。限得紧了,正常用户被误伤,产品经理找上门;限得松了,数据库连接池被打满,值班电话被打爆。
1.2 三类典型冲击:突发流量、慢请求堆积、上游抖动
限流防的到底是什么?我把它归纳成三类,理解了这三类,后面选算法、定参数才不会拍脑袋。
第一类,突发流量。典型场景就是秒杀、抢红包、热点新闻带来的瞬时访问。这种流量特点是一瞬间冲得很高,持续几秒到几分钟就回落。如果系统按峰值去扩容,平时会浪费大量资源,不按峰值扩容,一瞬间就扛不住。限流在这里的作用是给流量“削峰”,把超过处理能力的那部分请求挡在外面或者排队慢慢放进来。
第二类,慢请求堆积。这类最容易被忽视。假设你的系统正常每秒能处理1000个请求,单个请求平均50毫秒扛得住。但某天一个下游接口突然变成500毫秒返回,你如果不对流量做控制,线程池里的线程全被卡住等响应,新的请求进来全部排队,最终所有接口都变慢,整个应用进入假死状态。这个场景下限流的核心是“优先保证系统可用”,哪怕丢弃一部分请求,也要保住主流程不崩。
第三类,上游抖动。在大流量网站里,一个接口往往是很多上层服务共用的。比如你负责会员服务,突然有个活动页疯狂调用会员信息接口,把你这边的资源打满,结果所有依赖会员数据的页面全部跟着卡。这种场景下,单靠业务代码里的错误处理不够,必须在入口处做保护,把“那边流量超标”的影响限制在局部。这其实是限流、熔断、降级三个机制配合的事情,限流管的是“不让过多流量进来”,熔断管的是“下游已经不行了,快速失败”,降级管的是“面子保不住,先保里子”。三者经常一起出现,但根子上的第一道闸门是限流。
1.3 用“水库泄洪”理解限流的调度逻辑
我一直喜欢用一个比喻跟新人解释限流:你的系统像一座水库,上游河流就是流量。水库的泄洪闸门开多大,取决于下游河道的承受能力,而不是上游来了多少水。如果上游暴雨,水库硬扛,坝体就得垮;如果下游河道窄,你却把闸门全开,下游城市就得淹。限流算法本质就是那个闸门的控制策略。固定窗口像是一个死板的调度员,每秒只放100个;令牌桶像是有个蓄水池,平时攒着令牌,突发时可以把攒的全放出去;漏桶像是一个固定的水管,不管上游怎么颠,下游始终匀速出水。理解了这层关系,你就知道限流不是简单地“拒绝请求”,而是“安排请求的节奏”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 五种限流算法选型对比:从固定窗口到令牌桶的博弈
2.1 固定窗口与滑动窗口:阈值判断里的边界问题
先聊最基础的固定窗口。把时间切成固定长度的窗口,比如1秒,每个窗口内维护一个计数器,请求来了加1,超过阈值就拒绝,下一个窗口清零。这个方案实现简单到极致,Java里一个AtomicInteger就能搞定。但它有一个非常经典的问题:临界突发。假设你设定的阈值是每分钟1000次,窗口切分在0分0秒到0分59秒、1分0秒到1分59秒。用户在0分59秒前发了1000个请求,1分0秒后又发了1000个请求,那么从宏观的1分钟跨度看(0:59:30到1:00:30),系统实际上放过了2000个请求。这2000个请求落在同一个秒级时段内,DB瞬间就可能被打爆。
滑动窗口就是解决这个问题的。它不是固定地平铺窗口,而是让窗口的起点跟着当前请求的时间移动,比如看“最近1分钟”而不是“当前这一分钟”。实现上,滑动窗口需要对时间切片,比如把1分钟切成60个小格,每个小格统计请求数,然后维护一个总和,每进来一个请求,就滑到对应的最新格子,同时把已经滑出窗口的格子的计数扣掉。这样临界突发的问题就被解决了,代价是内存占用和实现复杂度都上去了。我用过Guava的EventRecorder,发现滑动窗口在客户端做还可以,在分布式高并发下对存储的读写在极端情况下还是有压力的。
2.2 漏桶与令牌桶:恒定速率还是允许突发
漏桶算法,名字很形象。请求像水一样倒进一个桶里,桶的底部有一个固定速率的漏孔,水以恒定的速度流出去。不管上游怎么突发,下游看到的永远是匀速流量。漏桶非常适合保护下游对速率有硬性要求的场景,比如你调用的第三方支付接口只允许每秒20笔,漏桶就能保证永远不超过这个速率。但它的问题也很明显:不能应对突发消费。就算桶是满的,下游也只能按固定的速率处理,用户如果一时间涌进来大批请求,除了排队就是丢掉,响应时延会急剧升高。
令牌桶的逻辑刚好反过来。系统以恒定的速率往桶里放令牌,请求来的时候要先拿一个令牌才能被处理,拿不到就直接拒绝或者排队。桶有容量上限,说明允许攒令牌。如果系统长时间空闲,桶里攒满了令牌,等突发流量一到,它可以一次性消耗大量令牌,把攒着的能力都吐出来。这正是很多互联网接口想要的:平时保持低速率,遇到秒杀这种瞬时流量时又能顶上一阵子。
这个区别在选型时至关重要。如果你的下游是数据库或者第三方API,它对速率敏感,漏桶更合适;如果你的目标是让用户请求在系统能力范围内尽量被满足,允许短时间超跑,令牌桶更合适。Redis实现令牌桶限流之所以那么火,就是因为令牌桶可以放到Redis里做全局协调,正好把分布式系统的多个节点统一到同一个桶上。
2.3 计数器、滑动日志与算法取舍对照
再往下还有滑动日志算法,本质是记录每个请求的时间戳,统计最近一个时间段内有多少个时间戳落在区间里。它的精度是最高的,没有窗口粒度丢失问题,但代价是要为每个请求维护一个独立记录,内存消耗很大。用在超低QPS的接口上没问题,用在每秒几万请求的核心接口上,光存时间戳都能把内存吃干净。
我把几个主流算法整理成一个对照表,方便你按场景选型:
| 算法 | 核心思想 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|---|
| 固定窗口 | 每秒/每分计数,超阈值拒绝 | 实现极简,性能高 | 窗口边界可能突发双倍流量 | 粗粒度限制、临时保护 |
| 滑动窗口 | 窗口起点随当前时间移动 | 解决临界突发 | 内存和时间切片精度权衡 | 对突发敏感的业务接口 |
| 漏桶 | 恒定速率消费 | 输出流量绝对平整 | 无法快速响应急需处理的流量 | 保护下游第三方API |
| 令牌桶 | 匀速存令牌,允许突发消费 | 允许突发且可控制突发量 | 实现稍复杂,仍然依赖桶容量设定 | 大部分互联网后端接口 |
| 滑动日志 | 记录全量请求时间戳 | 精度最高 | 内存占用极大 | 超低QPS的高精度控制 |
3. Redis+Lua实现令牌桶限流:原子性背后的代码细节
3.1 为什么必须用Lua脚本而不是简单的GET和SET
Go到网上搜“Redis实现令牌桶限流”,搜索量很大,说明大家都在用,但很多文章只给脚本不解释为什么。我在这里把底层逻辑讲透。
实现令牌桶,最简单的思路是:每个请求来了,先算一下当前桶里还有多少令牌,够就减1,不够就拒绝。但如果你用普通的Redis命令先GET再INCR,在高并发下一定会出问题。因为两个请求同时读到相同的令牌数,然后都各自判断“够”,接着各自减1,结果桶里的令牌被多扣了。为了避免这种竞态,要么用Redis的事务加WATCH,要么用Lua脚本。我强烈推荐Lua脚本,原因很简单:Redis执行Lua脚本是原子的,脚本执行过程中不会被其他命令打断。
这个“原子性”你可以理解成,Redis是一个只有一条流水线的厨房,普通命令是一个一个炒菜,而Lua脚本是预先把所有步骤写好的全流程菜单,厨房一次性把整桌菜做完,中途不允许别的菜插队。这样检查令牌数、扣减令牌数、补发过期令牌这三个步骤就不会被拆开,不会出现“检查完还没扣,另一个线程已经把令牌抢光了”的问题。
3.2 令牌桶的Redis存储结构与核心Lua脚本
在Redis里,令牌桶需要保存的数据只有两个字段:tokens,当前桶里的令牌数;timestamp,上次补充令牌的时间。用Hash结构存,key设计成限流资源的唯一标识,比如rate_limiter:user:12345,也可以带方法名、接口路径:
lua复制-- 参数:KEYS[1] 限流key
-- 参数:ARGV[1] 令牌桶容量 capacity
-- 参数:ARGV[2] 令牌补充速率 rate(每秒补充多少)
-- 参数:ARGV[3] 当前时间戳(毫秒)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'timestamp')
local tokens = tonumber(data[1])
local lastTime = tonumber(data[2])
if tokens == nil then
tokens = capacity
lastTime = now
end
-- 计算从上次补充到现在应该新增的令牌数
local delta = math.max(0, (now - lastTime) / 1000 * rate)
tokens = math.min(capacity, tokens + delta)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HSET', key, 'tokens', tokens, 'timestamp', now)
-- 设置过期时间,避免key长期驻留
redis.call('PEXPIRE', key, 3000)
return allowed
这段脚本的逻辑拆开来看其实很简单。先读取当前令牌数和时间戳,第一次访问就初始化成满桶。算一下距离上次补充过了多少时间,补上这个时间段内新生产的令牌,但以容量为上限。然后判断令牌够不够,够就扣一个放行,不够就拒绝。最后把状态写回Redis,并设置一个稍长的过期时间。
注意:时间戳必须由Redis所在的服务器一侧统一生成,或者由业务侧传入同一个时钟源。如果每台业务机器各传各的系统时间,一旦机器间时钟偏差超过几百毫秒,令牌补充的计算就会出现明显误差。我在生产中倾向于由业务侧统一传入毫秒时间戳,因为Redis所在的服务器如果和业务服务器时钟不一致,以Redis时钟为准也会有问题。
3.3 通过Redisson或其他客户端调用Lua的Java示例
在Java里调用这段脚本,最裸的方法是直接用Lettuce或Jedis执行eval:
java复制public boolean tryAcquire(String resourceKey, int capacity, double rate) {
String luaScript = "此处粘贴上面的Lua脚本";
Long result = jedis.eval(
luaScript,
Collections.singletonList(resourceKey),
Arrays.asList(
String.valueOf(capacity),
String.valueOf(rate),
String.valueOf(System.currentTimeMillis())
)
);
return result != null && result == 1L;
}
业务侧不用关心令牌桶内部怎么更新,只需要传入“资源标识”“桶容量”“补充速率”三个参数。这也是我建议把限流逻辑封装成一个独立组件的原因,调用方完全不需要理解Lua脚本细节,只需要知道“我这个接口每秒最多放行多少个请求”。实测下来,一个Redis节点跑Lua限流,单线程模式下可以支撑很高的QPS,因为每个请求只是一次内存操作加一个HSET。瓶颈通常不在Redis脚本本身,而在网络往返。
3.4 容量与速率的参数设计
很多人在这一步栽跟头。Capacity设太大,突发流量直接穿透保护;设太小,正常流量全被拦。Rate设太大,Redis层面形同虚设;设太小,用户抱怨接口变慢。
我一般按下面这个思路来推算:
假设一个核心接口的扩容后处理能力是每秒500个请求,平滑期平均流量是300个每秒。我的设定是:
rate = 500,因为这就是系统能力的上限,超过这个值响应时间会显著恶化。capacity = 500 * 2,也就是允许连续两秒的突发流量全量通过。为什么是两秒?因为从监控发现流量异常到动态扩缩容系统介入,至少需要几十秒,两秒的突发窗口够给下游一个缓冲,但也避免了一个突击流量直接把线程池打穿。- 预测到某个活动流量会陡增,比如是平时的3倍,那就按3倍重新压测一下,确认下游DB在3倍流量下的慢查询情况,再定capacity。
参数设定有个铁律:每个接口独立压测,不要全局统一套一个模板。我曾经接手过一个项目,全公司统一用capacity=500、rate=100,结果一个正常的批量导入接口QPS只有个位数,却因为一次性拉取500条数据触发了限流,导致运营同学反复报障。
4. AOP注解限流:给接口“贴标签”的优雅落地姿势
4.1 自定义注解的设计:把限流参数暴露给使用者
选定了算法和存储,接下来要解决“怎么用起来最方便”的问题。我强烈推荐基于AOP的自定义注解方式。思路很直观:你定义了一个注解,在接口方法上加一行@RateLimit,切面自动拦截这个方法的调用,执行限流逻辑,通过则放行,拒绝则抛出异常或者返回降级结果。业务代码零侵入,这是它最大的价值。
我在项目里设计的注解是这样的:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
String key() default "";
int limit() default 100;
int window() default 1;
TimeUnit timeUnit() default TimeUnit.SECONDS;
String fallback() default "";
}
key定义限流维度。可以是固定字符串表示“整个接口共享一个桶”,也可以带 #userId 这种SpEL表达式,从请求参数中动态提取用户ID,实现“每个用户每秒最多100次”的效果。limit和window组合起来就是一个完整的速率配置,比如limit=100、timeUnit=SECONDS就是每秒100次。fallback在限流触发时指定一个降级方法名,避免调用方必须try-catch。
4.2 切面拦截与SpEL表达式解析
AOP切面的核心代码非常直白,主要是三件事:解析注解参数、解析key中的动态表达式、执行限流判断。
java复制@Aspect
@Component
public class RateLimitAspect {
@Autowired
private RateLimiter rateLimiter;
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
String key = resolveKey(joinPoint, rateLimit.key());
boolean allowed = rateLimiter.tryAcquire(key, rateLimit.limit(), rateLimit.window(), rateLimit.timeUnit());
if (!allowed) {
// 触发降级方法或者默认抛异常
return handleFallback(joinPoint, rateLimit);
}
return joinPoint.proceed();
}
}
resolveKey里用SpEL表达式解析出真实的key,比如接口入参是个对象,#req.userId就能取到请求体里的用户ID。这个设计我用了好几年,最大的感受是:key的设计决定了限流的粒度。同一个用户维度、同一个IP维度、同一个设备维度,效果差别非常大。比如防刷场景必须按用户维度限流,否则一个恶意用户就能把全站的额度给耗光;但如果是保护底层数据库,那必须按整体维度限流,否则每个用户各限各的,总量照样能打满数据库。
4.3 Spring AOP的自调用陷阱与切面顺序问题
AOP注解限流有两个非常容易踩的坑,必须提前规避。
第一个是自调用陷阱。Spring AOP是基于代理实现的,如果在一个类内部,方法A调用方法B,而方法B上有@RateLimit注解,这个注解不会生效。因为内部调用走的是this对象,而不是经过Spring生成的代理对象。解决方法很简单:把限流注解加在外部入口方法上,或者把被限流的方法拆到另一个Spring Bean里,通过注入调用。我见过有同事排查了半天限流不生效,最后发现就是同一个类里方法自调用,这种问题日志里看不到任何报错,非常隐蔽。
第二个是切面顺序。项目中往往同时存在限流切面、日志切面、鉴权切面。如果不指定@Order,Spring的默认顺序可能让限流切面排在日志切面后面,意味着日志把请求参数都打出来了,限流才拒绝请求,浪费了日志IO。我自己一般会把限流切面的@Order(0)定位成最高优先级,保证在业务逻辑执行前、其它切面最外围就做拦截,这样可以最大限度降低被限流的请求消耗系统资源。
5. 微服务集群场景下的分布式限流:Redis、网关与Sentinel的配合
5.1 单机限流在多副本下为什么会失效
单体应用时代,限流写在自己进程里就完事了。但到了微服务架构,一个服务可能部署了十多个副本,每个副本的进程里都有一个独立的计数器。如果每个副本每秒限100次,十三个副本加起来每秒就能放1300个请求。维护者以为自己在限流,实际整体流量早就超过了预期,这就是多副本下“本地限流失真”的问题。
我见过最典型的误判是这样:系统总链路压测显示数据库瓶颈在每秒2000个请求,按这个值给每个副本分100,开了20个副本。但实际流量在副本间分布不均匀,某个实例因为负载均衡策略、或者某些慢请求的粘性会话,实际流量是其他副本的三倍,结果不是数据库被打爆,就是某些实例频繁限流导致那一路用户大量报错。按副本平均分摊阈值,在流量不均的场景下就是个幻觉。
5.2 网关层做全局限流:Spring Cloud Gateway与Redis结合
要解决多副本限流失真,核心思路是把限流状态从“进程内”提升到“公共存储”。最常见的是在网关层做统一入口限流。比如Spring Cloud Gateway的RequestRateLimiter过滤器,配合spring-boot-starter-data-redis-reactive,用Redis+Lua的令牌桶实现全局限流。
网关限流的配置大致长这样:
yaml复制spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://user-service
predicates:
- Path=/user/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 200
redis-rate-limiter.burstCapacity: 400
key-resolver: "#{@userKeyResolver}"
这里replenishRate就是令牌补充速率,burstCapacity就是桶容量,key-resolver决定限流维度。你可以按用户、按IP,也可以按请求路径来区分。在网关层限流有个天然优势:请求还没进入具体的微服务,就被挡在门外了,不消耗业务实例的线程池和数据库连接。
但网关限流并不能解决所有问题。如果网关后面有几十个服务,每个服务的处理能力不同,一个统一的限流阈值根本不够精确。比如用户服务的DB弱,订单服务的DB强,在网关层统一限2000对用户服务还是太高,对订单服务反而太浪费。所以我更推荐的做法是“两级限流”:网关层做粗粒度的入口保护,拦截明显异常的突发流量;业务服务内部再针对自己的核心接口,用AOP注解细粒度限流。这样上半层拦截大水位,下半层精调每个接口的具体额度。
5.3 Sentinel集群限流:Token Server与规则下发的思路
Sentinel是主打流控的组件,它默认是单机限流,也就是每个机器本地维护规则。后来提供了集群限流方案,原理是让所有实例的流量都经过一个“Token Server”统一分配令牌,相当于把多副本的流量重新汇合到一个集中式的逻辑节点上。
实际项目中,如果已经用了Nacos或者Consul做配置中心,Sentinel的规则可以通过配置中心统一管理,动态下发到所有机器。出现流量突增时,运维可以实时调整限流阈值而不需要重新发布应用。这和Redis+Lua方案对比,Sentinel的优点是它自带控制台、实时监控、流控规则多样(QPS、并发线程数、热点参数),开箱即用;缺点是集群模式部署Token Server会增加运维复杂度,而且Token Server自身也成了新的单点。Redis+Lua方案更轻,代码透明可控,不依赖额外的控制台,适合团队不想引入重量级框架的场景。
如果你在SpringCloud生态里,又希望快速落地,我的建议是:小团队、接口少、追求透明可控,优先Redis+Lua;大团队、接口众多、需要实时监控和动态规则调整,优先Sentinel。两者不冲突,Sentinel的本地限流和集群流控,底层同样可以挂Redis。
6. 限流之后的动作:排队、降级与被拒绝的返回值设计
6.1 被拒绝的响应:不要只给一个503
限流不是“把请求丢掉就完事”。被限流的请求怎么返回,直接影响调用方的体验和后续的恢复策略。很多团队偷懒,直接给个HTTP 503或者500,结果调用方把它当成系统错误,疯狂重试,反而让系统雪上加霜。我的经验是:定义一套专用于限流的响应码,明确告诉调用方“你不是程序错误,你是被限流了,过一会儿再试”。
常见的返回方式有三种:
- HTTP 429 Too Many Requests。这是语义最标准的限流状态码,网关、浏览器、客户端SDK都能正确识别。配合
Retry-After响应头,告诉调用方多少秒后再试,是最专业的做法。 - 业务自定义错误码。比如统一响应体
{code: 42901, message: "rate_limit_exceeded"}。好处是前端可以根据错误码弹出“操作太频繁”的提示,而不是“系统异常”。 - 降级返回兜底数据。比如搜索接口被限流,但你可以返回最近半小时的缓存结果,而不是直接失败。这种体验最好,但对缓存可用性有要求。
6.2 排队等待 vs 直接拒绝:别让用户摸不着头脑
限流的处理动作不只是“拒绝”。对于部分场景,比如秒杀系统,直接拒绝会让用户非常恼火,更合理的方式是“排队”。把请求放进一个队列,按顺序处理,前端轮询状态。这种方式本质上就是把瞬时流量转化为延迟流量,用户感受到的不再是“买不到”,而是“正在排队中”。
实现排队也不复杂。以Redis为例,用列表做队列,请求进队列后返回“排队中”的状态码,后台Worker从队列里取请求并发给下游处理。这里有个关键参数需要控制:最大排队长度。队列太长会导致用户等待时间超出一个可接受的范围,我这里一般定在“能处理能力的两倍耗时以内”。假设系统每秒钟能处理100个,大部分请求平均耗时200毫秒,那队列里排200个就差不多约等于4秒的等待,超过这个数就不要再排队了,直接拒绝,避免排在后面的人等半天还是失败。
6.3 限流和熔断降级的边界,别把责任推给错误码
最后补充一个经常被问的边界问题:限流触发了降级方法,那这个方法里是返回空数据还是报错?
我的经验是:降级方法绝不能再依赖可能被打爆的公共资源。比如你的缓存是把双刃剑,限流时如果你想用缓存结果来兜底,一定要确认这个缓存是独立的、不会因为热Key问题也挂掉。有些设计里,限流降级方法还会去查DB查Redis,结果发现真正的瓶颈就是DB,降级方法等于把流量又引入了DB,限流就白做了。一个合格的降级方法应该是不依赖外部资源、直接返回静态值或者最近一次成功的结果。
限流、熔断、降级三者的配合,我更倾向于用一段话来总结他们的关系:限流解决的是“入口流量过多”,熔断解决的是“下游已经不可用”,降级解决的是“保底方案给点什么结果”。这三件事经常需要联动。比如下游开始大量超时,触发熔断后,上游的限流阈值就可以适当放宽,因为熔断已经帮你挡掉了一部分坏请求。如果限流和熔断各做各的,可能出现下游还没熔断,上游已经被限流挡住了所有流量,白白浪费系统能力。
7. 限流落地踩坑实录:多副本、预热与时钟偏差的真实教训
7.1 多副本流量不均导致限流阈值失真
有一年我们上线了一个大促接口,按单机压测的结果给每台机器设了相同的限流阈值。结果上线第一天就出问题:第一波流量直接把其中两台的线程池打满,另外几台却闲得很。查看负载均衡日志,发现流量不均匀的原因不是负载均衡策略有问题,而是部分用户客户端对某个IP建立了大量长连接,这些连接在一定时间段内会一直复用同一个后端节点,导致热点连接把流量集中在了少数机器上。
这个事故之后,我把核心接口的限流全部改成了Redis+Lua的全局桶,不再依赖单机计数。同时给负载均衡加了一个“最少连接数”的策略,并在客户端做了连接池回收。全局限流在这种场景下的意义,不仅是总量准确,更重要的是避免了“某些节点已经过热、其他节点还在傻傻放流量进来”的不均衡问题。
平均分配的另一个变体是“按实例权重分配”:有些机器配置高理应承担更多流量,但权重必须由压测数据给出,不能拍脑袋。我见过有人把高配机器权重调成老机器的3倍,结果高配机器先被打挂,因为扩的连接数、线程数放大了单个请求的开销,单机吞吐并没有线性上涨。
7.2 预热与冷启动:令牌桶被瞬间打空
有次我们给一个核心读接口加了令牌桶限流,rate=100,capacity=200。平时运行得很平稳,但每次服务重启后的头几分钟,总会出现一小波限流误伤。排查下来发现原因在于:令牌桶在每个Redis key被创建时初始化成满桶,但如果应用刚重启,所有key都是新的,先是所有桶都是满的,能放大量流量进来;等到桶里的令牌被消耗得差不多了,新令牌补充速度还没跟上,又开始大量限流。这个“先松后紧”的过程就像冷启动之后的抖动,用户体验很差。
解决办法有几个思路:一是在应用启动时预热,提前为常用的key初始化令牌桶,让令牌在生产流量进入之前就已经攒满;二是放弃“满桶启动”,而是按一个较低的初始令牌数启动,让系统在启动后有一个逐渐升温的过程;三是在业务侧增加“最小可用令牌数”的判断,当系统状态处于冷启动期时,放宽对低优先级的限流限制,优先保证核心用户。
7.3 时钟一致性与过期时间:Redis Key自动过期导致的窗口失真
最后一类容易踩的坑,是Redis key的过期时间。很多人写令牌桶脚本时,会忘记给key设置过期时间,或者设置得过短。假设你在HSET之后调了EXPIRE key 10,而你的令牌补充逻辑依赖timestamp字段,一旦key因为超过10秒没被访问而自动过期了,下一个请求进来会重新初始化一个满桶。这意味着:如果某个限流key的请求频率较低(比如每5秒才来一个),它每次请求都会重新“满桶”,限流就形同虚设。
另外,令牌补充依赖“当前时间减去上次时间”,如果业务服务器和Redis服务器的时钟不一致,或者某些时间条用了系统时间而另一些用了Redis时间,就会出现令牌补发异常的诡异现象。我建议统一使用一个时钟源,最简单的方法是直接在请求参数里把毫秒时间戳传进去,让Lua脚本用这个时间戳计算补充令牌数,这样各节点之间的时钟差异就不会干扰限流判断。
这些坑回头看去,问题都不复杂,但每一个都在线上真实发生过,而且报障时很难一眼定位。我现在每做一个限流方案,一定会同步加上三样东西:限流相关监控指标(命中率、放行QPS、拒绝QPS、redis调用耗时)、一次完整的压测报告、一个可以手工开启关闭或调整阈值的开关。缺了这三样,限流方案上线后就是“薛定谔的保护”——你也不知道它到底在拦什么,也不知道拦得多不多,只能等着下次事故来提醒你。
