
双十一那天晚上我守在监控屏前面看到订单服务的一台机器CPU飘到95%GC日志刷得飞快紧接着另一台机器也跟着抖了一下。我们把流量打到了依赖的上游接口那边直接开始超时然后超时请求堆积在我们的线程池里把整个服务拖垮。事后复盘时负责人问了一句话你们不是配了限流吗那一刻我意识到我写的那个计数器限流代码在突发流量面前基本等于摆设。那件事之后我把限流这件事从随手加个计数器彻底重做了一遍也把四种主流限流算法的行为模型、实现代价、坑位和选型依据梳理成了自己的知识体系。这篇就聊限流的算法适合正在写后端接口、接网关或者负责系统整体稳定性的同学。你能看懂每一行代码也能在下次大促前知道阈值该怎么定。1. 四种经典限流算法从行为模型到适用边界很多人一说限流就只提令牌桶但实际生产中你一定会遇到固定窗口、滑动窗口、漏桶和令牌桶这四兄弟。它们各有各的脾气不理解清楚就会像我当初一样踩完坑还不知道坑在哪。1.1 固定窗口计数器实现最直观但突刺问题最严重固定窗口计数器的思路极其直白把时间切成固定长度的小段比如1秒作为一个窗口记录这个窗口内进来的请求数。窗口内请求数达到阈值后直接拒绝多余请求窗口结束就清零重新计数。这套实现做起来很快一个AtomicLong加一个时间戳就能跑起来。但它的行为模型有个致命问题窗口切换的那一瞬间可能出现两倍阈值的请求通过。假设阈值是每秒100个请求第1秒的最后100ms来了100个请求第2秒的最初100ms又来了100个请求那在200ms内服务实际承受了200个请求。业务方看到的效果就是明明限流设了100怎么一瞬间还是被怼了两倍流量固定窗口适合那些对流量突刺不敏感、或者本身有足够资源冗余的内部系统。我见过有人用它做短信通知接口的限流因为短信这种场景多放行几条也没大碍。但如果你面对的是电商抢购、秒杀这类下游脆弱场景固定窗口就是在赌运气。1.2 滑动窗口计数器把时间切成更细的格子滑动窗口是对固定窗口的改良。它的思路是不用一个粗粒度的大窗口去覆盖整个时间段而是把窗口分成N个更小的子窗口。比如把1秒分成5个200ms的格子每个格子独立计数。判断当前时间是否超限时要统计当前时刻往前推1秒覆盖的所有格子的累计值。这样处理之后窗口边界处的突刺问题被大幅削弱因为新请求进来时窗口是平滑移动的它永远只看最近这1秒内的总量。你可以理解为固定窗口是看整点滑动窗口是看滚动过去的任意连续时间段。实现上一般用环形数组或者队列。数组里每个元素对应一个时间片的计数当前时间片往旧过期就清零。滑动窗口的问题在于内存占用和计算成本比固定窗口高但比它比漏桶和令牌桶要简单得多。很多网关默认用的就是滑动窗口因为它在行为平滑度和实现复杂度之间取得了很好的平衡。1.3 漏桶算法流量整形让出口速率恒定漏桶的核心是把请求装进一个固定容量的桶里桶底按固定速率漏出请求交给下游。如果桶满了新的请求直接丢弃。它的行为模型非常像水龙头往桶里加水而桶底是个恒定流量的孔无论水龙头开多大流出去的水速都一样。漏桶的两个关键参数是桶容量和流出速率。它的最大特点是强制平滑流量完全没有突发能力。哪怕上游瞬间来了1000个请求下游看到的仍然是每秒10个的匀速请求流。这在某些场景是优点在另一些场景是缺点。如果下游是一个对流量抖动特别敏感的旧系统漏桶能保证下游永远只看到平稳的请求节奏。但如果你的下游有并行处理能力能够短时间内处理突发请求那漏桶反而会白白拉长请求排队时间让延迟升高。我个人的经验是漏桶适合做流量整形它更像一个节流阀而不是限流阀。如果你不仅要限制量还要限制速率的平稳度漏桶是首选。1.4 令牌桶算法允许突发兼顾平均速率令牌桶的逻辑和漏桶正好相反。系统以恒定速率往桶里放令牌每个请求进来时必须先取走一个令牌才能通过。如果桶里有令牌请求就被放行桶空了请求被拒绝或等待。桶的容量决定了最大突发量。举个例子桶容量为100每秒往桶里放10个令牌。如果桶一直是满的那么短时间内可以连续放行100个请求然后后续只能以每秒10个的速度放行。这个模型非常贴合业务的真实需求平时积累的令牌可以应对瞬时高峰但从长期看平均速率又会被恒定速率限制住。令牌桶是生产环境中最普及的限流模型原因很简单大多数业务系统都有一定程度的突发请求容忍度同时又不希望突发总量超出资源边界。Java里的Guava RateLimiter、分布式限流的常见实现底层都是令牌桶。它也是本文后面工程化代码的主要讨论对象。2. 令牌桶和漏桶的工程化实现代码逻辑与关键参数讲完模型来看代码。我见过不少网上流传的手写限流示例其实都写得不够严谨要么没有考虑并发安全要么把时间计算写错。这里给出一段我在生产环境里实际用过的令牌桶实现并逐行说清楚为什么这样写。2.1 一个能直接落地的令牌桶实现以Java为例出于演示我用Java写一个简单的令牌桶核心思路是用一个变量保存当前桶内令牌数用一个保存上次补水时间的时间戳每次获取令牌时先按时间差补上令牌再尝试扣减。public class TokenBucket { private final long capacity; // 桶容量即最大突发量 private final double refillRate; // 每秒补充令牌数 private double tokens; // 当前令牌数 private long lastRefillTime; // 上次补充令牌的时间戳ms public TokenBucket(long capacity, double refillRate) { this.capacity capacity; this.refillRate refillRate; this.tokens capacity; this.lastRefillTime System.currentTimeMillis(); } public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); // 计算这段时间应该补多少令牌 double addTokens (now - lastRefillTime) / 1000.0 * refillRate; tokens Math.min(capacity, tokens addTokens); lastRefillTime now; if (tokens 1) { tokens - 1; return true; } return false; } }这段代码的关键点有三个。第一个是synchronized令牌桶的判空和扣减必须是一个原子操作否则并发请求下会出现超卖。第二个是补充令牌时要用当前时间与上次补充时间的差值来计算而不是固定用某个定时任务去补这样能保证流量在两次调用之间也能自然积累。第三个是Math.min把令牌数限制在容量内避免桶里的令牌无限膨胀。这个写法的问题在于synchronized锁的是整个实例在高并发下可能会成为热点。工程上可以做分段锁、CAS自旋或者直接用AtomicLong加上下面要说的Redis Lua方案。不过对于单机一万QPS以下的场景简单锁的性能还是够用的不必过度优化。2.2 漏桶的实现要点队列不是唯一选择漏桶最常见的实现是拿一个队列当桶请求进来时如果队列长度小于桶容量就入队后台线程按固定速率从队头取请求转发。这个模型直观但它的问题也很明显排队等待会放大延迟。现实中我建议你区分两个概念漏桶的匀速流出不一定要靠队列等待来实现。有些场景下我们可以用预留令牌的思路请求到来时计算它应该被放行的理想时间如果这个时间与当前时间之差小于容忍阈值就放行否则拒绝。这本质上是一种时间戳漏桶。不过更常见的做法还是直接用队列。比如你有一个推送系统上游群发消息下游只支持每秒500条那就把消息放进一个容量5000的队列一个线程池以每秒500条的速率消费。这里队列的容量就是桶容量消费线程就是漏孔。这个方案的缺点是入队和出队都要加锁吞吐量不高所以它更多用在消息处理、任务调度这类场景而不是纯API网关。2.3 为什么很多生产环境选了令牌桶而非漏桶我在给团队做技术选型时核心考量标准就一句话业务到底允不允许瞬时突发漏桶把突发全抹平了对下游最友好但抹平意味着请求要排队等待等待会让用户感觉到延迟。令牌桶允许最多等于桶容量的突发请求立即通过对于大多数Web接口来说这更贴近用户的真实行为。举个例子首页有一个活动接口平时每秒请求量是200活动开始后的第一秒冲到了2000。如果使用漏桶速率200桶容量200那第一秒的2000个请求中只有200个能立刻被处理剩下1800个要么排队要么被丢弃。如果使用令牌桶速率200桶容量2000那么之前积累的令牌可以同时放行2000个请求虽然之后会回到每秒200的速率但至少第一秒的请求不会出现大面积超时。具体业务具体分析但现代服务都倾向于允许短时冲高的风格。这个选择还会影响一个东西超时和重试。漏桶导致请求排队排队超过客户端超时后客户端会重试重试会重新进入漏桶反而加重了排队。令牌桶由于突发放行重试的概率天然更低。这也是很多网关默认用令牌桶的原因之一。3. 从单机到分布式限流在集群环境下的正确姿势单机限流只能在每台机器内部独立统计。如果你的服务有10个实例每台机器限流100 QPS那整个服务在负载均衡下最多能扛接近1000 QPS。这听起来没问题但存在两个致命缺陷。3.1 单机限流的局限性为什么一台机器扛不住整个集群第一个缺陷是负载不均衡。有些请求的key哈希到固定实例或者某个实例因为GC、网络抖动导致性能下降流量倾斜到其他实例时单机限流会让整体容量估算失效。你明明给每台机器设了100 QPS但某台机器实际被分到了300流量它自己限不住这300因为另外几台机器可能只分到了30。第二个缺陷是整体保护目标不明确。假设你的下游数据库只能承受500 QPS10台机器每台限100看上去总和是1000远超数据库的承受能力。限流参数本质上应该基于下游容量去设置而下游容量在集群视角下是一个总数必须在入口处做全局控制。因此只要服务节点数大于等于两个我就强烈建议把限流做在网关或一层独立的公共组件上而不是完全依赖单机限流。前面Java版本的令牌桶在分布式场景下是不够用的。3.2 基于Redis Lua 的分布式限流原子性是核心分布式限流最常见的方案是使用Redis配合Lua脚本保证读取-计算-写回的原子性。Lua脚本执行期间不会被其他命令打断这是它比普通get加set更安全的关键。以令牌桶为例写一个Lua脚本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 redis.call(get, key .. :tokens) if not tokens then tokens capacity else tokens tonumber(tokens) end local lastRefill tonumber(redis.call(get, key .. :ts) or now) local addTokens (now - lastRefill) / 1000.0 * refillRate tokens math.min(capacity, tokens addTokens) redis.call(set, key .. :ts, now) if tokens requested then redis.call(set, key .. :tokens, tokens - requested) return 1 else redis.call(set, key .. :tokens, tokens) return 0 end调用时每次请求都执行这个脚本返回1表示放行返回0表示拒绝。这个方案的优点是可以把桶状态集中放在Redis里所有节点共享同一个计数从而实现了全局限流。但这里有一个很现实的注意点Redis本身也会遇到单点瓶颈。如果每次请求都访问Redis做一次限流判断那限流模块自身的QPS就会成为新的瓶颈。解决方式通常是分层限流在网关层用本地限流做快速失败在Redis层做全局粗粒度控制。同时要为Redis加上降级逻辑当Redis不可用时至少保证本地的兜底限流还能工作。3.3 降级与容错限流器本身挂了的处理策略限流器是一个拦路虎它会影响所有业务的可用性。如果限流器挂了最安全的选择是放行还是拒绝我见过团队的做法是分场景处理。如果是核心链路宁可让流量全部放行也不愿意因为限流组件故障导致所有请求全挂。这种情况下Redis超时时间设置得很短比如100ms超时直接放行同时打一条告警日志。如果是风控、支付这种强保护链路可以反过来做成超时即拒绝宁可损失一部分用户也不冒险。另外本地限流的单机兜底是必须的。不管外部限流组件多成熟每次请求都依赖网络调用本身就是引入额外风险。我的习惯是先在本地用令牌桶做一个粗筛比如本地允许1000 QPS再向Redis请求精确的全局配额。两次判断都通过才放行。本地粗筛实际上就是有限度的自我保护防止某个节点失控时把Redis打爆。4. 限流参数调优从拍脑袋到有据可依限流设为100还是1000这是每次评审都会吵起来的问题。拍脑袋设一个值上线后容易出现两种情况要么限得太狠正常用户被弹窗要么限得太松下游还是被打挂。我总结了三个维度的推导方法不敢说精准但比拍脑袋要靠谱得多。4.1 把业务容量拆成QPS、并发和超时时间限流参数的最终来源是下游系统的真实容量。以数据库为例你能查到数据库最大连接数是100平均每条SQL执行时间20ms那单实例每秒最多处理50个请求100 / (1000/20)。如果服务有10个实例每个实例到数据库的连接池也是100那总体容量就不是简单相加还要看数据库所在主机能提供的连接数上限。我建议做一次压测在压测报告里提取三个数据最大QPS、稳定状态的平均RT、可接受的99分位RT。然后把限流阈值设定在最大QPS的80%左右留出20%缓冲。注意限流阈值代表的是入口请求数不是成功请求数如果存在大量访问缓存、静态资源等不需要经过下游的请求要在计算时打折扣。还要考虑并发数和QPS的关系。在Java里线程池大小决定了最大并发处理数QPS 并发数 / 平均响应时间。如果你的服务支持200并发平均RT为50ms那么最大吞吐就是4000 QPS。但RT会随负载增加而升高所以稳妥的做法是让限流阈值低一些别卡在理论上限附近。4.2 预热、冷启动与热门资源的差异化配置令牌桶的初始状态通常是满桶这意味着服务刚启动时拥有最大突发能力。但实际场景中下游服务往往需要一段时间进行JIT编译、缓存预热和连接池初始化。如果一开始就放行满桶流量很容易把正在降温的下游瞬间打满。所以生产环境中常常要做冷启动限流让限流阈值从很低的值逐步爬升比如一开始只有最终阈值的10%在30秒内线性增加到100%。Guava的RateLimiter.create(capacity, warmupPeriod, TimeUnit.SECONDS)就是干这个用的。它的实现方式是一种不对称的令牌生成速率冷启动阶段生产令牌速率慢之后逐渐加速到设定值。热门资源比如某个爆款商品详情页和冷门资源比如用户头像接口决不能用同一个阈值。我习惯在网关层面按URI前缀拆分组给每个分组单独配置限流。否则热门接口争抢所有配额冷门接口也可能被拖累。4.3 限流阈值设置过小的代价那些被误杀的请求限流的本质是代价选择你选择牺牲一部分请求保护系统的主体可用性。但阈值设置过低会带来隐性代价。第一个代价是用户对系统超时的感知。限流通常直接返回错误或者快速失败这对用户来说意味着这页面打不开了。一次促销活动如果因为限流设太低导致大量用户看到系统繁忙那一整年的转化KPI可能就没了。第二个代价是重试风暴。移动端App在接口失败后会自动重试如果限流把请求拦住了客户端不仅会重试还会在短时间内并发重试几次。这些重试请求会再一次被限流拦住但拦截本身也消耗了限流组件和网关的资源。最坏的情况下重试流量占到了总流量的90%以上反而把系统的CPU打满。我在调限流时有个习惯先按计算值的50%设置观察QPS和RT曲线如果没有明显降速再逐步上调。同时开启一个限流事件日志记录哪些调用方被限、他们的重试次数、平均等待时间。数据拿到手调参就不再需要猜了。5. 踩坑复盘与选型对照不同场景下我用过的组合前面讲了不少理论最后说几段实操复盘。这些场景既有我自己写过的也有帮别人救过火的都很典型。5.1 一次误限流事故滑动窗口粒度引发的血案有次我给一个下单接口配了滑动窗口限流窗口时间设为1秒子窗口数量设为5即每个子窗口200ms阈值是每秒500。表面上看逻辑没毛病上线后却发现某个整点时刻大量请求被拦截但监控显示流量只有200 QPS远低于阈值。排查后发现问题出在子窗口的边界对齐上。我按系统时间每200ms划一个新子窗口但开启限流的时间可能在10:00:050子窗口边界是固定的秒内等分点10:00:000-199、10:00:200-399、10:00:400-599……假设某个子窗口正好积累了300个请求而计算最近1秒时我只统计了当前子窗口向前推的5个子窗口如果当前时间位于子窗口的中部那么统计窗口起始点并不是精确的当前时间减1秒而是落在了某个子窗口边界导致某些请求被算在了窗口外。最后造成的效果是实际窗口长度偶尔小于1秒可容纳的请求数被压缩。这个坑的根因是窗口滑动的粒度不足。正确的滑动窗口应该以当前时间为终点向前推完整的一个窗口时间而不是简单地把N个固定子窗口相加。后来我改用时间戳记录每个请求的准确时间再通过排序或二叉树统计窗口内的实际请求数才彻底解决了边界问题。如果你的限流组件不支持精确滑动窗口至少把子窗口粒度调得更细比如每个50ms误差会小很多。5.2 高并发秒杀场景的限流选型秒杀场景的特点是瞬时流量极高然后迅速下跌。对这类场景我通常选择多层级联限流而不是单用某一种算法。第一层在接入层Nginx使用令牌桶配置一个较高的突发容量比如每秒放行5000桶容量直接10000用来挡住最粗粒度的恶意冲击。第二层在业务网关使用Redis分布式限流按SKU或者用户维度设置配额防止同一商品被少数用户刷爆。第三层在应用代码内部做单机并发控制限制了线程池的并发数上限防止瞬时排队把系统资源耗尽。秒杀这里有一个很另类的点很多团队反而会刻意地限得更狠。因为真正能抢到商品的用户比例本来就低与其让所有请求都穿透到业务层不如在网关直接随机拒绝大部分请求用概率性放行来降低系统压力。这种限流算法的选型已经不是保护下游而是主动过滤流量。如果你想尝试这种方案建议用纯随机的拒绝策略配合令牌桶而不是让所有请求都排队。5.3 微服务网关层的限流实践与统一配置最后讲讲网关层的限流。微服务架构下网关是天然的统一入口每个服务不应自己再单独去对接Redis做全局限流否则限流逻辑散落在各处后期维护会很痛苦。我在团队里的做法是网关统一做分布式限流、每个实例做本地限流兜底业务代码只负责业务逻辑。网关的限流配置尽量做成动态的因为阈值调整太频繁了。可以用配置中心比如Nacos下发每个限流规则对应一个JSON片段里面包含算法类型、窗口大小、容量、速率等参数。网关定期监听配置变更热加载进去不需要重启进程。注意热加载时一定要处理加载过程中请求被漏掉的窗口期通常的做法是配置变更后先让新的限流器在后台构建构建完成后再原子地替换引用。这份实践让我总结出一个简单的选型表格你去除了很多判断成本场景推荐算法原因内部API下游稳定固定窗口计数器实现简单成本低Web接口需要容忍短时突发令牌桶允许突发平均速率可控对下游抖动极其敏感如消息推送漏桶强制平滑速率全局精确控制多实例部署Redis Lua 令牌桶共享状态原子操作网关层统一限流滑动窗口或令牌桶平衡平滑度和实现复杂度限流算法没有银弹每种都在放多少和等多久之间做取舍。我现在的习惯是遇到新系统先看下游容量和业务容忍度再选择最合适的那一个。千万不要因为某个框架默认支持令牌桶就机械套用你的业务可能更适合滑动窗口也可能更适合干脆不加限流只用熔断。代码写多了你会发现稳定性的本质不是堆一堆组件而是抽丝剥茧地把每个环节的量化边界都算清楚。每次大促前我总是会再看一遍那四个算法的行为曲线尤其是边界处到底放了多少流量确认无误后才敢睡个安稳觉。