一句话结论:前端"打不开"的根因通常不在前端代码,而在入口不可达。要把防线拆成四层:边缘抗攻击 → 多入口(域名池)→ 浏览器侧兜底(Service Worker)→ 数据降级。只做 Service Worker 缓存,只能保护"访问过的老用户",保护不了首次访问的新用户。
很多"前端容灾"文章讲的是弱网、接口抖动、发版事故。这些很重要,但如果你的实际风险是下面这些,就需要另一套思路:
| 编号 | 威胁 | 用户看到的现象 | 本质 |
|---|---|---|---|
| T1 | 大流量 DDoS(L3/L4) | 全站打不开 | 带宽/连接被打满 |
| T2 | CC 攻击(L7,刷接口/动态页) | 页面慢、接口 502/503 | 源站资源被耗尽 |
| T3 | 域名被封 / DNS 污染 | 部分地区或运营商打不开 | 域名层不可达 |
| T4 | IP 被封 / 黑洞 | 连接超时 | IP 层不可达 |
| T5 | 单个 CDN 故障 | 静态资源 404/超时,白屏 | 单点依赖 |
| T6 | 配置中心与业务同域 | 主域出事时切换能力一起失效 | 控制面与数据面耦合 |
| T7 | 发版缓存错配 | 老用户白屏 | 缓存版本管理 |
需要先接受的现实:
合规提示:面向中国大陆用户的加速域名需要完成 ICP 备案;域名池里的每个域名都应当是合规可用的域名。本文方案用于提升业务可用性,不涉及规避监管。
用户
│
├─ 第一道:边缘层 高防 / WAF / 多 CDN / 智能 DNS / 源站隐藏
├─ 第二道:入口层 域名池 + 签名下发 + 独立导航页 + 客户端竞速
├─ 第三道:浏览器层 Service Worker 预缓存 + 多 CDN 资源加载 + 兜底页自动迁移
└─ 第四道:数据层 多域名 API + 重试预算 + 熔断 + 快照读
降级等级(比"L0–L3 全套状态机"更贴近攻击场景):
| 等级 | 触发 | 表现 |
|---|---|---|
| L0 正常 | 主域名健康 | 实时数据 |
| L1 域名切换 | 主域名失败或超时 | 自动切到池内其他域名,用户无感 |
| L2 只读快照 | 所有 API 域名失败 | 展示本地快照 + "数据较旧"提示条 |
| L3 兜底迁移 | 当前入口域名整体不可达 | SW 返回兜底页,自动探测并跳转到可用入口域名 |
为什么这么分:每一级都能指回一类具体故障,指不回去的手段就是过度设计。
攻击者一旦拿到源站真实 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 回源,直接断开
# ...
}
CC 攻击打的是昂贵的动态请求。思路是分层消耗攻击者的成本:
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;
}
}
version(防回滚)和 expiresAt(防过期)。否则谁劫持了一个渠道,就能把你的用户导向恶意域名。// 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 同时发布到多个渠道(至少两家不同云)
// 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);
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;
publicPath 设为运行时变量,例如 webpack 的 __webpack_public_path__ = window.__CDN_BASE__ + '/assets/';chunk 加载失败时,换下一个 CDN 重试。和常见写法相比,这版做了几处关键改动:
| 常见写法的问题 | 本方案的处理 |
|---|---|
| 导航成功就把响应存成首页,可能缓存了劫持页/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: *
当前入口域名被封时,浏览器里已装 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。常见做法是新域名重新登录,或使用一次性短时效授权码换票。
502/504)→ 换域名重试,有意义。429 / 503 + Retry-After)→ 不要换域名,换了只是把压力再转发一遍。按 Retry-After 退避,并给用户降级提示。x-idempotency-key,服务端按该 key 去重(例如 Redis SET NX 保留 24 小时)。没有服务端去重,就不要对写操作做任何重试。Authorization: Bearer 头;所有 API 域名的 CORS 要放行所有入口域名,并允许 authorization, content-type, x-idempotency-key 头。// 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 = []; }
}
对冲请求(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);
});
}
// 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 压预发环境 | 限流生效,核心接口仍可用 |
验收标准:
边缘层
429/503 带 Retry-After入口层
version 防回滚、expiresAt 防过期浏览器层
sw.js 与 index.html 为 no-cache;预缓存清单由构建生成app-shell 标记校验offline.html < 30KB、内联一切、自带域名探测与跳转(含防循环)数据层
x-idempotency-key429/503 不换域名schema 版本流程
navigator.onLine 当真理:连上 WiFi 但出站被封时它仍是 true。真实判定靠 /__health 探测,并区分"本地没网"和"站点不可达"。skipWaiting 无条件调用:用户填表单时被强刷;用灰度比例 + 脏表单检测控制。| 优先级 | 事项 | 说明 |
|---|---|---|
| P0 | 高防/WAF、源站隐藏、多 CDN、DNS 健康检查 | 不写前端代码,收益最大 |
| P0 | 域名池 + 签名下发 + 独立导航页 | 让首次访问用户也有救 |
| P1 | 多 CDN 引导脚本 + SW(修正版)+ 兜底页自动迁移 | 保护老用户,实现自动迁移 |
| P1 | 多域名 API + 重试预算 + 熔断 | 注意服务端幂等与 CORS 前置 |
| P2 | 快照读、观测与众包拨测 | 提升体验与发现速度 |
| P3 | 离线写队列、Background Sync | 仅在业务确实需要离线提交时再做 |
前端容灾不是"多写几层缓存",而是让用户在任何一层失效时,都有下一层可以接住:边缘层扛流量,入口层保证有路可走,浏览器层保证老用户不白屏、能自动换路,数据层保证有东西可看。
最后再强调两点:入口层决定上限,客户端只能锦上添花;容灾手段必须能指回具体故障,并且定期演练,没有演练过的容灾,等于没有。