☰
令牌桶算法原理、代码实现与分布式限流实战解析
2026/10/9 16:24:41 网站建设 项目流程

面试聊到限流算法,十个面试官里有九个都会问令牌桶,剩下一个问你“那漏桶呢”。这不是面试官偷懒,是因为令牌桶确实是生产环境里用得最多的限流方案,从网关到接口层再到消息消费,到处都有它的影子。但这个题目有个很尴尬的现象:背过答案的人能说出“令牌按速率生成,桶满丢弃”,可真让他手写一个带突发场景的完整实现,或者讲讲为什么Guava的RateLimiter和你自己写的版本表现不一样,就会卡壳。

这篇我把令牌桶算法按面试官的提问逻辑拆开讲:先从原理和数学直觉说起,再给一份能直接讲给面试官听的代码实现,接着把令牌桶和漏桶、滑动窗口的取舍讲透,最后落到真实项目里的分布式限流落地。全程按我这些年面试候选人和被追问的实战经验来写,适合准备跳槽的后端开发,也适合刚接触限流、想知道“为什么是令牌桶”的初学者。

1. 令牌桶算法的核心原理:从“水池模型”到面试官真正想听的答案

1.1 五个要素讲清令牌桶的运转过程

令牌桶本质上是一个带容量的“令牌仓库”。令牌不是请求来的时候才生成的,而是由系统按固定速率持续往桶里放,请求到达后必须先拿一枚令牌才被放行。如果桶里没令牌,请求要么排队等令牌,要么直接拒绝,具体策略由业务决定。

面试官问“令牌桶原理”时,心里其实在期待这五个关键要素:桶的容量(burstLimit)、令牌生成速率(rate)、初始状态(桶是否预满)、获取令牌的行为(阻塞还是非阻塞)、桶满后的处理(丢弃新令牌)。能在回答里自然地提到这五点,就已经拉开和背答案的人的差距了。

  • 桶容量:决定允许的最大突发量。比如容量是100,那瞬间打来的100个请求都可以拿到令牌,第101个开始就要等。
  • 生成速率:稳定状态下每秒往桶里放多少令牌。这是匀速限流的根本来源。
  • 初始状态:最常见的实现是启动时桶就是满的。这样系统刚上线的短时间内能承受一波突发流量,然后才逐渐进入稳态限速。
  • 获取行为:拿到令牌就执行,拿不到就阻塞等待或直接失败。这决定了接口的表现是“排队”还是“熔断”。
  • 桶满策略:令牌生成速率固定,桶满了再生成就直接丢弃,不会让桶无限膨胀。

这里有个很容易被忽视的点:令牌桶对突发流量的容纳能力,本质上是“容量 + 生成速率”共同决定的。瞬时冲进来100个请求,如果能拿到令牌,是因为桶里攒了100个令牌的“积蓄”。这100个令牌用完之后,后续请求只能按生成速率来。所以令牌桶不等于“可以随便突发的算法”,它限的是“长期平均速率 + 有限突发”。

1.2 为什么令牌桶能应对突发流量:从数学直觉理解

我用一个生活场景来解释。想象你有一个漏斗,底部每秒漏出一滴水,顶上你随时可以倒水进去——漏桶的出口速率是恒定的,不管上面怎么倒,下面每秒就是一滴。令牌桶反过来:令牌的水龙头每秒稳定滴入桶里,桶满了水会溢走;但请求来的时候,可以从桶里直接舀一勺水。桶里存的水越多,你一次能舀的就越多。

这个直觉很关键。漏桶算法的流出速率恒等于生成速率,所以不管上游怎么突发,下游看到的都是恒定速率的数据流。令牌桶虽然也按恒定速率生成令牌,但请求消费令牌时却是“随到随取”,只要桶里有存量,来得再猛都能一次性取完。这就是令牌桶“允许突发”的原理:它把匀速生成的令牌攒起来,等你需要爆发的时候一次性释放。

