面试知识库

09 · 限流熔断#

简历原话:限流熔断:模型网关以 Redis Lua 令牌桶按供应商 RPM/TPM 限流;断路器基于 5xx 与首 token 超时熔断,切换备用模型。压测 100 并发下准入请求成功率 100%,首事件 P95 0.32s;超额请求返回 429 + Retry-After。

30 秒口述版#

ShoppingX 一个任务要调 4~9 次模型,多副本一起跑时,每个进程各自限速没用,供应商看到的是所有副本加起来的速率,429 就是这么来的。我在模型网关里做了两件事:一是 Redis Lua 令牌桶,按「供应商 + 模型」开 RPM、TPM 两个桶,两个桶要么一起扣、要么都不扣,TPM 先按估算预扣、响应回来按真实 usage 找平;二是断路器,只把 5xx、连接失败和首 token 超过 15 秒记成故障,429 和慢流不算,连续 5 次故障就熔断,请求直接切到备用模型。入口那层再用一条 Lua 做原子准入,队列满了直接回 429 + Retry-After。压测 100 并发,准入请求 100% 成功,首事件 P95 0.32 秒。

背景与问题#

  1. 多副本各限各的,等于没限。 早期网关只有进程内的并发信号量和「请求起点间隔」。API 和 worker 拆成多副本后,每个进程都觉得自己很克制,但供应商按账号算 RPM/TPM,3 个 worker 就是 3 倍速率。现象是高峰期一轮对话里连着撞 429,框架自带的重试再撞一次、再退避,一条 query 的延迟从 20 秒抖到一两分钟。
  2. 只限条数挡不住 TPM。 本项目单次模型调用的输入从 1k 到 30k token 不等(planner 小、主环带工具 schema 和候选列表大)。只按 RPM 限,一条 30k 的长请求和一条 500 token 的短请求同价,TPM 那条线照样会撞。
  3. 供应商挂了要等 60 秒才知道。 没有断路器时,某家出口挂掉,每一次调用都要耗满 60 秒总超时才失败。并发在飞的请求全卡在这 60 秒上,credit 已经预扣、用户盯着空白页、队列越堆越长。
  4. 「超时」这个信号是混的。 有的超时是对面真没干活(连首 token 都不来),有的是长回答在正常吐字、只是慢。一刀切按超时熔断,长回答一多就误熔断;完全不管超时,又抓不到「连上了但不出字」这种最常见的半死状态。
  5. 入口没有背压。 任务交给队列之后,API 进程本身一个 AgentLoop 都不跑,原来「进程内并发池满了就 429」的闸失效了。队列看着什么都能收,用户却在等一个永远排不到的位置。后来加的队列深度闸又是「读深度 → 判断 → 入队」三步,100 个请求同时读到「未满」,上限 8 实际放进 47 条。

做法与取舍#

整个方案分三层,按「越靠外越便宜、越早拒绝」排:入口准入 → 模型网关令牌桶 → 断路器 + 备用模型。

层拦什么拦住后怎么办状态放哪
入口准入新任务,队列装不下回 429 + Retry-After 10 秒Redis 有序集合 + Stream 消费组积压
令牌桶每次模型调用,超出供应商 RPM/TPM睡到有令牌,满 30 秒放行并告警Redis hash,按供应商/模型各两个键
本进程闸门每进程同时在飞的请求数、起点间隔排队等并发位;撞 429 后推 5 秒进程内信号量
断路器出口连续真实故障快速失败,切备用模型进程内,按供应商/模型一个

