放行、排队与拒绝:限流设计的算法选型、数据结构与并发代价

目录

一、先把"限流"这个问题问清楚

(一)过载为什么会自我强化

1、容量不是一条水平线

2、正反馈环:越忙越慢,越慢越忙

3、四种过载来源与它们各自的处置

(二)限流与熔断、降级、隔离、排队的分工

1、五种手段各站在哪个位置

2、三条最容易混淆的边界

(三)动手之前必须回答的五个问题

1、限什么、按什么维度分、限多少

2、超了怎么办、谁能改

3、顺序颠倒的三种后果

二、四类基础算法:它们各自把什么信息丢掉了

(一)固定窗口计数

1、结构与判定

2、临界突刺与三种修法

(二)滑动窗口的三种落地

1、滑动日志

2、环形数组分桶

3、加权外推

(三)漏桶:两种被混为一谈的形态

1、排队式漏桶

2、速率判定式漏桶

3、它抹平了什么,又牺牲了什么

(四)令牌桶:用桶容量买突发

1、惰性补充:为什么不需要定时器

[2、桶容量 b 的含义与定法](#2、桶容量 b 的含义与定法)

3、预支与冷启动预热两个变体

(五)同一段流量,四条不同的放行曲线

三、漏桶与令牌桶的数学本质

(一)对偶关系:水位与余额互为镜像

1、两条曲线的镜像

2、参数上的相互翻译

(二)把限流压缩成一个时间戳

1、理论到达时间与三种情形

2、为什么它天生适合原子执行

(三)差异的真正落点

1、突发容忍度

2、是否引入排队延迟

3、状态的物理含义与监控口径

四、数据结构:限流器里到底存了什么

(一)五种结构的字段布局

1、从两个字段到一个数组

2、能否被一次原子操作改完

(二)把状态压进一个长整数

1、位宽切分与定点表示

2、比较交换循环与它的边界

(三)分布式场景下的存储结构

1、三种远端结构的取舍

2、键空间:多维度限流最先撞上的墙

[2.1 维度必须封闭可枚举](#2.1 维度必须封闭可枚举)

[2.2 每个键都要带过期时间](#2.2 每个键都要带过期时间)

[2.3 超预算的维度用近似统计替代](#2.3 超预算的维度用近似统计替代)

3、热点键与分片

五、并发下的实现:锁、比较交换与队列

(一)三种同步方案

1、互斥锁

2、比较交换

3、分段累加

(二)并发下队列到底有没有性能问题

1、第一层:吞吐上的锁竞争

2、第二层:延迟上的排队时间

3、第三层:内存上的堆积

4、三种队列结构与伪共享

(三)不排队的排队

1、把等待算出来

2、信号量与并发数控制

3、有界等待的两个上界

六、分布式限流:精度、开销与可用性的三角

(一)三种落地形态

1、单机限流加配额分摊

2、集中判定

3、本地缓冲加批量领取

(二)限流器自身的可用性

1、失败放行还是失败拒绝

2、超时、兜底与降级判定的观测

七、工程落地:一个项目里限流到底怎么做

(一)分层布防:从接入层到资源层

1、五道闸各自看得见什么

2、分层不是叠加保险

(二)规则模型与配置下发

1、一条规则至少要有哪些字段

2、下发链路、热更新与本地兜底

(三)超限之后返回什么

1、拒绝:状态码、重试提示与额度回传

[1.1 状态码要专用](#1.1 状态码要专用)

[1.2 带上重试提示](#1.2 带上重试提示)

[1.3 回传剩余额度](#1.3 回传剩余额度)

2、排队与降级

3、优先级卸载

(四)自适应限流:不再手工配阈值

1、基于延迟的闭环控制

2、基于排队论的并发上限估计

3、基于系统负载的兜底

八、可观测性与参数标定

(一)必须暴露的四组指标

1、通过与拒绝

2、等待与排队

3、规则与限流器自身

(二)阈值的三条来路

1、压测拐点反推

2、容量模型正推

3、历史分位兜底

(三)参数之间的硬约束与一条排查路径

九、常见陷阱与演练

(一)四层反模式

1、算法与实现层

2、配置与阈值层

3、分布式层与运营层

(二)怎么验证限流真的有效

1、四种注入形态

2、量化验收标准

十、把这道题讲清楚:作答范式

(一)三种时间长度下的骨架

1、三分钟版

2、十分钟版

3、四十分钟版

(二)六处最容易被追问穿的地方

(三)一句话收尾

参考资料


干货分享,感谢您的阅读!

限流是一道看起来人人会答、真正答满却极少的设计题。

多数人能在半分钟内说出"漏桶和令牌桶",画两个桶,再补一句"令牌桶允许突发",然后就讲不下去了------而决定这套机制在生产环境是护身符还是定时炸弹的,恰恰是这两个桶之外的东西:限的是速率还是并发、按哪个维度切键、阈值凭什么定成这个数、超限之后返回什么、状态存成什么结构、这个结构能不能被一次原子操作改完、跨节点共享额度要付多少延迟、以及那个几乎必被追问的问题------高并发下引入队列到底有没有性能问题。

本文把这道题从两个桶展开成一套可落地的工程方法:先说清限流要挡住什么、它与熔断降级隔离的边界在哪,再逐层推到四类算法的结构与它们各自丢掉的信息、漏桶与令牌桶的对偶关系、五种数据结构的字段布局与并发更新方式、锁与比较交换与队列的真实代价、分布式共享额度的三角权衡、分层布防与规则模型、指标与阈值标定,最后给出三种时间长度下的作答骨架与十六个反模式。核心判断是:限流的价值不在于"能拒绝",而在于它在正确的层次、以正确的粒度、按可推导的阈值拒绝正确的那部分请求,并且在拒错的时候能被人立刻看见、立刻关掉。

一、先把"限流"这个问题问清楚

(一)过载为什么会自我强化

1、容量不是一条水平线

谈算法之前,必须先谈清楚限流要挡住的是什么。工程直觉里,系统容量像一个水桶的容积:压力小于容量时一切正常,超过容量时多出来的部分被丢掉,仅此而已。如果现实真是这样,限流其实可有可无------反正超出的请求自己会失败。

真实系统不是这样。把并发压力作为横轴、吞吐作为纵轴画一条曲线,会看到三个明显不同的区间。压力很小时,吞吐随压力线性上升,这是线性区;压力接近某个值时,吞吐增长变缓并趋于平坦,这是拐点区;压力继续上升,吞吐不但不再增长,反而开始下跌,这是崩塌区。同一张图上再画一条响应时间曲线,会看到它在拐点之前几乎是平的,在拐点之后近乎垂直地拉起来。

崩塌区的存在,是限流一切设计的起点。它意味着"多压一点无非是多失败一点"这个直觉是错的:多压一点,会让本来能成功的请求也一起失败。系统在过载时的实际有效吞吐,可能只有拐点处的三分之一甚至更低。限流要做的,就是把系统钉在拐点左侧------主动放弃一部分请求,换取剩下全部请求的确定性。

2、正反馈环:越忙越慢,越慢越忙

吞吐为什么会跌,而不是简单地封顶?因为过载期系统里存在一个转个不停的正反馈环。

请求到达速率超过处理速率,队列开始堆积;队列堆积让排队延迟上升,端到端响应时间随之变长;响应时间超过调用方设定的超时时间,调用方判定失败并发起重试;重试意味着同一个逻辑请求变成了两次、三次物理请求,有效负载翻倍;负载翻倍进一步加剧堆积------环闭合了。

这个环有几个特点值得注意。第一,它不需要外部持续加压就能自我维持:即使原始流量已经回落到正常水平,环内的重试流量也足以让系统留在崩塌区,这就是所谓"打死了就起不来"。第二,环上每一个环节看起来都很合理:队列是为了削峰,超时是为了不无限等待,重试是为了提高成功率------三个正确的设计叠在一起,产生了一个错误的系统行为。第三,环里堆积的请求中,有相当比例调用方早已放弃,服务端仍在为它们消耗资源,这部分工作量在监控上表现为"负载很高但吞吐很低",是最难解释的一类现象。

限流的作用,就是在这个环上剪一刀:把超出容量的到达量挡在环外,不让它有机会进入队列。

3、四种过载来源与它们各自的处置

需要挡住的流量并不是同一种东西,把它们分开,后面的算法选型才有依据。

第一种是突发流量:秒杀开售、消息推送触达、大量定时任务在整点同时醒来。它的特征是幅度大、持续短、通常可预期。对这类流量,正确处置是允许有限的突发通过(令牌桶的桶容量就是为它准备的),并用排队做一定程度的削峰,而不是一刀切地拒绝------它本来就是业务想要的流量。

第二种是持续超容:业务增长或运营活动带来的稳态超载。幅度中等、持续时间长、不会自愈。对这类流量,限流只能兜底,真正的解法是扩容与容量规划;同时应当引入优先级卸载,让核心链路优先获得额度。

第三种是异常放大:爬虫、刷单、失控的重试、循环调用。特征是极不均匀地集中在少数主体上。对这类流量,全局限流几乎无效------它的量可能只占总量的百分之几,却能把某个下游打穿;正确处置是按调用方、用户、来源等维度分别设闸。

第四种是能力退化:流量没变,但某个下游变慢,导致等效容量下降。到达量正常,通过量却撑不住。对这类流量,速率限流是失明的------每秒一千次仍然是每秒一千次;需要的是并发数限流与自适应限流,让闸门跟着实际处理能力走。

(二)限流与熔断、降级、隔离、排队的分工

1、五种手段各站在哪个位置

治理手段之间的混淆,是这道题最常见的失分点。把边界画清楚,后面的设计才不会互相打架。

限流站在被调方或入口的视角,依据是"我当前的到达速率或在途并发与配置上限的比较",动作是拒绝、排队或降级处理超出上限的那部分请求,保护的是自己与自己的下游。

熔断站在调用方的视角,依据是"最近一段时间内下游的失败率与慢调用率",动作是一段时间内不再发起调用、直接在本地快速失败,保护的是调用方自身的资源。它与限流方向相反:限流拦住进来的请求,熔断拦住出去的调用。

降级站在业务视角,回答的是"这个能力不可用时,产品体验上如何退让"。它可以由限流触发,也可以由熔断、人工开关或容量预案触发。限流是"要不要受理这次请求"的技术判断,降级是"不受理之后给用户看什么"的业务判断。

隔离站在资源视角,把不同链路的线程、连接、内存配额分开,确保一处故障不会耗尽全局资源。它是静态划分,不做实时判断;它限定爆炸半径,限流缩短爆炸时间。

排队站在调度视角,依据是队列长度与等待时长是否越过上界,动作是让请求有界等待、超时后再转入拒绝或降级。它保护的是瞬时波动下的成功率。

2、三条最容易混淆的边界

第一条是限流与熔断。两者方向相反、依据不同,经常被同时需要,但不能相互替代:限流无法阻止上游持续发起注定失败的调用,熔断也无法保证被调方不被打垮。一个只做限流不做熔断的服务,会在下游故障时把自己的线程池耗尽;一个只做熔断不做限流的服务,会在流量洪峰面前毫无抵抗。

第二条是限流与排队。排队是限流的一种超限动作,不是另外一种算法。把请求放进队列,等价于把"立即拒绝"换成"延迟拒绝",而延迟本身要消耗调用方的资源。无界队列尤其危险:它把过载搬进了内存,最终以内存溢出这种更难处置的形式暴露出来。

第三条是限流与扩容。限流只解决安全问题,不解决容量问题。长期依赖限流硬扛,等于把容量缺口转嫁给用户。正确的姿势是:限流作为兜底,容量规划与弹性伸缩解决根因;如果一个接口的拒绝率长期非零,那说明该扩容了,而不是该把阈值调高。

(三)动手之前必须回答的五个问题

1、限什么、按什么维度分、限多少

第一个问题是限什么。它包含两层:保护的是哪种资源(处理器、线程、连接、内存,还是下游的合同配额),以及限的是速率还是并发。这两者不是一回事:速率限流控制的是单位时间内的到达量,适合保护那些"处理时间大致恒定"的资源;并发限流控制的是同时在途的请求数,适合保护那些"处理时间会变化"的资源。当下游变慢时,速率限流完全失明,并发限流会自动收紧------因为在途请求归还得更慢了。一个成熟的接口往往两者都需要。

第二个问题是按什么维度分。全局一个闸、每个接口一个闸、每个调用方一个闸、每个用户一个闸,还是细到某个业务参数?维度的选择直接决定了键空间的量级,而键空间决定了数据结构与内存预算。这里必须提前想清楚三件事:维度取值从哪里来、能不能被伪造、未能识别出维度取值的请求落到哪个默认桶里。最后一条尤其容易漏:缺省桶如果不设,攻击者只要把标识去掉就能绕过限流。

第三个问题是限多少。阈值是压测拐点反推的,是容量模型正推的,还是历史分位兜底的?留了多少安全余量?允许多大突发?多副本部署时,集群阈值如何折算成单机阈值?这些问题的答案合起来,才叫"这个数字有推导过程"。

2、超了怎么办、谁能改

第四个问题是超限之后做什么。直接拒绝、有界排队、降级返回,还是按优先级随机丢弃?拒绝时返回什么状态码、带不带重试建议、要不要回传剩余额度?不同优先级的请求是否区别对待?这些看起来是细节,实际决定了两件大事:用户的体感,以及被拒请求会不会立刻重试打回来形成第二波冲击。

第五个问题是谁能改。规则存在哪里、改动是否需要发布、下发延迟多久、下发失败时本地用什么兜底、谁有权限改、改动是否留痕可回滚、误配一个零会不会瞬间打挂全站。限流是少数几个"配置错误的破坏力远大于代码错误"的组件:一行阈值从一万改成一,效果和拔掉网线没有区别。因此规则的变更链路必须像代码一样被对待,甚至更严格。

3、顺序颠倒的三种后果

这五个问题有顺序,颠倒会付出代价。

先定阈值再想限什么,典型形态是拍一个整数写进配置,事后再也说不清它代表什么。后果是阈值与真实瓶颈无关,压测结论一变就得重配,事故现场没人敢动它------因为没人知道动了会发生什么。

先选算法再想维度,典型形态是先决定用令牌桶,编码到一半才发现要按用户维度限,键空间一下子涨到百万级。后果是内存与过期策略推倒重来,或者被迫退回粗粒度限流,而粗粒度限流恰恰拦不住真正的异常流量。

先做拦截再想超限动作,典型形态是拦截逻辑先上线,超限只抛一个通用异常给前端。后果是用户看到裸错误页,客户端按默认策略立刻重试,形成紧随其后的第二波冲击------限流器本身成了放大器。

二、四类基础算法:它们各自把什么信息丢掉了

(一)固定窗口计数

1、结构与判定

固定窗口是最简单的限流算法:把时间切成等长的窗口,每个窗口内维护一个计数器,请求到达时计数加一并与上限比较,跨窗口时计数清零。它的全部状态是两个字段------当前窗口编号与当前计数。

判定逻辑同样简单:算出当前时刻属于哪个窗口,与保存的窗口编号比较;编号相同则计数加一,编号不同则先把计数重置为一。它的空间复杂度是常数,判定复杂度也是常数,在共享存储上可以用一次自增加一次过期设置实现,是所有算法里成本最低的一个。

正因为便宜,它被用得极广。但它的便宜是有代价的:它丢掉了窗口内部的时间分布。计数器只知道"这个窗口里来了多少个",不知道"它们是均匀来的还是挤在最后一毫秒来的"。

2、临界突刺与三种修法

丢掉分布信息的直接后果,是窗口交界处的突刺。

设限额为每秒一百次,窗口按整秒划分。如果一百个请求集中在第零点九秒到第一秒之间到达,它们全部落在第一个窗口内,计数达到一百,全部放行;紧接着时钟跨过整秒,计数清零,第一秒到第一点一秒之间又来一百个请求,同样全部放行。于是在跨越边界的这两百毫秒里,实际通过了两百次------是配置值的两倍,而计数器全程没有超限。

更一般地说:对固定窗口,任取一个长度等于窗口的时间段,实际通过量最高可达配置值的两倍。这意味着如果阈值是贴着容量配的,固定窗口随时可能把下游打穿。

三种修法各有代价。第一种是滑动日志:把窗口内每次请求的时间戳都记下来,判定时统计"当前时刻往前一个窗口长"内的条数。精度最高,代价是内存与清理成本随流量线性增长,适合低频高价值的接口,比如短信下发与登录验证。第二种是分桶累加:把一秒切成十个一百毫秒的小桶,环形复用,判定时对最近十个桶求和,误差被压到一个桶宽以内。内存固定、实现简单,是工程上最常用的折中。第三种是加权外推:只保留上一窗口与当前窗口两个计数,按当前时刻在窗口中的位置对上一窗口做线性折算。内存是常数,误差在流量平稳时很小,但它依赖"上一窗口内请求均匀分布"这个假设,流量剧烈抖动时会低估或高估。

(二)滑动窗口的三种落地

1、滑动日志

滑动日志把每次通过的请求都记成一条带时间戳的记录,判定时先删除窗口之外的记录,再统计剩余条数。它是唯一没有近似误差的实现:任意时刻的窗口计数都是精确的。

它的代价直接写在结构里:每个限流键的状态大小与窗口内的请求数成正比。一个每秒一千次的接口,一秒窗口就意味着一千条记录常驻;如果按用户维度切键,总内存等于活跃用户数乘以人均请求数。在共享存储上,它通常用有序集合实现,一次判定要做删除、统计基数、插入三个操作,往返成本和存储压力都不小。

因此滑动日志的适用面很窄,但很清晰:低频、高价值、必须精确的接口。短信与验证码的每日上限、实名认证的调用次数、支付下单的频次控制,都属于这一类。这些场景的共同点是------多放一次的代价,远大于多存一条记录的代价;而且它留下的记录顺带就是一份审计明细。

2、环形数组分桶

分桶是把窗口切成固定数量的小格,每格维护一个计数,用环形数组循环复用。判定时遍历有效的格子求和,写入时只改当前格。

实现上有四个细节容易出错。第一,每个格子要带一个"归属时刻",用来判断它是当前一代还是上一代的残留;否则环形复用会把很久以前的计数当成刚刚发生的。第二,跨格时必须先清零再累加,且这两步要一起完成,不能被其他线程看到中间态。第三,求和与写入之间格子可能翻转,读到跨代数据,因此求和时要对每格做时效判定,而不是无条件相加。第四,格子数与窗口长度共同决定了精度:格子越细,最大误差越小,但遍历与写入成本同步上升。

内存上,环形分桶有一个很好的性质:它与流量无关,只与格子数有关。一万个限流键、每键十二个格子、每格八字节,总共约一兆字节------这个量级对任何服务都不构成压力。这也是它成为单机高频限流首选的原因。

3、加权外推

加权外推只保留两个计数:上一个窗口的总数和当前窗口的累计数。判定时按当前时刻在窗口中已经过去的比例,对上一窗口的计数做线性折算,再与当前窗口计数相加。

举个具体例子:窗口一秒,上一窗口通过了八十四次,当前窗口已经过去七成、累计三十六次。估算值等于八十四乘以零点三,加上三十六,等于六十一点二;与上限一百比较,判定放行。随着时间推进,上一窗口的权重持续下降,到本窗口结束时归零。

它的优势是内存恒定为两个整数、判定是常数时间、在共享存储上一次往返就能完成,因此特别适合键空间巨大的网关层。它的误差来源也很明确:均匀分布假设。如果上一窗口的请求全部挤在开头,实际上它们早已滑出,估算值会偏大,导致过度限流;如果全部挤在结尾,估算值会偏小,导致放行过多。在流量平稳的场景,这个误差通常在个位数百分比,可以接受。

选型可以归纳成一句口诀:先问键有多少,再问精度要多高。键在千级以内且单机可判定,选环形分桶;键在十万级以上、集中判定,选加权外推;键极少但每次都很贵,选滑动日志。

(三)漏桶:两种被混为一谈的形态

1、排队式漏桶

"漏桶"这个比喻在中文技术讨论里被严重超载了:同一个词对应着两种完全不同的工程实现,混着说必然讲不清。

第一种是排队式漏桶,也就是把桶理解成一个真实存在的缓冲区。请求到达时被放入一个有界队列,队满则丢弃或拒绝;另一侧有一个消费者以固定速率从队列中取出请求并处理。它的输出是严格恒定的速率,无论输入怎么波动,下游看到的永远是一条平直的线。

这种形态的价值在于流量整形:当下游是一个必须匀速喂食的资源时------比如一个对调用间隔有硬性要求的第三方接口,或者一个批量写入性能远好于随机写入的存储------把突发流量抹平本身就是目的。

它的代价也很直接:请求要在队列里等待,等待时间等于队列长度除以消费速率,随队长线性增长。这带来两个必须处理的问题:等待中的请求持有内存,队列越长内存占用越大;调用方可能在轮到它之前就已经超时,那么这段等待就是纯粹的浪费。因此排队式漏桶必须同时设置队列长度上界和单请求等待时长上界,两者缺一不可。

2、速率判定式漏桶

第二种形态里,桶只是一个概念,不存在真实的缓冲区。它保存的状态是一个时间戳------通常叫"理论到达时间",含义是"按照配额,下一个请求最早应该在什么时刻出现"。

判定逻辑是这样的:请求到来时读出这个时间戳,如果当前时刻比它早得太多(早出的量超过允许的容差),就拒绝,且不修改任何状态;否则放行,并把时间戳更新为"当前时刻与旧时间戳中较大的那个,加上一个最小间隔"。最小间隔等于速率的倒数,容差则决定了允许多大的提前量。

这种形态不保存请求,因此不引入任何排队延迟,超限就是立刻拒绝。它的状态只有一个数,判定只需要两次比较和一次加法,天然适合放进一段脚本里原子执行,也天然适合跨节点共享。

3、它抹平了什么,又牺牲了什么

把两种形态放在一起对照,区别就清楚了:排队式漏桶改变的是请求的时间分布------它把突发搬到了未来;速率判定式漏桶只回答"现在能不能过"------它把突发直接丢掉。

两者共同牺牲的是突发容忍。严格的漏桶语义里,两次通过之间必须隔满一个最小间隔,哪怕系统已经空闲了一个小时。这对面向用户的接口通常过于苛刻:用户打开页面时并发发起十个请求是完全正常的行为,把它们摊到十个间隔里逐个放行,体验会很差。这正是令牌桶存在的理由。

在面试或评审里被问到"漏桶怎么实现"时,最稳妥的答法是先反问一句:是要把流量整形成匀速流,还是只要拦住超速的请求?前者用队列,后者用时间戳------这两个答案的工程含义天差地别。

(四)令牌桶:用桶容量买突发

1、惰性补充:为什么不需要定时器

令牌桶的模型是:有一个最多能装 b 个令牌的桶,系统以每秒 r 个的速度往里放令牌,桶满则溢出丢弃;请求到来时从桶里取走对应数量的令牌,取得到就放行,取不到就拒绝。

初学者最容易产生的误解,是以为需要一个后台线程按固定周期往桶里加令牌。真实实现从来不这么做------那会带来定时器开销、唤醒风暴,以及"限流键有多少就要多少个定时任务"的荒谬后果。

正确的做法是惰性补充:状态只保存两个字段------当前令牌数与上次补充时刻。请求到来时,先算出当前时刻与上次补充时刻的差值,把这段时间该补的令牌一次性补上(补到桶容量为止),再做判定与扣减,最后把新的令牌数与当前时刻写回。整个过程是三步算术:一次减法算时间差、一次乘加算补充量、一次比较加扣减。

这个设计的价值远超"省掉一个线程":因为状态只有两个数、更新只是纯算术,整套逻辑可以塞进一次原子操作里。它之所以在高并发下表现优秀,根源就在这里。

2、桶容量 b 的含义与定法

桶容量 b 是令牌桶唯一区别于漏桶的参数,它的含义只有一个:允许的最大突发量。系统空闲一段时间后,桶会攒满,此时一瞬间到来的 b 个请求会被全部放行。

因此 b 的取值直接对应着风险敞口。b 等于一时,令牌桶退化成速率判定式漏桶,两次通过之间必须隔满一个间隔。b 等于速率乘以一秒,是最常见的默认取值,允许一秒的量瞬间打进来。b 等于速率乘以若干秒,意味着长时间空闲后可以放行数倍于稳态的流量------这时必须确认下游扛得住。b 配得过大,限流器会长期不触发,只有持续超速才会拦截,等于形同虚设;很多"限流上线一年从没生效过"的案例,根因就在这里。

定 b 的判据不是"我想允许多大突发",而是"下游在 b 个请求同时到达时会不会倒"。这是一个可以压测出来的数:把并发压到 b,看响应时间与错误率是否还在可接受范围内。

3、预支与冷启动预热两个变体

工程实现里有两个常见变体值得知道。

第一个是允许预支。本次请求需要的令牌若不足,仍然放行,但把欠下的时间记在下一次请求头上------也就是说,下一个调用者要替它等待。这种设计让突发请求不必被拒绝,代价是把等待代价转移给了后来者,在公平性上有争议,使用时需要向调用方说明清楚。

第二个是冷启动预热。长时间空闲后,先以较低速率放行,再在预热期内线性升到额定速率。它要解决的问题是:某些下游在冷状态下的实际能力远低于热状态------缓存是空的、连接池是空的、即时编译还没发生。如果空闲之后立刻按额定速率打过去,下游会被打垮。预热本质上是承认"额定速率是热态下的额定速率"。

(五)同一段流量,四条不同的放行曲线

把四种算法放在同一段输入上跑一遍,差别会比任何文字描述都直观。设限额同为每秒一百次,输入是一条低速背景流量上叠了两段突发:第一段持续半秒、幅度中等,第二段只有两百毫秒、但极陡。

固定窗口的输出呈现明显的块状:突发被整块放行,直到本窗口额度用尽,然后本窗口剩余时间内全部拒绝;窗口一翻转,又是一整块。它的问题不只是边界突刺,还有公平性------窗口开头的请求把额度按先到先得领完,后半个窗口即使流量很少也会被拒。

滑动窗口的输出收敛得更早:额度耗尽后,必须等最早的请求滑出窗口才能放行下一个,因此突发的尾巴被削得更平。但它对突发同样不友好------只是不友好得更均匀一点。

排队式漏桶的输出是一条直线:无论输入怎么波动,通过量始终等于稳态额度。代价藏在图外------超出的部分都在队列里等着,尾延迟被显著抬高。

令牌桶的输出最有意思:第一段突发几乎整体通过了,因为之前的空闲期攒下了额度;第二段突发则被削掉了一部分,因为额度还没攒回来。平均速率不变,体验却明显更好。

这张对比图真正要说的是:选型不是在比较"谁更先进",而是在回答一个业务问题------这段突发,应该被吸收、被摊平,还是被拒绝?

三、漏桶与令牌桶的数学本质

(一)对偶关系:水位与余额互为镜像

1、两条曲线的镜像

把令牌桶的令牌余额随时间的变化画出来:没有请求时它以斜率 r 上涨,涨到桶容量 b 就封顶;每来一个请求它下跌一格。再把漏桶的水位随时间的变化画出来:没有请求时它以斜率 r 下降,降到零就见底;每来一个请求它上涨一格。

把其中任意一条曲线上下翻转,就得到了另一条。两者之间的换算关系是一行等式:水位等于桶深减去令牌余额。令牌桶的判定条件"余额大于等于本次需要的令牌数",等价于漏桶的判定条件"加水之后水位不超过桶深"。

这不是巧合,而是同一个数学对象的两种表述。任何一个令牌桶都可以改写成一个漏桶,反之亦然,两者的准入决策逐个请求完全一致。理解了这一点,"漏桶和令牌桶有什么区别"这个问题就有了正确的答法:先指出两者的状态互为镜像,再说明真正的分歧在哪里------而不是背两条互不相干的定义。

2、参数上的相互翻译

对偶关系还可以落到参数层面,这一层的翻译在实现选型时非常实用。

把令牌桶的桶容量设为一,它就退化成了速率判定式漏桶:每次通过之后余额归零,必须等满一个间隔才能再通过。反过来,给速率判定式漏桶配一个容差,容差等于桶容量减一再乘以最小间隔,它就等价于一个容量为 b 的令牌桶------允许连着放行 b 次,之后必须等额度重新长出来。

这条翻译规则的价值在于:当团队里有人坚持"必须用漏桶做整形"、有人坚持"必须用令牌桶容忍突发"时,可以指出这其实是同一个旋钮的两个刻度,争论应该落在"容差配多大"上,而不是"用哪种桶"上。

(二)把限流压缩成一个时间戳

1、理论到达时间与三种情形

速率判定式漏桶有一个很紧凑的实现形态:状态只保存一个"理论到达时间",判定只做两次比较和一次加法。

设最小间隔为 T(等于速率的倒数),容差为 τ(等价于 b 减一个 T 的突发额度)。请求到达时读出保存的理论到达时间,与当前时刻比较,有三种情形。

情形一:当前时刻比"理论到达时间减去容差"还早,说明来得太早,超出了允许的提前量,拒绝------并且不修改任何状态,一个字节都不写。

情形二:当前时刻落在容差窗口内,说明来得略早但在允许范围内,放行,并把理论到达时间往后推一个 T。这一步相当于把未来的额度预支给了当前请求。

情形三:当前时刻已经晚于理论到达时间,说明来得很晚,放行,并把理论到达时间设为"当前时刻加 T"。这一步很关键:它保证了长时间空闲不会累积出无限额度------空闲再久,最多也只能享受一个容差窗口的宽容。

用具体数值走一遍:T 取一百毫秒、τ 取两百毫秒。零毫秒时首次请求,放行,理论到达时间置为一百;二十毫秒时请求,读到一百,一百减两百等于负一百,二十大于负一百,在容差内,放行,置为两百;四十毫秒时同理放行,置为三百;五十毫秒时读到三百,三百减两百等于一百,五十小于一百,来得太早,拒绝,状态不变;五百毫秒时读到三百,五百大于三百,来得很晚,放行,置为六百。合起来的效果是:允许连着放行三次,之后必须等额度重新长出来------这正是容量为三的令牌桶的行为。

2、为什么它天生适合原子执行

这套写法在工程上有三个好处,值得单独说清楚。

第一,状态只有一个数。跨节点共享时,一次读改写就能完成,不必搬运数组或有序集合,也不必担心多个字段之间读到不一致的中间态。在共享存储上,它可以写成一段五行的脚本,整体原子执行。

第二,过期时间自然成立。一个键在 τ 加 T 之后必然回到初始状态,因此可以放心地把过期时间设为这个值,交给存储自动淘汰------这解决了限流最头疼的键回收问题。相比之下,滑动日志的过期需要显式清理,固定窗口的过期需要与窗口对齐。

第三,拒绝路径零写入。被拒的请求不修改任何状态,因此拒绝比放行更便宜。这个性质在过载时格外重要:越是被打得厉害,限流器自身的开销反而越低------它天然具备了正确的过载行为。

(三)差异的真正落点

1、突发容忍度

既然数学上等价,为什么还要把漏桶和令牌桶分开讲?第一处真正的分歧是突发容忍。

令牌桶的初始余额通常被设为满桶,因此允许"开局突发":服务刚启动、或者某个键第一次出现时,可以立刻放行 b 个请求。严格漏桶的初始水位为零,只允许恒定速率,第一个请求之后必须等满间隔。

这个差别在冷启动和长尾键场景下影响巨大。考虑一个按用户维度限流的接口:绝大多数用户一天只来几次,每次来都是"这个键第一次出现"。如果用严格漏桶语义,用户连点两下就会被拒;如果用满桶初始化的令牌桶,体验就正常得多。

2、是否引入排队延迟

第二处分歧是超限时做什么。这是行为差异,不是公式差异------同一份状态,既可以拿去做拒绝判定,也可以拿去计算等待时长。

令牌桶的经典语义是余额不足即拒绝,但它同样可以回答"还要多久才有足够的令牌":缺口除以补充速率,就是等待时长。排队式漏桶的经典语义是入队等待,但它同样可以在队满时拒绝。

真正的工程决策不是"选漏桶还是令牌桶",而是"这次超限,我们要不要让调用方等"。想清楚这一点之后,算法的选择往往是自明的。

3、状态的物理含义与监控口径

第三处分歧看起来最不重要,实际最影响长期维护:状态的物理含义不同。

令牌桶的状态是"还能花多少",是一个面向调用方的量;漏桶的状态是"下游还剩多少余量",是一个面向被调方的量。这个差别会传导到监控口径上:一个团队用"剩余令牌数"做告警,另一个团队用"水位百分比"做告警,两者的方向相反------令牌数低是危险信号,水位低是安全信号。

如果同一个系统里两种口径混用,排查时的干扰会非常大:看到"水位百分之九十"的第一反应是快满了,但如果这个指标实际上是按令牌余额命名的,含义就正好反过来。因此在指标设计阶段就应当统一:要么全部用"已使用比例",要么全部用"剩余比例",并在指标名里写明白。

四、数据结构:限流器里到底存了什么

(一)五种结构的字段布局

1、从两个字段到一个数组

"你用的什么数据结构"是这道题的第二个高频追问,正确答法不是给一个名词,而是按算法逐个说清字段布局。

固定窗口计数器的状态是两个字段:窗口编号与计数。判定时先算出当前窗口编号,编号不同则重置计数;更新可以是一次比较交换,或者在共享存储上用一次原子自增。它的陷阱在于编号与计数必须一起换:如果分两步改,有并发线程在中间读到"新编号加旧计数",这一窗口的额度就凭空少了一大截。

环形分桶数组的状态是一个起始时刻加 K 个整数。判定时遍历 K 个桶求和并跳过已过期的桶,更新时只改当前桶、其余桶只读。它的陷阱在于求和与写入之间桶可能翻转,读到跨代数据;解法是每个桶自带时效判定。

令牌桶状态是两个字段:上次补充时刻与令牌余额。判定时按时间差补令牌,再与需求量比较。它的陷阱是两个字段必须一起写------如果分别用两个原子变量存,会读到"余额已更新而时刻还是旧的"这种撕裂状态,结果是额度凭空多出来。这个坑很隐蔽,因为在低并发下几乎不会复现。

速率判定式漏桶的状态只有一个时间戳,判定是两次比较,更新是单字段比较交换,是五种里最容易做对的一个。它唯一的陷阱是跨节点共享时必须用同一个时钟源。

并发数信号量的状态是许可总数与已占用数。判定是"占用数是否达到上限",更新是获取与归还各一次原子操作、成对出现。它的陷阱是异常路径漏归还:一旦某条分支忘了释放,许可就只减不增,最终全部卡死,而且现象是"服务突然不响应了",排查方向很容易跑偏。

2、能否被一次原子操作改完

把五种结构横着看,会发现一条贯穿全表的规律:能压进一个机器字的状态,就能用一次比较交换改完;压不进去的,要么加锁,要么接受读到中间态。

固定窗口、令牌桶、速率判定式漏桶的状态都在两个字段以内,都可以压进一个六十四位整数;环形分桶是一个数组,压不进去;信号量本身就是单个整数。

这条规律解释了一个经常被问但很少被答好的问题------为什么令牌桶与速率判定式漏桶在高并发场景下比滑动窗口更受欢迎?答案不是"算法更先进",而是"状态更小,因而可以无锁更新"。

(二)把状态压进一个长整数

1、位宽切分与定点表示

具体怎么压?以令牌桶为例,六十四位的一种切法是:高二十二位存令牌余额,低四十二位存上次补充时刻。

时刻用毫秒表示,四十二位可以表示大约一百三十九年,从任意基准时刻算起都绰绰有余。令牌余额需要小数------因为补充量等于时间差乘以速率,很少是整数------所以用定点数表示:把真实余额乘以一千再取整,高二十二位可以表示大约四千个令牌,对绝大多数限流场景够用。

这样切完,读一次得到完整状态,写一次覆盖完整状态,不存在任何中间态。这正是前面提到的撕裂问题的解法:不是给两个字段加锁,而是让它们本来就不可分。

2、比较交换循环与它的边界

有了打包状态,更新就是一个标准的无锁循环:读出当前值,解包出余额与时刻,取当前单调时钟,算出补充后的余额,判定是否足够;不足则直接返回拒绝(注意:拒绝路径不写状态),足够则打包新状态并做一次比较交换;交换失败说明有人抢先修改,重读重算即可。

这套写法有三个必须交代清楚的边界。

第一,位宽是有代价的。余额用定点数表示,放大倍数决定了最小可表示额度;当速率极高时,每次补充的量可能小于最小刻度,精度损失会累积成系统性偏差------限流器会比配置值稍严或稍松。设计时应当估算这个偏差,必要时调整放大倍数或改用更宽的字段。

第二,时钟必须单调。如果用墙上时钟做时间差,一次校时就可能算出负的时间差(补出负令牌)或者天量的时间差(一次性补满桶)。正确做法是使用不受校时影响的单调时钟;在共享存储上,则应当以存储服务端的时间为准,而不是各个客户端各取各的。

第三,热点键要拆。同一个键上的竞争无法靠算法消除------比较交换只是把阻塞换成了重试,重试次数会随竞争线程数上升,缓存行也会在核之间反复弹跳。唯一的解法是分片:把一个键拆成 M 份,每份配总额度的 M 分之一,请求按某种哈希落到其中一份。代价是分片间余额不互通,流量倾斜时会出现"总量没超、某个分片先被限"的现象;缓解办法是分片数取小、按调用方哈希而非随机哈希,以及允许跨分片借额度。

(三)分布式场景下的存储结构

1、三种远端结构的取舍

当额度需要跨节点共享时,状态就要放到一个共享存储上,结构的选择直接决定了带宽、内存与延迟。

第一种是单字符串加脚本。键按"限流前缀加接口加调用方"组织,值是余额与上次补充时刻(或者打包成一个整数),一段脚本内完成读、算、写,整体原子。每个键约几十字节,可用过期时间自动回收;往返一次即可判定,最省带宽也最省内存。这是绝大多数场景的默认选择。

第二种是有序集合滑动日志。成员是请求标识,分值是到达时刻;每次判定要先按分值删除过期成员、再统计基数、再插入新成员。精度最高,但内存与窗口内的请求数成正比,高频键会迅速膨胀,也最容易把存储写成瓶颈。只在真正需要精确计数的低频场景使用。

第三种是单时间戳判定。值就是一个理论到达时间,判定是两次比较加一次条件写入;每键一个整数,过期时间等于容差加最小间隔;拒绝路径完全不写入。状态最小、行为最可预测,特别适合"防刷"类的最小调用间隔控制。

2、键空间:多维度限流最先撞上的墙

多维度限流最先撞上的不是性能,而是键空间。

只按接口维度,键的量级是数百;接口乘以调用方,数千;接口乘以用户,数百万;接口乘以用户再乘以某个业务参数,上亿------这个量级已经不可行了,不是存不下,而是绝大多数键只会被访问一两次,存储里全是垃圾。

2.1 维度必须封闭可枚举

第一条纪律是维度取值必须封闭可枚举。接口名是封闭的,调用方标识是封闭的,用户标识在业务上是封闭的;但"任意请求参数"不是------它由外部输入决定,攻击者可以构造出无限多的取值,把存储撑爆。如果确实需要按参数限流,必须先做白名单或哈希收敛,把取值空间压到可控范围。

2.2 每个键都要带过期时间

第二条纪律是每个键都要带过期时间,且过期时间要与算法语义对得上。令牌桶的过期时间应当不短于"桶从空到满所需的时间",否则键被提前淘汰,等于给了调用方一次免费的满桶;速率判定式漏桶的过期时间就是容差加间隔,非常自然。忘记设置过期时间是分布式限流最常见的内存泄漏来源。

2.3 超预算的维度用近似统计替代

第三条纪律是超出预算的维度用近似统计替代精确计数。当目标只是"找出异常放大的那几个主体"而不是"精确控制每个主体的额度"时,可以用近似计数结构:内存固定、允许一定误差、能可靠地识别出头部大流量。用它先筛出候选,再对候选做精确限流,是一个成本低得多的组合。

3、热点键与分片

键空间过大是一个问题,键空间过小、流量高度集中在少数键上则是另一个问题------热点键。

热点键的表现是:限流判定本身成了瓶颈。在共享存储上,它表现为单个分片的处理能力被打满,其他分片却很空闲;在本地实现里,它表现为一个内存地址上的比较交换重试率飙升。

解法只有分片一条路:把一个键拆成若干份,每份配总额度的相应比例,请求哈希落到其中一份。需要付出的代价前面已经说过------分片间余额不互通。此外还要注意:分片数不宜取大,因为分片越多、倾斜带来的相对误差越大;哈希应当按调用方或用户标识做,而不是每次请求随机选,否则同一个调用方的额度会散布到所有分片上,任何一个分片都限不住它。

远程判定还有四笔账必须在设计时就算清楚:延迟账(同机房一次往返约零点几毫秒,跨机房数毫秒,对一个本身只要几毫秒的接口,这可能是三成以上的额外开销)、可用性账(限流器依赖的存储一旦抖动,全部请求都要等它)、放大账(每个请求一次远程调用,等于给存储引入了与业务等量的流量)、一致性账(多副本各自判定必然有偏差,先问业务能接受多大误差,再决定形态)。

五、并发下的实现:锁、比较交换与队列

(一)三种同步方案

1、互斥锁

限流器是全链路上竞争最集中的一个点------每一个请求都要经过它,而它要修改共享状态。因此它的同步方式直接决定了拦截成本。

最直观的方案是互斥锁:所有线程排成一队,逐个进入临界区完成判定与更新。它的优点是语义直观,任意复杂的状态都能安全更新;缺点是临界区外的线程会被挂起,唤醒需要一次上下文切换,而上下文切换的代价通常在微秒量级------比一次算术判定贵三个数量级。竞争越激烈,切换开销占比越高,最终表现为吞吐随线程数上升而下降。

这里有一个必须避免的具体错误:把限流判定放进一个更大的锁的临界区里。有些实现为了"顺便保证一致性",在同一把锁里既做限流判定又做业务前置检查,结果限流器成了全链路上最大的竞争点------它本来是为了保护系统,反而先把自己变成了瓶颈。

2、比较交换

第二种方案是比较交换:读出旧值,在本地算出新值,用一次原子指令写回;如果期间有人改过,写回失败,重读重算。线程全程不挂起,只在用户态自旋。

它的适用前提是状态能压进一个机器字------这正是前一章讨论状态压缩的原因。低竞争时,一次比较交换的开销约等于一次内存读写,几乎可以忽略;中等竞争时会出现偶发重试,但仍显著优于互斥锁;高竞争时,重试次数上升,缓存行在核之间反复弹跳,吞吐会掉头向下。

需要强调的是:比较交换不是"没有竞争代价",而是"把阻塞代价换成了重试代价"。当竞争足够激烈时,两者都不好,此时唯一的出路是减少竞争本身------也就是分片。

3、分段累加

第三种方案是分段累加:把一个计数拆成多个独立单元,线程按自身标识散列到不同单元各写各的,读取时把所有单元求和。

它的优势在写多读少的场景极为明显:不同线程写不同的内存地址,缓存行各自独立,几乎没有竞争。代价有两条:读取要遍历所有单元,成本随单元数上升;读到的是一个近似值------因为遍历过程中其他单元还在变化。

对限流而言,这个特性决定了它的适用边界:分段累加适合做"只统计不判定"的指标计数,比如通过量与拒绝量的埋点;不适合做实时判定,因为判定需要一个即时准确的总量。把这两件事分开------判定用压缩状态加比较交换,统计用分段累加------是一个很自然的组合。

选型可以归纳成四条判断线:状态能压进一个机器字且判定是纯算术,用比较交换;状态是数组或集合、必须多字段一致更新,用互斥锁但要尽量缩小临界区;只统计通过量、不做实时判定,用分段累加;单键竞争已经打满一个核,先分片,再谈同步方式。

最后有一点容易被忽略:限流器的开销要和它保护的资源相比才有意义。如果一次数据库查询要五毫秒,那么限流判定花五百纳秒还是一百纳秒并不重要;但如果它挂在一个纯内存接口前,或者挂在每秒百万次的网关热路径上,这个差别会直接变成机器成本。

(二)并发下队列到底有没有性能问题

1、第一层:吞吐上的锁竞争

"并发下队列有没有性能问题"是这道题最容易答偏的一问。答"有",会被追问"具体是什么问题";答"没有",会被追问"那为什么高性能框架都不用阻塞队列"。正确的答法是把它拆成三层,分别回答。

第一层是吞吐。传统阻塞队列的瓶颈来自入队与出队对同一把锁的争抢。以数组阻塞队列为例,它内部只有一把锁,生产者入队和消费者出队互相排斥,所有生产者线程被串行化,核数再多也用不上。更糟的是,队空或队满时线程会被挂起,唤醒又要一次上下文切换------在高频场景下,这部分开销可能超过实际工作量本身。

链式阻塞队列做了改进:入队一把锁、出队另一把锁,生产与消费可以并行。但它带来了新的成本:每次入队都要分配一个节点对象,垃圾回收压力随流量线性上升;而且队列长度需要用一个原子计数维护,这个计数仍然是所有线程共同争抢的点。

2、第二层:延迟上的排队时间

第二层是延迟,而这一层与队列的实现完全无关。

排队时间等于队列长度除以消费速率,这是一个纯粹的排队论结论:无论用什么数据结构实现队列,只要长度是一千、消费速率是每秒一百,最后一个请求就要等十秒。换句话说,把数组队列换成无锁队列可以提高吞吐,但一秒也减少不了排队延迟。

这带来两个必须面对的判断。第一,队列越长,尾延迟越高,而且是确定性的恶化------不是"可能会慢",是"必然会慢这么多"。第二,如果调用方的超时时间早于队列消费到它,这段等待就是纯浪费:服务端做完了全部工作,结果没人接收。这类"无效工作"在过载时占比可能非常高,是吞吐塌陷的重要成因。

因此排队式限流必须同时设置队列长度上界和单请求最长等待时长,并且在取出请求时先检查它是否已经超时------已经超时的直接丢弃,不要再往下走。

3、第三层:内存上的堆积

第三层是内存。每个排队中的请求都持有它的上下文对象------请求参数、连接、缓冲区、上下文变量,堆积等于内存膨胀。链式结构还要额外为每次入队分配节点。

最危险的形态是无界队列。它会把过载从"拒绝"变成"内存溢出",而后者的故障形态差得多:拒绝是可控的、局部的、可观测的;内存溢出是突发的、全局的、且往往伴随着长时间的垃圾回收停顿,把服务彻底拖死。很多"限流上线之后反而更容易挂"的案例,根因就是给限流配了一个无界的等待队列。

4、三种队列结构与伪共享

如果确实需要队列,三种结构的取舍是清楚的:数组阻塞队列容量固定、无节点分配,但单锁限制了并发上限;链式阻塞队列双锁分离,生产消费可并行,代价是节点分配与长度计数;环形缓冲预分配槽位、用序号推进,生产者与消费者各自推进自己的序号,槽位复用几乎不产生垃圾,是高性能场景的标准选择。

环形缓冲还有一个容易被忽略的细节:伪共享。一条缓存行通常是六十四字节,如果队头序号与队尾序号恰好落在同一条缓存行上,生产者修改队尾会让消费者持有的缓存行失效,反之亦然------两个本不相干的变量互相拖累,性能可能下降数倍。解法是在两者之间填充无用字节,把它们隔到不同的缓存行上。这个技巧在高性能队列实现里几乎是标配。

把三层合起来,就是这一问的完整答案:队列的性能问题在限流场景里通常不是"队列本身慢",而是"本来不该排的队被排上了"。限流判定本身不需要队列------用余额或时间戳做算术判定,全程无队列;只有当业务确实要求匀速下发时,队列才应该出现,而且必须同时约束长度、等待时长与内存占用。

(三)不排队的排队

1、把等待算出来

如果目标只是"让请求慢一点通过",那么有一种比队列便宜得多的实现:把等待时长算出来,而不是排出来。

算式很简单:缺口等于本次需要的令牌数减去当前余额,等待时长等于缺口除以补充速率。把这个数返回给调用方,它可以自行选择------睡这么久再重试、立刻走降级分支,或者干脆放弃。决定权回到了业务侧。

这种做法的好处是限流器自己不持有任何等待中的请求,因此不会堆积,也不需要任何额外的数据结构或线程。它的失效形态也最温和:即使调用方全部选择不等待,系统的行为也只是退化成直接拒绝,不会产生二次伤害。

代价是把等待责任交给了调用方,因此需要在接口契约里明确约定:返回的等待时长上限是多少、调用方是否必须遵守、不遵守会发生什么。没有这层约定,调用方可能选择忽略等待建议立刻重试,把限流变成了空转。

2、信号量与并发数控制

第二种"不排队的等待"是信号量占位。它不排请求,只发许可:拿到许可才能进入,用完归还。

信号量控制的是在途并发数,而不是到达速率------这是它与前面所有算法的本质区别。当下游变慢时,请求归还许可的速度下降,可占用的许可自然减少,闸门自动收紧。这个自适应特性正是速率限流所缺乏的,因此在保护"处理时间会变化"的资源时,信号量往往比令牌桶更合适。

它的状态只有一个整数,占用与归还各一次原子操作。风险集中在一处:异常路径漏归还。一旦某条分支忘了释放许可,额度就只减不增,最终全部锁死,表现为服务无响应。防御手段是把归还写在语言提供的资源自动释放结构里,并且为许可占用设置最长持有时间,超时强制回收。

3、有界等待的两个上界

允许等待时,两个上界缺一不可。

第一个上界是等待时长:不超过调用方剩余的超时预算。这个数不应该由限流器自己拍,而应当由调用链上传递下来------上游还剩多少时间,下游就最多等多久。只设这个上界而不限制请求数,内存会先撑不住。

第二个上界是等待中的请求数:不超过内存与线程能承受的量。只设这个上界而不限制时长,请求会在队列里一直等到自己超时,白白占用资源。

两个都不设,限流器在过载时会从保护者变成故障放大器------这是"限流上线后事故反而更严重"的典型成因。

三种实现横向对照,结论很清楚:真队列等待的额外内存是队列长度乘以请求上下文、需要专门的消费线程、过载时会堆积并转成内存压力,只在必须匀速时使用;信号量占位只要一个整数、调用线程原地阻塞、过载时表现为线程池被占满,是控并发的首选;算出等待时长零额外内存、零额外线程、过载时退化为直接拒绝,应当作为默认选择。

一句话概括:能用一次减法和一次除法表达的等待,就不要用一个真实存在的队列去表达。

六、分布式限流:精度、开销与可用性的三角

(一)三种落地形态

1、单机限流加配额分摊

第一种形态最省事:把集群阈值除以副本数,每个副本各管各的,判定完全在本地完成。

它的优势是零远程调用,判定在纳秒级完成,限流器本身不引入任何额外的故障点。劣势有两条。第一,副本数变化时必须同步调整------在弹性伸缩的环境里,这意味着限流组件需要感知实例数,否则扩容之后集群总阈值会随之翻倍,限流形同虚设;缩容之后则会过度限流。第二,流量不均时误差极大:如果负载均衡不够均匀,或者某些副本承接了长连接的大客户,就会出现某个副本先被限、而集群总量远未到顶的情况。

尽管有这些问题,它仍然是最常用的形态------因为对绝大多数场景来说,限流的目的是"防止打垮"而不是"精确计费",百分之二三十的误差完全可以接受。

2、集中判定

第二种形态是把判定放到共享存储上:每个请求都做一次读改写,集群视角唯一且一致。

它的精度最高,也最容易解释------阈值是多少,通过量就是多少。代价同样直白:每请求一次往返,延迟与存储压力都随业务量线性增长;存储抖动会直接放大成全站问题,因此必须设置远小于业务超时的独立超时,以及明确的兜底策略。

它的合理适用面是那些"额度本身就是钱"的场景:第三方接口的合同配额、多租户的付费额度、按次计费的能力调用。这些场景里,超发的代价远高于一次往返的开销。

3、本地缓冲加批量领取

第三种形态是前两者的折中:副本一次性向中心领取一批额度,本地慢慢消耗,用完再领。

远程调用次数被批量大小整除,开销可控;误差被限制在"一批额度"以内,可以通过调整批量大小在精度与开销之间连续折中------批量取一就退化成集中判定,批量取无穷大就退化成单机限流。

它需要额外处理三件事:领取失败时如何降级(通常是退回本地保守额度);额度的有效期(避免副本领了额度不用、把配额锁死);副本下线时未用完额度的回收(否则集群总额度会持续缩水)。这三件事不复杂,但每一件漏掉都会在运行一段时间后暴露出来。

三种形态构成一个三角:精度、低开销、高可用,任何方案都只能挑两个。集中判定牺牲开销与可用性换精度,单机分摊牺牲精度换另外两者,批量领取是三者之间的可调折中。正确的决策顺序是:先确定业务能接受多大误差,再回头挑形态------顺序反了必然反复返工。

(二)限流器自身的可用性

1、失败放行还是失败拒绝

共享存储不可用时,请求是放行还是拒绝?这个问题没有普适答案,但有清晰的判据。

选择失败放行,意味着限流器故障不影响业务可用性。它适合那些"限流主要用于防御异常流量"的场景:正常情况下限流几乎不触发,它的存在是为了应对爬虫和突发攻击。风险是故障期真实过载无人拦截,因此配套措施必须有------本地限流作为第二道闸,哪怕阈值配得宽松一些。

选择失败拒绝,意味着宁可少做,也不让下游被打垮。它适合下游是脆弱或昂贵资源的场景:一个容量很小的旧系统、一个按次计费的第三方接口。风险是限流器故障直接变成业务故障,因此必须有人工强制关闭开关,且这个开关本身不能依赖那个已经挂掉的存储。

2、超时、兜底与降级判定的观测

无论选哪一种,有四条落地纪律是共通的。

第一,限流器不能依赖那些在故障期最可能一起挂掉的组件。判定路径上的每个远程依赖都要有超时和兜底;如果限流规则要从配置中心拉取,那么配置中心不可用时必须能用上一次成功拉取的版本继续工作。

第二,远程判定必须设独立的、远小于业务超时的超时时间。一个常见错误是限流判定复用了业务的默认超时(比如三秒),结果存储抖动时每个请求都要先等三秒才走到业务逻辑------限流器从保护者变成了新的阻塞点。合理的取值通常在几十毫秒以内。

第三,本地兜底规则要预先下发并常驻内存,不能在故障发生时才去拉取------那时往往已经拉不到了。这一条听起来显然,但很多实现的兜底逻辑写的是"降级时读取本地配置文件",而那个文件在容器里根本不存在。

第四,无论选放行还是拒绝,都要把这次"降级判定"打进指标。否则事后复盘时无法解释那段时间的流量为何异常------是真的没人访问,还是限流器悄悄放行了全部请求。

七、工程落地:一个项目里限流到底怎么做

(一)分层布防:从接入层到资源层

1、五道闸各自看得见什么

被问到"你们项目里限流是怎么做的"时,最能体现工程水平的答法不是报出某个组件的名字,而是说清楚系统里有几道闸、每道闸站在哪一层、各自拦的是什么。因为每一层看得见的信息不同,能做的判断也就不同。

接入层或网关看得见来源地址、证书、请求路径、请求头、租户标识,看不见业务语义与下游的真实负载。它适合做的规则是按来源与租户限速、拦截爬虫与异常来源;算法上通常选加权外推或令牌桶,因为这一层键空间大、判定要极快。

业务应用层看得见用户身份、业务参数、接口语义、请求优先级,看不见其他副本的流量与集群总量。它适合做的规则是按接口与用户限速、按业务动作区分额度;算法上以本地令牌桶为主,必要时叠加集中判定。

下游调用层看得见调用的是哪个下游、下游的配额与响应时间,看不见请求最初从哪里来。它适合做的规则是对第三方接口按合同配额限速、控制在途并发;算法上选速率判定式漏桶或信号量。

资源层看得见连接数、线程数、队列长度、慢查询,看不见请求的业务含义。它适合做的规则是连接池上限、线程池队列上界、慢查询保护;本质上是并发数控制,不是速率控制。

全局自适应这一层看得见本机负载、响应时间分位、在途请求数,看不见过载的原因。它适合做的是负载越界时按优先级卸载低价值流量;算法上是基于延迟或排队论的闭环控制。

2、分层不是叠加保险

多层限流不是层数越多越安全。每加一层都必须说清它拦的是哪一类流量,否则会踩到四个坑。

第一个坑是重复拦截。同一类流量被多层重复拦截,等于把有效额度悄悄缩小了数倍。典型症状是压测时"怎么调都上不去"------调高了应用层阈值没用,因为网关先拦了;调高网关阈值也没用,因为下游调用层又拦了。排查这类问题的唯一办法是让每一层的拒绝都能被单独观测。

第二个坑是层次错配。让看不见业务语义的那一层去做需要业务语义的判断,比如在网关上按"是否为核心接口"做优先级卸载------网关只知道路径,不知道这条路径今天是不是大促主链路。正确做法是把判断所需的信息显式传下去,或者把判断挪到看得见它的那一层。

第三个坑是不可关闭。每一层都要能单独关闭,并且关闭后系统仍然可用。做不到这一点,说明这层限流已经变成了业务依赖------它不再是保护措施,而是逻辑的一部分,出问题时无法止损。

第四个坑是口径不一。所有层应当共用一套指标口径与命名,否则排查时无法回答"到底是哪一层把它拒了"。最低要求是:每一次拒绝都带上规则标识与层次标识。

(二)规则模型与配置下发

1、一条规则至少要有哪些字段

限流规则不是一个数字,而是一条结构化记录。一条能评审、能交接的规则,至少要有六组字段。

规则标识:全局唯一,用于埋点、告警与审计关联。没有它,指标里就只有"拒绝数"这一个总量,无法定位。

匹配条件:资源名加上维度取值,支持通配与默认兜底。必须明确写出未匹配到任何具体规则时落到哪一条,以及默认规则的阈值是多少。

算法与参数:算法类型、速率、桶容量或窗口长度。参数名应当与算法语义严格对应,避免出现"桶容量"字段实际存的是窗口长度这种历史遗留。

超限动作:拒绝、排队、降级中的一种,以及对应参数(等待上限、降级返回值等)。

生效范围:单机阈值还是集群阈值;如果是集群阈值,副本数从哪里取、多久刷新一次。

优先级与开关:多条规则命中时谁生效,以及一键关闭的开关。开关必须能独立于其他配置生效,且关闭动作要能在秒级完成。

2、下发链路、热更新与本地兜底

规则的下发链路本身就是一个可靠性问题,需要按三个层次设计。

第一层是变更控制。限流规则的破坏力与代码相当,因此应当有评审、灰度与回滚机制。灰度可以按实例比例进行:先在少量实例上生效新规则,观察拒绝率与业务指标,再全量。回滚必须比生效更快。

第二层是下发与刷新。规则从配置中心推送到实例,要有版本号与时间戳,并把当前生效版本作为指标暴露出来。这样在事故现场可以立刻回答"这台机器上跑的是哪一版规则"。刷新失败时不能清空已有规则------保持上一版继续工作,同时把失败次数打进指标。

第三层是本地兜底。实例启动时如果拉不到规则,应当使用打包在镜像里的兜底规则启动,而不是"无规则放行"或者"启动失败"。兜底规则的阈值应当偏保守,宁可稍严一些。

(三)超限之后返回什么

1、拒绝:状态码、重试提示与额度回传

"限流了"这三个字在设计文档里往往就是终点,但对调用方来说它只是起点------被拒之后返回什么,直接决定了会不会有第二波冲击。

1.1 状态码要专用

用专门表示"请求过多"的状态码,不要混进通用错误。原因有两个:调用方的客户端库通常对这个状态码有内建的退避处理,混进通用错误会让这些处理失效;监控与告警需要区分"被限流"和"出错了",混在一起会让错误率指标失去意义。

1.2 带上重试提示

响应里应当带上建议的重试等待时间。这一步的价值常被低估:它把重试节奏的控制权从客户端交回了服务端。没有这个提示,客户端只能按自己的默认策略重试,多个客户端的默认策略叠在一起,很容易形成同步的重试波峰;有了它,服务端可以给不同调用方下发不同的等待时间,天然把重试打散。等待时间的计算恰好可以复用前面说过的算式------缺口除以补充速率。

1.3 回传剩余额度

在响应头里回传当前额度、剩余量与重置时刻,让调用方能够自我约束。这对开放平台类的接口几乎是必备的:调用方拿到剩余量之后可以主动降速,而不是撞墙之后再退。即使是内部接口,这组信息在排查时也非常有用------它把"服务端认为你还剩多少"这个原本不可见的状态暴露了出来。

2、排队与降级

排队在前面已经讨论过:它是把拒绝换成延迟,必须同时约束队列长度与等待时长,且取出请求时要先检查它是否已经超时。

降级是另一条路:不做这次真实处理,而是返回一个替代结果。它的设计要点是明确"返回什么"以及"调用方能不能分辨"。返回缓存的旧数据、返回空集合、返回一个明确的"稍后重试"标记,三者对调用方的含义完全不同。最危险的做法是返回一个看起来正常但实际是兜底的结果,而调用方无从分辨------这会把一次限流变成一次静默的数据错误。

需要特别指出:不是所有场景都适合降级。凡是会引发不可逆动作的路径------扣款、发货、发送通知、写入外部账本------都不应该用兜底值糊弄过去,应当显式失败,让调用方决定是否重试。

3、优先级卸载

当过载持续存在、扩容来不及时,一刀切的限流会让所有流量同等受损。更好的做法是按优先级卸载:先拒绝低价值流量,保住核心链路。

落地需要三件事。第一,请求要携带优先级标识,并且这个标识在调用链上能够传递下去------否则下游无从判断。第二,优先级的划分要有业务共识,通常按"是否影响交易主流程""是否可以延后""是否为重试流量"这几个维度划。第三,卸载的阈值要分级:负载达到第一档时只拒绝可延后的流量,达到第二档时再拒绝次要流量,核心流量最后才受影响。

一个常被忽略的细节是:重试流量本身应当被赋予更低的优先级。原因很直接------过载时重试是放大器,优先拒绝重试可以更快让系统脱离崩塌区。

(四)自适应限流:不再手工配阈值

1、基于延迟的闭环控制

手工配阈值有一个根本困难:容量不是常数。同一台机器,缓存命中率变化、下游响应时间变化、共存服务的资源占用变化,都会让实际容量上下浮动数倍。因此近年来的方向是让闸门自己找位置。

第一类做法以延迟为信号:持续测量请求的响应时间,把最近一段时间内观测到的最小响应时间作为"无排队时的基准",用当前响应时间与基准的比值估计排队程度,据此调整允许的并发上限。基准越接近当前值,说明排队越少,可以放宽;两者差距越大,说明排队越严重,应当收紧。

它的优点是不需要任何人工阈值,且能自动跟随容量变化;难点在于基准的选取------如果基准是长期最小值,系统进入稳态高负载后就再也无法认为"现在没有排队",闸门会一直收紧;因此实现上通常给基准一个衰减或滑动重置机制。

2、基于排队论的并发上限估计

第二类做法直接从排队论出发:吞吐量乘以最小响应时间,得到"无排队时系统内的请求数",这就是并发上限的估计值;再乘以一个大于一的系数,作为允许的队列余量。

这种做法的好处是含义清晰、参数少,只有一个余量系数需要调。它同样面临基准漂移的问题,也同样需要处理"探测"------如果闸门一直收得很紧,系统永远观测不到更高的吞吐,就会陷在一个次优点上,因此需要周期性地放宽一点看看会发生什么。

3、基于系统负载的兜底

第三类做法最粗但最可靠:直接看机器的负载水位,超过某个门限就开始按比例拒绝。它不关心业务语义,也不关心是哪个接口把系统压垮的,只保证机器不被打死。

它适合作为最后一道兜底闸,与前面几层配合使用。需要注意两点:一是负载指标本身有滞后性,等它涨上去的时候往往已经晚了,因此门限要留足余量;二是必须限制拒绝比例的变化速率,否则会出现"负载一到门限就拒绝百分之九十、负载一降就全放行"的振荡。

自适应限流不是万能的,它有两个明确的不适用场景:一是容量瓶颈不在本机的情况(比如下游数据库被别人压垮了,本机负载完全正常);二是需要精确控制额度的场景(合同配额、付费额度)------自适应给出的数字是"当前能承受多少",不是"合同允许多少"。这两类场景仍然需要显式阈值。

八、可观测性与参数标定

(一)必须暴露的四组指标

1、通过与拒绝

限流最基础的一组指标是通过量与拒绝量,且必须按规则标识与维度分别统计。只有总量是不够的------事故现场需要立刻回答"是哪个规则、哪个维度在拒"。

其中拒绝率是最重要的一个数。它的含义需要在团队内统一:长期非零意味着稳态流量已经超过配置阈值,此时该做的是扩容或调整阈值,而不是把它当成正常现象;突然升高则意味着要么流量真的涨了,要么阈值或副本数被改了。

还有一条容易被忽略的要求:拒绝必须能区分"限流拒绝"与"下游失败"。这两者在调用方看来可能都是一个错误码,但成因和处置完全不同。把它们混在一个指标里,是排查效率最大的杀手之一。

2、等待与排队

如果实现里包含等待,就要暴露排队中的请求数、等待时长的分位数,以及因超时而放弃的数量。

这里要强调分位数而不是平均值。等待时长的分布通常极度右偏:绝大多数请求几乎不等,少数请求等很久。平均值会把这个特征完全抹平,而用户感知到的恰恰是那少数。

排队数持续大于零是一个明确的信号:稳态流量已经越过阈值,队列在做的不是削峰而是持续缓冲------这种状态下队列只会越来越长,直到触及上界开始拒绝。

3、规则与限流器自身

第三组是规则与配置相关的指标:当前生效的规则版本与下发时刻、规则加载失败次数、本地兜底生效次数。没有这组指标,配置变更引发的事故几乎无法归因------因为最关键的证据(那一刻这台机器上跑的是哪一版规则)已经丢失了。

第四组是限流器自身的运行指标:判定耗时分位、远程判定超时率、比较交换的重试次数、热点键的键分布。这组指标回答的是"限流器自己有没有成为瓶颈"。判定耗时的高分位突然抬升、重试次数飙升、某个键的占比远超其他,都是需要立刻处理的信号。

(二)阈值的三条来路

1、压测拐点反推

这是最可靠的一条路:阶梯加压,逐档提高并发或到达速率,记录每一档的吞吐与响应时间,找到响应时间开始明显劣化的那个点------也就是第一章说的容量拐点------取其八成左右作为阈值。

留出两成余量的理由是:压测环境与线上环境总有差异,流量画像也不可能完全一致;而且拐点附近的曲线很陡,稍微越过一点,响应时间就会显著恶化。

它的前提条件是要有与线上等配的环境和接近真实的流量画像。如果压测用的是单一接口、单一参数,得出的拐点往往偏乐观------真实流量的缓存命中率、参数分布、读写比例都不一样。

2、容量模型正推

当接口是新的、没有历史数据也来不及压测时,可以走正推:估算单个请求消耗的资源(处理器时间、内存、下游调用次数),用可用资源总量除以单请求开销,得到理论上限,再乘一个安全系数。

它的准确度完全取决于开销估算。一个实用的技巧是找一个已经上线的、逻辑相近的接口作为参照,用它的实测开销做基准再做调整,比凭空估算靠谱得多。

正推得到的阈值应当被标记为"临时值",并在接口上线积累一段时间数据后用实测值替换。

3、历史分位兜底

第三条路是取历史峰值的某个分位,再放大一定倍数。它的定位必须说清楚:这是"防异常"的闸,不是"防过载"的闸。

它能拦住的是明显偏离历史行为的流量------爬虫、失控重试、循环调用。它拦不住的是"业务真的增长了"这种情况,也不能声称它保护了容量,因为历史峰值与实际容量之间没有必然联系:可能历史峰值只有容量的一成,也可能已经贴着容量。

无论走哪条路,都要把这个数字的推导过程写下来,并随规则一起保存。写不出推导过程的阈值,下一个人不敢动它------而不敢动,在事故现场就等于没有这个旋钮。

(三)参数之间的硬约束与一条排查路径

限流的参数不是相互独立的,它们之间存在若干硬约束,违反任何一条都会在某种场景下出问题。

桶容量决定的瞬时突发量,必须小于下游能承受的瞬时并发------否则限流器"合法地"把下游打垮。集群阈值除以副本数得到的单机阈值,必须大于单副本的正常峰值------否则日常流量就会触发限流。排队上限乘以单请求内存,必须远小于可用堆内存------否则队列本身会引发内存问题。最长等待时长,必须小于调用方的超时时间------否则等到的结果没人接收。远程判定超时,必须远小于业务接口的超时预算------否则限流器成为新的阻塞点。

有了指标与约束,就可以给出一条可执行的排查路径。第一步看拒绝率:突然升高时,先确定是哪个规则、哪个维度。第二步看到达量:到达量真的涨了,还是阈值或副本数变了。第三步看规则版本:最近有没有配置下发,版本号与时间是否对得上。第四步看下游:是不是下游变慢导致并发数限流先触发------这种情况下到达量和阈值都没变,但通过量掉了。第五步看键分布:是不是某个键成了热点,或者分片出现倾斜。第六步下结论:是该扩容、该调阈值,还是该拦住某个异常来源。

这条路径的价值在于它把"限流告警"从一个模糊的信号变成了一个有确定处置流程的事件。

九、常见陷阱与演练

(一)四层反模式

1、算法与实现层

第一类反模式出在代码里。

用固定窗口却把阈值贴着容量配------边界处的两倍流量会直接打穿下游,而计数器全程显示没有超限,排查时极难定位。

用墙上时钟算时间差------一次校时就可能算出天量令牌或负数等待时长。这类问题的可怕之处在于它只在校时那一刻发生,事后完全无法复现。

令牌数与时间戳分成两个变量存------并发下会读到撕裂状态,导致额度凭空多出来。低并发时几乎不复现,压测到一定量级才会暴露。

把限流判定放进锁的临界区里------限流器自己成了全链路最大的竞争点。这类实现在功能测试中完全正常,只有在真实并发下才会显出问题。

2、配置与阈值层

第二类反模式出在配置里,而且往往能潜伏很久。

阈值来自拍脑袋,说不出与压测拐点或容量模型的关系------一旦需要调整就无从下手。

桶容量配得过大,限流长期不触发,出事时才发现它从没起过作用------"我们有限流"和"我们的限流有效"是两件事。

多层限流拦同一类流量,有效额度被悄悄缩成几分之一------压测怎么调都上不去,团队却以为是代码性能问题。

改配置需要发布,导致故障期最需要调整时反而动不了------限流规则应当是运行时可变的,这是它区别于普通配置的关键属性。

3、分布式层与运营层

第三类出在跨节点协同上:每个请求都做一次远程判定,把限流器的延迟加进了业务超时预算;共享存储抖动时没有兜底策略,限流器故障直接变成全站故障;集群阈值除以副本数却没有感知弹性伸缩带来的副本数变化;热点键不分片,单键竞争打满一个核,限流器先于业务被打垮。

第四类出在运营与体验上:被拒时返回通用错误,客户端立刻重试,形成紧随其后的第二波冲击;限流拒绝与业务失败混在一个指标里,排查时无法区分;没有一键关闭开关,误配之后只能靠回滚发布来止损;从不演练,限流规则写完就没人验证过它究竟拦不拦得住。

这十六条背后其实只有三个根因:把限流当成一个开关而不是一套机制,只写了拦截代码,没写标定、观测、演练与止损;在错误的层次上做正确的事,让看不见业务语义的那一层去做需要业务语义的判断;忽略了限流器自身也是一个有状态、会出错、会成为瓶颈的组件。

(二)怎么验证限流真的有效

1、四种注入形态

限流是少数几个"平时不生效、生效时必须一次做对"的机制,因此必须演练。演练需要四种注入形态。

第一种是突发注入:在极短时间内打出数倍于阈值的流量,验证限流器是否在预期的量级上开始拒绝,以及被拒请求的响应是否符合契约。这一项要特别关注跨窗口边界的行为。

第二种是持续超载注入:以阈值的一点几倍持续压一段时间,验证系统能否稳定在拐点左侧,以及拒绝率是否稳定在预期水平------如果拒绝率剧烈振荡,说明参数之间存在耦合问题。

第三种是依赖变慢注入:不改变到达量,只让下游响应时间变长,验证并发数限流或自适应限流是否按预期收紧。这一项最能区分"只做了速率限流"和"做了完整限流"。

第四种是限流器故障注入:让共享存储不可用或响应超时,验证兜底策略是否按设计生效、降级判定是否被正确记录、业务是否仍然可用。

2、量化验收标准

演练必须有量化的验收标准,否则做完只能得到一句"看起来没问题"。

至少应当包含六条:超限开始拒绝的时间不超过某个上界;拒绝期间通过量与配置阈值的偏差在可接受范围内;被拒请求的响应符合契约(状态码、重试提示、额度回传均正确);下游承受的瞬时并发不超过其容量;限流器自身的判定耗时高分位不超过某个值;故障注入期间业务的可用性符合预案。

还有两个工程技巧值得一提。第一,测试限流逻辑时应当把时钟抽象成可注入的组件,用虚拟时钟推进时间,这样窗口边界、退避、过期等行为都可以确定性地验证,不必真的等待。第二,边界条件应当用表驱动的方式穷举:刚好到阈值、刚好差一个、时间刚好跨越窗口、余额刚好为零------这些点恰恰是最容易写错的地方。

十、把这道题讲清楚:作答范式

(一)三种时间长度下的骨架

1、三分钟版

先给结构:限什么、按什么维度、限多少、超了怎么办。再给算法:四类算法各一句话,指出漏桶与令牌桶的状态互为镜像,真正的区别在突发容忍与超限动作。然后落到数据结构:余额加时刻两个字段,可以压进一个长整数,因而能用一次比较交换更新。接着正面回答队列问题:判定路径不需要队列,排队只是超限动作之一,而且必须有界。最后收尾:阈值必须有推导过程,限流必须可观测、可关闭。

三分钟版的关键是不要陷进任何一个细节。听者想确认的是你脑子里有没有一张完整的图,而不是你能不能背出某个参数的默认值。

2、十分钟版

在三分钟版的骨架上补五处。补上固定窗口的临界突刺与三种修法的代价对比------这是判断一个人有没有真正实现过限流的分水岭。补上桶容量的含义:它就是允许的最大突发,也是风险敞口,定它的判据是下游扛不扛得住。补上并发实现:为什么比较交换可行、状态为什么要压缩、热点键为什么必须分片。补上分布式三形态与精度、开销、可用性的三角。补上超限动作的完整设计:状态码、重试提示、剩余额度回传。

3、四十分钟版

四十分钟或白板题需要从头讲一遍完整工程。从过载曲线讲起,说清限流的目标是把系统停在拐点左侧;逐层展开五道闸,说明每层看得见什么、该拦哪类流量;给出规则模型与配置下发链路,包括灰度、版本可观测与本地兜底;给出指标清单、阈值标定方法与那条六步排查路径;最后以演练收尾------注入突发与依赖变慢,验证既拦得住也放得开。

这个版本里,最能加分的往往不是算法细节,而是两处:一是明确说出限流器自身也会故障、也需要兜底;二是明确说出哪些场景不该用限流,而该用扩容、隔离或熔断。

(二)六处最容易被追问穿的地方

第一处,"漏桶和令牌桶到底有什么区别"。正确答法是先说两者状态互为镜像、数学上等价,再落到超限动作与突发容忍这两处真正的分歧,最后指出漏桶其实有排队式与判定式两种形态,说的是哪一种要先讲清楚。

第二处,"你用的什么数据结构"。按算法分别答:固定窗口是两个字段,滑动窗口是环形数组或两个计数,令牌桶是余额加时刻、可压进一个长整数,判定式漏桶是一个时间戳。并补一句:结构的大小决定了它能不能被一次原子操作改完。

第三处,"并发下队列有性能问题吗"。拆成吞吐、延迟、内存三层答,指出延迟那一层与队列实现无关,然后给出关键判断------限流判定本身不需要队列,队列只在必须匀速下发时才出现。

第四处,"为什么不用定时器补令牌"。补充是请求到来时的一次乘加,没有定时器开销与唤醒风暴,状态也因此保持在两个字段以内------这正是它在高并发下表现好的根源。

第五处,"阈值怎么定的"。给出压测拐点反推、容量模型正推、历史分位兜底三条路及其优先级,并说明推导过程必须被写下来,否则事故现场没人敢动。

第六处,"限流组件挂了怎么办"。先说判定路径的独立超时与本地兜底,再说失败放行与失败拒绝各自的适用场景与配套措施,最后强调降级判定本身要打进指标。

(三)一句话收尾

如果整篇只留一句话,那就是这句:限流不是把请求挡在门外,而是在容量之内,为最有价值的那部分请求留出确定性。

沿着这句话往回看,前面所有的技术选择都有了统一的解释。选算法,是在决定这段突发该被吸收、被摊平还是被拒绝;选数据结构,是在决定这个判断要花多少代价;讨论队列,是在决定这次等待有没有意义;做分层,是在决定每一类流量应该在哪一层被识别;标定阈值,是在给"容量之内"这四个字一个可验证的数字;建可观测性与演练,是为了让这套机制在真正需要它的那一刻不掉链子。

参考资料

模式与理论

站点可靠性工程实践

协议与接口规范

框架与组件实现

并发与数据结构

相关推荐
Trae_陈冀23 分钟前
写详细设计总堆代码没逻辑?我做了个自动生成工具,3套模板+规范内置,输出直接过评审
架构·agent
Tokenfree24 分钟前
用户一句「忽略以上指令」,客服机器人就开始骂人:提示词注入的四类入口和四层防线
后端
Linye30 分钟前
AI 越跑越偏?从 500 行臃肿规则到 80 行核心约束:多 Agent 项目中的「规则瘦身与防腐」四步排查法
人工智能·架构
Bazingga40 分钟前
一次线上CPU 100%的问题排查
后端
AskHarries1 小时前
如果人生可以重新投胎一次,你有多大概率出生在富豪家庭?😂
后端
nice_lcj5201 小时前
ThreadLocal 入门:它是怎么隔离数据的、为什么会导致内存泄漏
java·开发语言·后端·面试·并发编程·内存泄漏·threadlocal
紧跟先前的步伐1 小时前
Go AI Agent 实战 28|Agent 的预算、超时与停止条件
开发语言·人工智能·后端·ai·golang
Hespethorn1 小时前
112 个 Valgrind 错误,1 个根因:C++ 多线程服务的关机顺序复盘
后端·架构
小兔子2 小时前
new Date('2024-01-01') 和 new Date('2024/01/01') 差在哪
后端