从数学上看,令牌桶实际上做了两层约束。第一层是长期速率约束:任意一个足够长的时间窗口内,通过请求数 ≈ 令牌生成速率 × 时间,不会超出太多。第二层是短期突发约束:任意一个极短的时间窗口内,通过请求数不能超过桶容量。这两层约束叠加,既保证了总体流量可控,又保留了应对峰值的能力。面试官听到你把这个数学直觉讲出来,基本就知道你是真懂而不是背的。

2. 手写一个令牌桶:从“能跑”到“能讲给面试官听”

2.1 单机版实现:同步阻塞与异步非阻塞两种形态

面试里最常让人手写的是单机单进程版本的令牌桶。我先把最常见的实现贴出来,然后逐行解释面试官最在意的地方。

public class TokenBucket { private final long capacity; private final double refillRatePerMs; private double tokens; private long lastRefillTime; public TokenBucket(long capacity, double refillRatePerSecond) { this.capacity = capacity; this.refillRatePerMs = refillRatePerSecond / 1000.0; this.tokens = capacity; this.lastRefillTime = System.currentTimeMillis(); } public synchronized boolean tryAcquire() { refill(); if (tokens >= 1) { tokens -= 1; return true; } return false; } private void refill() { long now = System.currentTimeMillis(); long elapsed = now - lastRefillTime; if (elapsed > 0) { tokens = Math.min(capacity, tokens + elapsed * refillRatePerMs); lastRefillTime = now; } } }

这段代码有一个非常重要的设计:令牌不是用定时任务按周期生成的,而是“惰性填充”。当请求来了,才根据距上次请求的时间差值计算出这段时间产生了多少令牌。这样做的好处是不需要额外的后台线程,也没有定时器开销,性能极高,而且不会出现“系统空闲时还在空转生成令牌”的浪费。

tokens = Math.min(capacity, tokens + elapsed * refillRatePerMs)这行的核心在于Math.min。它保证桶绝不会超过容量上限,换句话说,系统空转一小时再突然来一波流量,桶里最多也只有capacity个令牌,绝不会变成一小时生成的令牌数量总和。这正好对应了“突发上限受桶容量限制”的约束。

但这段代码也有明显瑕疵,synchronized锁力度太大。高并发下所有请求都在抢同一把锁,性能会下滑。早年我看过很多生产项目里的限流组件都是这种写法,并发上来后锁竞争反而成为瓶颈。如果面试官追问性能问题,你可以提出用AtomicLong或 CAS 配合乐观锁来优化,思路是让令牌数量的更新变成原子操作,减少线程阻塞。能说到这一步,说明你确实在线上见过这些问题。

2.2 Guava RateLimiter 的平滑突发实现

Guava 的RateLimiter是面试里绕不开的明星类。很多人在项目里直接用RateLimiter.create(10)就完事了,但对它内部做了什么完全没概念。面试官一旦追问“Guava 和你自己写的区别在哪”,立刻露馅。

RateLimiter.create(10)默认创建的是SmoothBursty实例,字面意思是“平滑突发”。它的核心思路是存储稳定间隔(stableInterval),也就是两个令牌之间的生成间隔,比如速率是每秒10个令牌,那 stableInterval 就是100毫秒。下一个令牌的允许时间是通过计算当前时间和 nextFreeTicketMicros 之间的关系得出来的。

和简单实现相比,Guava 的SmoothBursty有一个显著差异:它允许“预支未来令牌”。当桶里现有令牌不够时,它不会直接拒绝,而是把这次请求所需的令牌记到未来时间上,让请求等待相应的时间后即可通过。这种设计让流量表现为“排队平滑突发”,而不是“硬性拒绝”。举个例子,桶容量是5,一次来了10个请求,前5个立刻通过,后面5个会各自排队等待,但等待时间会被分摊掉,而不是简单粗暴地返回 429。