1. 入口准入:一条 Lua 做判定 + 登记,满了回 429 + Retry-After#

  • 做法:POST /api/task 进门第一件事就是准入判定,早于会话归属登记、读历史轮数这些数据库操作。判定是一条 Lua:先清掉登记表里超过 30 秒的旧记录,再算「队列里还没投递的 + 已准入但还没入队的」,没到上限就把本次任务 id 登记进一个有序集合(globex:admitted,分数是 Redis 服务器时间),超了返回 -1。API 回 429,带 Retry-After: 10。任务入队成功、或中途失败没走到入队时,都会撤掉这条登记。
  • 为什么判定和登记必须是一步:分开做就是经典的「检查再执行」竞态,100 个请求同时读到未满全放进来。放进 Lua 里,Redis 单线程执行脚本,天然串行。
  • 为什么登记要带 30 秒过期:API 进程可能在「准入」和「入队」之间被 kill,不带过期的话这个名额永久占着。这和带超时的计数信号量是同一个写法。
  • 为什么闸放在最前:被拒的请求不该先付数据库开销。挪之前 429 的 P95 是 0.54 秒,和放行的请求一样慢;挪之后 0.17 秒。
  • 两种拒绝码分开:同一用户并发超限、队列满,回 429 + Retry-After(前面的跑完就能进,重试是对的);当天额度用完回 402,不给 Retry-After(等 10 秒也不会好)。队列满排在额度检查之前,过载时先让人看到「稍后重试」。
  • 否掉的方案:用 Nginx limit_req 在最外层按 IP 限流。它只能按请求速率限,看不到队列里积压了多少,挡不住「每秒进来不多、但每个都要跑 20 秒」的堆积;而且多用户共用出口 IP 时会误伤。

2. 模型网关:Redis Lua 双令牌桶,按「供应商/模型」一本账#

  • 键的形态:每个模型两个 hash 键,globex:llmbucket:{供应商}/{模型}:r(RPM 桶)和 …:t(TPM 桶),字段是当前令牌数和上次填充时刻,5 分钟不用自动过期。
  • 限额按「模型 → 供应商 → 全局」三级取,每个维度取第一个非 0 的值。模型那一级不能省:同一个百炼出口下,deepseek 的 TPM 是 120 万、qwen3.8-flash 是 500 万,差 4 倍,只按供应商配就只能取小的。
  • 两个桶同生共死:任一个不够,一格都不扣,只回「建议等多少毫秒」。扣了 RPM 却因 TPM 不够退回,那一格就漏了,这种漏账不报错,只表现成「桶没满却老在等」,几百次之后才看得出来。
  • TPM 预扣 + 结算:发请求前不知道会烧多少 token,就先估:输入用分词器数(消息正文 + 工具调用参数 + 18 个工具的 JSON schema),输出按 1024 预留。响应回来拿供应商给的 usage 算差额,多退少补;少估的部分允许把桶扣成负数,欠的账让下一次多等一会儿还回来。调用失败不结算:拿不到 usage 不等于没花 token,预扣留在账上是保守的一侧。
  • 时钟只认 Redis 的 TIME:各副本系统时间差几秒是常态,让每个副本用自己的 now 去填同一个桶,等于凭空多发或少发令牌。
  • 拿不到令牌就睡,不回 429:Lua 返回建议等待时间,网关睡这么久再试(多睡 20ms,免得刚好差一点白跑一趟 Redis)。等满 30 秒仍拿不到就放行并记告警。理由是这一层拦的是任务中途的模型调用,此时前面几次调用和 credit 都已经花了,拒掉等于整轮白跑;真撞上 429 还有下一层兜着。429 只在入口回,那时用户还没花任何东西。
  • 顺序是断路器 → 令牌桶 → 本进程并发位:前两道都是「先别发」的判断,占着并发位去等全局配额,等于拿 20 个槽排一条跨副本的队。
  • 否掉的方案:
    • 固定窗口计数(INCR + EXPIRE):窗口边界会放出 2 倍突发,而供应商背后还有秒级的 RPS/TPS 线。
    • 滑动窗口日志(ZSET 存每次请求时间戳):RPM 能做,TPM 做不了——每条请求的「重量」不同,还要事后结算,令牌桶的「可为负」天然支持。
    • 只在进程内限、按副本数均分额度:副本数一变就要重配,扩缩容时必然有一段时间超发或欠用。

3. Redis 慢了就退进程内桶,不让限流层拖垮主链路#

  • 每次 Lua 调用限时 50ms,超时或连不上就改用本进程的一份桶(算法同 Lua,时钟换单调时钟),并进入 10 秒冷却:冷却期内直接走本地桶,不再为每次模型调用赔一个 50ms 超时。
  • 退化期不按副本数分摊限额,宁可整体放宽 N 倍。这一层的定位是「少撞 429」的优化,不是正确性前提;为了精确分摊要引入「我是第几个副本」这种状态,比 Redis 本身还脆。
  • 预扣和结算之间刚好发生切换时,这笔差额会记到另一本账上,不去追。键 5 分钟过期会自然清零,为此在主链路上多存一份「这次在哪本账上扣的」不划算。

