加了限流之后用户投诉变多了,但系统确实不挂了。上线前每次投放都要人工盯盘,上线后不用了——代价是一部分用户看到 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. 自测
- 保护自己与保护下游的阈值为什么不在同一个数量级?各自的参照物是什么?
- 用历史峰值定限流阈值有什么问题?什么样的数才是更可靠的依据?
- 令牌桶与漏桶分别把突发变成了什么?哪种放在用户体验敏感的位置更糟?
- 限流与配额回答的问题有什么不同?哪类资源天然该用配额?
- 配额里"被挡的请求算不算计数"如果不定,会分别导致哪两种后果?
- 为什么限流上线后需要重新测一次下游容量?能举一个绕过限流的路径?