令牌桶算法原理、代码实现与分布式限流实战解析
发布时间:2026/10/9 16:24:45 锦皓数字建站

面试聊到限流算法十个面试官里有九个都会问令牌桶剩下一个问你“那漏桶呢”。这不是面试官偷懒是因为令牌桶确实是生产环境里用得最多的限流方案从网关到接口层再到消息消费到处都有它的影子。但这个题目有个很尴尬的现象背过答案的人能说出“令牌按速率生成桶满丢弃”可真让他手写一个带突发场景的完整实现或者讲讲为什么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. 从单机到分布式RedisLua落地令牌桶限流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.22400的速率容量7200看似够用了但秒杀流量峰值8000瞬间就能把7200个令牌全部吃掉后续令牌补充速率也只是每秒2400根本跟不上请求的到达速度。表面上是限流实际上请求还是在下游积压了大量。最后我们调整的思路是“保护目标倒推法”先明确下游能扛的峰值QPS是多少然后在这个基础上打七折作为限流速率再把桶容量定为速率的1.5倍。这样秒杀流量过来前几千个请求可以放过去顶着一旦令牌耗尽后续请求立刻被拒反而保护了下游不被打爆。事后看这个案例我发现调参的核心不是追求“放更多请求进来”而是“在下游能承受的范围内尽可能放”这个思维转变才是关键。5.3 给面试者的三句话总结最后分享一点个人体会不是模板化的技巧而是我自己在被面试和面试别人时的真实感受。第一句是“一定要动手写过再上桌”。很多候选人能流畅背出令牌桶的步骤但让他分析一下为什么Guava能预支令牌、为什么Redis版本要用Lua立刻语塞。纸上得来终觉浅你哪怕花一个下午自己实现一遍单机版在面试中的底气会完全不同。第二句是“别把限流算法当成独立话题”。画一张你自己服务的架构图标明哪一层做网关限流、哪一层做本地限流、哪一层做Redis分布式限流把这套体系讲出来远比单点知识更打动面试官。限流是系统保护的一个环节你的目的是证明自己有整体架构意识。第三句是“答不上来的部分要会诚实开场”。如果面试官问到一个你没接触过的点比如Sentinel源码不要硬编答案。可以直接说“我项目中用的方案是XXSentinel源码这块我还没深挖但我理解它的匀速排队应该是基于XX思路回去我会再补一下源码”。这种真实感和求知欲比强行圆场要有效得多。令牌桶这个题目能挖的深度非常足从单机实现到分布式落地从参数调优到组件选型每一步都能筛掉很多人。这篇文章我还会持续更新后续会把Redis Lua脚本在集群模式下的原子性问题、Sentinel匀速排队源码分析、以及更多大厂真实限流案例补进来。如果在面试或实际项目中你遇到了其他坑欢迎随时交流我们一起把这道题吃透。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。