4. 断路器:只对「对面挂了」计数,三档超时口径分开#

  • 状态机:按「供应商/模型」一个断路器。关闭态下连续 5 次真实故障转打开;打开态直接快速失败、不发请求、不占并发位;30 秒后转半开,放一次探测,成功回关闭、失败回打开。
  • 计为故障:5xx、连接失败、首 token 超时。
  • 不计(中立,既不累计也不清零):
    • 429:说明我们发太快,对面好得很。降速交给网关闸门(撞到 429 就把下一个可发时刻整体后推 5 秒)。拿 429 熔断等于自己把自己关在门外。半开态撞上 429 也记中立,否则探测会悬在半开出不来。
    • 60 秒总超时:一条流跑满 60 秒说明它慢,不说明它坏,首 token 早到了、内容也在吐。
    • 4xx:请求本身有问题,换谁都是 400。
    • 用户点停止引起的取消、本地解析 bug:与对面无关,拿本地 bug 熔断供应商只会把一个错误变成两个现象。
  • 首 token 超时 15 秒,而且真掐断:只记一笔故障却继续等,就只是换个地方等满 60 秒。超时后主动关掉 HTTP 流,否则对面真卡住时连接数会跟着请求一起涨。15 秒的依据是实测首 token:本机 1.12.1 秒、线上 1.92.8 秒,和输入长度基本无关(前缀缓存命中后 22k 输入反而比 1k 快),15 秒约是线上峰值的 5.5 倍。
  • 首 token 计时的两个细节:
    1. 从拿到并发位之后开始算。排队等令牌、等并发位是我们自己的节流,算进去会在高并发时集体误判对面挂了。
    2. 每次重试重新计时。框架的重试在模型调用内部,如果从第一次尝试算起,限流风暴里前两次 429 的耗时会吃掉第三次的预算,把本来要成功的调用误掐还记一笔故障,等于绕过了「429 不计」。
  • 断路器按进程,不进 Redis:多副本下各进程各学各的,最多各白等 5 次。它的收益是「第 6 次之后不再等 60 秒」,每进程前 5 次的代价可以接受;模型调用是每轮最高频的外呼,每次再多一跳 Redis 不值得。

5. 熔断后切备用模型,备用要先过能力门#

  • 切换链:主出口的断路器打开后,调用直接走备用链下一跳,顺序是「先换出口、再换模型家族」,例如主用百炼上的 deepseek,备用先换 deepseek 开放平台,再换 qwen3.8-flash。主备都挂时才对上层报错,由任务级重试接手。
  • 能力门:备用模型必须能做工具调用,不然切过去整条 Agent 主环都跑不动,「切了不如不切」。能力信息查 LiteLLM 自带的模型能力表:查到不支持就剔出备用链并记错误日志;查不到(比如新模型还没收录)放行并告警。这是否决门不是放行门,把「查不到」当「不支持」会把自己在用的模型判死。
  • 切换要让人看见:第一次切到备用模型时推一条 model_fallback 事件到前端,否则「今天怎么变慢 / 变笨了」永远查不出。
  • 流中途断不切:备用切换只在首 token 之前成立。HTTP 200 已经回来、流吐到一半断掉,这时换模型接不上半截输出,只能整次调用重试;断路器把它记成一次连接失败。
  • 否掉的方案:
    • 滑动窗口失败率熔断(如 Hystrix 的 50% 错误率):单进程每分钟模型调用只有几十次,窗口里样本太少,一两次抖动就能把失败率拉过阈值。
    • 部署一个独立的 LLM 网关服务(LiteLLM Proxy / Envoy):多一跳网络、多一个要高可用的组件;而且 429 不计、首 token 掐断、TPM 结算这些口径都要改它的源码才能做。最后只用 LiteLLM 的 Router 做多供应商寻址(它把「每接一家就要重读一遍兼容层差异」的活包了),令牌桶和断路器仍然自己做,包在它外面。

