2897 字
约 9 分钟
15
S17-A17 · 限流与配额的设计:保护自己还是保护下游

加了限流之后用户投诉变多了,但系统确实不挂了。上线前每次投放都要人工盯盘,上线后不用了——代价是一部分用户看到 429,业务方认为"这个功能变差了"。这不是限流做错了,是两个目的被混成了一个阈值:保护自己(别让实例倒下)与保护下游(别把第三方或数据库打垮)是两件事,判据、维度、阈值都不在同一个数量级。


1. 两个目的:判据完全不同

维度 保护自己 保护下游
超出后果 本服务雪崩 下游拒服、配额罚没、账单上涨
超限表现 快速失败,保住已有请求 排队、降级或延后重试

把两者做成一个数字的典型后果是:按下游容量定则本机先扛不住,按本机容量定则下游先崩。判据要分开写、分开测、分开改,即使最终由同一个组件执行。

一个顺序上的细节:先保护谁取决于哪一边不可替代。自建服务挂了可以重启,但第三方若记入违约或封禁账号,恢复成本高得多——这时保护下游的阈值必须更保守。


2. 维度、阈值与算法在突发下的差异

维度越细成本越高、收益递减:

维度 成本 适用 常见问题
全局 最低 总闸,防御性 一个租户吃光所有人
按接口 低 接口间资源差异大 热点接口仍能打垮共享池
按来源 IP 中 外网入口 共享出口 IP 集体误伤
按用户 / Token 中高 有明确身份的业务 需先解决身份归一

阈值定多少争议最大,因为两种常见依据差很多:容量实测值(压测拐点,通常比"不报错"的位置低得多)与历史峰值(常出现在系统已经不正常的那几天)。用历史峰值定阈值,等于把故障日负载当成目标容量。判据是阈值低于实测拐点并留余量:余量的意义不是保守,而是让限流在系统还有处理能力时生效——在饱和点附近才触发,队列里已堆满必然超时的请求。

可用的依据只有三种:压测拐点乘系数(有物理依据可复现,但压测环境与线上有偏差)、下游承诺值除以实例数(对齐外部约束,但实例数一变就漂移)、成本预算反推(与账单挂钩而与容量无关)。历史峰值只适合交叉验证。

四种算法在突发流量下的行为差异,是选型的核心:

算法 对突发的态度 代价 用在哪
固定窗口 边界处可放行双倍 简单,最省 粗粒度、非关键路径
滑动窗口 平滑,无可利用边界 需保存时间片,内存上升 需要精确的入口限流
令牌桶 允许配置容量的突发 桶容量是隐藏的突发预算 正常业务,允许短时脉冲
漏桶 强行匀速 突发被削平,延迟上升 保护脆弱下游

判断点只有一句:你要"平均速率"还是"瞬时速率"。令牌桶管平均并容许突发,漏桶管瞬时并把突发转成排队;把漏桶放在体验敏感的位置,等于把"被挡住"换成"被拖慢",看着友好,其实占着连接不放。

─── 令牌桶入口限流:按用户维度,桶容量即突发预算
limit_req_zone $binary_remote_addr zone=perip:10m rate=20r/s;
limit_req_zone $http_authorization   zone=peruser:10m rate=50r/s;
limit_req_status 429;
limit_req_log_level warn;          ── 先记日志,便于灰度期观察
server {
    location /api/ {
        limit_req zone=peruser burst=100 nodelay;   ── burst=nodelay 允许突发
        limit_req zone=perip   burst=40  delay=10;  ── 超出部分才排队
    }
}

burst 与 nodelay 常被误解:burst 是允许排队的请求数,nodelay 表示这些请求不排队、立即放行。写 nodelay 等于承认"瞬时速率可超 rate",桶容量被放大——这项必须算进余量。


3. 超限响应与配额:限流之外的第二个问题

超限时怎么回应,比挡住本身重要。同样被拦,"429 无说明"与"429 + 重试时间"是两种体验,后端也看到两种流量形态:前者立刻重试放大流量,后者多数会等。

做法 效果 缺失后的表现
明确的 429 与错误码 客户端能区分"限流"与"故障" 一律重试,流量放大
Retry-After 或剩余配额头 退避有依据 重试时机随机,抖动大
幂等键贯穿重试 重放不产生重复写入 重试导致重复下单
─── 限流响应的最小契约:告诉客户端"是限流"和"什么时候来"
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 3
X-RateLimit-Limit: 50
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1712000000

{"error":"rate_limited","scope":"per_user","retry_after_ms":3000}

再往下是限流与配额的区分,两个词常被当同义词,但回答的问题不同:限流是速率(每秒多少),配额是总量(这个周期一共多少)。

对比 限流 配额
计量单位 每秒请求数 每周期累计量
超限后的恢复 等下一个时间窗口 等下个周期或购买更多
适用资源 容量型(连接、CPU) 付费型、调用型、稀缺型

按周期分配的付费或调用型资源天然适合配额:模型调用次数、短信条数、导出次数、API 调用包。做成限流会浪费额度(前一秒没用完的存不到下一秒),做成配额才能与计价对齐。

