高防CDN回源层扩容 8→12 台,源站 DB 被打到 91%:reload 期间双 hash 环共存、proxy_next_upstream 连环重试、max_fails 级联摘除
一、事故时间线:边缘一切正常,源站却在流血
下午两点,源站集群扩容窗口。原计划把回源层的 upstream 从 8 台加到 12 台,脚本生成配置后 nginx -t && nginx -s reload,全程 3 秒,没报错。
- 14:02:11 reload 完成,边缘节点侧的所有监控指标——带宽、QPS、5xx 比例、缓存命中率——一条曲线都没抖。
- 14:03:40 回源 QPS 从 42k 涨到 68k,涨幅 62%。注意:边缘缓存没变,理论上回源量应该几乎不变,多出来的 26k 全是"本不该发出去"的请求。
- 14:04:50 源站开始吐 502,error log 出现
upstream prematurely closed connection while reading response header。 - 14:05:30 error log 开始刷
upstream server temporarily disabled while connecting to upstream,三台源站被摘。 - 14:07:00 剩下 9 台,DB CPU 冲到 91%,点查 P99 从 40ms 飙到 1.8s。
- 14:09 回滚 upstream 配置,14:15 全部恢复。
事后第一反应肯定是"被打了",但边缘层的 QPS 曲线是平的,没有 SYN 洪峰,没有异常 UA 聚集。问题不在外面,在我们自己那 3 秒的 reload 里。
二、先把 log_format 补齐,否则什么都看不见
回源层的 access log 原来只有 combined 格式,出了事根本看不出请求落到了哪台源站、重试了几次。当天就改成:
log_format upstream '$remote_addr [$time_local] "$request" $status '
'$host$request_uri $upstream_addr $upstream_status '
'$upstream_response_time $request_time $upstream_cache_status';
access_log /var/log/nginx/upstream.log upstream buffer=64k flush=1s;
按这行格式,awk 字段是:$8=host+uri,$9=upstream_addr,$10=upstream_status,$11=upstream_response_time,$13=cache_status。($time_local 带空格会拆成 $2/$3,别数错。)
2.1 第一刀:重试放大,实锤
$upstream_addr 在发生重试时是逗号分隔的列表,这个特性以前没人用,这次成了救命稻草:
# 统计每个请求经过了几跳 upstream
awk '{n=split($9,a,", "); print n}' /var/log/nginx/upstream.log \
| sort | uniq -c | sort -rn
# 输出(故障时段 10 分钟切片)
412033 1
48120 2
9104 3
2266 4
318 5
12.5% 的请求跳了至少两次,最多一个请求串了 5 台源站。而我们的 upstream 配置是这样的:
upstream origin_pool {
hash $origin_key consistent;
server 10.20.1.11:8080 max_fails=3 fail_timeout=10s;
server 10.20.1.12:8080 max_fails=3 fail_timeout=10s;
# ... 共 8 台
keepalive 320;
keepalive_timeout 65s;
keepalive_requests 1000;
}
# proxy_next_upstream 没显式配置 = error timeout(默认值),
# proxy_next_upstream_tries 没配 = 不限次数,只受 max_fails 约束。
这就是放大器的全部:任何一台源站因为 reload 后瞬间建连排队变慢(不是挂,就是 slow),请求就会被 error 或 timeout 判定失败,然后顺着环找下一台。本来只该打到新节点的那 33% 流量,被重试放大成了 62% 的额外回源量。而源站 DB 的余量只有 20%,直接被击穿。
2.2 第二刀:reload 期间两套 hash 环在同时服务
这件事最阴。nginx 的 reload 不是重启:旧 worker 收到 QUIT 后会继续处理已建立的连接,而 upstream 用了 keepalive 320,回源长连接最长活 65 秒。也就是说,从 14:02:11 到 14:03:16 这一分多钟里,旧 worker 抱着旧配置的 hash 环往旧 8 台打,新 worker 用新的 12 台环,同一个 key 算出两个不同的落点。
验证方法很土但很准,看 worker 的存活分布:
# 每个 worker 进程还挂着多少条到源站的 established 连接
ss -tnp '( dport = :8080 )' state established \
| grep -o 'pid=[0-9]*' | sort | uniq -c | sort -rn | head
# 输出
2841 pid=18422
2790 pid=18423
611 pid=18390 <-- 旧 worker,启动时间早于 reload
588 pid=18391 <-- 旧 worker
旧 worker 还扛着近 1200 条回源连接,占总量 17%。这 17% 的流量和其余 83% 走的是两个环,key 对不上。
后果在源站侧:源站不是裸的,每台上面还有一层本地 nginx cache(二级缓存,存热门静态化和半动态结果)。同一份 URI 以前固定落 A,现在一会儿 A 一会儿 B,两台都得回源一次、各存一份,命中率当场腰斩。这也解释了为什么回源 QPS 涨了但边缘缓存命中率没掉——掉的是一级没人看的二级。
三、把 hash 环的迁移率算出来,别靠感觉
一致性哈希的理论迁移量是 新增节点数 / 总节点数,8→12 就是 33%。但我们的实测远超这个数。别去猜 nginx 内部用的是 crc32 还是 md5、虚拟节点多少个——直接拿线上日志做 key→peer 的快照对比,一秒出结果:
# 变更前 10 分钟的映射快照
awk '{print $8"\t"$9}' upstream.log | sort -u > /tmp/pre.map
# 变更后 10 分钟的映射快照
awk '{print $8"\t"$9}' upstream.log | sort -u > /tmp/post.map
# 有多少 key 换了源站
join /tmp/pre.map /tmp/post.map | awk -F'\t' '$2!=$3' | wc -l
join /tmp/pre.map /tmp/post.map | wc -l
结果 78190 / 203441 = 38.4%。比理论 33% 高 5 个点,这 5 个点来自 hash key 的碎片化——我们的 $origin_key 定义是:
map $host$request_uri $origin_key {
default $host$request_uri; # 带 args,长尾 query 全算不同的 key
}
带 args 的 key 里,像 ?t=1693821.. 这种时间戳参数占了 11% 的请求,每个时间戳都是独立 key,虚拟节点分布被打散,环的均匀性差了,迁移率自然上浮。顺带说一句,别想着在 upstream 里同时写 hash 和 least_conn 搞两级兜底——nginx 一个 upstream 只能用一种负载均衡算法,写了会直接报 duplicate,我们试过,删掉了。
3.1 第三刀:max_fails 触发级联摘除
max_fails=3 fail_timeout=10s 是 ngInx 从十年前抄到现在的默认值,放在高防CDN 回源层就是灾难。reload 后新节点瞬间涌进一批连接,listen 的 accept 队列(backlog=511)打满,新建连超时被判 error,3 次就摘。
关键点:摘除本身会再次改变 hash 环。12 台摘掉 3 台,剩下 9 台的环重新分布,原本落在那 3 台的长尾 key 全部涌向邻座,邻座压力再涨,再被摘——14:04 到 14:05 这一分钟里我们摘了 3 台,就是这个链条。摘除日志就是那句 upstream server temporarily disabled,看到它就该知道环已经不稳了。
四、落地修复:四层拆解
4.1 缩短双环共存窗口
# nginx.conf 全局
worker_shutdown_timeout 10s; # 旧 worker 最多苟 10s,不再等 keepalive 超时
# upstream 内
upstream origin_pool {
hash $origin_key consistent;
server 10.20.1.11:8080 max_fails=0; # 关闭被动摘除
# ...
keepalive 320;
keepalive_timeout 15s; # 从 65s 砍到 15s,长连接别拖过变更窗口
keepalive_requests 1000;
}
max_fails=0 意味着完全关掉被动健康检查,摘除全部交给主动探测(下面说)。回源层是内网链路,被动摘除的收益远小于级联重排的代价。
4.2 掐死重试放大
map $request_method $nx_upstream {
default "error timeout";
POST "off";
PUT "off";
DELETE "off";
}
proxy_next_upstream $nx_upstream; # 1.9.13+ 支持变量
proxy_next_upstream_tries 1; # 最多找下一台,绝不串 5 台
proxy_next_upstream_timeout 3s;
proxy_connect_timeout 1s;
proxy_send_timeout 10s;
proxy_read_timeout 10s;
非幂等方法直接 off,重试只会造成重复扣款/重复下单,收益是负的。tries 1 的意思是失败了就认,把 502 抛给边缘层去做节点级重试——边缘层本来就有别的 PoP 可以兜,让源站之间互相踢皮球毫无意义。
4.3 hash key 收敛 + 主动健康检查
# key 去掉 args 的长尾,只保留规范化后的参数白名单
map $host$uri$norm_args $origin_key {
default $host$uri$norm_args;
}
upstream origin_pool {
zone origin_pool 1m; # 主动健康检查需要共享内存
# ...
# 主动探测由 nginx-plus / 或外部探针 + API 下发成员变更完成
}
开源版 nginx 没有 health_check,我们的做法是外部探针(每 2 秒一次 TCP connect + 一次带 Host 的 GET /healthz)结果通过控制面 API 改 upstream 成员,nginx -t 后 reload。这块我们把变更脚本接到了轻云互联的回源节点控制台上,他们那边给 upstream 成员变更留了权重灰度和 dry-run 接口,能先把新节点按 weight=1 挂上去,观察 5 分钟再调满——比我们自己写分批脚本靠谱,至少不用每次手抖改配置。
4.4 变更流程:从"一把梭"改成"三批 + 双环检测"
#!/bin/bash
# 分批扩容:每次 +1 台,每批之间等 90 秒(> keepalive_timeout + worker_shutdown_timeout)
for node in 10.20.1.19 10.20.1.20 10.20.1.21 10.20.1.22; do
sed -i "/# POOL_END/i\ server ${node}:8080 max_fails=0 weight=1;" \
/etc/nginx/conf.d/upstream_origin.conf
nginx -t && nginx -s reload || { echo "FAIL ${node}"; exit 1; }
sleep 90
# 双环检测:旧 worker 是否还活着
OLD=$(ps -eo pid,lstart,cmd | grep 'nginx: worker' | grep -v grep | wc -l)
CNT=$(ss -tnp '( dport = :8080 )' state established | wc -l)
echo "${node} reloaded, workers=${OLD}, upstream_conns=${CNT}"
# 迁移率抽检,超 45% 就停
awk '{print $8"\t"$9}' /var/log/nginx/upstream.log | sort -u > /tmp/now.map
RATE=$(join /tmp/pre.map /tmp/now.map | awk -F'\t' '$2!=$3' | wc -l)
TOTAL=$(join /tmp/pre.map /tmp/now.map | wc -l)
echo "migration: ${RATE}/${TOTAL}"
done
顺带一个监控项:nginx -T | grep -c 'server .*:8080' 和线上实际分布对不上就告警。我们后来加了个 Prometheus 的 nginx_upstream_server_count 自采,比什么都实在。
五、复盘:几个值得记下来的点
- reload 不是原子的。只要 upstream 用了长连接,新旧配置就会并存一段时间,hash 环这种"全量状态"的东西最怕这个。要么缩短窗口,要么变更时先
keepalive_timeout 0再改。 $upstream_addr的逗号列表是重试的唯一证据。没这一列,你只能看到 502 数量上涨,永远想不到是重试串了 5 台。max_fails在哈希类 upstream 上是毒药。摘一台 = 环重排一次,摘除和重排互为因果。哈希场景要么max_fails=0,要么把fail_timeout拉到分钟级。- 一致性哈希的迁移率必须实测。理论值只是下限,key 设计带随机参数就会上浮 5~10 个点,扩容前用日志快照跑一遍
join,比任何仿真都准。 - 源站二级缓存对落点变化极度敏感。回源层做亲和不是为了让源站"负载均衡",是为了让源站的本地 cache 有效。环一变,二级缓存的收益直接归零,压力全灌到 DB。