流程图 / 架构图#

图 1:一个任务从进门到每次模型调用经过的闸#

flowchart TD
    C["前端 POST 提交任务"] --> A{"入口准入 Lua<br/>未投递 + 已准入未入队 小于上限?"}
    A -- 否 --> R429["429 + Retry-After: 10"]
    A -- 是 --> H{"用户级并发 / 额度"}
    H -- 并发超限 --> R429
    H -- 额度用完 --> R402["402 额度不足"]
    H -- 通过 --> Q["写入 Redis Stream 任务队列<br/>撤掉准入登记"]
    Q --> W["worker 领取任务<br/>跑 AgentLoop"]
    W --> M["某一次模型调用"]
    M --> B{"断路器<br/>主出口是否打开"}
    B -- 打开 --> F["直接走备用链下一跳"]
    B -- 关闭或半开 --> T{"Redis Lua 双桶<br/>RPM 1 格 + TPM 预扣"}
    F --> T
    T -- 不够 --> S["按建议时间睡眠后重试<br/>满 30s 放行并告警"]
    S --> T
    T -- Redis 超 50ms --> L["进程内桶 冷却 10s"]
    L --> P
    T -- 扣成功 --> P["本进程并发位 + 起点间隔"]
    P --> U["发请求 首 token 计时 15s"]
    U -- 首 token 超时 / 5xx / 连接失败 --> X["记故障 掐断连接"]
    U -- 429 --> N["记中立 闸门后推 5s"]
    U -- 正常结束 --> OK["记成功<br/>按 usage 结算 TPM 差额"]

图 2:断路器状态流转#

stateDiagram-v2
    [*] --> 关闭
    关闭 --> 关闭: 成功 清零计数
    关闭 --> 关闭: 429 / 总超时 / 4xx 记中立
    关闭 --> 打开: 连续 5 次 5xx / 连接失败 / 首 token 超时
    打开 --> 打开: 请求直接快速失败 转备用
    打开 --> 半开: 30 秒恢复窗口到期
    半开 --> 关闭: 探测成功
    半开 --> 打开: 探测失败
    半开 --> 半开: 探测撞 429 记中立 下次再探

图 3:TPM 预扣与结算时序#

sequenceDiagram
    participant G as 模型网关
    participant R as Redis 桶
    participant V as 供应商
    G->>G: 估输入 token 含工具 schema + 输出预留 1024
    G->>R: Lua 取令牌 RPM 1 格 + TPM 预扣
    R-->>G: 允许 或 建议等待毫秒数
    G->>V: 发流式请求
    V-->>G: 首片 15 秒内到达
    V-->>G: 末片带真实 usage
    G->>R: Lua 结算 真实减预估 可扣成负数

数字怎么来的#

压测的目的是看服务这条路本身的承载:入口准入 → 入队 → worker 领取 → 推出第一条事件。为了不让供应商那边的抖动混进来,模型调用换成固定 3 秒的打桩实现,其余全是真组件(真 MySQL、真 Redis Stream、真 WebSocket 推送)。断路器和令牌桶另外用故障注入单独验。

1. 100 并发,准入请求成功率 100%

  • 工具:k6,100 个虚拟用户先建 WebSocket 连接,再同时提交任务(connect-first,模拟 100 个人同一时刻按下发送)。
  • 配置:API 4 个进程;worker 2 个进程 × 每进程 50 个并发槽;队列上限 200。
  • 口径:「准入请求」指没被 429 挡掉、进了队列的请求。成功 = 收到终态事件且没有 5xx / 超时 / 连接断开。跑 3 轮,每轮 100/100,其他失败 0。

2. 首事件 P95 0.32 秒

  • 首事件 = 客户端发出 POST 到 WebSocket 上收到这个任务第一条 AG-UI 事件的时间。k6 和服务在同一台机器上,同一个时钟,可以直接相减。
  • 3 轮分别是 0.32 / 0.26 / 0.24 秒,简历取最差的一轮。
  • 对照:同配置下 API 只开 1 个进程,3 轮是 0.66 / 0.59 / 0.64 秒。
  • 拆段看:首事件 ≈ 提交请求的往返时间 + 约 30ms。连 WebSocket、在队列里等 worker、推事件这几段都只有个位数到几十毫秒,瓶颈在 API 处理提交请求这一段,所以加进程基本按进程数摊薄。

