系统架构 · 流量治理与负载控制

请求太多怎么办: 令牌桶限流, 一致性哈希粘会话, 背压让上游慢下来, 来不及就弃车保帅 — LB / Rate Limit / Backpressure / Load Shedding 一张图定价

流量治理 · 请求太多怎么办 — 入口漏斗 / 窗口形状 / 两条路 / 分发策略 cyan = 入口 · amber = 守门 · orange = 排队 · emerald = 后端 · rose = 雪崩 ① 入口漏斗 — 每一层都在替后端争取时间 用户流量 峰值 10k QPS L7 负载均衡 least_conn / hash 限流器 令牌桶 rate=1000 burst=200 有界队列 max=500, 满了就拒 后端 ×N DB 连接池 100/实例 每个箭头都是一个决策点: 放行 / 排队 / 拒绝 — 队列是借时间, 不是还命 拒绝也要讲礼貌: 429 + Retry-After, 别让客户端盲等 ② 窗口的形状 — 固定窗口双突刺 vs 令牌桶平滑 固定窗口计数器 — 临界双突刺 rate 限额 临界双突刺 ≈ 2× 限额 窗口 N 窗口 N+1 窗口尾部 + 下一窗口头部 = 交界处最多放过 2× 请求 令牌桶 — 平均速率 + 可控突发 rate 限额 burst 上限 burst: 攒下的令牌一次花掉, 封顶不冲破 恒速放令牌, 拿到令牌才放行; 长期均值 = rate, 短期峰值 ≤ burst ③ 两条路 — 同一场洪峰, 有无限流是两种命运 洪峰 3× QPS 突袭 大促 0 点整 无限流 → 全部进队列 排队无上界, 越排越多 线程池 200/200 BLOCKED 每请求新建 DB 连接 × 3 倍流量 雪崩现场 · 连接池打满, MySQL 拒绝新连接 ERROR 1040 (HY000): Too many connections # 排队 30s 上游早已超时重试 → 流量翻倍 → 全站 503 同一场洪峰 3× QPS 大促 0 点整 令牌桶放行 1000/s burst 200 吸收正常抖动 超出部分立即 429 队列不积压, 线程池半数空闲 弃车保帅 · 核心交易链路保住 HTTP/1.1 429 Too Many Requests Retry-After: 5 # 弃营销弹窗流量, 保下单/支付 限流的本质: 在入口把"系统扛不住"翻译成客户端能理解的信号(429/503 + Retry-After), 而不是让超时随机蔓延成雪崩 ④ 负载均衡策略 — 分发只是手段, 均衡才是目的 Round Robin 轮流分发, 写死顺序 最简单, 无视在途负载 Weighted RR weight=3 按配比分流 异构机器各尽所能 Least Connections 挑在途连接最少者 长短请求混合更均匀 Least Response Time 挑历史响应最快者 统计窗口有滞后 Consistent Hash 同 key 粘同节点, 会话友好 扩缩容只迁 1/N 会话 nginx: upstream 里 least_conn; hash $cookie_session_id consistent; — 一致性哈希环 + 虚拟节点抹平机器异构 读法: ① 漏斗 = 每关只放行下一层扛得住的量 · ② 形状 = 突刺从哪来 · ③ 两条路 = 限流与弃车的价值 · ④ 策略 = 分发算法怎么选

机制视角 — 入口漏斗

  • • 每一层只放行下一层扛得住的量: LB 分发, 限流守门, 队列缓冲
  • • LB 是分发不是减负: 总容量不够, 怎么轮询都是慢
  • • 令牌桶 = 长期均值 rate + 短期上限 burst, 两个旋钮分开调
  • • 固定窗口的临界双突刺是几何缺陷, 不是参数没调好

行为视角 — 雪崩如何滚起来

  • • 慢 → 堆积 → 资源耗尽 → 级联: 雪崩永远从"再撑一下"开始
  • • 无界队列 = 无界延迟: 排队 30s, 上游早就超时重试了
  • • 重试是放大器: 3 倍洪峰 × 3 次重试 = 9 倍冲击
  • • 背压把"下游扛不住"传回上游, 比队列硬扛便宜得多

生产价值 — 治理四件套

  • • LB 分流 / 限流守门 / 背压泄压 / 弃车保帅, 各管一段
  • • 弃车先分级: 弃营销弹窗, 保下单支付, 别弃错对象
  • • 大促预案 = 弹性扩容 + 限流阈值联动, 而不是只堆机器
  • • 阈值来自全链路压测, 不来自拍脑袋; 429 一定带 Retry-After

