请求太多怎么办: 令牌桶限流, 一致性哈希粘会话, 背压让上游慢下来, 来不及就弃车保帅 — LB / Rate Limit / Backpressure / Load Shedding 一张图定价
地铁站高峰期的层层闸机就是全部答案: 外面拉限流栏杆(令牌桶, 控制进站速率), 站厅当缓冲区(有界队列, 满了就停止进人), 安检口按人流开闸数量(负载均衡), 客流超过承载就甩站(load shedding, 弃车保帅)。核心矛盾只有一句话: 上游可以无限快, 下游永远有限容量。
治理的全部工作是把这个容量约束翻译到入口: 负载均衡决定"给谁", 限流决定"给多少", 准入控制决定"现在收不收", 背压决定"让上游多快", 弃车决定"关键时刻丢谁"。做对了, 洪峰只是一条平滑的 429; 做错了, 洪峰变成 ERROR 1040 和全站 503 的雪崩现场。
upstream app { server 10.0.0.1:8080; server 10.0.0.2:8080 max_fails=3 fail_timeout=30s; } // 关键: L7 才能按 location/cookie 分流, L4 只认五元组
upstream app { server 10.0.0.1 weight=3; # 异构机器配重 server 10.0.0.2 weight=1; # 关键: 权重只管分发比例, 不管在途负载 }
upstream app { least_conn; # 关键: 谁在途连接少给谁, 长请求不再扎堆 server 10.0.0.1:8080; server 10.0.0.2:8080; }
1/N 的 key, 不像 mod-N 几乎全动。 upstream ws { hash $arg_uid consistent; # 关键: 同一用户粘同一节点, 扩容只动 1/N 会话 server 10.0.0.1:8080; server 10.0.0.2:8080; }
rate 恒速往桶里放令牌, 桶容量 burst 封顶, 请求拿到令牌才放行: 允许瞬时突发(花攒的令牌), 又锁住长期均值。限流算法的默认答案。 limiter := rate.NewLimiter(rate.Limit(100), 200) // golang.org/x/time/rate if limiter.Allow() { serve() } else { tooMany() } // 关键: burst=200 允许瞬时突发, 长期均值 ≤ 100/s
固定速率, 突发被整成平滑流。适合流量整形(protect 下游), 不适合应对突发(自己就是瓶颈)。 for range time.Tick(20 * time.Millisecond) { job := <-queue process(job) // 关键: 出口恒速, 再陡的突发也被整成平滑流 }
weighted = prev_count * overlap + curr_count # overlap = 上一窗口与当前滑动窗的重叠比例(0~1) # 关键: 加权拼接后, 窗口交界处不再有 2x 突刺
zone 定义共享内存与速率, burst 是允许超额排队/放行的容量, nodelay 让 burst 额度立即放行而不排队。三者组合决定"突发是拒绝还是缓冲"。 limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s; # 关键: zone 是共享内存(10m ≈ 16 万个 IP 状态), 全局一份 limit_req zone=api burst=200 nodelay; limit_req_status 429;
# EVAL "script" numkeys key [arg ...] — 整个脚本原子执行 $ redis-cli EVAL "$(cat bucket.lua)" 1 rl:uid:42 100 200 1777000000000000 1 # → (integer) 1 # 关键: 返回 1=放行 0=拒绝, 判断+扣减不竞态
pause()、响应里的 429, 本质都是反压信号。比在中间垫一个无限大的队列便宜得多。 consumer.pause(consumer.assignment()); // 关键: 缓冲区满就暂停拉取, 别让内存爆 consumer.poll(Duration.ofMillis(100)); // 心跳照常, 只是不再取新消息 consumer.resume(consumer.assignment()); // 缓冲区消化后恢复拉取
L = λ × W: 系统内请求数 = 到达率 × 停留时间。排队人数由这两项决定, 队列深度直接换算成延迟——所以无界队列就是无界延迟。 L = lam * W # 关键: 排队数 = 到达率 × 停留时间 (Little's Law) # λ=2000/s, 平均停留 0.15s → 系统里同时有 300 个请求 # 结论: 无界队列 → W 无界 → L 无界, 内存和延迟一起爆
if queue.Depth() > 400 || p99() > 200*time.Millisecond { return http.StatusServiceUnavailable } // 关键: 宁可 503 快失败, 不排队慢慢死
func shed(req *Request) bool { if load() < 0.9 { return false } return req.Tier == TierFree // 关键: 先分类再丢弃, 别弃到付费用户 }
429 告诉客户端"被限了", Retry-After 告诉它"什么时候再来"。没有恢复语义的限流, 等于教唆客户端立刻重试。 HTTP/1.1 429 Too Many Requests Retry-After: 5 # 关键: 告诉客户端 5s 后再试, 别立即重试 # → 客户端退避 5s, 重试风暴变成平滑恢复曲线
大促前给核心 API 上限流。按客户端 IP 维度限(作为兜底), burst 吸收正常抖动, nodelay 让额度立即放行而不是排队加延迟。
# http 块: 定义限流区, 10m 共享内存 ≈ 16 万个 IP 状态 limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s; limit_req_status 429; limit_conn_status 429; # server 块: API 路径套上桶; burst=200 + nodelay = 突发立即放行不排队 location /api/ { limit_req zone=api burst=200 nodelay; limit_conn perip 50; # 单 IP 并发连接再卡一道 proxy_pass http://app; }
压测验证: 400r/s 打 30s, 放行约 100/s+burst, 其余 429; 后端 p99 稳定在 80ms 以内。
8 台网关各自限流等于 8 倍配额。桶放 Redis, 判断+扣减放进同一段 Lua, 原子执行无竞态。
-- bucket.lua: KEYS[1]=桶名; ARGV: rate/s, burst, now_us, 请求量 local rate, burst = tonumber(ARGV[1]), tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts') local tokens = tonumber(b[1]) or burst -- 首次访问: 满桶 local ts = tonumber(b[2]) or now tokens = math.min(burst, tokens + (now - ts) * rate / 1e6) local ok = tokens >= tonumber(ARGV[4]) if ok then tokens = tokens - tonumber(ARGV[4]) end redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now) redis.call('EXPIRE', KEYS[1], 60) return ok and 1 or 0
8 台网关共享一个桶后, 集群真实出口速率从"理论 1000/s 实测 8000/s"修正到 1000/s±2%。
长连接建立后就不再过 LB, 轮询会让 2 万条 ws 连接随机分布; 按用户哈希粘住, 扩容时只有 1/N 会话重连。
upstream ws_pool {
hash $arg_uid consistent; # 一致性哈希: 同 uid 粘同节点
server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
server 10.0.0.2:8080 max_fails=3 fail_timeout=30s;
server 10.0.0.3:8080;
keepalive 64;
}
# 扩容加一台: 一致性哈希只迁移 ~1/(N+1) 会话, mod-N 要近乎全量重连
历史大促曾把 50 万条消息一次 poll 进内存直接 OOM。改为有界缓冲 + 暂停拉取, 心跳不丢、再平衡不触发。
if buffer.size() >= MAX_BUFFER { // 例如 5000 条 consumer.pause(consumer.assignment()); // 暂停拉取, 心跳照常 } consumer.poll(Duration.ofMillis(100)); if buffer.size() < MAX_BUFFER / 2 { consumer.resume(consumer.assignment()); // 消化过半再恢复, 防抖动 } // 配套: max.poll.records=200, 别让一次 poll 就塞满缓冲区
队列只借时间不还命: 排队 10 秒的请求上游早就超时了。队列深度设为"2 倍并发能力", 越水位立刻拒绝并带 Retry-After。
private static final Semaphore CONN = new Semaphore(200); private static final BlockingQueue<Req> QUEUE = new ArrayBlockingQueue<>(400); // 有界! 2× 并发能力 public Response handle(Req req) { if (!QUEUE.offer(req)) { // 满了立即拒绝, 不排队 throw new OverloadException("503", "Retry-After: 2"); } return worker.process(req); }
扩容改变容量, 限流阈值必须跟着改, 否则扩容白扩——机器多了配额没变, 流量还是被旧阈值拦死在门外。
# hpa.yaml: CPU 65% 触发扩容, 缩容冷却 5 分钟防抖 apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler spec: minReplicas: 4 maxReplicas: 16 metrics: - type: Resource resource: name: cpu target: { type: Utilization, averageUtilization: 65 } behavior: scaleDown: stabilizationWindowSeconds: 300 # 预案: 副本 ×2 时, 网关限流 rate 同步 ×2 (脚本联动, 阈值写在配置中心)
全站过载时无差别 503 等于把付费用户和爬虫一起扔掉。请求进门先打优先级标签, 弃车从最低档开始。
// 网关打标 → 后端按档位限流, 而不是全局无差别拒绝 enum Tier { CRITICAL(1), PAID(2), FREE(3) } // 交易 > 付费功能 > 免费浏览 public boolean admit(Request r, double load) { if (load < 0.85) return true; // 绿色: 全放行 if (load < 0.95) return r.tier != Tier.FREE; // 黄色: 弃免费 return r.tier == Tier.CRITICAL; // 红色: 只保交易 }
上线后大促过载期间, 下单成功率保持在 99.9%, 免费页承担全部 429。
发券瞬时 50 倍流量。用 rate.Limiter.WaitN 把"排队等待"变成有上界的等待, 等不到就走降级模板。
limiter := rate.NewLimiter(rate.Limit(50), 100) func handler(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond) defer cancel() if err := limiter.WaitN(ctx, 1); err != nil { renderCouponPage(w, degradatedTpl) // 200ms 等不到令牌 → 静态兜底页 return } renderCouponPage(w, realTpl) }
告警: 订单服务大量超时。登录 DB 一查连接数打满——根因是新版本 SDK 没接连接池, 每请求新建连接, 洪峰一来直接顶到 max_connections。
mysql> SHOW VARIABLES LIKE 'max_connections'; -- → 500 (曾被临时调大过, 别再无脑调, 调大只是推迟爆炸) mysql> SHOW STATUS LIKE 'Threads_connected'; -- → 500 (已打满) mysql> SHOW PROCESSLIST; -- → 大量 Sleep 连接, 全来自未池化的 v1 SDK -- 止血: 杀掉空闲连接 + 回滚该 SDK; 治本: 应用连接池(上限 20/实例) + 网关 limit_req
一台节点磁盘慢导致 p99 5s, 轮询照样把 1/3 请求打过去。被动健康检查 + 熔断剔除, 让慢节点自动退出轮询。
# Envoy: 连续 5 次 5xx 剔除 30s, 最多剔一半实例 outlier_detection: consecutive_5xx: 5 interval: 10s base_ejection_time: 30s max_ejection_percent: 50 # nginx 被动式: max_fails=3 fail_timeout=30s (见场景 1/3) # 注意: 被动探活靠失败计数, 对"慢而不挂"的节点要配 p99 告警兜底
固定窗口的临界突刺, 上一窗口尾部 + 下一窗口头部的请求都被放行. 正解: 换滑动窗口或令牌桶, 让边界加权平滑. # 错: 计数器按分钟整点重置, 0:59 和 1:01 各打 60 发 = 2 分钟 120 发 # 对: 滑动窗口加权 / 令牌桶, 交界处不再有 2x 漏洞
# 错: rate=3000r/s (抄了别家的配置) # 对: 压测拐点 2100/s → rate=1500r/s, 留 30% 余量给抖动
# 错: 8 台 nginx 各自 limit_req rate=100r/s → 集群 800r/s # 对: Redis+Lua 集中计桶, 8 台共享同一个 100r/s
burst=0 时任何瞬时抖动都算超额, 直接拒绝; 真实流量从来不是数学上均匀的. 正解: burst 设为 1~2 秒的量并配 nodelay, 吸收正常抖动. # 错: limit_req zone=api burst=0; → 毛刺全变 429 # 对: limit_req zone=api burst=200 nodelay; → 抖动吸收, 均值不变
# 错: if resp.status == 429: retry(now) # 立刻再打一发 # 对: if resp.status == 429: sleep(resp.headers["Retry-After"])
max.poll.records 限单次批量 + 缓冲区满 pause() 暂停拉取. // 错: while(true) { records = poll(MAX_VALUE); ... } → 堆爆 // 对: max.poll.records=200 + buffer 满 pause(), 消化后 resume()
max_fails/fail_timeout 被动探活, 或 Envoy/云 LB 主动健康检查. # 错: upstream 里裸写 server, 挂了也照样轮到它 # 对: server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
# 错: least_conn; # 只影响新建, 老连接永远压在同一台 # 对: hash $arg_uid consistent; + 连接数上报做再均衡
# 错: if load > 0.9: reject(random) # 可能拒掉支付 # 对: if load > 0.9: reject(lowest_tier_first)
429 + Retry-After, SDK 里把 429 单列. # 错: return 500 "rate limited" → 客户端当故障疯狂重试 # 对: 429 + Retry-After: 5 → 客户端按 5s 退避
uid/token, IP 只做未登录场景的兜底维度. # 错: limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s; # 对登录用户 # 对: map $http_authorization $rlkey { ~. $http_authorization; default $binary_remote_addr; }
X-Load-Test 标头, 网关路由到影子池, 影子表/影子 topic 隔离. # 错: 压测机直接打生产域名, 流量与真实用户无差别 # 对: if header["X-Load-Test"]: route_to(shadow_pool) else: prod_pool
# 错: Queue queue = new LinkedBlockingQueue(); # 无界, 延迟与内存同爆 # 对: new ArrayBlockingQueue(400); offer() 失败即 503
ERROR 1040. 原因: 无连接池, 连接数随并发线性涨, 直接顶到 max_connections. 正解: 应用侧连接池(如 HikariCP, 上限 20/实例), 连接复用率进监控. # 错: 每次 query: DriverManager.getConnection(url, u, p) → 1040 # 对: HikariDataSource, maximumPoolSize=20, 连接复用率 > 99%
# 错: redis 超时 → 异常直接 500, 限流器成了新单点 # 对: redis 失败 → 本地兜底桶放行 + 打告警, 恢复后自动切回
# 错: 所有 location 共用 zone=api → 导出把配额吃光 # 对: /api/export 单独 zone(低配额), /api/login 单独 zone(保底)
# 错: img.example.com 反代到同一网关同一 zone # 对: 静态走 CDN; 网关里静态/API 分 zone, 互不挤占
# 错: gateway retry=3 + svc retry=3 → 3×3=9 倍放大 # 对: 只在网关层重试, retry_budget: max_extra_requests = 10%
# 错: nginx split_clients 按 $remote_addr 随机 → 用户来回漂 # 对: split_clients "${arg_uid}" → uid 哈希, 同用户粘同版本
# 错: 改完配置看一眼控制台就宣布生效 → 3 台实例还在跑旧阈值 # 对: 下发后校验全部实例 config_version 一致, 再确认生效