1. 为什么要做前端域名容灾:先想清楚故障到底发生哪一层
先说一个真实场景。去年我们负责的一个面向 C 端的运营活动页,凌晨两点突然有用户反馈页面打不开、接口大面积报错。排查了十来分钟才发现,不是后端崩了,也不是数据库慢查询,而是我们依赖的主接口域名在部分地区解析异常,CDN 的节点也出现了区域性回源失败。那一刻你会发现,后端服务一切正常,但用户就是进不来,因为入口域名本身已经“不可达”了。
这类问题在后端视角里很容易被忽略,因为服务端监控看到的各项指标都健康,但站在前端视角来看,这就是一次完整的“站点不可用”事故。域名容灾要解决的本质问题就是:当主用域名在 DNS 解析、网络连通性、证书校验、CDN 回源等任意环节出现故障时,前端能否自动切换或降级到备用入口,让用户无感或尽可能少感知地继续使用产品。
很多人听到“域名容灾”第一反应是运维的活,跟 DNS 和负载均衡有关,前端能做什么?其实不是这样。前端能做的方案并不少,而且越是贴近用户端的容灾手段,切换速度越快、对故障的感知越精准。前端域名容灾至少有四种可选路径:DNS 多 IP 与智能切换、HTTPDNS 改造、静态资源多域名冗余、以及本文重点要讲的请求封装(Request Wrapper)。它们分别作用在网络的不同层级,各有各的适用场景、接入成本和控制粒度。
这篇文章我打算把这四种方案完整拆一遍,说清楚各自的原理、优点、坑点,以及我们团队为什么最后选了请求封装。如果你正在做前端架构、性能优化,或者准备面试时被问到“前端怎么做高可用”这类话题,这篇内容应该能帮你省不少翻资料的力气。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四种主流域名容灾方案拆解
2.1 方案一:DNS 多 IP + 智能 DNS 切换
这个方案其实是偏运维侧的经典做法,但前端也必须理解,因为很多前端同学手里的“域名容灾”方案就是这个的衍生品。
基本原理不复杂:一个域名在权威 DNS 上配置多条 A 记录或 AAAA 记录,对应不同机房的 IP。正常情况下,Local DNS 会根据轮询或就近原则返回其中一个 IP。当某个机房故障时,运维人员通过切换 DNS 解析结果,把流量导向健康机房。更高级一点的是结合拨测系统,由监控程序自动检测各 IP 的可用性,发现异常后自动修改解析记录,实现故障转移。
听起来很顺滑,但它有几个先天短板。首先,DNS 解析结果存在缓存,这个缓存既包括 Local DNS 上面的 TTL,也包括操作系统和浏览器层面的 DNS 缓存。即便你在权威 DNS 上把解析结果改掉,全网生效仍然需要几十分钟甚至更久。那些号称秒级切换的方案,通常是把 TTL 调得很低(比如 30 秒),但低 TTL 会显著增加 Local DNS 的查询压力,并不是所有场景都适合。其次,这个方案只解决“域名解析到哪个 IP”的问题,一旦域名本身被精准封禁或权威 DNS 被污染,你连解析都拿不到正确结果,后面的一切都无从谈起。
结论是:作为前端,可以了解这个方案,但别指望它能解决所有前端侧的容灾问题。它更多是基础设施层面的第一道防线,前端无法主导它的切换时效。
2.2 方案二:HTTPDNS 客户端改造
DNS 解析不可控的问题催生了 HTTPDNS。做法是前端不再走系统默认的 Local DNS 去解析域名,而是通过 HTTP 接口直接向服务商(阿里云、腾讯云都有对应产品)请求某个域名的 IP 列表。拿到的是精准的、基于用户出口 IP 选路后的 IP,既能避开 Local DNS 被劫持/污染的问题,还能拿到多个候选 IP 做连接级别的容灾。
HTTPDNS 的粒度比传统 DNS 细很多。它能在客户端拿到多个 IP,并且 SDK 内部有健康检查机制:当前 IP 连接失败,自动换下一个 IP。如果你的 App 是原生客户端,且公司有自建的 HTTPDNS 服务,这套方案会非常顺。
但放到纯前端项目里,HTTPDNS 没那么好落地。浏览器环境下,你很难绕过系统 DNS 去自定义连接 IP,除非用 WebSocket 这类不依赖标准 DNS 的连接方式,又或者自己做私有协议,这已经超出常规 Web 应用的范畴了。倒是小程序、uni-app 这类运行在特定容器里的场景,有部分容器能力允许配置域名解析或使用内嵌的 HTTPDNS SDK,但兼容性和平台政策限制都比较多。
所以 HTTPDNS 是重型方案,适合大厂自研 App 的客户端团队,不适合普通前端团队在 Web/H5 项目里快速落地。如果你们是混合开发,App 壳里接一套 HTTPDNS 倒是可行,但那就属于客户端改造的范畴了。
2.3 方案三:静态资源多域名 + 域名替换
这个方案在页面依赖大量 JS/CSS 资源的场景里很常见。你的 JS 文件、CSS 文件、图片等静态资源,分散存放在多个 CDN 域名下,比如 static1.example.com、static2.example.com。正常情况主域名加载,主域名失败时切换备用域名。
静态资源的容灾和接口容灾有一个明显区别:静态资源是通过 <script>、<link> 标签加载的,它们的加载失败不像 fetch/XHR 那样有清晰的 catch 回调。所以很多团队会用一个备用域名动态加载 JSONP 探活文件,比如在主资源加载失败后,动态插入一个指向备用域名的 <script>,如果它成功执行了,就说明备用域名可用,再通过 document.write 或重新设置打包入口来加载真正的资源。
这套方案有效,但很脏,主要体现在工程化层面。打包时你需要产出带主/备域名区分的资源地址,运行时还要维护一套“当前使用哪个域名”的全局状态。遇到按需加载(动态 import)的场景,要改造 webpack 的 publicPath 运行时配置。整个过程很容易出问题,排查起来也费劲。
而且它只解决静态资源加载问题,接口请求的容灾依然要另做一套。现实中我们的页面往往是“HTML + CSS + JS + 接口数据”都要可用才算真可用,只保住静态资源是不够的。所以这个方案适合作为辅助手段,不适合当作核心防线。
2.4 方案四:请求封装(Request Wrapper)——应用层统一容灾
请求封装这个名字听起来朴素,但它是离用户最近、也最可控的一层容灾方案。核心做法是在应用层封装一个统一的请求函数,给每个请求配置一个域名池。正常情况下请求主域名,当主域名连续失败或响应异常时,自动把后续请求切换到备用域名,并且支持失败后的自动重试。
它和前面几个方案最大的区别在于:不依赖 DNS 解析链路,不依赖客户端网络栈,只依赖“当前请求是否成功”这个应用层事实。只要请求发出去了,返回了,状态码不对,或者超时了,前端就能立即感知并作出反应。这个反应速度比运维去改 DNS 快得多,第一次失败后的下一次请求就可以切到备用域名。
同时,请求封装的粒度是请求级别的,可以做得非常灵活:按接口分组配置域名池、按用户群体灰度切换、按时段调整主备优先级、以及结合本地存储记录上一次的成功域名作为下次优先项。它还可以和监控系统打通,把切换事件上报到日志平台,方便事后分析。
当然,请求封装也有自己的边界,最典型的一条是:它救不了静态资源。你的 JS/CSS 文件如果加载失败,页面压根跑不起来,封装在 JS 里的请求逻辑自然也不会执行。所以严格意义上,请求封装解决的是“接口请求的域名容灾”,而不是整个页面的域名容灾。但如果结合方案三的静态资源冗余,两者一组合,覆盖度会非常可观。
3. 为什么我们最终选了“请求封装”:决策过程与理由
3.1 先看清我们的业务形态和约束
在做技术选型之前,我们把自身条件列了个清单:
- 业务以 Web/H5 为主,部分跑在微信小程序和 uni-app 容器里;
- 前端团队规模不大,没有专职的运维和客户端同学可以长期维护基础设施;
- 项目迭代节奏快,经常一周上线两三个活动页;
- 产品对可用性要求高,但又不允许为了可用性做太重的客户端改造;
- 我们已经有了一个统一的请求封装层(基于 axios 二次封装),改造的入口天然存在。
看完这个清单,结论其实已经比较明显了。DNS 层方案需要运维深度参与,HTTPDNS 需要客户端能力支撑,静态资源冗余需要大改构建链路。只有请求封装是我们可以完全掌控、独立交付、快速迭代的方案。
3.2 请求封装真正解决了我们的核心矛盾
我们的核心矛盾是:基础设施团队和前端团队之间,总是存在一个“认知真空地带”。基础设施认为只要机房没挂就算可用,前端认为用户能完成核心操作才算可用。域名解析异常、运营商线路波动、甚至某个地区性证书校验问题,都不会体现在后端监控上,但对用户来说就是“打不开”。
请求封装把这个真空地带抹掉了。前端把自己的可用性标准变成了代码里的判断逻辑:请求成功就是可用,请求失败且重试仍然失败,就切换域名池里的下一个入口。它的判断标准非常简单粗暴,但有效。因为我们发现,在很多真实故障里,主域名不可用时,备用域名往往是完全正常的——尤其是当故障原因是域名被精准拦截、某个 CDN 厂商的区域节点故障这类情况时。
另外还有一个选择它的重要理由:请求封装让我们具备了业务降级的能力。比如在秒杀活动期间,我们可以在备用域名上部署一套精简版接口,当主站接口压力过大时,前端自动切换到备用域名的限流接口,保证核心的下单链路仍然可用。这已经不是单纯的域名容灾了,而是一种应用层的流量治理手段。
3.3 落地过程中必须接受的约束
当然,选择请求封装不代表它没有代价。我们在落地过程中清楚认识到了以下几点约束:
第一,请求封装只能管接口,管不了静态资源。我们在方案设计时,必须另外给静态资源单独做一层“主备 CDN 域名 + 刷新页面重试”的策略。好在这个策略不需要很复杂,因为我们发现静态资源加载失败往往是全量性的,一旦出现,刷新一次大概率能解决;而接口失败则可能是局部或间歇性的,必须靠请求封装来精确处理。
第二,切换逻辑不能无限重试。如果不加控制,主域名挂掉后所有用户同时切换到备用域名,备用域名如果没做好容量评估,会被瞬间流量打爆。我们在代码里必须加入降级开关、失败熔断和随机延迟。
第三,域名池的运维需要前端团队自己维护。备用域名要提前申请、证书要定期检查、CORS 配置要提前配好。很多细节如果不提前处理,真到了故障发生时,你会发现备用域名因为没配跨域头,照样请求不通。
4. 请求封装的实战实现:从结构设计到容灾状态管理
4.1 基础结构:域名池与请求函数设计
先看一个最简版的域名池设计和请求函数骨架。以 axios 封装为例,核心思路是:把“请求地址”和“域名选择”解耦,每次请求前动态决定使用哪个域名。
javascript复制// domainPool.js
// 每个业务域对应一组域名,按优先级排列
const domainPool = {
default: [
'https://api.example.com',
'https://api2.example.com',
'https://api3.example.com',
],
order: [
'https://order-api.example.com',
'https://order-api2.example.com',
],
};
// 记录每个域名池当前使用的下标,以及连续失败次数
const poolState = new Map();
function getCurrentDomain(poolName) {
const pool = domainPool[poolName];
if (!pool) {
throw new Error(`Unknown domain pool: ${poolName}`);
}
const state = poolState.get(poolName) || { index: 0, failCount: 0, degradeUntil: 0 };
// 如果当前处于熔断期,直接走备用域名
if (Date.now() < state.degradeUntil) {
return pool[1] || pool[0];
}
return pool[state.index];
}
export function markDomainSuccess(poolName) {
const state = poolState.get(poolName) || { index: 0, failCount: 0 };
state.failCount = 0;
poolState.set(poolName, state);
}
export function markDomainFailure(poolName) {
const pool = domainPool[poolName];
const state = poolState.get(poolName) || { index: 0, failCount: 0 };
state.failCount += 1;
// 连续失败超过阈值,切换主备域名并进入 30 秒熔断期
if (state.failCount >= 3) {
state.index = state.index === 0 ? 1 : 0;
state.failCount = 0;
state.degradeUntil = Date.now() + 30 * 1000;
}
poolState.set(poolName, state);
}
这里有一个容易被忽略的细节:为什么用“连续失败次数”而不是“总失败次数”?因为网络环境总有偶发抖动,偶尔一次两次失败是很正常的,如果一失败就切换,会导致域名频繁来回跳,反而影响稳定性。连续失败三次再切换,既不会对偶发故障过于敏感,也不会在真正的故障面前反应太慢。
4.2 封装请求拦截:失败自动重试与域名切换
接下来是请求封装的核心逻辑。我们需要在拦截器里做几件事:请求发出去之前,注入当前选中的域名;响应回来之后,判断状态码和业务码;请求失败后,自动切换到下一个域名重试。
javascript复制// request.js
import axios from 'axios';
import { getCurrentDomain, markDomainSuccess, markDomainFailure } from './domainPool';
const service = axios.create({
timeout: 8000,
});
// 用于标记当前请求是否已经切换过域名,避免无限递归重试
let retryFlag = false;
service.interceptors.request.use((config) => {
const poolName = config.poolName || 'default';
config.poolName = poolName;
// 将 URL 替换为域名池中当前选中的域名 + 原始路径
const currentDomain = getCurrentDomain(poolName);
const originalUrl = config.url || '';
// 这里假设 config.url 是以 /api/xxx 形式传入的路径
config.url = `${currentDomain}${originalUrl}`;
config.currentAttemptDomain = currentDomain;
return config;
});
service.interceptors.response.use(
(response) => {
const poolName = response.config.poolName;
markDomainSuccess(poolName);
retryFlag = false;
// 业务状态码判断
if (response.data && response.data.code !== 0) {
return Promise.reject(new Error(response.data.message));
}
return response.data;
},
(error) => {
const config = error.config || {};
const poolName = config.poolName || 'default';
const currentDomain = config.currentAttemptDomain;
markDomainFailure(poolName);
// 如果还没重试过,切换域名重试一次
if (!retryFlag) {
retryFlag = true;
// 强制让 domainPool 前进一个域名
const pool = [currentDomain];
const nextAttempt = getNextDomain(poolName, currentDomain);
if (nextAttempt) {
config.url = config.url.replace(currentDomain, nextAttempt);
config.currentAttemptDomain = nextAttempt;
return service(config);
}
}
retryFlag = false;
return Promise.reject(error);
}
);
export function getNextDomain(poolName, currentDomain) {
const pool = domainPool[poolName];
if (!pool) return null;
const currentIndex = pool.indexOf(currentDomain);
if (currentIndex === -1) return null;
const nextIndex = (currentIndex + 1) % pool.length;
if (nextIndex === currentIndex) return null;
markDomainFailure(poolName);
return pool[nextIndex];
}
这个实现还比较粗糙,但它把核心逻辑说清楚了:每个请求最多重试一次,重试时切换到另外一个域名;请求成功则重置失败计数器;请求失败则累加失败次数,达到阈值后进入熔断状态。我们在实际项目中还加了参数:允许开发者在调用 API 时指定某个接口是否参与域名容灾。因为有一些内部接口对域名有强依赖,乱切反而会出问题。
4.3 避免流量风暴:重试节流与并发控制
这里要重点说一下我们踩过的坑。上线第一周,我们在压测环境里模拟主域名全部不可用,结果备用域名瞬间被打到 CPU 100%。原因很简单:所有用户在同一个时间点发现主域名挂了,然后同时发起了重试请求,雪崩效应直接砸到备用域名上。
解决方式有三个:
第一,把“主域名失败后的首次重试”加一个随机延迟,比如 0 到 2 秒之间的随机数。这样可以把集中的重试请求摊开,避免瞬时流量冲击。
第二,加一个前端全局熔断开关。如果短时间内切换次数过多,说明备用域名可能也不稳定,此时不再继续切换,而是直接返回一个降级响应,让页面展示友好提示。
第三,域名池里的备用域名,容量评估要按“主域名 100% 流量”来算,而不是按“平时备用域名 10% 流量”来算。这个容量评估要提前和运维沟通,否则备用域名就像备胎,看着存在,真上场立刻爆胎。
javascript复制// 随机延迟重试函数
function delayRetry(attemptNumber) {
const maxDelay = Math.min(2000, 200 * Math.pow(2, attemptNumber));
const jitter = Math.random() * maxDelay;
return new Promise((resolve) => setTimeout(resolve, jitter));
}
值得说的是,这个抖动延迟只加在“故障后的自动重试”上,正常用户操作发出的首次请求不加延迟。否则正常请求也会因为随机延迟而变慢,用户体验不升反降。
4.4 多端适配:Web / 小程序 / uni-app 的差异点
我们团队的项目除了纯 Web 页面,还有部分跑在 uni-app 和微信小程序里。请求封装在这几个平台上的落地方式略有差异。
在 uni-app 中,推荐使用 uni.request 而不是 axios,而且 uni.request 不支持拦截器机制。我们的做法是再包一层,把域名池逻辑写在一个公共函数里,API 层通过调用这个函数发起请求。需要注意的是 uni.request 的 url 参数必须带完整域名,而且小程序的域名白名单需要在后台配置,所以备用域名必须提前加到合法域名列表里,否则真到故障时你根本没法切。
微信小程序相对更受限,它有一套自己的 request 接口,域名校验严格,而且一个 request 请求必须对应一个合法域名。所以小程序侧的域名容灾实际上是在“合法域名列表”里预先配置多个域名,然后在代码层做切换。这里有个额外的坑:小程序后台配置的 request 合法域名不支持 IP 直连,必须是 HTTPS 且有备案的域名,所以备用域名的准备周期要提前很多。
Web 端自由度最高,但也别大意。主域名的 CORS 配置和备用域名的 CORS 配置要保持一致,特别要注意 Access-Control-Allow-Origin 是写死了域名还是用的通配符,如果写死了,备用域名也需要同样配置一份。
5. 域名容灾常见问题与排查技巧实录
为了不让你重复踩我踩过的坑,我把实际运行中遇到的典型问题整理成了一张速查表,遇到同样症状可以直接对号入座。
| 问题现象 | 可能原因 | 排查思路与解决方式 |
|---|---|---|
| 切换备用域名后接口报跨域错误 | 备用域名未配置 CORS 头或配置不全 | 打开控制台看具体报错信息,对比主备域名的响应头差异 |
| 切到备用域名后请求仍然失败 | 备用域名的证书过期或域名本身不可用 | 确认 HTTPS 证书有效性,确认备用 DNS 解析结果 |
| 故障恢复后一直使用备用域名,不切回主域名 | 没有恢复检测机制,失败计数器一直没接收到成功信号 | 在成功回调里调用 markDomainSuccess,并支持定时探测主域名是否恢复 |
| 主域名故障时,用户首次请求等待时间过长 | timeout 设置太长,失败重试链太长 | 将请求 timeout 控制在 5~8 秒,重试次数控制在 1~2 次 |
| 并发请求量大时备用域名被打挂 | 没有做节流和熔断 | 增加随机 jitter 延迟,增加前端熔断开关,提前和运维沟通容量 |
| localStorage 中记录的域名失效 | 用户手动清缓存或域名配置变更 | 不要只依赖持久化记录,建议以域名池配置为权威来源,内存状态优先 |
另外说两个看起来不起眼但实际很要命的细节:
第一,路径拼接问题。我们早期封装时,域名池里存的是带 https:// 的完整域名,请求路径是 /api/xxx,两者拼接时如果域名末尾不小心带了 /,路径里又恰好以 / 开头,就会变成 https://api.example.com//api/xxx。有些网关会做 301 跳转,有些直接 404。建议在入口处统一做一次字符串规范化。
第二,文件上传场景不要套用同一个重试策略。大文件上传如果因为主域名故障切换到备用域名,必须考虑断点续传逻辑,否则用户上传到一半的进度全没了,这个体验会很糟糕。我们的做法是:文件上传走独立的请求通道,只做域名切换,不做自动重试;自动重试只针对普通 JSON 接口。
6. 从“请求封装”延伸出去:面试与架构视角
“前端域名容灾”这几年在面试里出现的频率越来越高,基本属于“看着不会,但真被问到就露馅”的题目。面试官通常不会直接问“请实现一个域名容灾”,而是从某个现象切入,比如“你们的接口域名挂了怎么办”“如果后端机器没有挂但用户请求失败,你认为可能是什么原因”。
如果你能把 DNS、HTTPDNS、静态资源、请求封装这四个层面的方案和优劣讲一遍,再落到“综合业务场景下我选择哪个方案、为什么”,会比单纯背答案好得多。如果还能把熔断、重试、节流、降级这些关键词串起来,面试观感会再上一个台阶。
还有个容易被追问的点:请求封装和“轮询”的区别。很多候选人会把请求切备用域名说成轮询,实际上不是。轮询是无差别地把请求平均分发到每个域名上;而请求封装的切换是基于失败检测的,只有在故障时才切换,正常状态下所有流量都在主域名上。这个差别背后体现的是对“容灾”和“负载均衡”两个概念的理解深度。
从架构视角来看,域名容灾只是前端稳定性建设的一小块。做完了请求封装之后,你自然会发现它还可以复用:接口灰度发布时按比例切换域名、不同渠道(微信/抖音/浏览器)走高优先级域名、按用户 ID 哈希到不同的网关入口。这些能力会反向推动前端团队对基础设施有更多掌控力,也会让前端在整个稳定性体系里的话语权更强。
最后分享一个小技巧
在实际落地时,建议你在域名池里多留一个“手动控制位”。我们线上维护了一个 JSONP 接口,返回一个 domainEnable 的配置对象,可以远程控制某个域名是否参与请求。这个接口本身部署在另一个独立的域名上,平时不需要访问,只在极端情况下手动触发。这个设计在几次紧急变更里帮了大忙,不需要发版,不需要改配置,前端拿到最新配置后 5 秒内就能切换流量。
我个人在实际操作中的体会是:做域名容灾,前端的核心价值不是替代运维,而是把“用户侧可达性”这个被忽视的维度变成可控变量。DNS、网关、CDN 都是你控制不了的环节,但请求封装这一层写得好不好,直接决定了故障发生时用户是眼睁睁看着白屏,还是完全无感地继续操作。如果你正在做前端稳定性相关的工作,这个方向值得多花一些心思。
