前端高可用实战:扛住 DDoS / CC,应对域名与 IP 被封的多入口容灾方案
一句话结论:前端"打不开"的根因通常不在前端代码,而在入口不可达。要把防线拆成四层:边缘抗攻击 → 多入口(域名池)→ 浏览器侧兜底(Service Worker)→ 数据降级。只做 Service Worker 缓存,只能保护"访问过的老用户",保护不了首次访问的新用户。
一、先搞清楚:这套方案防什么,不防什么
很多"前端容灾"文章讲的是弱网、接口抖动、发版事故。这些很重要,但如果你的实际风险是下面这些,就需要另一套思路:
| 编号 | 威胁 | 用户看到的现象 | 本质 |
|---|---|---|---|
| T1 | 大流量 DDoS(L3/L4) | 全站打不开 | 带宽/连接被打满 |
| T2 | CC 攻击(L7,刷接口/动态页) | 页面慢、接口 502/503 | 源站资源被耗尽 |
| T3 | 域名被封 / DNS 污染 | 部分地区或运营商打不开 | 域名层不可达 |
| T4 | IP 被封 / 黑洞 | 连接超时 | IP 层不可达 |
| T5 | 单个 CDN 故障 | 静态资源 404/超时,白屏 | 单点依赖 |
| T6 | 配置中心与业务同域 | 主域出事时切换能力一起失效 | 控制面与数据面耦合 |
| T7 | 发版缓存错配 | 老用户白屏 | 缓存版本管理 |
需要先接受的现实:
- 大流量 DDoS 的上限取决于高防带宽,不取决于前端代码。前端能做的是"让攻击期间尽量少出事"和"让用户能自己找到可用入口"。
- 首次访问的用户没有 Service Worker、没有本地缓存。能救他们的只有入口层多活和多渠道分发的可用域名。
- 容灾代码本身可能放大故障。攻击期间所有客户端同时重试,等于给还活着的节点再来一轮 DDoS。所以重试要有"预算"。
合规提示:面向中国大陆用户的加速域名需要完成 ICP 备案;域名池里的每个域名都应当是合规可用的域名。本文方案用于提升业务可用性,不涉及规避监管。
二、总体架构:四道防线
用户
│
├─ 第一道:边缘层 高防 / WAF / 多 CDN / 智能 DNS / 源站隐藏
├─ 第二道:入口层 域名池 + 签名下发 + 独立导航页 + 客户端竞速
├─ 第三道:浏览器层 Service Worker 预缓存 + 多 CDN 资源加载 + 兜底页自动迁移
└─ 第四道:数据层 多域名 API + 重试预算 + 熔断 + 快照读
降级等级(比"L0–L3 全套状态机"更贴近攻击场景):
| 等级 | 触发 | 表现 |
|---|---|---|
| L0 正常 | 主域名健康 | 实时数据 |
| L1 域名切换 | 主域名失败或超时 | 自动切到池内其他域名,用户无感 |
| L2 只读快照 | 所有 API 域名失败 | 展示本地快照 + "数据较旧"提示条 |
| L3 兜底迁移 | 当前入口域名整体不可达 | SW 返回兜底页,自动探测并跳转到可用入口域名 |
为什么这么分:每一级都能指回一类具体故障,指不回去的手段就是过度设计。
三、第一道防线:边缘层(不写前端代码也能做的 P0)
3.1 抗 DDoS
- 使用云厂商高防 CDN / 高防 IP,业务入口全部走高防,不直接暴露源站。
- 至少两家入口服务(例如"高防 + 普通 CDN"或"两家 CDN"),用 DNS 权重切换。
- 如果业务部署在多个区域,入口使用 Anycast,分散单点被打的风险。
3.2 隐藏源站,只认回源
攻击者一旦拿到源站真实 IP,就会绕过高防直接打源站。三条硬规矩:
- 安全组/防火墙只放行 CDN/高防的回源网段,其余一律丢弃。
- 回源时由 CDN 注入一个固定密钥头,源站校验,不通过直接断开。
- 清理历史 DNS 解析记录、邮件头、证书透明度日志里可能泄露的源站 IP;泄露过就换 IP。
# 示例:只信任回源网段,并校验回源密钥头
set_real_ip_from 203.0.113.0/24; # 替换为厂商提供的回源网段
real_ip_header X-Forwarded-For;
real_ip_recursive on;
map $http_x_origin_auth $origin_ok {
default 0;
"replace-with-a-long-random-string" 1;
}
server {
listen 443 ssl;
if ($origin_ok = 0) { return 444; } # 非 CDN 回源,直接断开
# ...
}
3.3 抗 CC:把"贵"的请求挡在源站之前
CC 攻击打的是昂贵的动态请求。思路是分层消耗攻击者的成本:
- 能静态化就静态化:首页、列表页、配置文件走 CDN 缓存,回源率越低越抗打。
- 接口分级限流:按 IP、账号、接口三个维度,昂贵接口(搜索、导出、登录)单独收紧。
- 挑战只对可疑流量:WAF 上对异常 IP/指纹启用 JS Challenge 或验证码,正常用户不打扰。
- 全局保护水位:整体 QPS 超阈值时,主动对低优先级接口返回
429/503+Retry-After,保住核心链路。 - 缓存穿透防护:对"查不到"的结果也做短时间负缓存,避免攻击者用随机参数打穿到数据库。
# 示例:分级限流(http 块内)
limit_req_zone $binary_remote_addr zone=per_ip:10m rate=10r/s;
limit_req_zone $server_name zone=global:1m rate=2000r/s;
server {
location /api/ {
limit_req zone=per_ip burst=20 nodelay;
limit_req zone=global burst=500;
limit_req_status 429;
proxy_pass http://app;
}
location /api/search { # 昂贵接口更严
limit_req zone=per_ip burst=3;
limit_req_status 429;
proxy_pass http://app;
}
}
3.4 DNS 与域名策略
- 权威 DNS 使用两家服务商,TTL 设为 60–300s,开启健康检查和故障自动摘除。
- 域名池里的域名尽量分散:不同注册商、不同后缀、不同权威 DNS、不同 CDN。
- 不要把所有域名放进同一张证书的 SAN,否则一个域名被标记,整批会被关联。
- 备用域名要提前备案、提前解析、提前小流量使用,别等主域名出事再临时启用。
- 做多地区、多运营商拨测,持续监控每个域名的可达率,异常自动摘除并告警。
四、第二道防线:域名池 + 签名下发(让新用户也有救)
4.1 设计要点
- 包内内置一组入口域名、API 域名、CDN 域名。这是最后的底线,不依赖任何远端。
- 域名列表通过多个独立渠道下发:不同云的对象存储、业务域名上的静态文件、独立导航域名等。客户端并行拉取,取最快且验签通过的。
- 下发内容必须签名,并带
version(防回滚)和expiresAt(防过期)。否则谁劫持了一个渠道,就能把你的用户导向恶意域名。 - 私钥离线保管,客户端内置两把公钥,便于密钥轮换。
- 另外准备一个独立导航页:托管在与主站完全不同的服务商和域名上,只做一件事——展示当前可用地址并自动跳转。这个地址可以放在 App 内、官方社群、邮件/短信模板里。
4.2 服务端:生成并签名域名池
// scripts/sign-pool.mjs
import { generateKeyPairSync, sign } from 'node:crypto';
import { writeFileSync } from 'node:fs';
// 一次性:生成密钥对,私钥离线保存,公钥(JWK)写进前端包
// const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
// console.log(JSON.stringify(publicKey.export({ format: 'jwk' })));
const privateKey = /* 从离线介质读取 */ null;
const payload = JSON.stringify({
version: 13, // 每次发布递增
issuedAt: Date.now(),
expiresAt: Date.now() + 7 * 24 * 3600 * 1000,
entryDomains: ['https://www.example.com', 'https://www.example.net', 'https://www.example.org'],
apiDomains: ['https://api.example.com', 'https://api.example.net', 'https://api.example.org'],
cdnDomains: ['https://cdn-a.example.com', 'https://cdn-b.example.net'],
swRolloutPercent: 30
});
// WebCrypto 使用 IEEE P1363 格式(r||s),必须指定 dsaEncoding
const sig = sign('sha256', Buffer.from(payload), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64');
writeFileSync('pool.json', JSON.stringify({ payload, sig }));
// 把 pool.json 同时发布到多个渠道(至少两家不同云)
4.3 客户端:并行拉取 + 验签 + 防回滚
// pool/pool.js
const BUILTIN = {
version: 0,
entryDomains: ['https://www.example.com', 'https://www.example.net', 'https://www.example.org'],
apiDomains: ['https://api.example.com', 'https://api.example.net', 'https://api.example.org'],
cdnDomains: ['https://cdn-a.example.com', 'https://cdn-b.example.net'],
swRolloutPercent: 0
};
// 内置两把公钥:当前 + 下一把(用于轮换)
const PUBLIC_KEYS = [ /* JWK_CURRENT */, /* JWK_NEXT */ ];
// 独立于业务域名的多个下发渠道
const CHANNELS = [
'https://cfg-a.example-static.com/pool.json', // 云 A 对象存储 + CDN
'https://cfg-b.example-oss.net/pool.json', // 云 B 对象存储
'https://api.example.com/pool.json' // 业务域名上的副本
];
const LS_KEY = 'dr:pool';
function readCached() {
try { return JSON.parse(localStorage.getItem(LS_KEY)); } catch { return null; }
}
async function verifyEnvelope(env) {
const data = new TextEncoder().encode(env.payload);
const sig = Uint8Array.from(atob(env.sig), (c) => c.charCodeAt(0));
for (const jwk of PUBLIC_KEYS) {
const key = await crypto.subtle.importKey(
'jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']
);
if (await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, sig, data)) return true;
}
return false;
}
async function pullFrom(url, cached) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 3000);
try {
const res = await fetch(url, { signal: ctrl.signal, cache: 'no-store' });
if (!res.ok) throw new Error('status-' + res.status);
const env = await res.json();
if (!(await verifyEnvelope(env))) throw new Error('bad-signature');
const pool = JSON.parse(env.payload);
if (pool.expiresAt < Date.now()) throw new Error('expired');
if (cached && pool.version < cached.version) throw new Error('rollback');
return pool;
} finally {
clearTimeout(timer);
}
}
let current = readCached() || BUILTIN;
let lastRefresh = 0;
export async function refreshPool(force = false) {
if (!force && Date.now() - lastRefresh < 30000) return current; // 节流,避免攻击期间自我 DDoS
lastRefresh = Date.now();
const cached = readCached();
try {
const pool = await Promise.any(CHANNELS.map((u) => pullFrom(u, cached))); // 取最快且验签通过的
current = pool;
try { localStorage.setItem(LS_KEY, JSON.stringify(pool)); } catch {}
} catch {
// 全部渠道失败:继续使用缓存或内置,不抛错
}
return current;
}
export const getPool = () => current;
// 启动时刷新一次,之后每 10 分钟刷新;全部 API 域名失败时由 api.js 主动触发
refreshPool();
setInterval(() => refreshPool(), 10 * 60 * 1000);
五、第三道防线:浏览器层
5.1 多 CDN 资源加载:入口脚本里自带切换
index.html 里只放一小段内联引导脚本,按顺序尝试多个 CDN,并用 SRI(integrity)保证即使 CDN 被劫持也不会执行被篡改的代码。
<meta name="app-shell" content="1">
<script>
(function () {
// 构建时注入:入口文件路径 + SRI 摘要
var ENTRY = window.__ENTRY__; // { path: '/assets/app.3f9a1c7b.js', integrity: 'sha384-...' }
var CDNS = ['https://cdn-a.example.com', 'https://cdn-b.example.net', 'https://cdn-c.example.org'];
var TIMEOUT = 4000, i = 0;
// 上次成功的 CDN 排第一
try {
var last = localStorage.getItem('dr:cdn'), k = CDNS.indexOf(last);
if (k > 0) CDNS.unshift(CDNS.splice(k, 1)[0]);
} catch (e) {}
function load() {
if (i >= CDNS.length) return giveUp();
var base = CDNS[i++], done = false;
var s = document.createElement('script');
var timer = setTimeout(fail, TIMEOUT);
function fail() { if (done) return; done = true; clearTimeout(timer); s.remove(); load(); }
s.src = base + ENTRY.path;
s.integrity = ENTRY.integrity;
s.crossOrigin = 'anonymous';
s.onload = function () {
if (done) return; done = true; clearTimeout(timer);
window.__CDN_BASE__ = base;
try { localStorage.setItem('dr:cdn', base); } catch (e) {}
};
s.onerror = fail;
document.head.appendChild(s);
}
function giveUp() {
document.body.innerHTML =
'<div style="font:16px/1.6 system-ui;padding:24px">页面加载失败,请稍后刷新,或访问备用入口。</div>';
}
load();
})();
</script>
两个必须处理的细节:
- 防双重执行:超时后虽然把
<script>摘掉了,慢请求回来后仍可能执行。入口代码第一行加保护:
if (window.__APP_BOOTED__) throw new Error('already booted');
window.__APP_BOOTED__ = true;
- 懒加载 chunk 也要能换 CDN:构建时把
publicPath设为运行时变量,例如 webpack 的__webpack_public_path__ = window.__CDN_BASE__ + '/assets/';chunk 加载失败时,换下一个 CDN 重试。
5.2 Service Worker(修正版):只管资源,不碰接口
和常见写法相比,这版做了几处关键改动:
| 常见写法的问题 | 本方案的处理 |
|---|---|
| 导航成功就把响应存成首页,可能缓存了劫持页/WAF 页 | 校验 text/html 且含 app-shell 标记才写缓存;挑战页原样放行但不缓存 |
SW 缓存 GET /api/*,容易串用户数据 |
SW 不拦截接口,数据降级放在页面层,并按用户隔离 |
接口失败返回 200 + DEGRADED,业务把失败当成功 |
不在 SW 里伪造响应 |
| 静态资源缓存键带域名,换 CDN 就失效 | 缓存键只用 pathname,换 CDN 照样命中 |
无条件 skipWaiting,用户填表时被强刷 |
由页面按灰度比例和当前状态决定 |
| 缺少紧急下线手段 | 提供 kill-switch SW |
/* sw.js */
const VERSION = '__BUILD_ID__'; // 构建时注入
const PRECACHE = `pre-${VERSION}`;
const ASSETS = 'assets-v1'; // 带 hash 的资源,跨版本共享(天然形成"双版本窗口")
const SHELL = '/index.html';
const MANIFEST = self.__PRECACHE_MANIFEST__ || [SHELL, '/offline.html']; // 必须由构建生成,禁止手写
const NAV_TIMEOUT = 3000;
const ASSETS_MAX = 300;
const HASHED = /\/assets\/.+\.[0-9a-f]{8,}\.(js|css|woff2?|png|jpe?g|svg|webp)$/i;
self.addEventListener('install', (event) => {
event.waitUntil((async () => {
const cache = await caches.open(PRECACHE);
// 逐条预缓存:单条失败不能让整个 install 失败
await Promise.allSettled(MANIFEST.map((u) => cache.add(new Request(u, { cache: 'reload' }))));
})());
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k.startsWith('pre-') && k !== PRECACHE).map((k) => caches.delete(k)));
await self.clients.claim();
})());
});
self.addEventListener('message', (event) => {
const type = event.data && event.data.type;
if (type === 'SKIP_WAITING') self.skipWaiting();
if (type === 'PURGE') event.waitUntil(caches.keys().then((ks) => Promise.all(ks.map((k) => caches.delete(k)))));
});
async function fetchWithTimeout(req, ms) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try { return await fetch(req, { signal: ctrl.signal }); }
finally { clearTimeout(timer); }
}
async function isShell(res) {
if (!res.ok) return false;
if (!(res.headers.get('content-type') || '').includes('text/html')) return false;
return (await res.clone().text()).includes('name="app-shell"');
}
async function handleNavigate(req) {
const pre = await caches.open(PRECACHE);
try {
const res = await fetchWithTimeout(req, NAV_TIMEOUT);
if (res.status >= 500) throw new Error('bad-status');
if (await isShell(res)) pre.put(SHELL, res.clone()); // 只缓存真正的应用壳
return res; // WAF 挑战页等原样放行,但不缓存
} catch {
return (await pre.match(SHELL)) || (await pre.match('/offline.html')) ||
new Response('offline', { status: 503, headers: { 'content-type': 'text/plain' } });
}
}
async function trim(cache) {
const keys = await cache.keys();
if (keys.length > ASSETS_MAX) {
await Promise.all(keys.slice(0, keys.length - ASSETS_MAX).map((k) => cache.delete(k)));
}
}
async function handleAsset(req, url) {
const cache = await caches.open(ASSETS);
const key = new Request(url.pathname); // 缓存键不含域名:换 CDN 照样命中
const hit = await cache.match(key);
if (hit) return hit; // hash 文件不可变,CacheFirst 安全
const res = await fetch(req); // 失败会抛错 → 触发页面里的"换下一个 CDN"逻辑
if (res.ok && res.type !== 'opaque') cache.put(key, res.clone()).then(() => trim(cache)).catch(() => {});
return res;
}
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (req.mode === 'navigate' && url.origin === self.location.origin) {
event.respondWith(handleNavigate(req));
} else if (HASHED.test(url.pathname)) {
event.respondWith(handleAsset(req, url));
}
});
注册与更新(灰度 + 不打断用户):
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).then((reg) => {
reg.addEventListener('updatefound', () => {
const nw = reg.installing;
nw.addEventListener('statechange', () => {
if (nw.state === 'installed' && navigator.serviceWorker.controller) {
// 按稳定的设备分桶命中灰度比例,且当前没有未保存的表单时才立即切换
if (inRollout(getPool().swRolloutPercent) && !hasDirtyForm()) {
nw.postMessage({ type: 'SKIP_WAITING' });
} else {
showUpdateToast(); // 否则提示用户,或等下次导航自然生效
}
}
});
});
});
}
紧急下线(Kill Switch):SW 本身出 bug 会把用户锁死在坏版本。准备一份"自毁版",出事时把 /sw.js 替换成它:
/* kill-sw.js —— 紧急时替换 /sw.js 发布 */
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
await self.registration.unregister();
const clients = await self.clients.matchAll({ type: 'window' });
clients.forEach((c) => c.navigate(c.url));
})());
});
配套的响应头:
/sw.js Cache-Control: no-cache
/index.html Cache-Control: no-cache
/assets/* Cache-Control: public, max-age=31536000, immutable
/__health 204,并带 Access-Control-Allow-Origin: *
5.3 兜底页自动迁移:老用户无需自己找新域名
当前入口域名被封时,浏览器里已装 SW 的老用户访问它,SW 会返回 offline.html。让这个页面自己探测并跳转到可用入口,老用户就能自动迁移:
<!-- offline.html:体积 < 30KB,内联一切,零外部依赖 -->
<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>正在为你切换线路…</title>
<div id="msg" style="font:16px/1.6 system-ui;padding:24px">正在为你切换到可用线路…</div>
<script>
(async () => {
const BUILTIN = ['https://www.example.com', 'https://www.example.net', 'https://www.example.org'];
let pool = [];
try { pool = JSON.parse(localStorage.getItem('dr:pool') || '{}').entryDomains || []; } catch (e) {}
const candidates = [...new Set([...pool, ...BUILTIN])].filter((d) => d !== location.origin);
const probe = (d) => {
const c = new AbortController();
setTimeout(() => c.abort(), 3000);
return fetch(d + '/__health', { cache: 'no-store', signal: c.signal })
.then((r) => { if (!r.ok) throw 0; return d; });
};
// 防循环:已经被跳转过一次就不再自动跳
const hops = Number(new URLSearchParams(location.search).get('_hop') || 0);
try {
if (hops >= 2) throw 0;
const best = await Promise.any(candidates.map(probe)); // 谁先通就去谁
location.replace(best + '/?_hop=' + (hops + 1));
} catch (e) {
document.getElementById('msg').innerHTML =
'暂时无法连接。请稍后重试,或访问:' +
candidates.map((d) => '<br><a href="' + d + '">' + d + '</a>').join('');
}
})();
</script>
注意:
localStorage按域名隔离,跳到新域名后登录态不会自动带过去。不要把 token 放进 URL。常见做法是新域名重新登录,或使用一次性短时效授权码换票。
六、第四道防线:多域名 API + 重试预算 + 快照读
6.1 先说清楚几个设计取舍
- 域名池里的多个域名,本质是同一后端的多个入口,不是多个独立后端。所以:
- 网络层失败(超时、连接失败、
502/504)→ 换域名重试,有意义。 - 后端明确说"我忙"(
429/503+Retry-After)→ 不要换域名,换了只是把压力再转发一遍。按Retry-After退避,并给用户降级提示。
- 网络层失败(超时、连接失败、
- 写操作也可以换域名重试,前提是客户端生成
x-idempotency-key,服务端按该 key 去重(例如 RedisSET NX保留 24 小时)。没有服务端去重,就不要对写操作做任何重试。 - 多域名下不要依赖 Cookie 会话。不同域名之间 Cookie 不共享,用
Authorization: Bearer头;所有 API 域名的 CORS 要放行所有入口域名,并允许authorization, content-type, x-idempotency-key头。 - 同一域名内不再原地重试,只做"换域名"。原地重试会放大过载。
- 重试要有全局预算:攻击期间最怕所有客户端同时重试。
6.2 熔断器(带抖动,每个域名一个)
// net/breaker.js
export class Breaker {
constructor({ windowMs = 30000, minSamples = 6, errorRate = 0.5, openMs = 15000 } = {}) {
Object.assign(this, { windowMs, minSamples, errorRate, openMs });
this.events = []; this.state = 'CLOSED'; this.openUntil = 0; this.probing = false;
}
allow() {
const now = Date.now();
if (this.state === 'CLOSED') return true;
if (this.state === 'OPEN' && now >= this.openUntil) { this.state = 'HALF_OPEN'; this.probing = false; }
if (this.state === 'HALF_OPEN' && !this.probing) { this.probing = true; return true; } // 同时只放 1 个探测
return false;
}
record(ok) {
const now = Date.now();
if (this.state === 'HALF_OPEN') { this.probing = false; ok ? this.reset() : this.trip(); return; }
this.events = this.events.filter((e) => now - e.t <= this.windowMs);
this.events.push({ t: now, ok });
const bad = this.events.filter((e) => !e.ok).length / this.events.length;
if (this.events.length >= this.minSamples && bad >= this.errorRate) this.trip();
}
release() { this.probing = false; } // 请求被主动取消(不算成功也不算失败)
trip() {
this.state = 'OPEN';
// 冷却时间加 ±30% 抖动,避免全体客户端同一时刻去探测
this.openUntil = Date.now() + this.openMs * (0.7 + Math.random() * 0.6);
}
reset() { this.state = 'CLOSED'; this.events = []; }
}
6.3 API 客户端:对冲请求 + 重试预算
对冲请求(hedged request):先打第一个域名,如果 800ms 内没响应,再并行打下一个,谁先成功用谁。这对"黑洞型"故障(被丢包、DNS 污染导致一直挂起)远比"等 2.5s 超时再换"快。但对冲会增加请求量,所以只对 GET,并受重试预算约束。
// net/api.js
import { Breaker } from './breaker.js';
import { getPool, refreshPool } from '../pool/pool.js';
const breakers = new Map();
const breakerFor = (base) => breakers.get(base) || (breakers.set(base, new Breaker()), breakers.get(base));
// 全局重试预算:每分钟额外请求(对冲)不得超过总请求的 10%(至少 3 次)
const budget = { reqs: 0, spent: 0, resetAt: 0 };
function tick() {
const now = Date.now();
if (now > budget.resetAt) { budget.reqs = 0; budget.spent = 0; budget.resetAt = now + 60000; }
}
function noteRequest() { tick(); budget.reqs++; }
function trySpend() {
tick();
if (budget.spent < Math.max(3, budget.reqs * 0.1)) { budget.spent++; return true; }
return false;
}
// 上次成功的域名排最前
function orderedApiDomains() {
const list = [...getPool().apiDomains];
try {
const last = localStorage.getItem('dr:api');
const k = list.indexOf(last);
if (k > 0) list.unshift(list.splice(k, 1)[0]);
} catch {}
return list;
}
export function request(path, {
method = 'GET', body, token, expectJson = true, hedgeMs = 800, timeout = 3000
} = {}) {
const pool = orderedApiDomains();
const idem = method === 'GET' ? null : crypto.randomUUID(); // 写操作的幂等键:所有尝试复用同一个
const headers = {
...(token && { authorization: `Bearer ${token}` }),
...(body && { 'content-type': 'application/json' }),
...(idem && { 'x-idempotency-key': idem })
};
noteRequest();
return new Promise((resolve, reject) => {
if (!pool.length) return reject(new Error('empty-pool'));
const started = new Set(), ctrls = [], causes = [];
let settled = false, failed = 0;
const finish = (fn, v) => {
if (settled) return;
settled = true;
ctrls.forEach((c) => c.abort()); // 取消落后的请求
fn(v);
};
const onFail = (i) => {
failed++;
if (failed >= pool.length) {
refreshPool(); // 所有域名都不行:立刻拉一次新的域名池(内部已节流)
finish(reject, Object.assign(new Error('all-failed'), { causes }));
} else {
start(i + 1); // 顺序故障转移(上限为域名池大小,不受预算限制)
}
};
const start = (i) => {
if (settled || started.has(i) || i >= pool.length) return;
started.add(i);
const base = pool[i], br = breakerFor(base);
if (!br.allow()) { causes.push(`${base}:breaker`); onFail(i); return; }
const ctrl = new AbortController(); ctrls.push(ctrl);
const timer = setTimeout(() => ctrl.abort(), timeout);
// 对冲:只对 GET,且预算允许时才并行打下一个域名
if (method === 'GET') setTimeout(() => { if (!settled && trySpend()) start(i + 1); }, hedgeMs);
fetch(base + path, {
method, headers, signal: ctrl.signal, cache: 'no-store',
body: body ? JSON.stringify(body) : undefined
}).then((res) => {
// 后端明确过载:不换域名,交给上层按 Retry-After 退避并降级
if (res.status === 429 || res.status === 503) {
br.record(true); // 域名本身是通的
return finish(reject, Object.assign(new Error('overloaded'), {
status: res.status, retryAfter: Number(res.headers.get('retry-after')) || 5
}));
}
if (res.status >= 500) throw new Error('status-' + res.status); // 502/504 等:换域名
// 防止 WAF 挑战页(200 + HTML)被当成正常 JSON
if (expectJson && res.ok && !(res.headers.get('content-type') || '').includes('json')) {
throw new Error('not-json');
}
br.record(true);
try { localStorage.setItem('dr:api', base); } catch {}
finish(resolve, { res, base, idempotencyKey: idem });
}).catch((err) => {
if (settled) { br.release(); return; } // 被取消的落后者,不计入熔断统计
br.record(false);
causes.push(`${base}:${err.message || err}`);
onFail(i);
}).finally(() => clearTimeout(timer));
};
start(0);
});
}
6.4 业务侧用法:带快照的降级读
// store/snapshot.js —— 精简版 IDB 快照(内存层 + IDB 层,失败自动退化为只用内存)
const mem = new Map();
const SCHEMA = 1; // 数据结构变化时递增,旧快照直接丢弃
const open = () => new Promise((res, rej) => {
const r = indexedDB.open('dr', 1);
r.onupgradeneeded = () => r.result.createObjectStore('snap');
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
export async function saveSnap(key, value) {
const rec = { value, savedAt: Date.now(), schema: SCHEMA };
mem.set(key, rec);
try { (await open()).transaction('snap', 'readwrite').objectStore('snap').put(rec, key); } catch {}
}
export async function readSnap(key) {
if (mem.has(key)) return mem.get(key);
try {
const db = await open();
const rec = await new Promise((res) => {
const r = db.transaction('snap').objectStore('snap').get(key);
r.onsuccess = () => res(r.result || null);
r.onerror = () => res(null);
});
return rec && rec.schema === SCHEMA ? rec : null;
} catch { return null; }
}
// 业务:L0 实时 → L2 快照 → L3 空态
import { request } from './net/api.js';
import { saveSnap, readSnap } from './store/snapshot.js';
export async function loadList(path, key, token) {
try {
const { res } = await request(path, { token });
const data = await res.json();
saveSnap(key, data);
return { level: 'L0', data };
} catch (err) {
if (err.message === 'overloaded') {
// 后端明确过载:按 Retry-After 稍后重试,同时先展示快照
scheduleRetry(path, key, token, err.retryAfter);
}
const snap = await readSnap(key);
if (snap) return { level: 'L2', data: snap.value, savedAt: snap.savedAt }; // UI 显示"数据较旧"
return { level: 'L3', data: null };
}
}
快照里的注意事项:只缓存非敏感数据;快照键里带上用户标识;退出登录时清空。
七、观测:攻击来了你得第一时间知道
| 指标 | 采集点 | 告警建议 |
|---|---|---|
| 各域名可达率(按地区/运营商) | 云拨测 + 客户端上报 | 任一域名某地区 < 95% |
| 域名切换次数 / 会话 | request 的 causes |
突增 |
| 兜底页曝光率 | offline.html |
> 1% |
| 重试预算耗尽次数 | trySpend 返回 false |
持续出现 |
429/503 占比 |
边缘日志 | > 5% |
| 域名池版本分布 | 客户端上报 version |
老版本占比不降 |
| 回源率、WAF 拦截数 | CDN / WAF 控制台 | 突增 |
上报也要容灾:客户端上报用 navigator.sendBeacon,并且走与业务不同的域名;缓冲在本地、设上限、失败直接丢弃,别让观测链路反噬主业务。客户端上报的"域名失败"数据,其实就是一份免费的众包拨测。
八、演练与验收
| 注入方式 | 操作 | 期望 |
|---|---|---|
| 主 API 域名黑洞 | 防火墙 DROP(不是 REJECT)该域名 IP | ≤ 1.5s 内由对冲请求切到备域名,用户无感 |
| DNS 污染 | hosts 把主域名指向 0.0.0.0 |
自动切换,causes 有记录 |
| 入口域名整体不可达 | 老用户访问被封域名 | SW 返回兜底页并自动跳转到可用入口 |
| 单个 CDN 失效 | DevTools 屏蔽 cdn-a 的 *.js |
引导脚本换到 cdn-b,页面正常 |
| 后端过载 | mock 返回 503 + Retry-After: 10 |
不换域名、不重试风暴,展示快照 + 提示 |
| 挑战页混入 | mock 返回 200 HTML | 不被缓存为首页;API 不把它当 JSON |
| 配置渠道被劫持 | 返回签名错误 / 旧版本 / 已过期的池 | 全部被拒绝,继续用缓存或内置 |
| 发版错配 | 发布错误的 precache manifest | SW 保持旧版本,offline.html 可访问 |
| 隐私模式 / 配额满 | 无痕窗口 / 写满存储 | IDB 失败自动退到内存层 |
| CC 压测 | 用 k6 / wrk 压预发环境 | 限流生效,核心接口仍可用 |
验收标准:
- 主 API 域名完全黑洞时,用户可见的额外延迟 ≤ 1.5s。
- 全部 API 域名失败时,已访问过的页面仍能展示快照并提示"数据较旧"。
- 入口域名被封,老用户通过兜底页 ≤ 5s 自动迁移到可用入口。
- 任意单个 CDN、单个配置渠道、单个 API 域名失效,业务不报错。
- 攻击模拟期间,客户端额外请求量 ≤ 正常请求量的 1.1 倍。
九、上线检查清单
边缘层
- 业务入口全部经过高防/WAF,源站只放行回源网段并校验回源密钥头
- 至少两家 CDN/入口服务,权威 DNS 双服务商,TTL ≤ 300s,健康检查自动摘除
- 昂贵接口单独限流,全局保护水位已配置,
429/503带Retry-After - 备用域名已备案、已解析、已小流量使用,不共用同一张证书
- 多地区多运营商拨测已接入告警
入口层
- 包内内置域名池;域名池签名发布到 ≥ 2 个独立渠道
- 私钥离线保管,客户端内置两把公钥;
version防回滚、expiresAt防过期 - 独立导航页已部署在不同服务商与域名,并通过 App/社群/邮件分发
浏览器层
- 入口脚本带 SRI,多 CDN 顺序切换;入口代码有防双重执行保护
-
sw.js与index.html为no-cache;预缓存清单由构建生成 - SW 不拦截 API;导航缓存带
app-shell标记校验 - kill-switch SW 已演练过;SW 更新有灰度比例,且不打断未保存表单
-
offline.html< 30KB、内联一切、自带域名探测与跳转(含防循环)
数据层
- 所有 API 域名 CORS 已放行所有入口域名;使用 Bearer token,不依赖 Cookie
- 服务端已实现幂等键去重,客户端写请求固定带
x-idempotency-key - 重试预算、熔断抖动已启用;
429/503不换域名 - 快照只存非敏感数据,退出登录清空,带
schema版本 - 各域名共用同一份 DTO 契约测试,防止 schema 漂移
流程
- 每季度一次故障注入演练,结果进周报
十、常见的坑
- 只做了 Service Worker,没做入口层:首次访问的用户零保护,这是最常见的误区。
- 所谓"备用源"与主域名同源:域名被封或 IP 被打时同时失效,不是真备用。
- 配置中心与业务同域:主域出事时,切换能力一起失效。配置要走独立渠道并签名。
- 域名池不签名:谁劫持一个渠道,谁就能把用户导向恶意域名。
- 对所有请求重试:下单接口重复提交;必须前置生成幂等键,且服务端要真去重。
- 过载时还在换域名重试:把压力放大到所有入口,自己打自己。
- 缓存了 WAF 挑战页/运营商劫持页当首页:校验响应内容再写缓存。
navigator.onLine当真理:连上 WiFi 但出站被封时它仍是true。真实判定靠/__health探测,并区分"本地没网"和"站点不可达"。- SW 缓存了接口响应:容易串用户数据;接口降级放在页面层并按用户隔离。
skipWaiting无条件调用:用户填表单时被强刷;用灰度比例 + 脏表单检测控制。- 备用域名没预热:临时启用的新域名信誉为零,更容易被拦;平时就要有小流量。
- 容灾代码自己没有容灾:兜底逻辑抛异常比没有兜底更糟,每个兜底分支都要能安全返回。
十一、落地优先级
| 优先级 | 事项 | 说明 |
|---|---|---|
| P0 | 高防/WAF、源站隐藏、多 CDN、DNS 健康检查 | 不写前端代码,收益最大 |
| P0 | 域名池 + 签名下发 + 独立导航页 | 让首次访问用户也有救 |
| P1 | 多 CDN 引导脚本 + SW(修正版)+ 兜底页自动迁移 | 保护老用户,实现自动迁移 |
| P1 | 多域名 API + 重试预算 + 熔断 | 注意服务端幂等与 CORS 前置 |
| P2 | 快照读、观测与众包拨测 | 提升体验与发现速度 |
| P3 | 离线写队列、Background Sync | 仅在业务确实需要离线提交时再做 |
结语
前端容灾不是"多写几层缓存",而是让用户在任何一层失效时,都有下一层可以接住:边缘层扛流量,入口层保证有路可走,浏览器层保证老用户不白屏、能自动换路,数据层保证有东西可看。
最后再强调两点:入口层决定上限,客户端只能锦上添花;容灾手段必须能指回具体故障,并且定期演练,没有演练过的容灾,等于没有。
相关文档
评论与补充0
有问题或想补充的,写在下面 · 选中正文文字再点「报告错误」会自动引用还没有评论。有疑问、补充,或者发现错字,都可以写在下面。