3. 超额请求返回 429 + Retry-After

  • 把队列上限收窄到 8、worker 只留线上默认的 4 个槽,再打 100 并发突发。3 轮每轮放行 1516 条、其余 8485 条都是 429 + Retry-After,没有一条 5xx。
  • 放行数在理论区间内:闸的口径是「未投递 + 已准入未入队 ≤ 8」,被 worker 领走的 4 条不再计入,所以一次突发应放 812 条;打桩 3 秒期间有任务跑完腾出位置,才到 1516。把打桩改成 30 秒(压测期间没有任务结束)再跑,放行 9 条。
  • 429 本身的延迟 P95 是 0.17 秒。原子化之前同样的场景放进了 47 条(上限是 8),429 的 P95 是 0.54 秒。

4. 断路器 / 令牌桶怎么验的

  • 令牌桶:两个桶一起扣、结算、可为负这几条逻辑在 Lua 里,必须连真 Redis 验,本地起一个 Redis 容器跑;网关层的用例用框架真实的消息对象造请求,不用手写的 dict(下面追问里讲为什么)。
  • 断路器:本地起一个模拟 OpenAI 协议的端点,分别让它恒回 500、首片前睡 20 秒、恒回 429,看断路器计数、打开时间、是否切到备用、429 是否记中立。切到备用之后再检查请求体里的缓存标记和思考开关还在。
  • 首 token 15 秒的阈值来自线上真实调用的首 token 分布(见做法第 4 点)。

追问 Q&A#

Q1:为什么令牌桶要放 Redis,进程内不行吗?

供应商的 RPM/TPM 是按账号算的,不管你有几个副本。进程内桶每个副本各有一份,3 个 worker 就是 3 倍额度,扩容一次就要重新按副本数均分一次。放 Redis 之后所有副本扣同一本账,扩缩容不用动配置。进程内那份我也留着,只在 Redis 超时的时候临时顶上。

Q2:为什么用 Lua,直接 GET 再 SET 不行吗?

令牌桶一次取令牌是「读余量 → 按时间补 → 判断够不够 → 扣」四步,拆开发就是检查再执行的竞态,两个副本同时读到剩 1 个令牌、都扣成功。Lua 脚本在 Redis 里是原子执行的,中间不会插进别的命令。而且我这里是 RPM、TPM 两个桶要一起判断一起扣,用 MULTI/EXEC 做不了「先看两个桶的值再决定扣不扣」,WATCH 乐观锁在高并发下重试会很多,Lua 最直接。脚本本身只有几个 HGET/HSET,执行在微秒级,不会堵住 Redis。

Q3:TPM 在请求发出前根本不知道会用多少 token,你怎么扣?

先预扣再结算。输入用分词器数,消息正文、工具调用参数、工具 schema 都算;输出固定预留 1024。响应最后一片带真实 usage,拿「真实 − 预估」补差:估多了退回,估少了继续扣,允许扣成负数,欠的部分让后面的请求多等一会儿。失败的调用不结算,因为拿不到 usage 不代表对面没算,预扣留着是偏保守的一边。

Q4:(质疑)你说估算,那估不准怎么办?有没有出过问题?

出过一次,而且是不报错的那种。网关这一层拿到的消息是框架还没格式化的对象,不是 OpenAI 格式的 dict,最早的估算只认 dict,于是输入永远估成 0,预扣只剩输出那 1024。账最后靠结算还是对得上,所以没有任何告警,但突发时桶等于没拦。上线后我看日志里的估算值是个位数才发现。修法是两种形态都认,并且把网关层测试改成用框架真实的消息对象造请求,用手写 dict 造的测试在这个 bug 下照样是绿的。

Q5:为什么 429 不计入熔断?供应商都拒你了还不算故障?