不过在面试场景下,你不需要背 Guava 源码,你只需要说出三个关键点:第一,它支持预支令牌,所以表现为突发后平滑限流;第二,它使用存储速率和稳定间隔的换算关系,而不是简单的“剩余令牌数”判断;第三,它是进程内单机限流,不适用于分布式多节点场景。这三句话说完,面试官对你这块的认可度立刻就不一样了。

2.3 参数设置的经验公式:桶容量和速率到底怎么定

面试官假装不经意地问“那你生产环境里桶大小和速率怎么设”,其实是对方案落地能力的终极考验。我在实际项目里总结了一个经验框架,供你参考。

  • 速率(rate)的计算依据是后端服务的真实处理能力。如果下游数据库连接池最大能扛每秒1000个查询,那限流速率就不要超过这个值的80%,留出余量应对GC、慢查询等抖动。如果限流速率定得比下游真实能力还高,限流就失去了保护意义。
  • 桶容量(capacity)取决于你能容忍的瞬时突发幅度。假设上游每隔10秒有一次峰值请求,峰值QPS是3000,持续1秒,而稳态速率是500,那桶容量至少要达到2500才能容忍这波峰值不丢弃请求。经验上桶容量通常取速率的1到5倍,具体要看业务对延迟的容忍度。
  • 还有一个很容易踩的坑:限流速率是“每秒令牌数”,但桶容量如果小于速率,那实际上每秒钟生成的令牌都超过了桶能存的量,多余的令牌直接溢出,限流效果会变成一次性突发后立刻断流。这种“锯齿形”的流量曲线对下游非常不友好。

初始状态也值得关注。我见过不少团队把初始令牌数设为0,结果系统启动后所有请求全部被拒,直到桶慢慢攒满,这显然不是期望行为。合理的做法是启动时桶容量直接拉满,让系统刚上线时能正常承接流量,之后自然进入稳态。

3. 令牌桶 vs 漏桶 vs 滑动窗口:面试回答里的对比陷阱

3.1 同样限流,为什么结果差这么多:一张表看懂三大算法

面试官在问完令牌桶原理后,大概率会追加一句“那它和漏桶有什么区别”。如果不准备这个,现场很容易卡壳。我直接用一张表把三个常用限流算法的行为讲清楚:

算法长期速率控制瞬时突发支持公平性实现复杂度典型应用
固定窗口窗口内计数,边界易被击穿窗口内可全量突发边界前后请求不公平低简单计数场景,不推荐高价值业务
滑动窗口细化窗口,控制更精确支持,但受窗口粒度限制相对公平中API网关的单机限流
漏桶恒定流出,强制匀滑不支持突发,突发被排队削峰非常公平中保护下游数据库、消息消费削峰
令牌桶长期速率受生成速率约束支持突发,上限为桶容量突发请求先后差异明显中绝大多数业务接口限流

一眼就能看出,漏桶和令牌桶的差别集中在“突发”上。漏桶即使桶里有大量积压,出口速率仍然是固定的;令牌桶则允许积攒的令牌被一次性大量消费。这个特性差异直接决定了两者使用场景的分界线:下游对流量形态有严格要求的(比如数据库连接池、第三方支付接口),用漏桶;上游突发性强,但下游能扛住短时峰值的,用令牌桶。

还有一个面试官爱挖的坑:固定窗口的“临界突刺”问题。假设固定窗口按分钟计数,限流每分钟1000个,那么12:00:59到12:01:01这一秒内理论上能通过2000个请求。令牌桶和滑动窗口都能避免这个边界问题,但概率上令牌桶更优,因为它本质上是连续时间模型,没有窗口边界概念。

3.2 被低估的漏桶:什么时候它才是更优解

面试时如果能把漏桶“反向安利”出来,绝对会是加分项。令牌桶并不是万能的,它最怕的场景是下游无法容忍任何速率抖动。比如你在调一个外部计费接口,对方明确要求QPS不能超过500,哪怕瞬间600都会触发限流封禁。这时用令牌桶就非常危险,因为令牌桶允许突发500后再继续以长期速率补充,流量容易在突发瞬间越线。漏桶则完美贴合这种场景:所有请求进桶后无论桶内积压多少,消费速率恒定为每秒500个,绝对不会越线。

