
面试官抛出来的时候我的第一反应是“这题不是送分题吗”但紧接着被追问“令牌桶和漏桶本质区别是什么”“突发流量怎么处理”时才发现自己只记住了半吊子的概念。令牌桶算法几乎是后端限流场景里绕不开的基础设施无论是网关、接口层还是消息消费端你都能看到它的影子。对于做服务端开发的程序员来说这属于躲得过初一躲不过十五的必考题而且面试官往往会从一个简单的定义一路往深处挖挖到参数设计、分布式实现、业务场景取舍一条龙走完。这篇文章不是我临时背的八股文而是我把自己在项目里写限流、调参数、踩坑的经验结合面试时被追问过的所有细节整理出来的深度解析。全文适合这几类人准备面试的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. 分布式限流RedisLua怎么做4.1 为什么单机限流不够用单机版的令牌桶只能保护单台实例。在微服务架构下同一个接口通常会部署在多台机器上每台机器的限流阈值如果是“全局QPS除以实例数”那么部署变更、流量不均匀分配、某台机器重启等问题都会让这个静态分配失效。实例少的机器可能已经打满实例多的机器还有大量余量全局视角仍然处于失控状态。分布式限流的思路是把令牌桶的状态从本地内存搬到集中式存储里最常用的是Redis。所有实例共享同一个令牌桶状态通过Redis的原子性操作保证并发安全这样不管流量打到哪台机器上判断依据都是同一个全局视图。显而易见的问题是Redis成了新的瓶颈。如果每个请求都做一次Redis读写RT会增加1-2ms但这在大多数业务场景是可接受的真正要注意的是对Redis的访问频率上限——假如你的QPS是10万那么限流器本身就会给Redis带来每秒10万次的操作这已经不是一个小数字了。所以分布式限流适合应用在入口网关层或者中台接口层做粗粒度的全局保护而不是安置在核心链路里每一个细粒度的业务调用上。4.2 RedisLua原子操作实现令牌桶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对应的是SmoothWarmingUpSentinel对应的是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的集群流控实践”感兴趣我也可以继续写一写。