面试官抛出来的时候,我的第一反应是“这题不是送分题吗”,但紧接着被追问“令牌桶和漏桶本质区别是什么”“突发流量怎么处理”时,才发现自己只记住了半吊子的概念。令牌桶算法几乎是后端限流场景里绕不开的基础设施,无论是网关、接口层还是消息消费端,你都能看到它的影子。对于做服务端开发的程序员来说,这属于躲得过初一躲不过十五的必考题,而且面试官往往会从一个简单的定义一路往深处挖,挖到参数设计、分布式实现、业务场景取舍,一条龙走完。
这篇文章不是我临时背的八股文,而是我把自己在项目里写限流、调参数、踩坑的经验,结合面试时被追问过的所有细节整理出来的深度解析。全文适合这几类人:准备面试的Java/Go后端开发、刚接手网关或微服务限流模块的新人、以及想把自己系统里“拍脑袋配置的限流值”改成有理有据方案的工程师。我会把原理、公式、代码、分布式场景、以及面试官的追问套路全部揉在一起讲清楚,而且会直接给出可复现的代码和参数计算方法。
1. 令牌桶到底在解决什么问题
1.1 从限流说起:高并发下的第一道防线
任何一个线上系统,吞吐能力都有一个物理上限。并不是说服务器配置越高上限就越高,数据库连接数、下游接口响应时间、线程池大小、磁盘IO,任何一个环节都可能变成瓶颈。一旦请求速率超过了系统的实际处理能力,随之而来的就是连接超时、线程堆积、内存溢出,甚至连锁崩溃把整条链路打挂。
限流就是在这种背景下出现的保护机制,它的目标是“拦住超出能力之外的流量”,让系统始终运行在健康区间。注意这里强调的是“超出能力之外”,因为限流不是拿来替代容量规划或者代码优化的,它是在你来不及扩容、下游来不及优化时的最后一道安全阀。所以在设计限流方案时,一个很核心的问题就变成了:用什么标准来判断一个请求该不该被放行?
这里涉及到的算法有好几种,固定窗口计数器、滑动窗口、漏桶、令牌桶,它们本质上都在回答同一个问题:以什么粒度、什么节奏来限制请求进入系统。而令牌桶是控制得最细腻、也最贴近真实业务压力模型的一个。因为它不是简单地在时间窗口内数数,而是抽象出了一个“允许突发但整体速率可控”的模型,这在处理秒杀、热点事件、流量突刺时有天然优势。
1.2 令牌桶的直观模型:一个池子、两种操作
令牌桶的模型可以拿“游乐园的入园闸机”来打比方。想象一个游乐园门口有一个装满入场券的箱子,票务系统每隔固定时间就往箱子里补充一定数量的票。游客来了先从箱子里取一张票,取到了就能入园,取不到就只能等下一轮或者被劝返。箱子的容量是固定的,票放太久也不会累积超过箱子容量,最多就是满箱。
具体到技术实现,桶里装的不是票,而是令牌token;补票的动作由系统以恒定速率执行;游客就是发过来的请求。每个请求进来时,先尝试从桶里拿一个令牌,拿到了就说明“当前系统允许你进入”,拿不到就拒绝或者排队等待。令牌桶的关键设计点有两个:一个是补充速率,决定系统长期承受的QPS上限;另一个是桶容量,决定短期内能容忍多少突发流量。
正因为把“长期速率上限”和“短期突发承受力”这两个维度解耦了,令牌桶在实际项目中才格外好用。比如某个下单接口平时的QPS只有200,但遇到活动瞬间能冲到800,如果按固定窗口写死200的阈值,活动一开就必然误杀大量正常用户;如果放宽到800,平时业务低峰期又等于没有防护。令牌桶的做法是把长期速率设在200,把桶容量设到800,这样平时积累下来的令牌可以支撑活动开始的短暂爆发,但一旦连续高流量持续下去,因为补充速率只有200,最终还是会回到整体可控的状态。
1.3 为什么选令牌桶而不是别的:突发流量带来的取舍
如果只看“限流”二字,固定窗口计数器其实最直观——每一秒最多放行100个请求,超过就拒绝。但它的缺陷也显而易见:如果在窗口末尾的100ms内来了100个请求,窗口刚切换又来了100个,那么这200ms内系统实际扛了200个请求,窗口的“每秒100”就形同虚设。滑动窗口修复了这个问题,通过把窗口切成多个小格子来逼近均匀限流,但它本质还是“静态配额”,并不允许系统利用之前的空闲余量。
漏桶算法则走向了另一个极端。它把请求看作水滴,不管来得多猛,漏桶都按固定速率往下漏,真正做到完全均匀。缺点是桶里积压的水必须有地方放,一旦超过桶容量就直接溢出丢弃,这意味着系统没有办法应对任何突发流量。适合消息消费、数据同步这类必须匀速处理的场景。
令牌桶站在这两种方案中间:它允许请求速率“段时间内超过平均速率”,但从更长的观察窗口来看,单位时间通过的总量仍然被令牌补充速率锁死。这种特性与绝大多数互联网业务的流量模型天然匹配——平时低频、突发高频、整体平稳。面试官喜欢问“令牌桶和漏桶怎么选”,其实他真正在考你的是:有没有意识到突发流量这个因素,以及有没有理解这两种算法在“允许突发”上背道而驰的取舍逻辑。
2. 令牌桶算法的核心原理解析
2.1 核心公式与状态变量:从数学上拆解
令牌桶的实现模型可以收敛成三个关键变量和一条补充公式。三个变量分别是:桶容量capacity、令牌补充速率refillRate(每秒补充多少个令牌)、当前令牌数availableTokens。另外还有一个容易被忽略但极其重要的状态:上次补充时间lastRefillTime。
为什么lastRefillTime这么关键?因为“每固定时间补充令牌”如果靠一个后台定时任务来做,会有两个问题:一是浪费一个线程/协程在这件事上,二是在每次补充间隙内,令牌数并不会随着时间流逝而增长,导致刚补充完的一瞬间令牌最充裕、马上要补充前的瞬间令牌最紧张,限流变得“一跳一跳的”。成熟的实现都不是用定时器去补,而是懒计算:只有当请求到达需要判断时,才根据“现在距离上次补充过了多久”计算出这段时间应该补充多少令牌。
核心公式如下:
elapsedTime = currentTime - lastRefillTime availableTokens = min(capacity, availableTokens + elapsedTime * refillRate) lastRefillTime = currentTime然后再判断:
if availableTokens >= 1: 放行,availableTokens -= 1 else: 拒绝或等待注意两个细节。第一,availableTokens通常是浮点数而不是整数,因为补充速率可能是每秒5.5个令牌,时间差也可能不是整数秒,用浮点数才能让模型在时间轴上平滑运行。第二,min(capacity, ...)这个操作必须存在,否则长时间没有请求时令牌数会无限累积,后续一旦有流量涌入,突发窗口就不再受控,容量参数的意义就消失了。
整个算法的时间复杂度是O(1),不依赖任何容器来保存历史请求记录,这也是它比滑动窗口省内存的原因。滑动窗口要维护时间窗口内每个请求的时间戳数组,令牌桶只需要维护三个标量,无论是在本地内存还是在Redis里,资源占用都极其可控。
2.2 关键参数怎么定:QPS、容量、速率的设计依据
面试时经常被追问“你的容量和速率是怎么配的”,很多人的回答是“我们线上配的100和200”,但这个数值是从哪来的,往往说不清楚。我自己在项目里一般按下面这个思路来推导参数。
第一步,标定系统真实处理能力。不管你是做压测还是根据历史监控估算,先回答一个问题:在CPU、内存、数据库连接都健康的情况下,这个接口每秒最多能稳定处理多少请求?这个值不是拍脑袋拍出来的,而是从压测报告里看那个“响应时间开始明显上涨”的拐点。比如接口压测结果是每秒500时P99为50ms,每秒800时P99涨到500ms,那500才是你的安全QPS。
第二步,留出缓冲。线上不是实验室,GC停顿、网络抖动、下游慢查询都会蚕食处理能力。一般是把安全QPS的70%作为令牌补充速率refillRate,这样系统长期负载不会超过健康水位线的70%,留出的30%给各种意外情况。
第三步,根据业务可以接受的短暂过期时间来确定容量。桶容量可以理解为“系统能够忍受的最大瞬时积压”,它决定了在没有任何新令牌补充的情况下,存量令牌能支撑多久。如果这个接口允许在最坏情况下用1秒去消化突发,容量就约等于当前速率;如果允许3秒,容量就是速率的3倍。一个常见的初始值是capacity = refillRate,但如果你做的是秒杀入口,可以考虑放大到2-3倍,同时配合等待队列来做削峰填谷。
参数没有标准答案,关键是逻辑自洽。面试官要听到的不是“我配了500”,而是“我根据压测结果,按系统容量的70%设定补充速率,容量取补充速率的2倍以应对活动突刺,同时加了等待队列做削峰”,这样的回答才有说服力。
2.3 与漏桶、计数器、滑动窗口的对比
这个对比几乎是面试必考题,我习惯用一张表格来梳理,既方便自己记忆,也能在讲给面试官时把逻辑讲清楚。
| 维度 | 固定窗口 | 滑动窗口 | 漏桶 | 令牌桶 |
|---|---|---|---|---|
| 实现复杂度 | 最低 | 中 | 低 | 中 |
| 内存占用 | 极低 | 较高(存请求时间戳) | 极低 | 极低 |
| 是否支持突发流量 | 不支持 | 不支持 | 不支持 | 支持(受桶容量约束) |
| 请求输出节奏 | 窗口内不平滑 | 较平滑 | 完全平滑 | 较平滑、可短时并发 |
| 典型场景 | 简单计数限流 | 通用API限流 | 消息拉取、数据同步 | 网关、接口限流、削峰填谷 |
这里有一个容易被忽略的细节:令牌桶的“突发”和“平滑”是同时存在的。短期来看,只要桶里还有令牌,请求可以一股脑进来,表现出突发性;但长期来看,单位时间内的平均请求速率被补充速率钳制,又体现出平滑性。这是漏桶做不到的——漏桶不管桶里有没有水,出口流量都恒定;这也是滑动窗口做不到的——滑动窗口并不允许你“借用上一秒没用完的配额”。所以从系统保护的视角来看,令牌桶是“允许你短时间冲刺,但不允许你长时间超速”,这对大多数真实业务来说恰恰是最健康的节奏。
3. 从零实现一个令牌桶:本地版实战
3.1 手写核心逻辑与并发控制
理解了公式之后,自己动手写一个本地令牌桶其实非常快。下面这段Java代码就是一个生产可用的单机版本,我故意把逻辑写得非常朴素,方便你理解每一个分支在干什么。
public class TokenBucket { private final long capacity; private final double refillRate; // tokens per second private double availableTokens; private long lastRefillTime; public TokenBucket(long capacity, double refillRate) { this.capacity = capacity; this.refillRate = refillRate; this.availableTokens = capacity; this.lastRefillTime = System.nanoTime(); } public synchronized boolean tryAcquire() { long now = System.nanoTime(); double elapsedSeconds = (now - lastRefillTime) / 1_000_000_000.0; availableTokens = Math.min(capacity, availableTokens + elapsedSeconds * refillRate); lastRefillTime = now; if (availableTokens >= 1.0) { availableTokens -= 1.0; return true; } return false; } }用synchronized把整个方法包上,是因为availableTokens是一个共享可变状态,多线程并发下必须保证原子性。有的实现会为了性能改用AtomicLong存放大整数令牌,或者用CAS无锁方案,但单机场景下synchronized在JDK 8+由于偏向锁和轻量级锁优化,实际开销已经很小,一个接口限流的调用量完全扛得住。
这里有一个新手很容易写错的点:补充令牌的逻辑一定要放在“判断是否放行”之前执行,因为两个请求可能同时到达,如果先判断再补充,第二个请求可能因为看到同一个旧值而做出错误判断。更隐蔽的问题是,elapsedSeconds是浮点数,而判断用的是availableTokens >= 1.0,如果速率是每秒0.5个令牌,那么一个请求后令牌数是0.5,第二个请求必须等到1秒后才能通过,这与“每秒最多1个请求”的直觉一致。
3.2 用Guava RateLimiter实现:两类平滑限流器
自己实现了底层逻辑之后,你再去看Guava的RateLimiter就会觉得亲切很多。它帮我们把并发控制、时间计算、等待队列都封装好了,两种模式对应两个内部类:SmoothBursty和SmoothWarmingUp。
// 每秒放行10个请求,允许突发 RateLimiter limiter = RateLimiter.create(10); double waitTime = limiter.acquire(); if (waitTime > 0) { // 说明此时令牌不足,请求会阻塞等待 } if (limiter.tryAcquire(1, 1, TimeUnit.SECONDS)) { // 1秒内能拿到令牌才执行,拿不到就放弃 } else { // 走降级逻辑 }create(10)创建的是SmoothBursty,也就是标准令牌桶,允许桶里累积最多1秒的令牌量,这对应我刚才讲的capacity = refillRate的场景。如果想放大突发容忍度,可以调用create(10, Duration.ofSeconds(3)),这样新创建的RateLimiter会在预热期逐步达到目标速率,内部对应SmoothWarmingUp,这类限流器适合冷启动场景——系统刚启动时JIT还没编译完、缓存还没热,如果直接放满速流量,很容易把服务瞬间打垮。
我在项目里实际遇到过一个比较典型的场景:新发布的订单服务刚启动时,Redis缓存是空的,数据库连接池连接还没完全建立,如果网关限流阈值已经拉到满,第一批请求几乎全都会超时。后来就是把网关层限流器改成支持预热的RateLimiter,让流量在启动后2分钟内逐步爬坡,系统稳定后才达到全量限流水位,这个问题的表象就基本消失了。
Guava的RateLimiter还有一个容易被面试官追问的特性:acquire会预支未来的令牌。也就是说即使当前桶里令牌数为0,它依然允许你请求成功,但会计算一个等待时间,让线程睡到这个时间之后再返回。这种“透支”行为在有的场景下很有用,在另一些场景下则可能掩盖问题——如果你用的是阻塞式的acquire,而高峰期QPS远超补充速率,调用的线程就会大量堆积在等待队列里,最终表现为线程池被占满,限流保护链条后延到了线程池层。所以我的建议是:在接口层优先用tryAcquire+降级策略,而不是acquire+无限等待。
3.3 客户端接入与降级策略:拿不到令牌怎么办
限流的“拒绝策略”本身是独立的决策点。令牌桶只负责输出一个布尔值,拿到之后怎么处理完全取决于业务方。常见的做法有三种,各有各的适用场景。
第一种是直接返回错误码。比如HTTP 429 Too Many Requests,配合Retry-After头告诉客户端过多久再来。这种策略最直观,也不消耗额外资源,适合非核心接口,例如用户查询好友列表时偶尔被限制一下影响不大。
第二种是排队等待。令牌桶可以用一个有界队列来承接被限流的请求,本质上把“立即拒绝”变成了“延迟处理”,实现削峰填谷。我做过一个短信发送接口,峰值QPS是平时的20倍,但短信平台本身允许一定的延迟到达,所以我给限流后面接了一个线程池+有界队列,请求高峰期在队列里排队几秒,既保护了下游短信网关,又避免了用户直接看到发送失败。
第三种是降级返回默认值。比如推荐接口被限流时,直接返回热榜数据或缓存白名单,虽然效果不如个性化推荐,但至少没有把错误暴露给用户。这种情况下令牌桶的状态可以放在调用链路的入口,在Dubbo Filter或Spring MVC拦截器里统一处理,而不是散落在各个业务方法中。
无论采用哪种策略,都需要配合监控才能发挥作用。最简单的做法是把“放行数”“拒绝数”“等待时长”这三个指标接入监控大盘,看到拒绝率突然上涨,或者等待时长突破500ms,就该检查是不是有个调用方在异常重试、或者活动流量超出了预期水位、或者下游能力退化把压力传导到了这里。
4. 分布式限流:Redis+Lua怎么做
4.1 为什么单机限流不够用
单机版的令牌桶只能保护单台实例。在微服务架构下,同一个接口通常会部署在多台机器上,每台机器的限流阈值如果是“全局QPS除以实例数”,那么部署变更、流量不均匀分配、某台机器重启等问题都会让这个静态分配失效。实例少的机器可能已经打满,实例多的机器还有大量余量,全局视角仍然处于失控状态。
分布式限流的思路是把令牌桶的状态从本地内存搬到集中式存储里,最常用的是Redis。所有实例共享同一个令牌桶状态,通过Redis的原子性操作保证并发安全,这样不管流量打到哪台机器上,判断依据都是同一个全局视图。
显而易见的问题是Redis成了新的瓶颈。如果每个请求都做一次Redis读写,RT会增加1-2ms,但这在大多数业务场景是可接受的;真正要注意的是对Redis的访问频率上限——假如你的QPS是10万,那么限流器本身就会给Redis带来每秒10万次的操作,这已经不是一个小数字了。所以分布式限流适合应用在入口网关层或者中台接口层,做粗粒度的全局保护,而不是安置在核心链路里每一个细粒度的业务调用上。
4.2 Redis+Lua原子操作实现令牌桶
Redis官方没有直接提供一个令牌桶Registry命令,但我们可以用Lua脚本把“取令牌”的整个流程做成一站式原子操作。设计原则是:令牌桶的容量、速率、当前令牌、上次补充时间都保存在Redis的key里,Lua脚本在Redis服务端执行,进程内读改写一步完成,天然不用担心并发竞态。
下面是一个可以直接使用的Lua脚本,为方便理解我加了一些注释:
local key = KEYS[1] local capacity = tonumber(ARGV[1]) local refillPerSecond = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local requested = tonumber(ARGV[4]) local bucket = redis.call('HMGET', key, 'tokens', 'lastRefillTime') local tokens = tonumber(bucket[1]) local lastRefillTime = tonumber(bucket[2]) if tokens == nil then tokens = capacity lastRefillTime = now end local elapsed = math.max(0, now - lastRefillTime) tokens = math.min(capacity, tokens + elapsed * refillPerSecond) redis.call('HSET', key, 'lastRefillTime', now) if tokens >= requested then tokens = tokens - requested redis.call('HSET', key, 'tokens', tokens) return 1 else redis.call('HSET', key, 'tokens', tokens) return 0 endJava端只需要准备参数,然后调用redis.call执行脚本。注意ARGV里的now必须是调用方传入的当前时间戳,而不是在Lua脚本里用redis.time()去取——虽然redis.time()也能取,但它基于Redis服务器时间,如果服务器和业务容器之间有时钟偏移,限流的边界判断就会产生误差。
这个方案的原子性来自Redis对Lua脚本的“单线程执行”语义。脚本从读取令牌状态到写回更新结果之间不会有其他客户端命令插入,因此不需要额外加锁。从面试角度来讲,这点非常加分:能主动解释为什么Lua脚本能解决并发问题,说明你不是只会抄脚本,而是理解了Redis单线程执行模型。
4.3 工程化约束:时间同步、内存占用、压测标定
在按上述方案落地分布式限流时,有几个工程化问题值得提前考虑。
第一是时间同步问题。分布式限流的正确性依赖于调用方传的now是准确的。容器间的时钟漂移如果超过几百毫秒,限流算法的“上次补充时间”就会出现偏差。建议在网关实例上启用NTP时间同步,同时在压测环境验证一下不同机器各跑10万次请求后,Redis里记录的时间戳是否有异常跳动。
第二是Redis内存占用。每个被限流的接口key只存两个字段,内存开销非常小,但是要注意key的过期策略。如果只是调用HSET而没有设置过期时间,限流key会永久存在Redis里。建议在HSET之后追加EXPIRE key capacity,把key的TTL设为容量对应的最大有效时长,比如容量为500个令牌、补充速率为10个每秒,那么清空整个桶需要50秒,TTL设成60秒就可以保证在非活跃期自动回收。
第三是压测标定。上线的限流阈值必须通过压测验证,而不是按下限流量算出来的“理论值”。我的习惯是先用脚本把QPS拉到预设值的2倍,观察系统的响应时间、错误率、CPU使用率,找到真正打崩资源的那个点,然后反过来校准限流值。这个校准过程建议形成自动化脚本,在每次大促前或发布网关配置变更后重新执行,否则时间一长就容易出现“限流值大于系统容量”的危险配置。
5. 面试官的高频追问与避坑实录
5.1 高频追问:突发流量、冷启动、预支未来令牌
面试官如果会追问,往往会从下面三个角度里挑一个深挖。
问“令牌桶是不是就能防住突发流量”,是在考你对模型边界的理解。答案是不一定,默认的SmoothBursty允许桶里积累最多capacity个令牌,短时间确实能放过去,但如果流量是持续性的高出平均速率,很快桶就会被打空,后续请求全部拒绝。也就是说,令牌桶防的是“短暂突刺”,不是“持续高压”。想要在持续高压下依然平滑放行,就必须配合队列做削峰填谷,或者接受拒绝比例。
问“系统冷启动时会不会被自己的限流器打垮”,是一道经典陷阱题。系统刚启动时,JIT未编译、本地缓存为空、数据库连接池未饱满,此时如果限流器直接放满速流量,服务很容易在“假死”状态下被误判为健康。此时应该使用预热模式,让令牌补充速率从一个较低值逐渐爬升到目标速率。Guava对应的是SmoothWarmingUp,Sentinel对应的是WarmUp规则,核心都是在流量爬坡和系统热身之间找到平衡点。
问“为什么Guava允许透支未来的令牌”,是在考你对acquire语义的理解。Guava的预支设计是为了让调用方按照目标速率均匀地发出请求。比如你每秒只允许10个请求,当前已经用完所有令牌,第11个请求进来时不是立刻被拒,而是阻塞100ms后放行,这样从客户端视角看请求的发出间隔是均匀的。这个语义有一个隐含代价:调用线程必须能够被阻塞。如果业务不能接受保留线程等令牌,就应该使用tryAcquire而不是acquire。
5.2 常见误用与风险案例:我真实踩过的坑
我先说第一个坑:给令牌桶用了定时线程池来持续放令牌。很多初学者实现令牌桶时,会启动一个ScheduledThreadPool,每100ms往桶里放固定数量的令牌。这种实现至少在两个地方有问题,一个是线程资源长期占用,另一个是时间粒度越粗,限流越不平滑,比如每100ms补充一个令牌,那么每100ms只放行一个请求,感受上就是一批批地被放行而不是平滑分布。正确的做法永远是基于时间差懒计算,也就是请求到达时才按流逝时间补充。
第二个坑:容量设得过大,导致限流形同虚设。有一次我把某个内部接口的容量设为了补充速率的10倍,相当于允许10秒的突发积压。结果这个接口平时没有流量,令牌一直满桶,有一天上游任务异常重试,一瞬间把桶里的令牌全部打光,后面持续来的流量全部打到数据库,直接把一个核心库的连接池打满。复盘时发现这个10倍的容量完全没必要——内部接口对延迟容忍度高,完全可以用不漏桶思路限流,或者把容量压回1-2倍补充速率。
第三个坑:只做了入口限流,没有对内部依赖做保护。网关层的令牌桶挡住了大部分外部流量,但微服务之间互相调用的流量仍然是无限的。如果一个服务对下游Redis或数据库有较强的读放大效应——比如一次请求会循环调用几十次缓存——那么入口限流再严格,内部依赖也仍然可能被流量打满。后面我在每个核心DAO接口上也加了独立的RateLimiter,虽然粗糙但确实保住了底层存储。
第四个坑:把用户限流和容量保护混在一起。有些限流的目的是保护系统,有些限流的目的是控制单用户频率,比如每个用户每秒最多提交一次订单。这两种语义不应该用同一个令牌桶。如果混用,少数高并发用户会耗尽公共令牌,导致其他用户被误伤。正确做法是用户维度限流使用独立的key或者本地Guava限流器,容量保护用全局的分布式限流,两者并行、互不干扰。
5.3 一句话速记:面试时怎么组织答案
当你需要在面试现场组织语言时,建议按“定义-模型-参数-对比-场景”五步来回答。先一句话定义令牌桶是“一个以固定速率补充令牌、请求须取出令牌才可放行的限流模型”;再用“桶里有多少令牌、按什么速率补、桶多大”三要素画出模型;接着指出核心公式是availableTokens = min(capacity, availableTokens + elapsed * refillRate);然后对比漏桶和滑动窗口,强调它对突发流量的容忍是核心差异;最后结合你自己的项目,讲清楚容量和速率是如何压测标定的。
在回答的过程中,有意识地去点出“懒计算补充令牌”“支持短暂突发”“预热模式”这几个细节,基本就能和背八股文的候选人拉开差距。面试官真正期待的不是你背出定义,而是你能把自己写过的限流模块、调过的参数、遇到过的流量毛刺讲成一段有因果关系的技术故事。
我在实际项目里做了两年多的限流治理之后,最大的体会是令牌桶算法本身并不复杂,真正的复杂度永远在场景适配和参数调优上。同一个算法,用在网关入口和用在数据库防护层,容量和速率的选取逻辑截然不同;同一个速率,配合请求队列与配合直接拒绝,业务表现天差地别。如果你正在准备面试,不要只满足于记住公式,最好动手把单机版和Redis版都写一遍,再想想自己的业务里哪些流量模型是突发性的、哪些是持续性的,这比刷十道面试题都有用。后面如果你们对“固定窗口计数器在流量毛刺下的失效分析”或者“基于Sentinel的集群流控实践”感兴趣,我也可以继续写一写。