另一个适合漏桶的场景是消息消费的削峰填谷。生产者往MQ里猛灌消息时,如果消费者按令牌桶限流,头部消息会瞬间被消费,后面消息则会等待令牌,整体消费节奏并不均匀。用漏桶实现消费者限流,消费速率是恒定匀速的,对下游存储系统最友好,避免了一批请求集中命中的“惊群”效应。

所以说,面试官问对比,不是要你背“令牌桶更好”,而是要观察你能否根据场景做技术选型。这两个算法不是竞争关系,而是分别服务于“允许突发但保护总量”和“强制平滑保护下游”两种不同的业务需求。

3.3 滑动窗口为什么在网关层这么流行

通常会有人追问“既然令牌桶这么好,为什么很多网关还用滑动窗口”。这个问题要老实回答:令牌桶难在分布式状态管理,而滑动窗口在单机版本里很容易用 Redis 或本地内存实现,可控性更高,也更直观。

滑动窗口的核心是把时间切分得更细。比如把一分钟分成6个10秒小格,每格维护一个计数,请求到来时统计当前窗口内所有小格的计数总和。窗口边界不固定,所以不存在固定窗口那种边界击穿问题。实现上,它比令牌桶更简单,因为只需要计数器,不需要考虑令牌生成速率和桶容量之间的关系。

不过滑动窗口有个明显局限:它并不能真正平滑流量,即使窗口粒度细化到秒级,秒内依然允许整秒请求爆发。而令牌桶因为令牌生成是连续的,天然对高瞬时流量有抑制作用。所以网关里的滑动窗口往往会配合别的机制一起用,比如再按IP或用户维度做精细配额。生产环境里,没有银弹,组合拳才是常态。

4. 从单机到分布式:Redis+Lua落地令牌桶限流

4.1 为什么单机令牌桶在分布式中行不通:状态同步的噩梦

很多面试官在聊完原理后,一定会问“你这个限流是单机的,那多节点部署怎么办”。这是一个典型的落地检验题。如果你没在分布式系统里处理过限流,很可能第一反应是“每台机器各限各的”,但这样会导致总流量放大N倍,等于限流失效。

假设服务有10个节点,每个节点都用本地RateLimiter限流每秒100个请求,那整体系统每秒能放过的请求其实是1000个,远远超出了预期的保护目标。除非你能保证负载均衡把流量绝对均匀地分到每台机器,否则任何一台机器分配的流量多了,它自己限流就重新分发,整个系统的限流就乱套了。

分布式的本质问题是令牌桶的“状态”现在散落在多个进程里了。桶里的令牌数、上次生成的时间戳,这些数据必须被集中管理和同步,才能保证全局只有一个桶。这不是复杂到不可做,只是需要引入一个统一存储来承担状态维护。

4.2 用Redis缓存和Lua脚本实现原子性

Redis是实现分布式令牌桶最常见的手段。你只需要把桶的状态存到Redis里,用一个Lua脚本来完成“拿令牌”这个动作,就能保证原子性。Lua脚本在Redis中是原子执行的,期间不会插入其他命令,这正好解决了多节点并发更新的竞态问题。

local key = KEYS[1] local capacity = tonumber(ARGV[1]) local refillRate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local requested = tonumber(ARGV[4]) -- 获取当前桶中令牌数,初始为满 local tokens = tonumber(redis.call('get', key) or capacity) local lastRefresh = tonumber(redis.call('get', key .. ':last') or now) -- 计算应补充的令牌数 local elapsed = math.max(0, now - lastRefresh) tokens = math.min(capacity, tokens + elapsed * refillRate) -- 更新状态 redis.call('set', key, tokens) redis.call('set', key .. ':last', now) -- 判断是否放行 if tokens >= requested then redis.call('set', key, tokens - requested) return 1 else return 0 end