429 说明的是我们发太快,对面是健康的。如果拿它熔断,限流一来断路器就打开,所有请求连试都不试直接失败,等于自己把自己关在门外。429 的正确处理是降速:闸门把下一个可发时刻往后推 5 秒,同时令牌桶本来就在拦。还有一个细节,半开态的探测如果撞上 429 要记中立而不是什么都不记,否则断路器会一直悬在半开出不来。

Q6:总超时不计、首 token 超时计,为什么这么分?15 秒怎么定的?

总超时 60 秒触发时,首 token 早就到了,模型在正常吐字,只是回答长,这叫慢不叫坏。首 token 迟迟不来,才说明对面连上了但没在干活,这是供应商过载时最常见的样子。15 秒是看线上真实调用定的:首 token 在 1.9~2.8 秒之间,而且和输入长短关系不大(前缀缓存命中后长输入反而更快),15 秒大约是峰值的 5 倍多,留足余量又比 60 秒早得多。超时之后要真的关掉连接,只记故障不掐断的话,就只是换个地方继续等满 60 秒。

Q7:你实现首 token 超时的时候踩过什么坑?

两个。第一个是 asyncio.wait_for 在这里失效:它靠取消等待中的任务来实现超时,再看任务最终结果,而框架的流式包装把 CancelledError 吞了,还往外吐一片累计结果,于是 wait_for 把一片空响应当正常结果交回来,超时静默失效。我改成用 asyncio.wait 只看时间到没到,到了就取消并关流、自己抛超时异常。第二个是重试:重试发生在框架的模型调用内部,如果首 token 从第一次尝试开始算,前两次撞 429 的时间会吃掉第三次的预算,最后那次本来会成功的调用被误掐、还记一笔故障,正好绕过「429 不计」。所以每次尝试重新计时。

Q8:为什么令牌桶拿不到就睡、不直接回 429?简历上又说超额回 429,不矛盾吗?

两层拦的东西不一样。入口那层拦的是新任务,这时用户还没花 credit、也没发任何模型调用,拒掉最便宜,所以回 429 + Retry-After 让前端过 10 秒再试。令牌桶拦的是已经在跑的任务里的某一次模型调用,前面几次调用已经花了钱,这时回错误等于整轮白跑,所以只让它排队等。等满 30 秒还拿不到就放行并告警,这通常说明限额配小了或副本开多了;放行之后真撞 429 还有降速兜着,而把用户任务饿死在门口没有任何东西能兜。

Q9:Redis 挂了怎么办?

每次 Lua 调用限 50ms,超时就退到进程内的一份桶,并冷却 10 秒不再碰 Redis,免得每次调用都先赔 50ms。退化期不按副本数分摊额度,整体会放宽,真撞上 429 由闸门降速处理。入口准入的 Lua 如果读不到,也是放行:真是 Redis 不可达的话,后面的入队会失败并如实报错。限流这一层是优化,不能因为它自己挂了把主业务一起拖死。

Q10:断路器为什么不做成 Redis 共享的?

算账算下来不值。共享的收益是「一个副本发现对面挂了,其他副本立刻知道」,省的是其他副本各自的前 5 次失败;代价是每次模型调用多一跳 Redis,模型调用又是整个系统最高频的外呼。每进程前 5 次失败的代价是可以接受的,所以 LLM 的断路器按进程。重排、网页搜索这类低频外呼用的是共享那套。

Q11:(场景)主备都挂了怎么办?主备如果是同一个模型呢?

备用链按「先换出口、再换模型家族」配:先换同模型的另一个供应商出口,再换另一家模型。只换出口能救「这家云挂了」,救不了「这个模型本身出问题」,所以链的最后一跳一定是不同家族。全挂的时候本次调用对上层报错,任务级重试接手;再不行任务以失败结束,前端显示原因,credit 按实际用量结算,不会整笔扣掉。另外备用模型必须先过能力门,不支持工具调用的直接剔出链,切过去跑不动 Agent 比不切更糟。

Q12:为什么不用现成的,比如 Sentinel、LiteLLM Proxy、Envoy?