─── 配额分配:按套餐分层,周期滚动,超限行为显式声明
quotas:
  - plan: free
    window: month
    limits: {llm_calls: 500, exports: 20}
    on_exceed: hard-deny          ── 明确挡住并提示升级
  - plan: pro
    window: month
    limits: {llm_calls: 20000, exports: 500}
    on_exceed: soft-deny-with-overage   ── 允许溢出但计费
  rollover: false                 ── 未用完不结转,避免额度通胀
  count_on: [accepted, rejected]  ── 明确被拒是否计数,否则对账不一致

配额最容易埋的坑是**"被拒的请求算不算数"没有定义**:若重试都计数,用户几十秒就能耗尽整月额度;若不算数,被拒请求会反复打系统。这一项必须写进契约。


4. 余量、误伤与过载保护的分工

留余量的意思是阈值低于实际容量,但不是随手打八折,要按三件事算,见下。

三种余量必须显式算进阈值:容量拐点以下(否则限流在饱和后才生效)、实例数变动(本地阈值乘实例数才是全局上限,扩容即翻倍)、突发预算(burst 不进阈值就等于瞬时打穿下游)。

误伤几乎一定发生,集中在三种场景:共享出口 IP(企业 NAT、学校、办公网,一个 IP 后面几百人)、同租户多用户(一个用户的批量脚本耗尽全租户额度)、同 Token 多进程(CI 与生产共用)。识别方式是看被拦请求的身份分布:某 IP 拦截量占比异常高时,先怀疑 NAT 而不是攻击。

过载保护与限流的分工要分清:限流在入口决定放不放进来,过载保护在系统内部决定已进来的怎么处理。两者互替不了——入口放进来十万个请求而内部没有保护,就会全部超时,体验比直接 429 更差,因为它占着连接并延后了失败。

─── 过载保护:系统内部的自保,与入口限流分工
overload:
  signals: [threadpool_queue_depth, p99_latency, connection_pool_wait]
  thresholds:
    threadpool_queue_depth: 0.8     ── 队列到 80% 即触发
    p99_latency_ms: 800
  actions:
    - shed_batch_paths              ── 先砍批处理与导出等可延后流量
    - degrade_recommendation
    - reject_with_429               ── 最后才挡住在线主路径
  headroom_ratio: 0.2               ── 保留 20% 容量给恢复过程本身

5. 灰度、度量与边界

限流上线前必须先只记录不拦截。原因很实际:阈值准不准、维度合不合适、误伤多大,只有真实流量能回答,而拦截会立刻产生用户影响与投诉。顺序是观测(只统计"本应被拦")→ 灰度(部分路径真拦)→ 全量 → 校核(按周对齐容量与拦截率,阈值随容量同步更新),第一个阶段的出口条件是拦截率低于 1% 且误伤可解释。

─── 观测期:只记录不拦截,同时输出"假若拦截"的比例
ratelimit:
  mode: shadow              ── shadow 记录 / enforce 拦截
  emit: [would_reject_total, would_reject_by_dimension]
  fallback_switch: enforce  ── 单开关切换,便于回滚
metrics:
  - intercepted_ratio       ── 拦截率
  - false_positive_ratio    ── 误伤率:被拦身份中的正常用户占比
  - capacity_usage_peak     ── 容量使用率峰值,与阈值对照
  - retry_amplification     ── 重试放大倍数:下游收到的请求 ÷ 放行的请求

最后是限流的副作用:它把流量推到别的路径。被拒请求若客户端不遵守 Retry-After,重试会让总流量上升;降级通道没有自己的限流,会承接全部溢出而先崩;批量接口被拦后用户改用逐个调用,一次大请求拆成上千次小请求,既绕过速率限制又加重下游。所以上线后必须重新测下游容量,不要假定"拦掉就安全了"。

边界很清楚:流量平稳且远低于容量(峰值不到容量一半且增长可预测)时,复杂维度划分收益很低,一个全局阈值就够;内部服务之间调用可不用入口限流,靠过载保护与客户端并发控制更省事;调用方数量极少(三五个内部系统可协商)时,约定并发上限比做一套组件更划算。

反过来,出现任一条就该正式化:有对外承诺的 SLA、多租户共用一套资源、下游是计费或合同约束的第三方、出现过一次因单侧流量导致的雪崩、有按量计费的成本需要控制。


6. 自测

  1. 保护自己与保护下游的阈值为什么不在同一个数量级?各自的参照物是什么?
  2. 用历史峰值定限流阈值有什么问题?什么样的数才是更可靠的依据?
  3. 令牌桶与漏桶分别把突发变成了什么?哪种放在用户体验敏感的位置更糟?
  4. 限流与配额回答的问题有什么不同?哪类资源天然该用配额?
  5. 配额里"被挡的请求算不算计数"如果不定,会分别导致哪两种后果?
  6. 为什么限流上线后需要重新测一次下游容量?能举一个绕过限流的路径?
S17-A17 · 限流与配额的设计:保护自己还是保护下游
https://www.clxhxhhr.top/posts/6351/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。