调用时只需要把对应的参数传进去,Redis返回1表示允许请求,0表示拒绝。这里有个工程细节:lastRefresh在获取时如果没有值就默认当前时间,相当于第一次初始化时桶是满的,这和单机实现里“启动即满桶”的策略保持了一致。elapsed * refillRate就是惰性补令牌的核心逻辑,和前面单机版本的refill()思路一模一样,只是状态挪到了Redis里。

这个方案的好处是全局严格一致,10个节点看到的都是同一个桶,无论请求从哪台机器进来,全局放行速率都精确可控。代价是多了一次Redis访问的额外延迟。但如果你的限流判断不是每条请求都走,而是用“预取令牌+本地账本”的方式批量化,这个延迟完全可以接受。

4.3 工程化避坑:热点Key和Redis宕机的两难

分布式令牌桶落地时有两个典型的坑,我值得单独拿出来说。第一个是热点Key问题。所有限流请求都集中在同一个Redis key上,QPS一旦高起来,这个key在Redis集群里会集中在一个分片节点上,成为热点。这在极端流量下非常危险,因为即使Redis本身能扛,单分片的CPU也可能被高并发get/set打爆。

解决方案之一是“分片令牌桶”:把一个全局桶拆成多个子桶,每个子桶对应不同的key,流量哈希到对应子桶。用“总令牌数/分片数”作为每个子桶的单独速率,这样每个key的访问量被分散了。缺点是分片之间无法绝对精确共享全局限流额度,但对于大多数业务这种误差是可以接受的。

第二个坑是Redis宕机时怎么降级。如果限流依赖Redis,而Redis挂了,所有请求如果判失败,那整个业务就直接雪崩;如果判成功,限流就失效了。我见过两种做法:一是本地维护一份“最坏情况”的兜底限流,比如Redis不可用时每节点降级到本地令牌桶限制自身速率;二是把Redis故障视为“不限制但告警”,允许流量短暂突破,等Redis恢复后继续严格限流。取舍取决于业务对可用性的要求比准确性更高,还是反之。这个在面试里能谈出来,说明你真的在线上处理过系统故障。

4.4 Sentinel和网关层限流:令牌桶在主流组件的落点

分布式限流方案绕不开主流中间件。阿里开源的Sentinel默认支持按QPS计数或按并发线程数限流,它的匀速排队模式本质上就是令牌桶思路的变形——请求排队等待令牌,排队超时后才会拒绝。Nginx的limit_req模块用的是漏桶策略,强制每个请求之间保持最小间隔,这样在网关层限制了瞬间并发形状。云厂商的API网关一般同时提供“速率限制”和“并发限制”两类配置,前者就是令牌桶思想。

理解这些组件背后的算法映射很重要,因为面试中真正被追问的不是某个组件的配置项,而是“这个组件的限流策略和令牌桶有什么关系”。你能说出Nginx为什么是漏桶、Sentinel匀速排队为什么接近令牌桶,就相当于把题从“背概念”提升到了“懂设计”的层面。

5. 面试现场高频追问与避坑实录

5.1 这8个追问,每一个都藏着一个易错点

当面试官确定你理解了令牌桶原理后,他会开始掀开底层,看看你到底有几斤几两。我把这些年在面试中被追问过、也实际考察过别人的问题整理成了一张速查表,每一个问题背后都有对应的失分点,值得逐条对照。