Sentinel 是 Java 生态,而且它的熔断口径是通用的错误率/慢调用比例,没法表达「429 不计、总超时不计、首 token 超时计」。LiteLLM Proxy 和 Envoy 要多部署一个高可用的网关服务、多一跳网络,TPM 预扣结算和首 token 掐断也得改它的代码。我最后只用了 LiteLLM 的 Router 做多供应商寻址和协议兼容——这部分是「每接一家供应商就要重读一遍兼容层差异」的持续成本,交给社区划算;令牌桶和断路器这些跟业务口径强相关的,自己做、包在 Router 外面。

相关八股#

1. 常见限流算法有哪些?各自优缺点?

  • 固定窗口计数:按时间窗口计数,实现最简单(INCR + EXPIRE);缺点是窗口边界处可能放出 2 倍突发。
  • 滑动窗口日志:记下每次请求的时间戳(常用 ZSET),统计最近一个窗口内的条数;精确,但内存随请求量线性增长。
  • 滑动窗口计数:把窗口切成小格,按比例加权相邻两格,精度和开销折中。
  • 漏桶:请求进队列,以固定速率流出;输出绝对平滑,但不允许突发,排队会加延迟。
  • 令牌桶:按固定速率往桶里放令牌,请求取到令牌才放行,桶容量决定允许的突发量;既限平均速率又允许一定突发,最常用。
  • 关联本项目:供应商的额度本身就是「每分钟 N 个、允许一定突发」的形态,而且 TPM 需要按请求重量扣、事后结算,所以选令牌桶。

2. 令牌桶怎么实现?为什么不用定时器往桶里放令牌?

  • 惰性填充:不开定时器,每次取令牌时按「距上次时刻 × 速率」补上这段时间该有的令牌,封顶到容量,再判断够不够。
  • 状态只需两个字段:当前令牌数、上次填充时刻。
  • 定时器方案在分布式下要额外的调度进程,桶一多开销也大;惰性填充只在有请求时计算。
  • 分布式实现要注意时钟:各节点时间不一致,应统一用存储端(如 Redis TIME)的时间。
  • 关联本项目:桶是 Redis hash 两个字段,惰性填充,时间统一取 Redis TIME。

3. Redis 执行 Lua 脚本为什么是原子的?有什么注意事项?

  • Redis 命令执行是单线程的,一个 Lua 脚本在执行期间不会有其他客户端命令插进来,所以整段逻辑原子。
  • 脚本执行期间会阻塞整个 Redis,脚本必须短小,不能有循环扫大 key、不能调用慢命令。
  • 默认有执行时长上限(lua-time-limit,5 秒),超过后 Redis 只接受 SCRIPT KILL / SHUTDOWN NOSAVE。
  • 用 SCRIPT LOAD + EVALSHA 避免每次传脚本全文;客户端库一般封装成「先 EVALSHA,NOSCRIPT 时再 EVAL」。
  • 集群模式下脚本访问的所有 key 必须在同一个 slot,要用 hash tag 保证。
  • Lua 原子不等于事务:脚本执行到一半报错,已执行的写操作不会回滚。
  • 关联本项目:限流和准入两个脚本都只做几次 HMGET/HSET/ZADD,执行在微秒级;一个模型的 RPM、TPM 两个键同一次脚本里操作,上集群时要加 hash tag。

4. 断路器的三个状态是怎么流转的?

  • 关闭:请求正常放行,统计失败;失败达到阈值(连续次数或窗口内失败率)转打开。
  • 打开:不发真实请求,直接快速失败或走降级;过了恢复时间转半开。
  • 半开:放少量探测请求,成功则回关闭并清零,失败则回打开重新计时。
  • 两种计数方式:连续失败次数(简单,适合低频调用)、滑动窗口失败率(Hystrix / Resilience4j 默认,适合高频调用,低频时样本太少容易被抖动放大)。
  • 关联本项目:单进程模型调用频率低,用连续 5 次失败计数,30 秒后半开;另外多了一个「中立」结果,429 既不算失败也不清零。

5. 限流、熔断、降级、重试分别解决什么问题?

  • 限流:保护被调用方(或自己的配额),控制发出去的速率。
  • 熔断:调用方发现依赖坏了,停止继续调用,避免资源被慢请求占满、故障扩散(雪崩)。
  • 降级:熔断或过载时提供一个次优但可用的结果,比如换备用模型、返回缓存。
  • 重试:处理瞬时错误;要配合指数退避加随机抖动,否则大量客户端同步重试会形成重试风暴;非幂等操作不能随便重试。
  • 关联本项目:令牌桶是限流,断路器是熔断,切备用模型是降级,框架内的重试处理瞬时错误,而且重试时首 token 计时重新开始。

