1. 高可用链路里,限流为什么是不可省的那一环
接口限流、高可用、常用算法、实践,这四个词凑在一起,基本就是一次线上容量治理的完整命题。我先说结论:限流不是给系统"减速",而是给系统"划定一个不会被压垮的运行边界"。这两件事听起来像,实际差别巨大——前者是性能优化,后者是生存保障。我待过的一个交易团队,在大促前把所有服务的连接池、线程池、缓存 TTL 全部调优了一遍,压测数据很漂亮,结果活动开始后第四个分钟整条链路雪崩,根因不是某个服务慢,而是上游一个非核心的查询接口被打成了"慢请求制造机",线程被占满后连锁反应把核心下单链路一起拖死。事后复盘,唯一能拦住这件事的手段就是限流:在入口处就把超出承载能力的请求砍掉,让系统在可控负载下继续提供"部分可用",而不是"全体不可用"。
这就是高可用的本质逻辑:可用性不是靠"永远不失败"达成的,而是靠"失败得足够局部、足够快、足够可预期"达成的。限流是这套逻辑里最前置的一道闸门,它比熔断更早介入(熔断是等失败率上来才动作),比降级更轻量(降级要改业务逻辑),比扩容更即时(扩容有分钟级的滞后)。很多团队在搭高可用体系时,先做监控告警、再做熔断降级,最后才想起来限流,顺序其实是反的。限流应该是第一层,因为它决定了后面所有机制是否有机会生效——当流量洪峰在几百毫秒内涌进来,熔断器的统计窗口还没攒够样本,降级开关还没被人点下去,只有限流器是常驻在请求路径上、每毫秒都在做判断的。
这篇内容适合三类人看:一是正在给服务加限流但不确定选哪种算法的后端开发;二是被"限流阈值定多少"折磨过的技术负责人;三是遇到过限流误伤、限流组件自己挂掉这类疑难问题的运维和 SRE。我会把四种常用算法的取舍逻辑、单机与分布式两种落地路径、参数怎么算、生产环境会踩哪些坑,一条一条拆开讲,代码和配置都能直接拿去改。
1.1 限流到底在拦住什么:三类真实压力源
先分清敌人。限流要对付的压力源,粗分有三类,解法不一样。
第一类是突发型流量,典型场景是整点秒杀、活动开场、消息推送触达。特征是总量未必离谱,但集中在极短窗口内,比如平时 200 QPS 的服务,开场那一秒涌进 5000 个请求。这类压力对付的重点是"削峰",允许一定排队或延迟,但不能让瞬间并发穿透到数据库。
第二类是恶意或异常型流量,比如某个客户端因为重试逻辑写错,陷入死循环疯狂重发;或者某个爬虫脚本以极高频率扫接口。这类压力总量可能不大,但集中在单一来源上,对付的重点是"按维度隔离",让一个坏调用方消耗不掉所有人的配额。
第三类是依赖衰减型压力,这是最容易被忽略的。下游某个服务因为自身原因响应变慢,上游的线程池开始堆积,堆积到满之后新请求排队超时,用户重试又产生新请求,形成正反馈。这种时候总量可能只涨了 30%,但因为每个请求占用的时间变长了 5 倍,系统的实际并发占用翻了数倍。对付这类压力,限流要盯的不是 QPS,而是并发数。
我见过不少团队只做 QPS 限流,然后在依赖变慢的场景下依然被打穿,原因就在这——QPS 没变,但在途请求数暴涨。所以一个相对完整的限流体系,指标维度至少要有 QPS 和并发数两个,前者管入口速率,后者管在途占用量。
1.2 限流、熔断、降级三者的分工边界
这三个词经常被混着说,实际职责差别很大,画不清边界就会做出重复或真空的防护。
限流是"事前准入":请求还没进入业务逻辑,就判断要不要放行。它的判断依据是"我这边的承载能力",不关心对方是否健康。放行了但后续失败,那是熔断该管的事。
熔断是"事中拦截":请求已经在打了,根据失败率、慢调用比例等统计指标,判断下游是否已经不可用,如果不可用就快速失败,不再真的发出去。它的判断依据是"对方的状态"。
降级是"事后兜底":功能不可用或者被限流之后,给用户一个可接受的替代结果——返回缓存旧数据、返回默认值、关闭非核心模块。
三者的触发顺序通常是:限流先拦掉超出容量的部分,熔断在剩余流量里拦掉打向不健康依赖的部分,降级给被拦掉的请求一个体面的出口。我习惯把它们做成一条链:限流器在最前面,熔断器包在远程调用外层,降级逻辑挂在异常处理上。
注意:限流和熔断的阈值不要设成同一个数量级。如果限流阈值是 1000 QPS,熔断的慢调用比例阈值又设在很敏感的位置,会出现"限流放行的请求刚好触发熔断"的叠加效应,实际有效吞吐远低于预期。我的经验是熔断阈值要比限流阈值宽松 20%~30%,留出缓冲带。
1.3 限流指标怎么选:QPS、并发数还是连接数
选指标本质是选"你要保护什么资源"。
保护CPU 密集型接口(比如图片处理、加解密),QPS 是最直接的,因为每个请求消耗的 CPU 时间相对稳定,QPS 和 CPU 使用率近似线性。
保护IO 密集型接口(比如查数据库、调远程服务),并发数是更靠谱的指标。因为每个请求占用的时间波动很大,QPS 相同的情况下,在途请求数可能差好几倍,而线程池、连接池的容量是固定的,被撑爆的是后者。
保护网关层的连接资源,可以看新建连接速率,防止连接风暴把 accept 队列打满。这个指标用得少但对特定场景很有效,比如防护短连接为主的客户端。
还有一个维度是按业务重要性分级。核心接口(下单、支付)的限流阈值应该留足余量,甚至不设硬限流只设告警;非核心接口(推荐、评论、足迹)的阈值可以卡得紧一些,被限流了也不影响主流程。这个分级必须在压测阶段就定下来,不能等出事的时候临时决定牺牲谁。
2. 四种常用限流算法,各自的适用边界在哪
算法选型这件事,网上很多文章把四种算法列个表就完了,实际做的时候你会发现,真正的难点不是"哪种算法更好",而是"我的场景能不能容忍这种算法的缺陷"。下面我把每种算法的行为特征、临界问题、实现代价摊开讲。
2.1 固定窗口:实现最省事,但临界双倍问题绕不过去
固定窗口的思路极其朴素:把时间切成一个个等长的窗口(比如 1 秒一个),每个窗口一个计数器,请求来了加一,超过阈值就拒绝,窗口切换时计数器清零。
它的优点是实现成本几乎为零,一个AtomicLong加一个记录当前窗口起始时间戳的变量就够了,单机场景下性能极好,无锁竞争(用 CAS 的话)。很多轻量级场景用它完全够用,比如内部管理后台的接口、低频的配置拉取接口。
但它有个绕不过去的缺陷——临界双倍问题。假设阈值是 100 QPS,窗口按自然秒切分。在第 0.9 秒到第 1.0 秒这 100 毫秒里来了 100 个请求,全部放行;窗口切到下一秒,第 1.0 秒到第 1.1 秒这 100 毫秒里又来了 100 个请求,也全部放行。结果是200 毫秒内实际通过了 200 个请求,瞬时速率达到 1000 QPS,是设定阈值的十倍。如果下游是个脆弱的数据库,这 200 个并发足以把它打抖。
我踩过一次这个坑:某个对外的查询接口用固定窗口限流 50 QPS,平时一直很稳,结果某个客户端做了定时任务对齐整秒触发,每次都在秒边界附近集中发请求,正好卡在双倍窗口上,把后面的缓存层打出了几十毫秒的毛刺。查了两天才定位到是限流算法的临界问题,不是缓存本身的问题。
所以固定窗口的适用边界很清晰:下游能扛住短时 2 倍瞬时流量,或者流量本身比较均匀不会有明显的整点对齐特征。超出这个边界就得换算法。
2.2 滑动窗口:把临界问题摊平,代价是要维护更多状态
滑动窗口就是把大窗口再切成小格子,比如 1 秒切成 10 个 100 毫秒的格子,每次判断时把"最近 1 秒内的所有格子"的计数加起来。这样临界时刻的统计就平滑多了,双倍问题被压缩到"一个格子的大小"这个量级——格子越小越精确,但状态越多、计算开销越大。
工程上常见的实现有两种。一种是格子计数法,维护一个长度为 N 的环形数组,每个元素对应一个时间片,写入时定位到当前时间 / 片长 % N,读取时把 N 个元素求和。这种实现内存占用固定,O(N) 的求和开销,N 通常取 10 到 20,开销可以忽略。
另一种是精确日志法,把每个请求的时间戳存进一个有序集合,每次判断时先剔除窗口外的旧记录,再看集合大小。这个精度最高,但内存占用和请求量成正比,高 QPS 下没法用,一般只在低流量、高精度要求的场景(比如计费接口)才考虑。
滑动窗口的格子计数法是我个人最推荐的"通用默认选项":精度足够、实现不复杂、内存可控。它的缺点是不够平滑——请求分布在一个格子内的边界上时,仍然会有轻微的突刺,但幅度只有 1/N,可以接受。
2.3 漏桶:匀速输出,专治下游扛不住波动
漏桶的模型是一个底部有孔的桶,请求倒进桶里,从孔里以固定速率流出,桶满了新请求就丢弃。关键特征是输出速率恒定,无论输入怎么波动,出去的流量永远是均匀的。
这个特性让它特别适合保护"对流量波动敏感"的下游。典型例子:调用第三方支付渠道的接口,对方明确说每秒最多处理 20 笔,超了会返回错误甚至触发风控;或者写日志到某个按量计费的日志服务,突刺会造成费用尖峰。这种场景下限流的目的不是"挡住超量",而是"整形",把不均匀的输入变成均匀的输出。
实现上,漏桶有两种常见写法。一种是队列式,真的搞一个容量有限的队列,加一个固定频率的消费者线程往外取。这种写法直观但要管线程、要处理队列满的拒绝策略,资源开销大,生产环境用得少。另一种是计算式(也叫虚拟调度),不真的排队,而是记录"下一次允许通过的时间点",每次请求来时按固定间隔往后推,如果推算出的时间点已经超过当前时间很多,就说明积压了,直接拒绝。这种写法无队列、无线程,性能极好,我在实际项目里基本都用这一种。
漏桶的代价是它不允许突发。如果业务本身有合理的突发特征——比如页面首屏要并发拉 8 个接口——漏桶会把这 8 个请求强行摊到一个较长的窗口里,用户感知就是页面加载变慢。所以漏桶适合"下游极度敏感 + 业务能接受排队延迟"的组合,不适合面向用户的交互式接口。
2.4 令牌桶:允许突发,最贴近真实流量形态
令牌桶反过来:桶里预先放着一批令牌,以固定速率往里补充,请求来了要先拿一个令牌,拿不到就拒绝(或者短暂等待)。桶的容量决定了允许的突发上限,补充速率决定了长期平均速率。
这个模型精妙的地方在于它把"平均速率"和"瞬时突发"两个诉求解耦了。补充速率 200/s、容量 200 的令牌桶,长期看是 200 QPS,但允许某一瞬间一次性打进来 200 个请求(把桶里的存量一次取空)。这非常符合真实业务的流量形态:平时均匀,偶尔有小高峰。
它的另一个好处是实现极其轻量。不需要定时器去补充令牌,用"惰性补充"就行:每次请求进来时,用当前时间 - 上次填充时间算出这段时间应该补充多少令牌,加上去,然后尝试扣减。整个过程一次 CAS 或者一段 Lua 脚本搞定,没有后台线程。
令牌桶的局限是它的突发上限必须靠容量参数控制,如果容量设得太大,突发来的时候仍然会瞬间打出很大的流量。有些团队为了"不让用户被限流"把容量设成阈值的 10 倍,等于自己把保护墙拆了。我的经验是容量取"阈值 × 允许突发的秒数",一般 0.5 到 2 秒就够,具体看下游的缓冲能力。
2.5 四种算法的选型对照
把上面的分析压成一张表,实际选型时可以直接对照。
| 算法 | 突发处理 | 精度 | 实现复杂度 | 内存/状态 | 典型适用场景 |
|---|---|---|---|---|---|
| 固定窗口 | 临界可放大到 2 倍 | 低 | 极低 | 1 个计数器 | 内部接口、低频接口、流量均匀 |
| 滑动窗口 | 控制在 1/N 格子内 | 较高 | 低 | N 个计数器 | 通用默认选项,对外 API |
| 漏桶 | 完全不允许突发 | 高 | 中 | 时间戳或队列 | 第三方依赖保护、按量计费、写日志 |
| 令牌桶 | 允许,容量可调 | 高 | 低 | 令牌数 + 时间戳 | 面向用户的交互接口、有合理突发的业务 |
补充一个经验:不必追求单一算法统一全站。核心交易链路用令牌桶(允许合理突发,用户体验优先),下游第三方调用用漏桶(匀速整形,保护对方),内部管理接口用固定窗口(够用就行,省资源),这是我认为最务实的组合。
3. 从零落地一个能上生产的限流组件
算法清楚了,接下来是工程化。这一节我给两个可直接参考的实现:单机的令牌桶,和分布式的 Redis 方案。两套都在生产环境跑过。
3.1 单机令牌桶:惰性补充的一次 CAS 搞定
核心思路是这样的:维护三个状态——当前令牌数tokens、上次补充时间lastRefillTime、桶容量capacity和速率rate。请求来了先算时间差,补充令牌,再判断够不够扣。整个过程用 CAS 循环保证原子性,不用加锁。
public class TokenBucketLimiter { private final long capacity; private final double ratePerNano; // 每纳秒补充的令牌数 private final AtomicLong state; // 高 32 位存令牌数,低 32 位存上次补充时间(秒级偏移) private final AtomicLong lastRefill = new AtomicLong(System.nanoTime()); private final AtomicLong tokens; public TokenBucketLimiter(long capacity, long ratePerSecond) { this.capacity = capacity; this.ratePerNano = ratePerSecond / 1_000_000_000.0; this.tokens = new AtomicLong(capacity); } public boolean tryAcquire() { while (true) { long now = System.nanoTime(); long last = lastRefill.get(); long currentTokens = tokens.get(); long elapsed = now - last; long newTokens = currentTokens + (long) (elapsed * ratePerNano); if (newTokens > capacity) newTokens = capacity; if (newTokens <= 0) return false; if (lastRefill.compareAndSet(last, now) && tokens.compareAndSet(currentTokens, newTokens - 1)) { return true; } // CAS 失败说明有并发,重试 } } }这段代码有几个要点值得说。第一,时间用System.nanoTime()而不是currentTimeMillis()。后者受系统时钟调整影响,NTP 校时或者手动改时间会让elapsed变成负数,令牌数直接崩掉。nanoTime是单调递增的,不受墙上时钟影响,是限流器的正确选择。这个坑我在线上真实遇到过:某次运维调整了机器时间,限流器瞬间放行了海量请求,事后查日志才发现是时间回拨导致的。
第二,补充的时候要封顶到 capacity,不能无限累加,否则服务空闲一小时之后桶里攒了几十万个令牌,一个突发直接打穿。
第三,CAS 失败的循环里不要重新读时间。上面的写法每次都重读now,实际上更稳妥的做法是在失败重试时复用原时间,避免"重试过程中时间推进导致补充了额外令牌"。差异很小,但严格来说前者更精确。
还有一个细节:ratePerNano用 double 存会带来浮点误差,高精度场景可以改成"每 N 纳秒补充 1 个令牌"的整数运算,用累加余数的方式处理。多数业务场景下 double 的精度足够,不用过度设计。
3.2 分布式限流:Redis + Lua 保证原子性
单机限流有个天然局限:集群有 10 个实例,每个实例限 100 QPS,实际总放行量是 1000 QPS,但这个值会随着实例数变化。扩缩容的时候总阈值跟着漂移,这是运维的噩梦。
分布式限流的方案里,Redis 加 Lua 脚本是最常见的。原因有三:Lua 脚本在 Redis 里单线程执行,天然原子;令牌桶的"读时间戳、算补充、判断、写回"这一整串操作必须原子,否则并发下会超发;Redis 的时间可以通过TIME命令在服务端取,避免各应用机器时钟不一致。
下面是一个令牌桶的 Lua 实现:
-- KEYS[1]: 限流键 -- ARGV[1]: 桶容量 capacity -- ARGV[2]: 每秒补充速率 rate -- ARGV[3]: 本次请求需要消耗的令牌数(一般为 1) local key = KEYS[1] local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local requested = tonumber(ARGV[3]) -- 用 Redis 服务端时间,避免客户端时钟漂移 local time = redis.call('TIME') local now_ms = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) local bucket = redis.call('HMGET', key, 'tokens', 'ts') local tokens = tonumber(bucket[1]) local last_ts = tonumber(bucket[2]) if tokens == nil then tokens = capacity last_ts = now_ms end -- 惰性补充 local delta = math.max(0, now_ms - last_ts) local refill = delta * rate / 1000 tokens = math.min(capacity, tokens + refill) local allowed = 0 if tokens >= requested then tokens = tokens - requested allowed = 1 end redis.call('HMSET', key, 'tokens', tokens, 'ts', now_ms) -- TTL 设为桶从空到满所需时间的 2 倍,避免冷键长期占用内存 local ttl = math.ceil(capacity / rate * 2) redis.call('EXPIRE', key, ttl) return allowed几个关键设计点。用redis.call('TIME')而不是在脚本里接收客户端传的时间戳,这是很多人忽略的地方。一旦客户端时钟有偏差(哪怕几百毫秒),多实例之间的令牌补充节奏就会错乱,表现为限流忽松忽紧。Redis 服务端时间是单点的,天然一致。
TTL 的设置要精细。如果不设 TTL,每个限流键会永久占用内存,键的数量等于"用户数 × 接口数",量级轻松上百万,Redis 内存会被吃光。设 TTL 也有讲究:如果 TTL 比"桶从空到满的时间"短,键会在桶还没补满的时候被删掉,下次请求进来发现键不存在,直接重置成满桶,等于给了一次免费的突发。我的做法是 TTL 取capacity / rate × 2,留出一倍冗余。
Lua 脚本要控制长度。Redis 是单线程的,脚本执行期间所有其他命令都阻塞。上面的脚本大概十几条命令,执行时间在微秒级,没问题。但如果有人在脚本里加循环、加复杂的条件分支,就可能造成 Redis 卡顿。我见过一个限流脚本里写了 for 循环遍历 100 个键,QPS 一高 Redis 直接出现百毫秒级的延迟尖刺,连带所有依赖它的业务都抖了。
调用侧:
public boolean tryAcquire(String key, int capacity, int rate) { try { Long result = redisTemplate.execute( TOKEN_BUCKET_SCRIPT, Collections.singletonList(key), String.valueOf(capacity), String.valueOf(rate), "1" ); return result != null && result == 1L; } catch (Exception e) { // 降级策略:Redis 不可用时放行,避免限流组件成为单点 log.warn("rate limiter unavailable, fallback to pass", e); return true; } }这里的异常降级策略是个决策点,我单独说。Redis 抖动或者网络超时的时候,是选择"限流失效全部放行"还是"全部拒绝"?多数业务场景应该选放行——因为限流是保护性措施,不是业务规则,它挂了不应该导致业务不可用。但如果是支付、风控这类强一致场景,可能要选拒绝。这个决策必须在设计阶段就和业务方确认,不能等出事的时候临场发挥。
还有一个优化点:如果单个限流键的 Redis 调用成为瓶颈(比如核心接口 QPS 上万),可以做批量预取——一次脚本里申请 10 个令牌,本地扣完再来领,把 Redis 调用量降到 1/10。代价是令牌利用率下降(本地没用完的令牌就浪费了),精度换性能,看场景取舍。
3.3 参数怎么算:容量、速率、预热的具体推导
这是所有人最头疼的部分:阈值到底定多少。我给一套可落地的推导流程。
第一步,测出单实例的实际承载能力。不能拍脑袋,要压测。压测的时候不要只看"系统的极限 QPS",要看"在可接受的 P99 延迟下,能稳定跑多少 QPS"。我一般会做阶梯压测:200、400、600、800、1000 QPS 各跑 5 分钟,画出 QPS 与 P99 延迟、CPU 使用率、错误率的曲线。找到 P99 开始陡增的那个拐点,往前退 20%,得到单实例安全水位。
第二步,确定目标总吞吐。这个值来自业务侧的预期峰值。比如活动预计峰值 3000 QPS,扩容到 15 个实例,单实例安全水位 250 QPS,15 × 250 = 3750,有 25% 的余量,可以接受。
第三步,算速率和容量。速率 = 安全水位(比如 250/s)。容量(允许的突发量)取速率 × 突发容忍秒数。突发秒数怎么定?看下游的缓冲能力:如果下游有队列、有缓存,能扛住 1 到 2 秒的额外压力,容量就取250 × 1 = 250;如果下游极其敏感,直接取250 × 0.5 = 125。
第四步,决定是否需要预热。冷启动的实例,缓存是空的、JIT 没热、连接池没建立,直接给满额流量上去必崩。这种时候要用预热限流:新实例启动后,阈值从 10% 开始,在 5 到 10 分钟内线性爬升到 100%。Guava 的RateLimiter有warmupPeriod参数可以直接用;自己实现的方案就加一个启动时间戳,用min(1, (now - startTime) / warmupMillis)作为系数乘到速率上。
提示:预热系数不要用固定的线性函数,用平方或开方更好。线性预热在前期放量太猛,实测下来新实例在 20% 时间段就已经承受到 40% 的流量。开方函数前期慢后期快,更贴合 JIT 和缓存逐渐变热的实际曲线。
3.4 限流键的设计:一个键定错,全线白做
键的设计决定了限流的粒度,这比算法本身更容易出错。
按接口限流(limit:{api}:{method})是最基础的,防止单个接口被打爆。但这只能保证"接口不被压死",不能保证"公平"——一个恶意用户可以把某个接口的配额全部吃光,其他用户全被限流。所以按接口限流通常要配合其他维度。
按用户限流(limit:{userId})保证公平性,防止单用户霸占资源。适合面向终端用户的开放接口。比如普通用户 10 QPS,VIP 用户 50 QPS,通过用户等级映射不同的阈值。
按 IP 限流(limit:{ip})用来防爬虫和恶意刷量。但这个维度的误伤率很高,因为大量正常用户在公司或运营商 NAT 后面共享同一个出口 IP,一个 IP 可能对应几千人。我的经验是 IP 限流的阈值要给得比用户级宽松 10 倍以上,并且只用在登录、注册、短信发送这种明确有攻击动机的接口上,别一股脑加到所有接口。
组合维度是最实用的方案,用多个限流器串联,全部通过才放行:
public boolean check(Request req) { // 第一层:全局保护,防止总流量超限 if (!globalLimiter.tryAcquire()) return false; // 第二层:接口级保护 if (!apiLimiter.tryAcquire(req.getApi())) return false; // 第三层:用户级公平性 if (!userLimiter.tryAcquire(req.getUserId())) return false; return true; }串联的顺序有讲究:开销小的、拦截率高的放前面。全局限流器通常用本地内存实现,单次判断纳秒级;用户级限流走 Redis,单次毫秒级。把全局的放前面,能在 Redis 之前就砍掉大部分超量请求,显著降低 Redis 的压力。
3.5 限流之后给用户什么:拒绝、排队还是降级
被限流的请求怎么处理,是体验问题,也是设计问题。三种选择各有用武之地。
直接拒绝,返回429 Too Many Requests(HTTP 标准状态码),并在响应头里带Retry-After: 1告诉客户端多久后重试。这是最干脆的做法,但要确保客户端能正确解析并做退避重试,否则客户端疯狂重试会把限流变成"重试风暴"。我见过一个移动端没有处理 429,收到非 200 就立刻重试,结果把限流变成了放大器。
短暂排队,在服务端阻塞等待令牌,超时才拒绝。适合延迟容忍度高的场景(比如异步任务提交)。等待时间一定要设上限,一般 100 到 200 毫秒,超过就拒绝。无上限的等待会把线程挂死,比直接拒绝更危险。
降级返回,被限流时返回缓存数据、默认值或者精简版结果。适合查询类接口——比如推荐接口被限流了,返回一份静态的热门榜单,用户感知不到异常。这是体验最好的方案,但要提前准备好降级数据的生成和缓存链路。
我通常的做法是分接口类型定策略:写接口直接拒绝(不能重复写),读接口优先降级(可用性优先),异步接口短暂排队(用户本来就在等)。这三条策略要在接口设计文档里写清楚,让调用方知道该怎么做。
4. 生产环境的坑,以及怎么提前避开
算法和实现都对了,上线之后仍然会出问题。这一节全是我踩过的、以及旁观别人踩过的坑。
4.1 阈值设太紧导致误伤,设太松等于没有
这是最常见的两极失败。阈值设太紧,正常业务峰值被误伤,用户投诉、客服爆炸;设太松,保护作用形同虚设,出事的时候限流器根本没拦。
误伤的典型表现是限流日志和业务错误日志时间高度重合。排查的时候先看限流拒绝的时间分布,如果集中在整点、榜点、活动开始这些可预期的时段,说明阈值就是偏低了,该调的调、该扩的扩。如果拒绝分布是随机的、零散的,那要怀疑是不是某个客户端在刷。
另一个隐蔽的误伤来源是限流维度的粒度太粗。比如按接口限流,但同一个接口既有高频的轮询调用(前端每 3 秒查一次状态),又有低频的重要操作(提交表单),两者共享一个配额。轮询把配额吃光,提交就被限了。解法是把这两个用途拆成不同接口,或者在限流键里加上调用方标识。
阈值调优我推荐灰度调:先按压测值的 120% 设定,观察一周的实际拒绝率和业务指标,如果拒绝率接近 0 且延迟没恶化,说明还有余量,可以往下压;如果拒绝率超过 0.5% 且伴随用户投诉,说明要往上放。这个调整要记录变更历史,避免来回摇摆。
4.2 时间回拨、时钟漂移与"限流器失忆"
限流器高度依赖时间,时间一出问题就全乱。前面提过nanoTime的问题,这里再补充两个场景。
容器环境下的时钟。如果限流器用currentTimeMillis做窗口计算,宿主机做时间同步的时候可能瞬间回拨几十毫秒,固定窗口实现会把这个窗口的计数清零重来,等于白送一整个窗口的配额。令牌桶实现则可能出现elapsed为负,如果不做max(0, ...)的保护,令牌数会变成负数,后续所有请求都被拒绝——这比多放行更致命。
分布式场景的客户端时钟不一致。如果 Lua 脚本用客户端传的时间戳,10 个实例的时钟偏差 100 毫秒,它们在 Redis 里写入的时间戳就会来回跳,令牌补充量算出来忽大忽小。表现就是限流"一抽一抽的",监控上看拒绝率呈现周期性波动。必须用redis.call('TIME')。
还有一个隐蔽的坑:Redis 主从切换。如果限流用的 Redis 是主从架构,主挂了切到从,从库的数据可能落后几十毫秒,令牌数会回退一点点,理论上会多放行一两个请求。对于限流这种"允许极小误差"的场景可以接受,但如果业务对精度要求极高,就要考虑本地缓存加 Redis 双层的方案,本地层做兜底。
4.3 限流组件自己成为故障点
这个坑最讽刺:为了高可用加的组件,自己变成了不可用的原因。
最典型的场景是Redis 抖动导致所有接口超时。如果限流调用没设超时,Redis 卡了 2 秒,业务请求就跟着卡 2 秒,限流器把整个服务拖慢了。必须给限流调用设硬超时,一般 5 到 10 毫秒,超时就降级。
第二种场景是限流器的锁竞争。如果用synchronized实现单机限流,QPS 一高,线程全堵在锁上,限流器本身成了瓶颈。这也是为什么我一直推荐 CAS 无锁实现。
第三种场景是限流日志把磁盘打满。每次拒绝都打一条 WARN 级别的日志,被限流 QPS 上千的时候,日志量能把磁盘写满或者把日志框架的异步队列撑爆。我的做法是对拒绝日志做采样,每秒最多打 10 条,同时用一个原子计数器统计被拒绝的总量,这个量走监控指标而不是日志。
if (!allowed) { long total = rejectedCounter.incrementAndGet(); // 采样:每 100 次打一条日志,避免日志风暴 if (total % 100 == 0) { log.warn("rate limited, key={}, total rejected={}", key, total); } return rejected(); }注意:限流器初始化和配置拉取也要做保护。如果配置中心挂了,限流器拿不到阈值,应该用一份本地默认配置兜底,绝不能因为配置缺失就跳过限流或者用 0 阈值拒绝所有请求。
4.4 常见问题速查表
把上面这些经验整理成一张对照表,出问题的时候可以直接查。
| 现象 | 可能原因 | 排查手段 | 处理方式 |
|---|---|---|---|
| 正常峰值被大面积拒绝 | 阈值低于实际峰值 | 对比限流拒绝曲线与业务量曲线 | 上调阈值或扩实例 |
| 拒绝率周期性波动 | 客户端时钟不一致 | 检查限流脚本是否用服务端时间 | 改用 Redis TIME |
| 服务整体变慢但 CPU 不高 | 限流调用阻塞 | 看限流调用的 P99 耗时 | 设 5~10ms 超时并降级 |
| 限流时灵时不灵 | Redis 主从数据不一致 | 检查 Redis 部署架构 | 本地兜底层 + Redis 层 |
| 磁盘告警 | 拒绝日志量过大 | 统计单位时间日志条数 | 日志采样 + 指标统计 |
| 新实例上线即被限流 | 未做预热 | 检查实例启动后的流量曲线 | 加预热系数 |
| 突发流量全部穿透 | 令牌桶容量设置过大 | 核对 capacity 参数 | 容量设为速率的 0.5~2 倍 |
| 单用户占用全部配额 | 缺少用户级维度 | 分析拒绝请求的来源分布 | 增加用户/租户维度限流 |
还有两个排查小技巧值得记下来。一是限流器的判断要暴露"剩余配额"指标,这样在监控上能看到桶的余量变化趋势,比只看"是否被限"有信息量得多——余量持续走低说明流量在爬升,可以提前预警。二是给限流决策打上 trace 标记,在链路追踪里能看到每个请求经过了哪几层限流、每层的配额余量是多少,定位误伤问题的时候非常省事。
5. 把限流当成一项持续运营的工作
限流上线不是终点。我见过太多团队把阈值写死在配置文件里,一两年没动过,业务量翻了好几倍,限流器要么形同虚设要么疯狂误伤。
比较务实的做法是把阈值做成可动态下发的配置,放在配置中心里,支持按环境、按接口、按集群维度覆盖。调整要走审批,但审批链路要短,活动前调整能当天生效。
监控指标至少要有这四个:每个限流器当前的拒绝率(拒绝数/总请求数)、桶的剩余容量占比、限流调用的耗时分布、Redis 的可用性。前两个看业务水位,后两个看限流组件自身的健康度。告警规则可以这样设:拒绝率超过 1% 持续 5 分钟告警(可能是误伤),限流调用 P99 超过 10 毫秒告警(可能是 Redis 有压力),Redis 失败率超过 0.1% 告警(降级策略在生效)。
最后分享一个我一直在用的方法:每次大促或重要活动前,做一次"限流演练"。用压测工具打出 3 倍于预期峰值的流量,观察系统是在限流层被拦住,还是穿透到了业务层才崩。如果限流器放了 3 倍流量进去,说明阈值设置有问题;如果限流器正确拦截但系统仍然出现异常,说明限流的位置不够靠前,或者有多条绕过限流器的调用路径。这个演练花半天时间,能提前暴露的问题绝对值回票价。演练的时候记得把降级开关、告警、应急预案一起串一遍,别只测限流一个环节——毕竟高可用从来不是单个组件的能力,而是一整套协同机制在特定时刻的表现。