💡 一句话理解

地铁站高峰期的层层闸机就是全部答案: 外面拉限流栏杆(令牌桶, 控制进站速率), 站厅当缓冲区(有界队列, 满了就停止进人), 安检口按人流开闸数量(负载均衡), 客流超过承载就甩站(load shedding, 弃车保帅)。核心矛盾只有一句话: 上游可以无限快, 下游永远有限容量。

治理的全部工作是把这个容量约束翻译到入口: 负载均衡决定"给谁", 限流决定"给多少", 准入控制决定"现在收不收", 背压决定"让上游多快", 弃车决定"关键时刻丢谁"。做对了, 洪峰只是一条平滑的 429; 做错了, 洪峰变成 ERROR 1040 和全站 503 的雪崩现场。

🧠 必知必会 必考 & 必会

L4 vs L7 负载均衡
L4 只看 IP/端口转发包(IPVS / 云 NLB), 快但看不见内容; L7 解析 HTTP, 能按 path/header/cookie 路由、做灰度与 Canary。越靠上层越灵活, 也越贵。
upstream app {
    server 10.0.0.1:8080;
    server 10.0.0.2:8080 max_fails=3 fail_timeout=30s;
}  // 关键: L7 才能按 location/cookie 分流, L4 只认五元组
Round Robin 与 Weighted RR
轮询按顺序发, 加权轮询按 weight 配比发。共同盲区: 只管"发出去", 不管"在途多少"——请求耗时差异大时, 轮询会把长请求全砸到同一台。
upstream app {
    server 10.0.0.1 weight=3;  # 异构机器配重
    server 10.0.0.2 weight=1;  # 关键: 权重只管分发比例, 不管在途负载
}
Least Connections / Least Response Time
挑在途连接最少 / 历史响应最快的节点, 对长短请求混合的场景更均匀。代价: 需要维护实时统计, 且统计本身有滞后; 纯短请求场景 RR 反而够用。
upstream app {
    least_conn;              # 关键: 谁在途连接少给谁, 长请求不再扎堆
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
}
Consistent Hash LB
一致性哈希环 + 虚拟节点: 同一个 key(用户/会话)永远落到同一节点, 天然粘会话; 节点增减只迁移 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;
}
Token Bucket 令牌桶
以 rate 恒速往桶里放令牌, 桶容量 burst 封顶, 请求拿到令牌才放行: 允许瞬时突发(花攒的令牌), 又锁住长期均值。限流算法的默认答案。
limiter := rate.NewLimiter(rate.Limit(100), 200) // golang.org/x/time/rate
if limiter.Allow() { serve() } else { tooMany() }
// 关键: burst=200 允许瞬时突发, 长期均值 ≤ 100/s
Leaky Bucket 漏桶
出口恒速: 不管进多快, 流出永远 固定速率, 突发被整成平滑流。适合流量整形(protect 下游), 不适合应对突发(自己就是瓶颈)。
for range time.Tick(20 * time.Millisecond) {
    job := <-queue
    process(job)   // 关键: 出口恒速, 再陡的突发也被整成平滑流
}
Fixed vs Sliding Window
固定窗口按整分钟计数, 交界处可放行 2× 限额(临界双突刺); 滑动窗口把上一窗口按重叠比例加权拼进来, 边界平滑, 代价是多存一份计数。
weighted = prev_count * overlap + curr_count
# overlap = 上一窗口与当前滑动窗的重叠比例(0~1)
# 关键: 加权拼接后, 窗口交界处不再有 2x 突刺
nginx limit_req 三件套
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;
Redis+Lua 集群限流
单机限流各有各的计数, N 台网关就是 N 倍配额; 把桶放进 Redis, 用 Lua 把"读桶-判断-扣减"做成原子操作, 多实例共享同一个桶。
# EVAL "script" numkeys key [arg ...] — 整个脚本原子执行
$ redis-cli EVAL "$(cat bucket.lua)" 1 rl:uid:42 100 200 1777000000000000 1
# → (integer) 1   # 关键: 返回 1=放行 0=拒绝, 判断+扣减不竞态
Backpressure 背压
把"下游消化能力"反馈给上游让它们慢下来: TCP 滑动窗口、Kafka 消费者 pause()、响应里的 429, 本质都是反压信号。比在中间垫一个无限大的队列便宜得多。
consumer.pause(consumer.assignment());   // 关键: 缓冲区满就暂停拉取, 别让内存爆
consumer.poll(Duration.ofMillis(100));  // 心跳照常, 只是不再取新消息
consumer.resume(consumer.assignment());  // 缓冲区消化后恢复拉取
Little's Law 排队律
L = λ × W: 系统内请求数 = 到达率 × 停留时间。排队人数由这两项决定, 队列深度直接换算成延迟——所以无界队列就是无界延迟。
L = lam * W   # 关键: 排队数 = 到达率 × 停留时间 (Little's Law)
# λ=2000/s, 平均停留 0.15s → 系统里同时有 300 个请求
# 结论: 无界队列 → W 无界 → L 无界, 内存和延迟一起爆
Admission Control 准入控制
用负载信号(队列深度/延迟/CPU)决定"现在收不收": 信号越过水位就快速拒绝, 把过载挡在系统门口。快速失败 503 好过让所有请求慢慢超时。
if queue.Depth() > 400 || p99() > 200*time.Millisecond {
    return http.StatusServiceUnavailable
}  // 关键: 宁可 503 快失败, 不排队慢慢死
Load Shedding 负载丢弃
过载时按优先级主动丢弃低价值流量, 保住核心链路。前提是请求先被分类(免费/付费、营销/交易), 否则弃车弃到发动机就尴尬了。
func shed(req *Request) bool {
    if load() < 0.9 { return false }
    return req.Tier == TierFree // 关键: 先分类再丢弃, 别弃到付费用户
}
429 与 Retry-After
限流响应不是终点而是协议: 429 告诉客户端"被限了", Retry-After 告诉它"什么时候再来"。没有恢复语义的限流, 等于教唆客户端立刻重试。
HTTP/1.1 429 Too Many Requests
Retry-After: 5              # 关键: 告诉客户端 5s 后再试, 别立即重试
# → 客户端退避 5s, 重试风暴变成平滑恢复曲线