6. HTTP 429 和 503 有什么区别?Retry-After 怎么用?

  • 429 Too Many Requests:客户端(或某个维度的配额)请求太多,是「你」的问题。
  • 503 Service Unavailable:服务端整体不可用或过载、维护中,是「我」的问题。
  • Retry-After 响应头可以是秒数,也可以是一个 HTTP 日期,告诉客户端多久之后再试;429 和 503 都可以带。
  • 客户端应遵守 Retry-After,没有时自己做指数退避。
  • 402 Payment Required 在实践中常用于额度 / 付费相关的拒绝,重试没有意义。
  • 关联本项目:队列满、用户并发超限回 429 + Retry-After 10 秒;当天额度用完回 402,不带 Retry-After。

7. 什么是背压?在异步系统里怎么做?

  • 背压是下游处理不过来时,把压力反馈给上游,让上游减速或拒绝,而不是让中间的缓冲无限堆积。
  • 常见手段:有界队列(满了阻塞或拒绝)、信号量限制并发、令牌桶限速、直接返回 429 / 503。
  • 无界队列看起来「什么都能收」,实际是把故障推迟:内存涨、排队时间无上限、用户等一个永远轮不到的位置。
  • 判断和占位必须原子,否则「先看队列长度再入队」在突发下会超卖。
  • 关联本项目:入口按「未投递 + 已准入未入队」做有界准入,判定和登记在一条 Lua 里完成。

8. asyncio 里的超时和取消是怎么工作的?

  • asyncio.wait_for(aw, timeout):超时后取消内部任务,并等它真正结束,再抛 TimeoutError。
  • 取消是协作式的:向任务注入 CancelledError,任务要在 await 点响应;如果被调用方吞掉了 CancelledError,取消就不生效,wait_for 可能把一个「假成功」的结果交回来。
  • asyncio.wait(tasks, timeout):只按时间返回已完成和未完成两个集合,不自动取消,也不看结果,适合「只看时间到没到」的判断。
  • CancelledError 在 3.8 以后继承自 BaseException,不是 Exception,except Exception 抓不到它,这是为了防止业务代码误吞取消。
  • 关联本项目:框架的流式包装吞了 CancelledError,所以首 token 超时改用 asyncio.wait 自己判时间,再手动取消和关流。

9. P95 延迟是什么意思?为什么看 P95 而不是平均值?

  • P95:把所有请求延迟从小到大排,第 95% 位置的值,即 95% 的请求不超过它。
  • 平均值会被大量快请求拉低,掩盖少数慢请求;用户对慢的那一次更敏感。
  • 尾延迟在扇出场景下会放大:一次操作要等 N 个子请求都回来,整体延迟由最慢那个决定。
  • 压测报分位数时要说清样本量、测量起止点、客户端和服务端是否同钟。
  • 关联本项目:首事件报的是 3 轮里最差一轮的 P95,起点是客户端发出提交请求,终点是 WebSocket 收到第一条事件,同机同钟。

10. 限并发和限速率有什么区别?信号量能不能代替令牌桶?

  • 信号量限的是「同时在处理中的请求数」,与每个请求跑多久有关:请求越慢,单位时间放行的越少。
  • 令牌桶限的是「单位时间内发出的请求数 / 消耗量」,与每个请求跑多久无关。
  • 供应商的 RPM 是速率维度,信号量代替不了:请求很快时,并发 4 也能每分钟打出几百条。
  • 反过来令牌桶也代替不了信号量:长流式请求会长时间占着连接和内存,需要并发上限保护自身。
  • 流式调用要把并发位持有到流读完或关闭,而不是拿到生成器就还回去,否则并发上限形同虚设。
  • 关联本项目:两者都用,令牌桶管所有副本共享的速率,进程内信号量管本进程同时在飞的请求数,且流式请求的并发位持有到流结束。