前端高可用实战:扛住 DDoS / CC,应对域名与 IP 被封的多入口容灾方案
一句话结论:前端"打不开"的根因通常不在前端代码,而在入口不可达。要把防线拆成四层:边缘抗攻击 → 多入口(域名池)→ 浏览器侧兜底(Service Worker) 2026-11-1 06:13:0 Author: blog.upx8.com(查看原文) 阅读量:8 收藏

一句话结论:前端"打不开"的根因通常不在前端代码,而在入口不可达。要把防线拆成四层:边缘抗攻击 → 多入口(域名池)→ 浏览器侧兜底(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,就会绕过高防直接打源站。三条硬规矩:

  1. 安全组/防火墙只放行 CDN/高防的回源网段,其余一律丢弃。
  2. 回源时由 CDN 注入一个固定密钥头,源站校验,不通过直接断开。
  3. 清理历史 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 攻击打的是昂贵的动态请求。思路是分层消耗攻击者的成本:

  1. 能静态化就静态化:首页、列表页、配置文件走 CDN 缓存,回源率越低越抗打。
  2. 接口分级限流:按 IP、账号、接口三个维度,昂贵接口(搜索、导出、登录)单独收紧。
  3. 挑战只对可疑流量:WAF 上对异常 IP/指纹启用 JS Challenge 或验证码,正常用户不打扰。
  4. 全局保护水位:整体 QPS 超阈值时,主动对低优先级接口返回 429/503 + Retry-After,保住核心链路。
  5. 缓存穿透防护:对"查不到"的结果也做短时间负缓存,避免攻击者用随机参数打穿到数据库。
# 示例:分级限流(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 设计要点

  1. 包内内置一组入口域名、API 域名、CDN 域名。这是最后的底线,不依赖任何远端。
  2. 域名列表通过多个独立渠道下发:不同云的对象存储、业务域名上的静态文件、独立导航域名等。客户端并行拉取,取最快且验签通过的。
  3. 下发内容必须签名,并带 version(防回滚)和 expiresAt(防过期)。否则谁劫持了一个渠道,就能把你的用户导向恶意域名。
  4. 私钥离线保管,客户端内置两把公钥,便于密钥轮换。
  5. 另外准备一个独立导航页:托管在与主站完全不同的服务商和域名上,只做一件事——展示当前可用地址并自动跳转。这个地址可以放在 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>

两个必须处理的细节:

  1. 防双重执行:超时后虽然把 <script> 摘掉了,慢请求回来后仍可能执行。入口代码第一行加保护:
   if (window.__APP_BOOTED__) throw new Error('already booted');
   window.__APP_BOOTED__ = true;
  1. 懒加载 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 先说清楚几个设计取舍

  1. 域名池里的多个域名,本质是同一后端的多个入口,不是多个独立后端。所以:
    • 网络层失败(超时、连接失败、502/504)→ 换域名重试,有意义。
    • 后端明确说"我忙"(429 / 503 + Retry-After)→ 不要换域名,换了只是把压力再转发一遍。按 Retry-After 退避,并给用户降级提示。
  2. 写操作也可以换域名重试,前提是客户端生成 x-idempotency-key,服务端按该 key 去重(例如 Redis SET NX 保留 24 小时)。没有服务端去重,就不要对写操作做任何重试。
  3. 多域名下不要依赖 Cookie 会话。不同域名之间 Cookie 不共享,用 Authorization: Bearer 头;所有 API 域名的 CORS 要放行所有入口域名,并允许 authorization, content-type, x-idempotency-key 头。
  4. 同一域名内不再原地重试,只做"换域名"。原地重试会放大过载。
  5. 重试要有全局预算:攻击期间最怕所有客户端同时重试。

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 压预发环境 限流生效,核心接口仍可用

验收标准:

  1. 主 API 域名完全黑洞时,用户可见的额外延迟 ≤ 1.5s。
  2. 全部 API 域名失败时,已访问过的页面仍能展示快照并提示"数据较旧"。
  3. 入口域名被封,老用户通过兜底页 ≤ 5s 自动迁移到可用入口。
  4. 任意单个 CDN、单个配置渠道、单个 API 域名失效,业务不报错。
  5. 攻击模拟期间,客户端额外请求量 ≤ 正常请求量的 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 漂移

流程

  • 每季度一次故障注入演练,结果进周报

十、常见的坑

  1. 只做了 Service Worker,没做入口层:首次访问的用户零保护,这是最常见的误区。
  2. 所谓"备用源"与主域名同源:域名被封或 IP 被打时同时失效,不是真备用。
  3. 配置中心与业务同域:主域出事时,切换能力一起失效。配置要走独立渠道并签名。
  4. 域名池不签名:谁劫持一个渠道,谁就能把用户导向恶意域名。
  5. 对所有请求重试:下单接口重复提交;必须前置生成幂等键,且服务端要真去重。
  6. 过载时还在换域名重试:把压力放大到所有入口,自己打自己。
  7. 缓存了 WAF 挑战页/运营商劫持页当首页:校验响应内容再写缓存。
  8. navigator.onLine 当真理:连上 WiFi 但出站被封时它仍是 true。真实判定靠 /__health 探测,并区分"本地没网"和"站点不可达"。
  9. SW 缓存了接口响应:容易串用户数据;接口降级放在页面层并按用户隔离。
  10. skipWaiting 无条件调用:用户填表单时被强刷;用灰度比例 + 脏表单检测控制。
  11. 备用域名没预热:临时启用的新域名信誉为零,更容易被拦;平时就要有小流量。
  12. 容灾代码自己没有容灾:兜底逻辑抛异常比没有兜底更糟,每个兜底分支都要能安全返回。

十一、落地优先级

优先级 事项 说明
P0 高防/WAF、源站隐藏、多 CDN、DNS 健康检查 不写前端代码,收益最大
P0 域名池 + 签名下发 + 独立导航页 让首次访问用户也有救
P1 多 CDN 引导脚本 + SW(修正版)+ 兜底页自动迁移 保护老用户,实现自动迁移
P1 多域名 API + 重试预算 + 熔断 注意服务端幂等与 CORS 前置
P2 快照读、观测与众包拨测 提升体验与发现速度
P3 离线写队列、Background Sync 仅在业务确实需要离线提交时再做

结语

前端容灾不是"多写几层缓存",而是让用户在任何一层失效时,都有下一层可以接住:边缘层扛流量,入口层保证有路可走,浏览器层保证老用户不白屏、能自动换路,数据层保证有东西可看。

最后再强调两点:入口层决定上限,客户端只能锦上添花;容灾手段必须能指回具体故障,并且定期演练,没有演练过的容灾,等于没有。


文章来源: https://blog.upx8.com/DDoS-CC-IP
如有侵权请联系:admin#unsafe.sh