双11零点,商品页全是404:一次复盘,让我彻底想明白了“99.99%”
双11零点,商品页全是404:一次复盘,让我彻底想明白了“99.99%”
作者:Echo_Wish
说明:本文案例为根据行业常见大促故障综合改编,数字和细节做了脱敏处理。
干运维这些年,我最怕的不是半夜被电话叫醒,而是零点刚过、大屏上一片红,群里只有一个字:“404”。
那天是双11零点。倒计时结束的前三十秒,我们还在群里互相发“稳了稳了”。零点一到,流量像开闸一样涌进来,不到两分钟,监控上商品详情页和下单接口的 404 占比直接冲到 37%。
说实话,当时我第一反应是:这不对啊,404 是“找不到资源”,我们的接口明明都在,怎么可能找不到?
一、404 是个“假象”,真凶在后面
很多人一看 404,就去翻发布记录,看是不是有人把路由配错了。我们当时也这么干,白白浪费了四分钟。
后来拉着网关日志一条条对,才发现真相是这样一条链子:
- 零点瞬间,几千个新扩容的容器同时起来,全部去注册中心注册、发心跳;
- 注册中心被心跳打成了“半死不活”,响应超时;
- 网关拉取路由表时拿到了空列表,而网关的实现很“老实”:拉到什么就刷成什么;
- 路由表一空,所有请求匹配不到上游,直接返回 404。
你看,404 根本不是业务问题,是我们自己把自己的“地图”擦掉了。
更惨的是,用户一刷新,客户端又触发重试,每个请求变成三个请求,注册中心更扛不住,形成了典型的重试风暴。最后我们是靠手动重启网关加临时限流,十一分钟后才止住血。
十一分钟,对一个大促零点来说,是什么概念?后面算账你就知道了。
二、复盘:三个坑,一个比一个“常见”
事后复盘会开了三个小时,我把根因压缩成三句话:
- 依赖失败时,没有兜底:注册中心挂了,网关选择“相信空列表”;
- 失败时,没有节制:重试没有上限、没有退避,所有人一起冲;
- 没人知道“多坏算坏”:我们只有“CPU 高了告警”,没有“用户体验坏了告警”。
下面说落地措施,我只讲真上线了的、有代码的。
三、落地措施一:依赖挂了,别把旧地图擦掉
最核心的一条:拿到异常数据时,宁可用旧的,也不要用错的。
import time, logging
log = logging.getLogger("gateway")
class RouteTable:
def __init__(self, min_ratio=0.5):
self._routes = {}
self._min_ratio = min_ratio
self._last_ok = 0
def refresh(self, fetch):
try:
new_routes = fetch() # 从注册中心拉取
except Exception as e:
log.warning("registry down, keep old routes: %s", e)
return # 拉取失败:保持旧路由
# 关键防线:空列表 / 骤降,一律视为可疑
if not new_routes:
log.error("empty routes from registry, ignore")
return
if self._routes and len(new_routes) < len(self._routes) * self._min_ratio:
log.error("routes dropped >50%%, ignore (%d -> %d)",
len(self._routes), len(new_routes))
return
self._routes = new_routes
self._last_ok = time.time()
这段代码不复杂,但它背后是个很朴素的道理:监控系统和注册中心是“参考”,不是“真理”。参考出了问题,你得有自己的判断。
另外 Nginx 这一层,我们也把超时和重试收紧了:
upstream order_svc {
server 10.0.1.11:8080 max_fails=3 fail_timeout=10s;
server 10.0.1.12:8080 max_fails=3 fail_timeout=10s;
keepalive 256;
}
location /api/order/ {
proxy_connect_timeout 300ms;
proxy_read_timeout 1s;
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2; # 最多重试一次
proxy_next_upstream_timeout 1s; # 重试总耗时也要封顶
}
四、落地措施二:重试要有“预算”,别搞人海战术
重试本身没错,错的是不讲规矩的重试。我们的做法是:指数退避 + 随机抖动 + 重试预算(重试流量不能超过总流量的 10%)。
import random, time
class RetryBudget:
"""重试请求数不得超过总请求的 ratio"""
def __init__(self, ratio=0.1):
self.ratio, self.total, self.retries = ratio, 0, 0
def on_request(self):
self.total += 1
def can_retry(self):
return self.retries < self.total * self.ratio
def on_retry(self):
self.retries += 1
def call_with_retry(fn, budget, max_attempts=3, base=0.1, cap=1.0):
budget.on_request()
for attempt in range(max_attempts):
try:
return fn()
except Exception:
if attempt == max_attempts - 1 or not budget.can_retry():
raise
budget.on_retry()
# 指数退避 + 全抖动,避免所有客户端同一时刻重试
time.sleep(random.uniform(0, min(cap, base * 2 ** attempt)))
我现在跟开发同学聊天,最爱说一句话:**“重试是借钱,不是印钞。”**借多了,总有还不上的时候。
五、落地措施三:流量太大?先“有尊严地拒绝”
大促零点的流量,你无论如何都接不完。关键是:宁可明确拒绝 20% 的人,也不要让 100% 的人一起卡死。
import time, threading
class TokenBucket:
def __init__(self, rate, capacity):
self.rate, self.capacity = rate, capacity
self.tokens, self.ts = capacity, time.monotonic()
self.lock = threading.Lock()
def allow(self):
with self.lock:
now = time.monotonic()
self.tokens = min(self.capacity,
self.tokens + (now - self.ts) * self.rate)
self.ts = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
bucket = TokenBucket(rate=5000, capacity=8000)
def handle_order(req):
if not bucket.allow():
# 友好降级:返回排队页,而不是让用户转圈或看到 404
return {"code": 429, "msg": "前方拥挤,请稍后再试", "retry_after": 2}
return do_order(req)
降级文案也是产品力。“系统繁忙”和“前方有 3 万人在排队,预计 20 秒”,用户的感受完全是两回事。
六、落地措施四:用“错误预算”定义什么叫 99.99%
这是我这次最大的收获。
99.99% 听起来很美,算一下账:**一个月 30 天,只允许你坏 4.32 分钟。**而我们那次,一次故障就坏了 11 分钟,直接把当月(甚至更长时间)的预算花光了。
所以我们把“可用性”变成了可以告警的东西,而不是 PPT 上的口号:
# Prometheus:基于错误预算燃烧速率的告警(SLO 99.99%)
groups:
- name: slo-order-api
rules:
- alert: OrderAPIErrorBudgetBurnFast
expr: |
(
sum(rate(http_requests_total{job="order",code=~"5..|404"}[5m]))
/ sum(rate(http_requests_total{job="order"}[5m]))
) > (14.4 * 0.0001)
and
(
sum(rate(http_requests_total{job="order",code=~"5..|404"}[1h]))
/ sum(rate(http_requests_total{job="order"}[1h]))
) > (14.4 * 0.0001)
for: 2m
labels: { severity: page }
annotations:
summary: "订单接口错误预算正在快速燃烧,1小时内可能耗尽当月预算的2%"
意思很简单:不是等机器 CPU 红了才叫人,而是用户体验在变坏的时候就叫人。
七、最后,聊聊我的几点感受
做完这一轮,我有几个很朴素的体会:
**第一,高可用不是“堆机器”,是“管失败”。**机器多了,故障点也多了。真正的功夫在于:依赖挂了怎么办、请求太多怎么办、你不知道的时候怎么办。
**第二,99.99% 是一张“预算表”,不是一个“荣誉称号”。**它逼着你去算:这个发布值不值得冒这个险?这个功能值不值得牺牲这点稳定性?有了预算,吵架就有了依据。
**第三,演练比预案值钱。**我们后来每个月做一次故障注入,随机掐掉注册中心、拉高延迟,看路由表会不会被擦掉。头几次翻车翻得很难看,但这些难看,都比大促零点翻车便宜得多。
**第四,复盘的目的是让下一个人不用再踩,而不是找人背锅。**那次事故里,写那段“拉到什么刷成什么”代码的同学,复盘会上一直低着头。我跟他说:这段代码在没有大促的一年里跑得好好的,它不是你的错,是我们没把“失败”当成需求来设计。
从 404 到 99.99%,我们没有用什么神秘的黑科技,无非是把这些“都知道、但总觉得不会发生在自己身上”的事,一件一件落到了代码和流程里。
运维这行,平静的每一天,都是一堆没被注意到的细节撑起来的。
下次大促零点,愿你的大屏一片绿,群里只有一句:“稳。”
——Echo_Wish
- 点赞
- 收藏
- 关注作者
评论(0)