🏭 生产实战 real world

场景 1 · nginx 网关限流落地: 接口 100r/s, 突发吸收 200, 超额直接 429

大促前给核心 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 以内。

场景 2 · 网关集群级令牌桶: Redis + Lua 原子扣减(正确用法模板)

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%。

场景 3 · WebSocket 长连接会话粘住: 一致性哈希 upstream

长连接建立后就不再过 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 要近乎全量重连

场景 4 · Kafka 消费者背压: 缓冲区满就 pause, OOM 消失

历史大促曾把 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 就塞满缓冲区

场景 5 · 准入控制: 有界队列 + 水位拒绝, 洪峰只损失尾部

队列只借时间不还命: 排队 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);
}

场景 6 · 大促预案: HPA 弹性扩容与限流阈值联动(配置/部署型)

扩容改变容量, 限流阈值必须跟着改, 否则扩容白扩——机器多了配额没变, 流量还是被旧阈值拦死在门外。

# 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 (脚本联动, 阈值写在配置中心)

场景 7 · 付费用户加权: 弃车先弃免费弹窗, 保下单支付

全站过载时无差别 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。

场景 8 · 突发流量整形: 等不到令牌就走兜底, 而不是无限排队

发券瞬时 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)
}

场景 9 · 深夜告警 ERROR 1040: 连接数打满的十分钟止血(事故排查型)

告警: 订单服务大量超时。登录 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

场景 10 · LB 健康检查剔除慢节点: Envoy outlier + nginx 被动探活(配置型)