面试官追问容易踩的坑参考回答思路
桶容量怎么定?能无限大吗?说“越大越好”就废了容量决定突发上限,必须按下游承受能力定,容量无限大等于不限流
令牌生成需要单独线程吗?说“需要定时器”就暴露了不需要,惰性填充即可,请求到来时根据时间差计算出令牌增量
桶满了新令牌怎么办?说“挤掉旧令牌”就错了新令牌直接丢弃,桶内令牌数永远封顶在容量值
系统空闲很久突然来大流量怎么办?说“可以瞬间通过海量请求”就错了最多只能通过桶容量的请求量,不是“空闲积累多少就放多少”
和Semaphore信号量有什么区别?说自己用过的或说不出来信号量限制并发线程数,令牌桶限制速率;两者可以互补
服务重启后桶状态怎么恢复?说“重新攒满”就low了生产上通常初始化为满桶,避免启动后请求被误杀
为什么不用定时任务补令牌?觉得定时器也没问题定时器浪费资源且实时性差,时间差计算更优雅
预支令牌是什么意思?没听说过就慌了Guava的RateLimiter允许未来令牌被预支,表现为排队而非拒绝
能不能用令牌桶做并发控制?说可以就全错了不能,令牌桶限的是速率,并发数还需要信号量

这些问题没有标准答案,核心考查的是你对算法边界的认知。所谓“为什么”比“是什么”重要,在面试里体现得淋漓尽致。

5.2 我在实际项目中踩过的调参坑

纸上谈兵完了,我看还是得聊聊真实的调参过程,因为参数设置的经验直接决定一份限流方案是否能上线。之前维护过一个面向C端的高频接口,稳态QPS大概在2000左右,偶尔会有秒杀场景冲到8000。我最初按“速率 = 稳态值的1.2倍,容量 = 速率的3倍”设置,结果上线后秒杀流量一来,桶立刻被击穿,下游订单服务大量超时。

后来复盘发现问题出在“容量 = 速率3”这个公式上。20001.2=2400的速率,容量7200,看似够用了,但秒杀流量峰值8000瞬间就能把7200个令牌全部吃掉,后续令牌补充速率也只是每秒2400,根本跟不上请求的到达速度。表面上是限流,实际上请求还是在下游积压了大量。

最后我们调整的思路是“保护目标倒推法”:先明确下游能扛的峰值QPS是多少,然后在这个基础上打七折作为限流速率,再把桶容量定为速率的1.5倍。这样秒杀流量过来,前几千个请求可以放过去顶着,一旦令牌耗尽,后续请求立刻被拒,反而保护了下游不被打爆。事后看这个案例,我发现调参的核心不是追求“放更多请求进来”,而是“在下游能承受的范围内尽可能放”,这个思维转变才是关键。

5.3 给面试者的三句话总结

最后分享一点个人体会,不是模板化的技巧,而是我自己在被面试和面试别人时的真实感受。

第一句是“一定要动手写过再上桌”。很多候选人能流畅背出令牌桶的步骤,但让他分析一下为什么Guava能预支令牌、为什么Redis版本要用Lua,立刻语塞。纸上得来终觉浅,你哪怕花一个下午自己实现一遍单机版,在面试中的底气会完全不同。

第二句是“别把限流算法当成独立话题”。画一张你自己服务的架构图,标明哪一层做网关限流、哪一层做本地限流、哪一层做Redis分布式限流,把这套体系讲出来,远比单点知识更打动面试官。限流是系统保护的一个环节,你的目的是证明自己有整体架构意识。

第三句是“答不上来的部分要会诚实开场”。如果面试官问到一个你没接触过的点,比如Sentinel源码,不要硬编答案。可以直接说“我项目中用的方案是XX,Sentinel源码这块我还没深挖,但我理解它的匀速排队应该是基于XX思路,回去我会再补一下源码”。这种真实感和求知欲,比强行圆场要有效得多。

令牌桶这个题目能挖的深度非常足,从单机实现到分布式落地,从参数调优到组件选型,每一步都能筛掉很多人。这篇文章我还会持续更新,后续会把Redis Lua脚本在集群模式下的原子性问题、Sentinel匀速排队源码分析、以及更多大厂真实限流案例补进来。如果在面试或实际项目中你遇到了其他坑,欢迎随时交流,我们一起把这道题吃透。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询