CDN加速的效果好不好,很大程度上取决于回源质量。如果源站某台服务器挂了,而CDN节点还在不停地把请求回源到这台故障机器上,用户看到的就全是502和504。要解决这个问题,就需要一套靠谱的源站健康检查机制。本文从HTTP状态码判定和响应时间探测两个维度出发,聊聊具体的实现思路和容易踩的坑。

一、为什么状态码和响应时间是健康检查的两个核心指标
判断一台源站服务器是否健康,本质上是在回答两个问题:它能不能正确处理请求,以及它处理得快不快。第一个问题靠HTTP状态码来回答,第二个问题靠响应时间来回答。只看其中一个维度都可能产生误判。
只看状态码的问题是:一台服务器可能因为数据库连接池耗尽、CPU被打满,返回状态码依然是200,但响应时间从原来的50毫秒飙升到10秒。这种半死不活的状态对线上业务来说是灾难性的,用户的请求虽然最终能成功,但等待时间已经完全不可接受。所以响应时间探测是状态码探测的必要补充。
反过来说,只看响应时间也不行。有些接口本身设计得就比较慢,比如报表导出接口正常就要跑3秒,如果你把超时阈值设成1秒,那这个接口永远会被判定为不健康。更合理的做法是针对不同的探测路径设置不同的阈值,或者用一个专门的轻量级健康检查接口作为探测目标,这个接口只做最基本的自检逻辑,正常情况下响应时间应该在几十毫秒以内。
二、状态码判定规则的设计
哪些状态码应该算健康,哪些应该算不健康,需要根据业务实际情况来定,但大体上有一个通用的分层思路。2xx系列毫无疑问是健康的标志,说明服务器正常处理了请求。3xx需要特别注意:301和302如果是因为你的探测路径配置错了(比如探测了一个会跳转的地址),那说明配置有问题;但也有些场景下重定向本身就是健康的表现,比如源站对HTTP请求统一重定向到HTTPS。
4xx和5xx要区分对待。404通常意味着探测路径不存在,这更多是配置问题而非服务器故障,但如果你探测的就是一个约定好的健康检查路径,那么404也说明服务部署有问题,可以算不健康。5xx基本都可以认定为不健康,尤其是502和504,往往意味着后端服务挂了或者网络不通。而429这类限流响应则比较特殊,它说明服务还活着,只是压力大,是否剔除要看你的容灾策略。
还有一个容易被忽略的点:网络层面的超时和连接拒绝。这类情况拿不到任何状态码,应该直接判定为不健康,而且优先级要高于状态码判断。下面是一段用Python实现的探测逻辑,涵盖了状态码分层判定:
import time
import requests
def check_origin(url, timeout=3.0, slow_ms=1500):
"""探测单个源站,返回健康判定结果"""
start = time.time()
try:
resp = requests.get(url, timeout=timeout)
except requests.exceptions.Timeout:
return {"healthy": False, "reason": "timeout"}
except requests.exceptions.ConnectionError:
return {"healthy": False, "reason": "conn_refused"}
elapsed_ms = (time.time() - start) * 1000
code = resp.status_code
# 状态码分层判定
if 200 <= code < 300:
status_ok = True
elif code in (301, 302, 307, 308):
status_ok = True # 重定向视为存活,按需调整
else:
status_ok = False
# 响应时间超过阈值标记为慢节点
slow = elapsed_ms > slow_ms
return {
"healthy": status_ok and not slow,
"status": code,
"elapsed_ms": round(elapsed_ms, 1),
"slow": slow,
"reason": "slow" if slow else "ok"
}这段代码的关键在于把慢节点单独标记出来。实际生产中,慢但没死的节点可以采取降权处理而不是直接剔除,让负载均衡优先把流量分给健康且快的节点,慢节点只接一部分流量,这样既能缓解压力又能避免误杀。
三、探测策略:频率、次数与抖动
判定一个节点不健康,绝对不能只看一次探测结果。网络抖动、GC停顿、瞬时流量高峰都可能造成单次探测失败。通用的做法是连续N次失败才标记为不健康,比如连续3次失败踢出,连续2次成功再加回。恢复的门槛可以适当比剔除的门槛宽松一点,因为频繁的剔除和加回会造成流量震荡,这个现象叫雪崩效应或者抖动剔除,对业务伤害很大。
探测频率也要拿捏好。太稀疏的话故障发现慢,比如30秒一次的探测,最坏情况下要一个多分钟才能发现问题;太频繁的话,探测请求本身就成了一种压力,如果有成百上千个CDN节点都在探测同一个源站,还会形成探测风暴。解决办法有两个:一是使用独立的探测集群做集中探测,结果共享给调度系统,避免每个节点各自为战;二是在探测时间上加随机抖动,让不同节点的探测时间错开。
探测路径的选择同样重要。建议在源站上专门暴露一个轻量的健康检查接口,比如/healthz,这个接口内部只做关键依赖的快速检查(数据库ping一下、缓存连一下),不做任何重业务逻辑。千万不要拿首页或者商品详情页这种重接口当探测路径,否则探测流量会放大源站压力,而且这些页面的响应时间波动大,容易造成误判。
四、在Nginx中落地被动健康检查
健康检查分主动和被动两种模式。被动检查是等到真实请求失败了才把节点标记为不可用,Nginx开源版自带这个能力,通过max_fails和fail_timeout两个参数控制:
upstream origin_pool {
server 10.0.1.10:80 max_fails=3 fail_timeout=30s;
server 10.0.1.11:80 max_fails=3 fail_timeout=30s;
server 10.0.1.12:80 backup;
}
server {
listen 80;
location / {
proxy_pass http://origin_pool;
proxy_connect_timeout 2s; # 连接超时,判定节点存活的关键
proxy_read_timeout 5s; # 读取超时,对应响应时间阈值
}
}这段配置的含义是:某个节点在30秒内失败3次,就在接下来的30秒内不再向它转发请求。被动检查的优点是零成本、配置简单,缺点是必须拿真实用户流量当探针,第一个打到故障节点的用户注定要失败一次。而且它对状态码的判定比较粗糙,默认只把连接失败和超时算失败,后端返回500并不一定触发剔除。
五、主动探测的完整实现
对可用性要求高的场景,建议上主动探测,也就是由独立的探测程序周期性地访问所有源站。如果用的是OpenResty或者购买了Nginx Plus,可以用自带的主动健康检查模块,配置中能精确指定哪些状态码算失败:
http {
upstream origin_pool {
zone upstream_pool 64k;
server 10.0.1.10:80;
server 10.0.1.11:80;
}
match health_check_rule {
status 200;
header Content-Type = text/html;
body !~ "error|maintenance"; # 响应体不含错误关键字
}
server {
listen 80;
location / {
proxy_pass http://origin_pool;
health_check uri=/healthz interval=5s
fails=3 passes=2
match=health_check_rule;
}
}
}上面配置里interval=5s表示每5秒探测一次,fails=3表示连续失败3次才剔除,passes=2表示连续成功2次才恢复,正是前面提到的防抖动策略。match规则更进一步,不仅检查状态码,还检查响应体内容,这能防住一种隐蔽故障:接口返回200但返回的是错误页面或兜底降级内容。
如果需要自己写探测程序再对接调度系统,可以参考下面的定时探测框架,核心是把状态码和响应时间的判定结果汇总成一个健康分数,供上游系统决策:
import time
import requests
ORIGINS = ["10.0.1.10", "10.0.1.11", "10.0.1.12"]
INTERVAL = 5
FAIL_THRESHOLD = 3
FAIL_COUNTS = {ip: 0 for ip in ORIGINS}
HEALTHY = {ip: True for ip in ORIGINS}
def probe(ip):
url = f"http://{ip}:80/healthz"
start = time.time()
try:
r = requests.get(url, timeout=3)
cost = (time.time() - start) * 1000
return r.status_code < 400 and cost < 1500
except Exception:
return False
while True:
for ip in ORIGINS:
ok = probe(ip)
if ok:
FAIL_COUNTS[ip] = 0
if not HEALTHY[ip]:
# 连续成功达到次数才恢复,这里简化为成功即恢复
HEALTHY[ip] = True
print(f"[recover] {ip} 已恢复")
else:
FAIL_COUNTS[ip] += 1
if FAIL_COUNTS[ip] >= FAIL_THRESHOLD and HEALTHY[ip]:
HEALTHY[ip] = False
print(f"[down] {ip} 连续失败{FAIL_COUNTS[ip]}次,已剔除")
# 这里可以把 HEALTHY 状态同步给调度系统或LB
time.sleep(INTERVAL)六、常见坑与注意事项
第一个坑是健康检查接口形同虚设。有些团队直接用/当探测路径,Nginx返回一个静态页,应用本身挂了探测还是绿的。健康检查必须穿透到应用层,至少要覆盖应用依赖的核心组件,否则探测结果没有意义。
第二个坑是忽略探测流量本身的影响。健康检查接口如果做了鉴权或者触发了日志落盘,高频探测会产生大量无用日志,建议对/healthz路径跳过访问日志记录,并且不参与QPS统计。
第三个坑是阈值一刀切。不同机型的服务器、不同时间段的流量特征都不一样,响应时间阈值最好基于历史数据动态计算,比如取过去一小时的P95响应时间乘上一个系数,而不是写死一个数字。状态码方面,要和业务方约定好降级页面返回的状态码,有些系统降级时返回200加一个提示页面,这会让健康检查完全失效,降级页面应该返回503。
总结一下,一套完善的CDN源站健康检查体系,需要状态码分层判定加上响应时间阈值双重校验,配合连续失败次数的防抖机制和合理的探测频率,再辅以主动与被动探测的结合,才能做到快速发现故障、精准剔除节点、又不误伤慢节点。落地时先从Nginx的被动检查起步,逐步演进到独立的主动探测系统,是比较稳妥的路径。