一台节点磁盘慢导致 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 告警兜底

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 限流 60 次/分钟, 实际能打 120 次 — 压测发现峰值是阈值两倍. 原因: 固定窗口的临界突刺, 上一窗口尾部 + 下一窗口头部的请求都被放行. 正解: 换滑动窗口或令牌桶, 让边界加权平滑.
# 错: 计数器按分钟整点重置, 0:59 和 1:01 各打 60 发 = 2 分钟 120 发
# 对: 滑动窗口加权 / 令牌桶, 交界处不再有 2x 漏洞
坑 2 · 阈值 3000 是拍的, 容量只有 1500 — 大促按 3000/s 准备, 1800/s 就开始超时. 原因: 限流阈值拍脑袋定的, 没做全链路压测, 真实瓶颈在 DB 连接数. 正解: 压测出系统真实拐点, 阈值取拐点的 70% 并写进容量文档.
# 错: rate=3000r/s  (抄了别家的配置)
# 对: 压测拐点 2100/s → rate=1500r/s, 留 30% 余量给抖动
坑 3 · 每台网关限 100, 集群却放进 800 — 配置写着 100r/s, 实测入口 800r/s. 原因: 单机限流当集群限流用, 8 台网关各数各的. 正解: 桶放 Redis, Lua 原子扣减, 多实例共享配额(见场景 2).
# 错: 8 台 nginx 各自 limit_req rate=100r/s → 集群 800r/s
# 对: Redis+Lua 集中计桶, 8 台共享同一个 100r/s
坑 4 · burst=0, 正常抖动全被拒 — 上线限流后错误率飙升, 流量其实没超均值. 原因: burst=0 时任何瞬时抖动都算超额, 直接拒绝; 真实流量从来不是数学上均匀的. 正解: burst 设为 1~2 秒的量并配 nodelay, 吸收正常抖动.
# 错: limit_req zone=api burst=0;   → 毛刺全变 429
# 对: limit_req zone=api burst=200 nodelay;  → 抖动吸收, 均值不变
坑 5 · 收到 429 立刻重试, 洪峰翻倍 — 被限流的客户端秒级重试, 限流器压力更大. 原因: 客户端把 429 当普通错误放进统一重试逻辑, 没有退避. 正解: 429 走专门分支, 按响应里的 Retry-After 指数退避, 并设重试预算.
# 错: if resp.status == 429: retry(now)      # 立刻再打一发
# 对: if resp.status == 429: sleep(resp.headers["Retry-After"])
坑 6 · 消费者一次 poll 50 万条, OOM 重启循环 — 服务每 20 分钟重启一次, lag 永远追不上. 原因: 无背压, 一次拉取全部进堆内存, 处理速度远低于拉取速度. 正解: max.poll.records 限单次批量 + 缓冲区满 pause() 暂停拉取.
// 错: while(true) { records = poll(MAX_VALUE); ... }  → 堆爆
// 对: max.poll.records=200 + buffer 满 pause(), 消化后 resume()
坑 7 · LB 照着挂掉的节点打了一夜 — 1/3 请求 503, 其他节点却很闲. 原因: upstream 没配健康检查, 死节点照样参与轮询. 正解: nginx 配 max_fails/fail_timeout 被动探活, 或 Envoy/云 LB 主动健康检查.
# 错: upstream 里裸写 server, 挂了也照样轮到它
# 对: server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
坑 8 · least_conn 对长连接无效 — 2 万条 WebSocket, 一台扛了 1.4 万条. 原因: least_conn 只在"新建连接"时计数, 长连接建完就不再变化, 早期多分的机器一直多. 正解: 长连接用一致性哈希按用户分流 + 应用层把新建会话导向低负载节点.
# 错: least_conn;  # 只影响新建, 老连接永远压在同一台
# 对: hash $arg_uid consistent; + 连接数上报做再均衡
坑 9 · 弃车弃到支付接口 — 过载保护触发后, 客服被打爆: 付费用户全 503. 原因: load shedding 无优先级, 按请求到达顺序无差别拒绝. 正解: 请求进门先打档位(交易>付费>免费), 弃车从最低档开始(见场景 7).
# 错: if load > 0.9: reject(random)   # 可能拒掉支付
# 对: if load > 0.9: reject(lowest_tier_first)
坑 10 · 限流只回 500, 客户端盲等盲试 — 被限流方不知道该等多久, 要么立刻重试要么干等 30 秒. 原因: 限流响应没带恢复语义, 500 还会触发客户端"服务器错误重试". 正解: 限流必须回 429 + Retry-After, SDK 里把 429 单列.
# 错: return 500 "rate limited"   → 客户端当故障疯狂重试
# 对: 429 + Retry-After: 5        → 客户端按 5s 退避
坑 11 · 按客户端 IP 限流, 全公司一起 429 — 某写字楼用户集体被限. 原因: 大企业/校园网都在 NAT 后面, 一个出口 IP 背后上千真实用户, 按 IP 限直接误伤. 正解: 限流 key 用登录态 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; }
坑 12 · 全链路压测流量打进了生产库 — 压测 10 分钟, 生产订单多出 4 万条假数据. 原因: 压测流量没有标记和隔离, 顺着网关写进了真实存储. 正解: 压测流量带 X-Load-Test 标头, 网关路由到影子池, 影子表/影子 topic 隔离.
# 错: 压测机直接打生产域名, 流量与真实用户无差别
# 对: if header["X-Load-Test"]: route_to(shadow_pool) else: prod_pool
坑 13 · 队列 10 万积压, 全部请求超时白忙 — "排队"看似温柔, 客户端拿到的是 100% 超时. 原因: 无界队列让 W 无界(Little's Law), 排 30 秒的请求上游早就放弃了, 处理了也白发. 正解: 有界队列(约 2 倍并发能力), 队满立刻 503 + Retry-After.
# 错: Queue queue = new LinkedBlockingQueue();  # 无界, 延迟与内存同爆
# 对: new ArrayBlockingQueue(400); offer() 失败即 503
坑 14 · 每请求新建 DB 连接, QPS 翻倍连接数翻倍 — 流量一涨就 ERROR 1040. 原因: 无连接池, 连接数随并发线性涨, 直接顶到 max_connections. 正解: 应用侧连接池(如 HikariCP, 上限 20/实例), 连接复用率进监控.
# 错: 每次 query: DriverManager.getConnection(url, u, p)  → 1040
# 对: HikariDataSource, maximumPoolSize=20, 连接复用率 > 99%
坑 15 · Redis 一挂, 限流器瘫痪全站 — 集中限流的 Redis 抖动, 网关要么全部放行要么全部拒绝. 原因: 没定义 Redis 不可用时的降级策略. 正解: 明确 fail-open(放行+告警, 保可用)或 fail-close(拒绝, 保下游), 并加本地令牌桶兜底降级.
# 错: redis 超时 → 异常直接 500, 限流器成了新单点
# 对: redis 失败 → 本地兜底桶放行 + 打告警, 恢复后自动切回
坑 16 · 一个全局桶, 热点接口把别人饿死 — 导出接口一开, 登录接口跟着 429. 原因: 所有路由共用一个限流 zone, 热点把配额吃光. 正解: 按 route 拆 zone, 热点接口单独配额, 重要接口保底配额.
# 错: 所有 location 共用 zone=api → 导出把配额吃光
# 对: /api/export 单独 zone(低配额), /api/login 单独 zone(保底)
坑 17 · 图片请求和 API 共用限流池 — 首页大促, 图片瀑布流把 API 配额耗光. 原因: 静态资源与动态 API 同域名同池, 海量小图请求挤占交易流量. 正解: 静态资源独立域名/CDN, 至少独立 zone, 交易 API 不与任何共享配额.
# 错: img.example.com 反代到同一网关同一 zone
# 对: 静态走 CDN; 网关里静态/API 分 zone, 互不挤占
坑 18 · 网关重试 3 次 × 服务重试 3 次 = 9 倍风暴 — 下游一抖, 流量瞬间放大 9 倍, 直接压垮. 原因: 每一层都好心配了重试, 放大系数相乘. 正解: 全链路只允许一层重试, 且设重试预算(不超过 10% 流量), 下游错误上报给网关做决策.
# 错: gateway retry=3 + svc retry=3 → 3×3=9 倍放大
# 对: 只在网关层重试, retry_budget: max_extra_requests = 10%
坑 19 · 灰度按端口 5:5 分, 同一用户一会新一会旧 — 用户反馈"购物车时有时无". 原因: 灰度分流按随机/端口, 同一用户请求在新旧版本间漂移, 会话状态错乱. 正解: 按用户维度哈希分流(如 uid % 100 < 10 进灰度), 同一用户永远同一版本.
# 错: nginx split_clients 按 $remote_addr 随机 → 用户来回漂
# 对: split_clients "${arg_uid}" → uid 哈希, 同用户粘同版本
坑 20 · 限流配置热更 5 分钟才生效, 阈值形同虚设 — 应急调低阈值, 告警照样响. 原因: 配置中心下发有轮询间隔, 各网关实例生效时间不一, 窗口期内旧阈值照常放行. 正解: 应急阈值走推送(channel)而非轮询, 发布后校验各实例版本号一致再宣布生效.
# 错: 改完配置看一眼控制台就宣布生效  → 3 台实例还在跑旧阈值
# 对: 下发后校验全部实例 config_version 一致, 再确认生效