Theory · Microservice · Rate Limiting
容量有限而流量无限 —— 用算法把洪峰挡在容量之内,保护自己也被保护
对并发/速率设上限:超限的请求快速失败或排队,换取系统在容量内确定性地服务
固定窗口 · 滑动窗口 · 漏桶(整流)· 令牌桶(许可突发)——单机与分布式实现分开记
网关限流(防刷粗控)→ 服务限流(保容量细控)→ 自适应限流(按负载动态调阈值,BBR/SRE 思想)
Why Rate Limiting Matters
| 时刻 | 发生的事 |
|---|---|
| 平时 | 订单服务压测容量 3000 QPS,日常 800 QPS,单次请求 20ms,一切平静。 |
| 10:00 | 运营群发 push,20000 QPS 打进来——是容量的 6.6 倍。 |
| 10:00:03 | 线程池 / 连接池被打满,多出来的 17000 个请求在排队;延迟 20ms → 500ms → 5s。 |
| 10:00:10 | 客户端超时 → 自动重试 → 流量变成 25000 QPS(重试放大)。用户等不及反复点,雪上加霜。 |
| 10:01 | 服务彻底不可用——注意:不只是下单挂了,连原本正常的查询接口也一起挂了,因为资源被排队请求占满。 |
没有限流:20000 个请求全部涌进来,成功极少、全部超慢,最终 0 个可用服务。
装了限流:每秒只放行 3000 个,其余 17000 个立刻返回 429(不等、不排队)——3000 个请求仍是 20ms 返回,服务始终可用,被拒的客户端拿到 Retry-After 后错峰重试。
代价:17000 个用户被拒绝;收益:3000 个用户正常下单,且系统不会滚雪球到全站不可用。
它换掉的是"来者不拒、谁也服务不好"的过载状态。核心交换是:用"一部分请求快速失败"换"剩余请求的服务质量确定"——注意"快速"两个字很关键,被拒的请求必须立刻返回,如果让它们排队等待,就等于把洪峰留在了系统里。
先明确限什么、按什么限(第 3 页)→ 再看四种算法怎么把"速率"管住(第 4-6 页)→ 然后解决多台机器怎么协同限(第 7-8 页)→ 最后讲限在哪一层、被拒了怎么办(第 9-10 页)。读完你应该能回答:"这道闸该设在哪、设多少"。
Why & What
开场那场雪崩,解法就落在这一页的三个选择上:保护谁(护己 / 护依赖)· 按什么统计(限流 key)· 限哪种量(QPS / 并发)。
| 维度 | 场景 |
|---|---|
| 全局总量 | 保护服务总容量(QPS 上限) |
| 按调用方/API Key | 开放平台配额、计费 |
| 按用户/设备 ID | 防单用户刷接口 |
| 按 IP | 网关防爬/防刷(注意 NAT 共出口误伤) |
| 按接口/资源 | 热点接口单独限额(秒杀 vs 查询) |
| 按租户 | 多租户 SLO 隔离(防邻居效应) |
QPS(速率)、并发数(同时在处理的请求)、连接数、带宽——四种"量纲",先选量纲再选算法
压测得出容量拐点 → 乘安全系数(70-80%)→ 全链路压测验证 → 配置中心动态可调(大促调高/故障时调低)
限流是"服务端主动防御"(进来的流量),熔断/隔离是"调用方自我保护"(出去的调用)——见容错 deck
Prerequisites & Glossary
| 术语 | 一句话理解(先记住这个,细节后面展开) |
|---|---|
| 限流 Rate Limiting | 给速率或并发设上限,超出的请求快速失败(或排队),让系统在容量内确定性地服务 |
| QPS / 吞吐 | 每秒处理的请求数。与并发的关系:QPS = 并发数 ÷ 平均耗时(Little's Law) |
| 并发数 | 同一时刻正在处理的请求数。限并发 = 限制"同时占用的资源数",比限 QPS 更贴近资源 |
| 容量 / 拐点 | 系统能长期承受的最大吞吐;越过拐点后延迟会雪崩式上升(开场那张图) |
| 限流 key (维度) | 按什么口径统计次数:全局 / 用户 / IP / 接口 / 租户。选错 key 等于限了个寂寞 |
| 窗口 Window | 统计用的时间区间。固定=到点清零,滑动=区间随时间平移 |
| 令牌桶参数 r 与 b | r = 每秒投放的令牌数(长期平均速率);b = 桶容量,也就是允许的突发预算 |
| 突发 Burst | 短时间内超过平均速率的合法流量(如整点秒杀)。能不能放突发,是令牌桶与漏桶的分水岭 |
| 整形 vs 管制 Shaping / Policing | 整形=排队后再恒速发出(漏桶);管制=超限直接丢弃(令牌桶)。前者加延迟,后者直接拒绝 |
| 削峰填谷 | 用队列把瞬时洪峰摊平到后面慢慢处理(异步任务适用;交互式请求不适合) |
微服务总览 → 为什么一个系统会有很多实例(限流要跨实例协同的起因)
熔断降级容错 → 限流是"事前",熔断是"事中",降级是"善后"——三者是一套
Redis 单线程模型 → 后面分布式限流靠"Lua 脚本原子执行",根基在这里
统一直觉:所有限流算法都在维护同一个东西——一个"额度计数器"。来一个请求扣一次,扣得到就放行,扣不到就拒绝。四种算法的差别只有一件事:额度怎么补充。固定窗口 = 到点一次性清零重来;滑动窗口 = 随着时间滑走一点点还给你;漏桶 = 恒速往外放水;令牌桶 = 恒速往里发牌,且允许你攒着一次性花掉。
评价一个限流方案只看三件事:准不准(会不会超卖容量)、快不快(每个请求多付多少延迟/网络往返)、扛不扛得住异常(Redis 挂了、实例扩缩容了怎么办)。后面所有架构取舍都是这三者的权衡。
Window Algorithms
按上一页的心智模型:这两种算法的差别,就是"额度什么时候还给你"——一次性清零,还是随时间滑走。
Leaky Bucket
| 优点 | 缺点 |
|---|---|
| 输出绝对平滑,强整流 | 无法应对突发:即使系统空闲,突发的合法请求也只能按恒速排队 |
| 保护下游不给压力 | 排队引入延迟;桶大小与延迟直接挂钩(带宽-延迟积) |
| 实现简单(FIFO+定时器) | 分布式实现复杂(全局队列难做) |
① 队列版:请求排队、恒速处理(本页所述,true shaping);② 计数版:有的实现把漏桶当"恒速放行计数器"(如每 1/r 秒放一个),效果等同。面试先声明自己说的是哪种,避免和令牌桶对比时张冠李戴。
对下游限速的典型实现:golang.org/x/time/rate 是令牌桶(不是漏桶!);真正的漏桶多见于消息发送节流(如 Kafka producer 按字节速率节流)与出网关适配器。别把 x/time/rate 说成漏桶——这是常见翻车点。
Token Bucket
| 维度 | 漏桶 | 令牌桶 |
|---|---|---|
| 输出速率 | 严格恒定 | 平均 r,允许突发至 b |
| 突发流量 | 排队(延迟) | 直接放行(消耗存量令牌) |
| 控制对象 | 流出速率(整形) | 流入速率(管制) |
| 实现方向 | 队列+定时消费 | 定时/惰性补令牌 |
| 典型应用 | 下游保护、QoS | 接口限流默认选型 |
golang.org/x/time/ratelimiter := rate.NewLimiter(
rate.Limit(100), // r: 每秒100令牌
200) // b: 桶容量200
if !limiter.Allow() { // 惰性补令牌
// 429 Too Many Requests
}
limiter.Wait(ctx) // 阻塞等令牌(可超时)
惰性计算:不靠定时器,取令牌时按时间差补发——零定时器开销,单机限流首选。
保护自己接口 → 令牌桶(默认);保护脆弱下游/平滑流量 → 漏桶;要"窗口统计口径"(如报表/风控)→ 滑动窗口。变体:多桶组合(用户级+接口级+全局级串联,过三道闸)、预热令牌桶(Guava RateLimiter warmup:冷启动期速率低于 r,令牌产量爬坡——配合缓存预热场景)。
Distributed via Redis
// 错误做法:两条独立命令
n := GET key
if n < limit { SET key n+1 } // 竞态!
// 两个实例同时 GET 到 n=99
// 双双放行 → 超限
Redis 单线程执行脚本:Lua 里的"读-判-写"是一个原子操作,天然无竞态。配合 EVALSHA 减少传输、TTL 做窗口回收。
local n = INCRBY key 1 if n == 1 then EXPIRE key window_sec end if n > limit then return 0 -- 拒绝 end return 1 -- 放行
令牌桶 Lua 略复杂:存 {tokens, last_ts},每次按 (now-last_ts)*r 补令牌(封顶 b)再扣减——同样是"读-算-写"原子化。
ZSET 存请求时间戳:ZADD 当前请求 → ZREMRANGEBYSCORE 清窗口外 → ZCARD 计数判断。精确但内存高——只适合低频限流(如风控)
每次请求一次 Redis 往返(~1ms 内网):QPS 10w 时 Redis 单点 10w ops 压力大——引出下一页的"单机分摊"方案
Redis 挂了限流怎么办?fail-open(放行,靠服务自身容量扛)或 fail-close(拒绝,宁停不超)。一般选 fail-open + 本地限流兜底——限流组件不能成为新的单点
Cluster Quota
所有实例实时问 Redis/专用限流服务(如 Sentinel Token Server 模式)。精确但:每请求一次网络往返、中心组件成为吞吐瓶颈与可用性依赖。
适合:低频高价值(支付、开放平台配额)。
总配额 10000 QPS ÷ N 实例 = 每实例 10000/N 本地限流;实例上下线时配额管理器重分配(心跳感知)。
流量在实例间不均时会有局部误杀——配合负载均衡尽量让流量均匀,容忍 ±10% 误差换零网络开销。工业界主流。
本地令牌桶做第一道(零成本、扛毫秒级洪峰)+ 低频全局校准(每秒同步一次全局计数,动态微调本地阈值)。
两层宽松叠加 ≈ 全局精确;延迟敏感场景的折中最优解。
静态平分不感知实例数变化:扩容 10→20 台后每台还限老值 → 总限流翻倍;缩容后 → 总限流减半误杀。必须有实例发现 + 动态重分配(对齐注册中心 deck 的实例列表)。同理流量倾斜时平分也会局部误杀——所以分摊方案要配合 LB 的均匀性。
| 场景 | 方案 |
|---|---|
| 计费/配额(必须准) | 中心化 + 异步补偿对账 |
| 保护容量(±10% 可接受) | 配额分摊 |
| 毫秒级洪峰(网关入口) | 本地第一道 + 全局校准 |
Placement & Adaptivity
普通限流限"总 QPS",热点限流限"某个参数值":秒杀场景全局 10w QPS 没问题,但"iPhone 发布"这一个商品 ID 占 8w——按商品 ID 滑窗计数,单 ID 超阈值即拒绝/排队,把热点从"全局风险"降为"局部事件"。
可排队场景(异步任务提交)用漏桶/队列削峰,把洪峰摊平后慢慢处理;交互式请求(下单)不做排队——排队延迟会让用户反复重试反而放大流量,直接快速失败 + 前端友好提示更优。
Rejection
核心指标:限流触发次数(按维度细分)、被拒请求的特征分布(哪个 IP/用户/接口)、限流后的服务 SLO。告警规则:限流率突增 = 攻击或热点事件,自动通知——限流不是配完就完,是持续运营的开关。
发压超过阈值:验证① 通过量稳定在阈值附近(不超卖容量);② 被拒请求快速返回 429(不堆积占资源);③ 阈值下调时在线生效;④ Redis 故障时 fail-open 行为符合预期。限流不验证 = 上线后第一次大促必翻车。
Cheat Sheet
| 固定窗口 | 到点清零重来;最简(Redis INCR+EXPIRE),但边界两侧可放 2 倍 → 临界突刺 |
| 滑动窗口 | 区间随时间平移,任意时刻不超限;消除突刺,代价是记录样本的内存 |
| 漏桶 | 排队 + 恒速漏出;输出绝对平滑,但拒绝突发(空闲也不能加速)→ 护下游 |
| 令牌桶 ★ | 恒速发令牌、可积攒;平均限速 + 突发预算 b → 接口限流默认选型(x/time/rate) |
| 一句话区别 | 漏桶 整形(排队,加延迟);令牌桶 管制(超限直接丢) |
| 中心化 | 每次问 Redis → 精确;代价是每请求一次往返 + 中心成为单点。适合计费/配额 |
| 配额分摊 ★ | 总配额 ÷ 实例数,本地执行;需感知实例数变化动态重分配。±10% 误差换零开销,工业主流 |
| 本地 + 全局 | 本地令牌桶扛毫秒洪峰 + 秒级全局校准;延迟敏感场景的折中最优 |
| Redis 挂了 | 默认 fail-open(放行)+ 本地令牌桶兜底;限流组件不能成为新单点 |
| 压测找拐点 | 延迟开始上翘 / 错误率开始出现的那个 QPS |
| 乘安全系数 | 阈值 = 拐点 × 70~80%(留给 GC、抖动、依赖波动) |
| 分层梯度 | 网关 ≥ 服务 × 副本数 ≥ 资源池;外层松内层严,别让请求"过了网关又全被拒" |
| 阈值会过期 | 扩缩容 / 流量结构变化都会让静态阈值失准 → 自适应(CPU、maxPass×minRT、BBR/BDP) |
| 直接拒绝 | 429 + Retry-After(必带,否则引发重试风暴);交互式请求的首选 |
| 排队等待 | 只适合异步任务;交互接口排队会让用户反复重试、反而放大流量 |
| 降级返回 | 返回缓存旧值 / 默认页——有损但可用 |
| 优先级调度 | 有限容量下保核心(登录用户 > 游客、读 > 低价值写) |
Interview QA · 1/2
先盖住答案自己答一遍,再展开对照——想不起来比看得顺眼记得牢;答不出的直接翻回上一页速查表。
接口限流默认令牌桶(平均速率+突发预算,x/time/rate 即此);保护脆弱下游或需要严格平滑 → 漏桶;需要"窗口统计"语义(风控/报表口径)→ 滑动窗口;临时兜底/配置最简 → 固定窗口(知道边界突刺即可)。先说场景再说算法,别背顺序。
缓解手段:① 缩短窗口粒度(分钟→秒级,突刺比例不变但绝对量变小);② 双窗口/加权:当前窗口计数 + 上一窗口剩余比例加权预估(近似滑动,O(1));③ 突刺无害化:把阈值按"窗口边界最坏情况"设定(直接按 2 倍瞬时设计容量)。但根治还是滑动窗口/令牌桶。
令牌桶限制的是"平均流入速率、允许突发"(traffic policing,直接丢弃超出的);漏桶强制"恒定流出速率"(traffic shaping,排队平滑)。形象记法:令牌桶是"攒了票就能进",漏桶是"不管多少人排队,检票口恒速"。
需要"读-判-写"原子。Redis 事务(MULTI/EXEC)不能先读再条件写(EXEC 前拿不到结果做判断);分布式锁是"串行化执行",每请求加解锁两次往返,QPS 掉一个量级。Lua 在服务端单线程原子执行,一次往返搞定——正确且最快。
一般 fail-open:放行请求,靠服务自身容量与降级预案扛(限流组件不能成为新单点);高价值场景(计费配额)可 fail-close。工程标配:Redis 不可用时自动降级到本地令牌桶(阈值按单机分摊),恢复后切回——两种模式都要在压测里演练过。
标准答案:配额分摊——每实例本地限 200,配额管理器(或利用注册中心实例列表)动态感知实例数变化重分配;流量不均导致 ±10% 误差可接受。更高精度:本地第一道 + 每秒向 Redis 汇报/校准一次全局计数微调本地阈值。全中心化(每请求问 Redis)只在计费级场景用。
Interview QA · 2/2
同样建议先自答。这一页的题都要"结论 + 一句代价/边界"才完整——只答结论容易被追问。
层层漏斗:前端答题/按钮置灰防重复点击 → 网关按用户/IP 限流防刷 → 服务层按商品 ID 热点参数限流 → 库存扣减用 Redis 原子预扣(超卖保护)→ MQ 削峰异步落库。核心思想:流量在每一层都被"合法地"削减,到 DB 只剩库存量级。限流阈值 = 库存量×安全系数而不是机器容量。
三步:① 全链路压测找拐点(延迟开始上翘、错误率开始出现的 QPS);② 阈值 = 拐点 × 70-80% 安全系数(留缓冲给 GC/抖动/依赖波动);③ 上线后按 SLO 动态调:P99 达标且限流率高 → 阈值偏保守可上调;P99 超标 → 下调或扩容。自适应限流(CPU/负载维度)解决"阈值过期"问题。
限流是事前:流量超过容量就拒,防止进入过载状态(服务端视角);熔断是事中:依赖故障时停止调用,防止被拖垮(调用方视角);降级是善后:被限/被熔之后给用户什么(有损响应)。三者的观测指标:限流率、熔断打开数、降级率——大屏三板斧。
会,且这是设计目标(纵深防御),但要控制梯度:网关阈值 ≥ 服务阈值 × 服务副本数,且网关略宽松——让"正常的洪峰"被服务层精确保护,"恶意流量"被网关层提前挡掉,避免大部分请求"过了网关又全被服务拒"(白付了网关成本还体验差)。
不可能完全无感(被拒就是被拒),目标是"体验可控":① 优先级保核心(登录用户 > 游客、读 > 写低价值);② 可排队场景给排队页/进度反馈(抢票模型),把"被拒"变成"稍等";③ 前端退避重试 + 兜底数据展示;④ 预估等待时间真实可信(别让用户白等)。
先分类:正常业务增长(流量涨了)→ 扩容或调阈值;流量结构异常(单用户/单 IP 占比高)→ 防刷策略;依赖变慢导致上游重试放大 → 修复依赖 + 查重试预算;攻击 → WAF/封禁。工具链:按限流维度的 TopN 分布(哪个用户/接口触发最多)→ 追对应调用链 trace。限流指标要能下钻到维度,否则没法排障。
Related & References