一句话结论:浏览器里不能指定 IP、不能控制 DNS,所以"被墙"场景下前端唯一能掌控的资源就是域名。方案核心是:域名池 + 签名下发 + 客户端竞速选线 + 老用户自动迁移 + 众包探测自动换域名。服务端负责让每个域名背后的 IP 彼此独立,不要"一个 IP 被封、所有域名陪葬"。
"打不开"在用户眼里是一个现象,在网络层面至少有四种完全不同的形态。手段要对症,否则全是白做。
| 形态 | 机制 | 前端可观察到的现象 | 有效手段 | 无效手段 |
|---|---|---|---|---|
| DNS 污染 / 劫持 | 解析返回错误 IP 或空 | 连接到错误地址、证书报错、NXDOMAIN | 换域名;App/小程序内用 DoH 解析 | 同域名重试 |
| IP 封锁(黑洞) | 目标 IP 丢包 | 请求长时间挂起直到超时 | 换 IP(换 CDN/换入口);对冲请求 | 同 IP 下换域名 |
| SNI / TCP RST | 按域名识别后重置连接 | 握手阶段被重置,ERR_CONNECTION_RESET |
换域名;子域轮换;不同域名不同证书 | 换 IP 不换域名 |
| 平台/浏览器拦截 | 微信、QQ、浏览器安全库把域名标红 | 出现拦截提示页,而非网络错误 | 换域名;域名提前"预热"养信誉 | 任何网络层手段 |
由此得到两条硬规则:
合规说明:面向中国大陆用户的加速域名需要完成 ICP 备案;域名池里的每个域名都应当是合规、可备案的业务域名。本文讨论的是提升业务可用性与网络路径多样性,不涉及规避监管,也不适用于内容本身违规的站点。
| 想做的事 | 浏览器能不能做 | 结论 |
|---|---|---|
| 直连某个 IP 并带上指定域名 SNI | ❌ | 无法"IP 直连兜底" |
| 自己做 DNS 解析 | ❌ | 无法绕过 DNS 污染,只能换域名 |
| 通过 DoH 的 HTTP 接口查询 TXT 记录 | ✅ | 可以用作"域名池下发渠道" |
| 并行探测多个域名并选最快 | ✅ | 竞速选线 |
| 在 Service Worker 里拦截导航并返回兜底页 | ✅ | 老用户自动迁移的基础 |
| 记住上次可用的域名 | ✅ | localStorage / IDB |
所以:App / 小程序 / Electron 客户端可以额外做 DoH 解析、IP 直连、自带域名轮换;纯网页只能靠域名。下文以纯网页为主,并在对应处标注 App 可增强的点。
┌──────────────── 触达层(用户不依赖任何单一域名也能找到你)────────────────┐
│ 独立导航页 · App 内置 · 公众号/社群/邮件/短信 · 搜索引擎品牌词 │
└────────────────────────────────────────────────────────────────────────┘
用户 ──▶ ① 域名池(内置 + 多渠道签名下发)
② 竞速选线(并行探测,谁通走谁)
③ Service Worker 兜底页(入口被封时自动迁移)
④ 业务请求多域名 + 重试预算
⑤ 众包探测上报 ──▶ 服务端判定"某域名在某地区/运营商失效" ──▶ 自动换域名、下发新池
服务端基础设施配合:
域名A ──▶ CDN厂商1 ──▶ 回源网段1 ┐
域名B ──▶ CDN厂商2 ──▶ 回源网段2 ├──▶ 源站(只放行回源网段,校验回源密钥头)
域名C ──▶ 独立IP / 云厂商3 ─────┘
被墙是常态而不是意外,所以域名要像耗材一样管理:
采购 → 备案 → 配置(独立IP/CDN/证书) → 预热(小流量) → 上线 → 持续监测 → 摘除 → 替换
要点:
.com / .net / .cn 等,注意备案要求)、不同权威 DNS、不同 CDN、不同证书。*.example.com 通配证书,客户端访问 随机串.example.com。单个子域被封,换一个子域即可,主域不受影响。注意:如果封锁策略是按主域整体封,这一招无效,所以它是补充,不是替代。fetch 读取,渠道本身是 DoH 服务商的域名,与你的业务域名完全无关)。version(防回滚)和 expiresAt(防过期)。否则劫持任一渠道就能把用户导向恶意域名。// scripts/sign-pool.mjs
import { sign } from 'node:crypto';
import { writeFileSync } from 'node:fs';
// 一次性:生成 P-256 密钥对,私钥离线保存,公钥(JWK)写入前端包
// const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const privateKey = /* 从离线介质读取 PEM */ null;
const payload = JSON.stringify({
v: 21, // 版本号,每次发布递增
iat: Date.now(),
exp: Date.now() + 7 * 24 * 3600 * 1000, // 7 天过期,强制定期更新
entry: ['https://www.example.com', 'https://www.example.net', 'https://www.example.org'],
api: ['https://api.example.com', 'https://api.example.net', 'https://api.example.org'],
cdn: ['https://cdn-a.example.com', 'https://cdn-b.example.net']
});
// WebCrypto 验证的是 IEEE P1363 格式(r||s),签名时必须指定
const sig = sign('sha256', Buffer.from(payload), {
key: privateKey, dsaEncoding: 'ieee-p1363'
}).toString('base64');
writeFileSync('pool.json', JSON.stringify({ payload, sig }));
// 同时发布到 ≥ 2 家不同云的对象存储;TXT 渠道见下
把 base64(payload) + '.' + sig 切成多段,放在若干条 TXT 记录里(单条字符串 ≤ 255 字节,所以要分条,如 p1.pool.example.com ~ p4.pool.example.com)。客户端通过公共 DoH 的 JSON 接口读取:
// 通过 DoH JSON 接口查询 TXT(接口形态以所选 DoH 服务商文档为准)
async function readTxt(name, dohBase /* 如 'https://dns.alidns.com/resolve' */) {
const r = await fetch(`${dohBase}?name=${encodeURIComponent(name)}&type=TXT`, {
headers: { accept: 'application/dns-json' }, cache: 'no-store'
});
const j = await r.json();
return (j.Answer || []).map((a) => a.data.replace(/^"|"$/g, '')).join('');
}
说明:TXT 渠道的可达性取决于 DoH 服务商本身,且受限于单条记录长度;建议只用来下发精简版域名列表 + 签名,作为第三、第四条渠道,而不是唯一渠道。
// pool/pool.js
const BUILTIN = {
v: 0,
entry: ['https://www.example.com', 'https://www.example.net', 'https://www.example.org'],
api: ['https://api.example.com', 'https://api.example.net', 'https://api.example.org'],
cdn: ['https://cdn-a.example.com', 'https://cdn-b.example.net']
};
// 内置两把公钥:当前 + 下一把(轮换用)
const PUBKEYS = [ /* JWK_CURRENT */, /* JWK_NEXT */ ];
// 互相独立的渠道:不同云、不同域名
const CHANNELS = [
'https://cfg-a.example-static.com/pool.json',
'https://cfg-b.example-oss.net/pool.json',
'https://api.example.com/pool.json'
];
const LS = 'dr:pool';
const readCached = () => { try { return JSON.parse(localStorage.getItem(LS)); } catch { return null; } };
async function verify(env) {
const data = new TextEncoder().encode(env.payload);
const sig = Uint8Array.from(atob(env.sig), (c) => c.charCodeAt(0));
for (const jwk of PUBKEYS) {
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 pull(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 verify(env))) throw new Error('bad-sig');
const pool = JSON.parse(env.payload);
if (pool.exp < Date.now()) throw new Error('expired');
if (cached && pool.v < cached.v) throw new Error('rollback');
return pool;
} finally { clearTimeout(timer); }
}
let current = readCached() || BUILTIN;
let last = 0;
export async function refreshPool(force = false) {
if (!force && Date.now() - last < 30000) return current; // 节流,避免故障期自我 DDoS
last = Date.now();
try {
const pool = await Promise.any(CHANNELS.map((u) => pull(u, readCached())));
current = pool;
try { localStorage.setItem(LS, JSON.stringify(pool)); } catch {}
} catch { /* 全部失败:继续用缓存/内置,不抛错 */ }
return current;
}
export const getPool = () => current;
refreshPool();
setInterval(() => refreshPool(), 10 * 60 * 1000);
被墙里最常见的是黑洞型:请求不报错,只是一直挂着。传统"超时 3s → 换下一个"体验极差。正确做法是探测并行化。
每个域名提供一个极轻量的健康端点:
GET /__health → 204,带 Access-Control-Allow-Origin: *,Cache-Control: no-store
// net/race.js
import { getPool } from '../pool/pool.js';
function probe(base, ms = 2500) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
const t0 = performance.now();
return fetch(base + '/__health?_=' + Date.now(), { cache: 'no-store', signal: ctrl.signal })
.then((r) => { if (!r.ok) throw new Error('status-' + r.status); return { base, rtt: performance.now() - t0 }; })
.finally(() => clearTimeout(timer));
}
// 并行探测一组域名,返回最先成功的;上次成功的域名优先开跑,其余错开 300ms 起跑,兼顾速度与请求量
export function raceBest(domains, { stagger = 300 } = {}) {
let last = null; try { last = localStorage.getItem('dr:best'); } catch {}
const ordered = last && domains.includes(last) ? [last, ...domains.filter((d) => d !== last)] : domains;
return Promise.any(ordered.map((d, i) =>
new Promise((r) => setTimeout(r, i === 0 ? 0 : stagger)).then(() => probe(d))
)).then((hit) => {
try { localStorage.setItem('dr:best', hit.base); } catch {}
return hit.base;
});
}
// 入口:选出可用 API 域名
export const pickApiBase = () => raceBest(getPool().api);
两者用户提示完全不同,必须区分:
// 同时探测:你的域名 + 一个几乎必通的公共探针(图片,no-cors 即可)
async function diagnose(bases) {
const mine = await Promise.any(bases.map((b) => fetch(b + '/__health', { cache: 'no-store' }))).then(() => true, () => false);
if (mine) return 'ok';
const world = await new Promise((resolve) => {
const img = new Image(); const t = setTimeout(() => resolve(false), 3000);
img.onload = () => { clearTimeout(t); resolve(true); };
img.onerror = () => { clearTimeout(t); resolve(false); };
img.src = 'https://www.baidu.com/favicon.ico?_=' + Date.now(); // 换成你所在用户群体一定可达的探针
});
return world ? 'site-unreachable' : 'client-offline'; // 前者:提示"正在切换线路";后者:提示"请检查网络"
}
用户收藏的、历史记录里的,是已经被封的那个域名。他们能打开这个域名的唯一机会,是:浏览器里已经装了 Service Worker,且请求没有被网络层彻底阻断(例如被封之前就缓存的 offline.html)。
现实提醒:如果当前网络直接丢弃了所有到该域名的请求,浏览器也无法执行 SW 的网络请求,但 SW 依然可以在本地拦截导航并返回缓存里的兜底页——这正是这一层的价值:不依赖网络,也能把用户带到新入口。前提是用户之前访问过,SW 已安装。
/* sw.js —— 目标:入口被封时,仍能返回应用壳或迁移页 */
const VERSION = '__BUILD_ID__';
const PRE = `pre-${VERSION}`;
const ASSETS = 'assets-v1';
const SHELL = '/index.html';
const MANIFEST = self.__PRECACHE_MANIFEST__ || [SHELL, '/offline.html']; // 必须由构建生成
const HASHED = /\/assets\/.+\.[0-9a-f]{8,}\.(js|css|woff2?|png|jpe?g|svg|webp)$/i;
self.addEventListener('install', (e) => {
e.waitUntil((async () => {
const c = await caches.open(PRE);
await Promise.allSettled(MANIFEST.map((u) => c.add(new Request(u, { cache: 'reload' })))); // 单条失败不拖垮整体
})());
});
self.addEventListener('activate', (e) => {
e.waitUntil((async () => {
const ks = await caches.keys();
await Promise.all(ks.filter((k) => k.startsWith('pre-') && k !== PRE).map((k) => caches.delete(k)));
await self.clients.claim();
})());
});
self.addEventListener('message', (e) => {
if (e.data && e.data.type === 'SKIP_WAITING') self.skipWaiting();
});
async function isShell(res) {
if (!res.ok || !(res.headers.get('content-type') || '').includes('text/html')) return false;
return (await res.clone().text()).includes('name="app-shell"'); // 防止把劫持页/拦截页缓存成首页
}
async function nav(req) {
const c = await caches.open(PRE);
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 2500);
try {
const res = await fetch(req, { signal: ctrl.signal });
if (res.status >= 500) throw new Error('bad');
if (await isShell(res)) c.put(SHELL, res.clone()); // 只缓存真正的应用壳
else if (res.ok) throw new Error('not-shell'); // 200 但不是应用壳:多半是拦截页/劫持页
return res;
} catch {
// 失败或不是应用壳:直接给迁移页(offline.html 会自己探测并跳转)
return (await c.match('/offline.html')) || (await c.match(SHELL)) ||
new Response('offline', { status: 503, headers: { 'content-type': 'text/plain' } });
} finally { clearTimeout(t); }
}
self.addEventListener('fetch', (e) => {
const req = e.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (req.mode === 'navigate' && url.origin === self.location.origin) { e.respondWith(nav(req)); return; }
if (HASHED.test(url.pathname)) {
e.respondWith((async () => {
const cache = await caches.open(ASSETS);
const key = new Request(url.pathname); // 缓存键不含域名:换 CDN/换域名也命中
const hit = await cache.match(key);
if (hit) return hit;
const res = await fetch(req);
if (res.ok && res.type !== 'opaque') cache.put(key, res.clone()).catch(() => {});
return res;
})());
}
});
要点:
pathname:同一份静态资源,换了域名依然命中。sw.js 与 index.html 必须 Cache-Control: no-cache,否则更新通道会被缓存卡死。/* kill-sw.js —— 紧急时替换 /sw.js */
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil((async () => {
await Promise.all((await caches.keys()).map((k) => caches.delete(k)));
await self.registration.unregister();
(await self.clients.matchAll({ type: 'window' })).forEach((c) => c.navigate(c.url));
})()));
<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>正在切换线路…</title>
<div id="m" 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 pooled = [];
try { pooled = JSON.parse(localStorage.getItem('dr:pool') || '{}').entry || []; } catch (e) {}
const list = [...new Set([...pooled, ...BUILTIN])].filter((d) => d !== location.origin);
const probe = (d) => {
const c = new AbortController(); setTimeout(() => c.abort(), 3000);
return fetch(d + '/__health?_=' + Date.now(), { cache: 'no-store', signal: c.signal })
.then((r) => { if (!r.ok) throw 0; return d; });
};
const hop = Number(new URLSearchParams(location.search).get('_hop') || 0); // 防循环
try {
if (hop >= 2) throw 0;
const best = await Promise.any(list.map(probe));
location.replace(best + '/?_hop=' + (hop + 1));
} catch (e) {
document.getElementById('m').innerHTML = '暂时无法连接,请稍后重试,或尝试:' +
list.map((d) => '<br><a href="' + d + '">' + d + '</a>').join('');
}
})();
</script>
localStorage 与 Cookie 都按域名隔离,新域名不会带着旧域名的登录态,并且绝不能把 token 放进 URL。可选做法:
| 做法 | 体验 | 说明 |
|---|---|---|
| 新域名重新登录 | 一般 | 最简单,适合低频场景 |
| 一次性迁移码 | 好 | 旧域名(能打开时)生成 60 秒有效、单次使用的 ticket,跳转新域名后用 ticket 换新 token;ticket 放 URL fragment(#t=...),读取后立即清除 |
| 统一用 Bearer token 并由新域名静默刷新 | 好 | 配合 refresh token 的服务端校验 |
被墙往往是区域性、运营商性的,机房里的拨测看不全。让客户端把"某域名在我这里不通"上报回来,就是免费的全国拨测网络。
navigator.sendBeacon。rtt、地区/运营商(服务端根据 IP 反查,客户端不要采集多余隐私)。// telemetry.js
const buf = [];
export function reportFail(base, stage) {
if (buf.length < 50) buf.push({ base, stage, ts: Date.now() });
}
setInterval(() => {
if (!buf.length) return;
const batch = buf.splice(0, buf.length);
try { navigator.sendBeacon('https://stat.example-stat.com/dr', JSON.stringify(batch)); } catch {}
}, 30000);
客户端上报 + 云拨测
│
▼
按 (域名 × 省份 × 运营商) 聚合失败率
│ 失败率 > 阈值且持续 N 分钟,且样本量足够(防噪声/防恶意刷)
▼
标记该域名在该区域"疑似被封"
│
├──▶ 下发新池:针对该区域把坏域名降权/移除,把预热过的备用域名补进来(签名版本 +1)
├──▶ 告警:通知运维替换域名
└──▶ 进入"摘除→替换"流水线
注意防御恶意上报:对同一设备/IP 限频,要求样本量门槛,且下发"摘除"动作前建议结合云拨测交叉验证,避免被人刷数据把你的好域名摘掉。
域名池里的 API 域名本质上是同一后端的多个入口,所以策略要分清:
| 失败类型 | 含义 | 动作 |
|---|---|---|
超时 / 连接重置 / DNS 失败 / 502 / 504 |
入口不通 | 换域名重试(有意义) |
429 / 503(带 Retry-After) |
后端明确在忙 | 不换域名,按 Retry-After 退避,展示降级 UI |
200 但返回 HTML |
多半是拦截页/挑战页 | 当失败处理,不要当 JSON 解析,也不要缓存 |
// net/api.js
import { getPool, refreshPool } from '../pool/pool.js';
import { reportFail } from '../telemetry.js';
// 全局重试预算:额外请求不得超过总请求的 10%(至少 3 次/分钟),防止故障期自我放大
const B = { reqs: 0, spent: 0, reset: 0 };
const tick = () => { const n = Date.now(); if (n > B.reset) { B.reqs = 0; B.spent = 0; B.reset = n + 60000; } };
const spend = () => { tick(); if (B.spent < Math.max(3, B.reqs * 0.1)) { B.spent++; return true; } return false; };
function ordered() {
const l = [...getPool().api];
try { const k = l.indexOf(localStorage.getItem('dr:api')); if (k > 0) l.unshift(l.splice(k, 1)[0]); } catch {}
return l;
}
export function request(path, { method = 'GET', body, token, hedgeMs = 800, timeout = 3000 } = {}) {
tick(); B.reqs++;
const bases = ordered();
const idem = method === 'GET' ? null : crypto.randomUUID(); // 写操作:所有尝试复用同一幂等键(服务端必须按它去重)
const headers = {
...(token && { authorization: `Bearer ${token}` }), // 多域名下不依赖 Cookie
...(body && { 'content-type': 'application/json' }),
...(idem && { 'x-idempotency-key': idem })
};
return new Promise((resolve, reject) => {
const ctrls = []; const started = new Set(); let done = false, failed = 0;
const end = (fn, v) => { if (done) return; done = true; ctrls.forEach((c) => c.abort()); fn(v); };
const next = (i) => {
failed++;
if (failed >= bases.length) { refreshPool(); end(reject, new Error('all-failed')); } // 全挂:立刻拉新池
else start(i + 1);
};
const start = (i) => {
if (done || started.has(i) || i >= bases.length) return;
started.add(i);
const base = bases[i];
const c = new AbortController(); ctrls.push(c);
const t = setTimeout(() => c.abort(), timeout);
// 对冲:GET 且预算允许时,800ms 没响应就并行打下一个域名(对付"黑洞"最有效)
if (method === 'GET') setTimeout(() => { if (!done && spend()) start(i + 1); }, hedgeMs);
fetch(base + path, { method, headers, signal: c.signal, cache: 'no-store', body: body ? JSON.stringify(body) : undefined })
.then((res) => {
if (res.status === 429 || res.status === 503)
return end(reject, Object.assign(new Error('overloaded'), { retryAfter: Number(res.headers.get('retry-after')) || 5 }));
if (res.status >= 500) throw new Error('status-' + res.status);
if (res.ok && !(res.headers.get('content-type') || '').includes('json')) throw new Error('not-json');
try { localStorage.setItem('dr:api', base); } catch {}
end(resolve, { res, base, idempotencyKey: idem });
})
.catch((err) => { if (!done) { reportFail(base, String(err.message || err)); next(i); } })
.finally(() => clearTimeout(t));
};
start(0);
});
}
服务端配合清单(缺一不可):
x-idempotency-key 去重(如 Redis SET NX,保留 24h)。没有它,就不要对写操作重试。authorization, content-type, x-idempotency-key。这部分不写前端代码,但决定了域名池能不能真正生效。
/__health 返回 204,极轻,不走业务逻辑,不要让它回源数据库。# 源站:只信任回源网段,并校验回源密钥头
set_real_ip_from 203.0.113.0/24; # 替换为厂商提供的回源网段
real_ip_header X-Forwarded-For;
map $http_x_origin_auth $origin_ok { default 0; "replace-with-long-random-string" 1; }
server {
listen 443 ssl;
if ($origin_ok = 0) { return 444; } # 非 CDN 回源直接断开
location = /__health { return 204; add_header Access-Control-Allow-Origin * always; add_header Cache-Control no-store always; }
}
前面所有手段都假设"用户能拿到入口域名"。真正的最后一道保险是让用户有多条路径获知新入口:
| 渠道 | 做法 |
|---|---|
| 独立导航页 | 一个极简静态页,托管在完全独立的服务商 + 域名,永远指向当前可用入口 |
| App / 客户端内 | 内置域名池 + 内置导航页入口,启动即竞速 |
| 官方公众号 / 社群 / 邮件 / 短信 | 事先沟通"如打不开请访问导航页 xxx" |
| 搜索引擎品牌词 | 维护品牌词搜索结果指向最新入口 |
| 用户收藏的老域名 | 靠 SW 兜底页自动迁移(第七节) |
| 注入方式 | 操作 | 期望 |
|---|---|---|
| IP 黑洞 | 防火墙对主 API 域名 IP 做 DROP(不是 REJECT) |
对冲请求 ≤ 1.5s 切到备域名,用户无感 |
| DNS 污染 | hosts 把主域名指向 0.0.0.0 或错误 IP |
自动换域名,reportFail 有记录 |
| SNI/RST 模拟 | 代理对主域名直接断连 | 同上 |
| 入口域名被封 | 老用户访问被封域名(断开到该域名的网络) | SW 返回迁移页,≤ 5s 跳转到可用入口 |
| 平台拦截页 | mock 返回 200 + 拦截 HTML | 不被缓存成首页;API 不当作 JSON |
| 池渠道被劫持 | 返回签名错误 / 旧版本 / 已过期 | 全部被拒,继续用缓存或内置池 |
| 全部渠道失败 | 断开所有池渠道 | 继续使用缓存或内置池,业务不报错 |
| 全部域名同时失败 | 断开所有域名 | 展示快照/迁移页,且区分"断网"与"站点被封" |
| 恶意上报 | 脚本刷大量虚假失败 | 服务端不会据此摘掉好域名 |
| SW 出 bug | 发布错误 SW | kill-switch 能清理并恢复 |
验收标准:
域名与网络
/__health(204、CORS 放行、no-store)池与下发
v 防回滚、exp 防过期客户端
sw.js、index.html 为 no-cache;预缓存清单由构建生成;导航缓存带 app-shell 校验offline.html < 30KB、全内联、含探测跳转与防循环429/503 不换域名闭环与触达
流程
navigator.onLine 当真理:连着 WiFi 但出站被封时仍为 true,要靠探测判断。| 优先级 | 事项 | 说明 |
|---|---|---|
| P0 | 域名资产流水线 + IP/CDN/证书/DNS 分散 | 没有这个,后面都是空中楼阁 |
| P0 | 域名池 + 签名下发(≥ 2 渠道)+ 独立导航页 | 让首次访问用户也有救 |
| P1 | 竞速选线 + 多域名 API + 重试预算 | 对付黑洞型故障 |
| P1 | Service Worker 迁移页 + kill-switch | 让老用户自动搬家 |
| P2 | 众包探测 + 自动摘除/下发闭环 | 缩短发现和处置时间 |
| P3 | App 端 DoH / IP 直连等增强 | 仅在有原生客户端时做 |
被墙场景的本质是:你不能保证某个域名或 IP 永远可用,只能保证"换得快、换得到、换得安全"。
最后再强调一句:每个域名背后的 IP 必须独立,域名池才有意义;容灾要能指回具体故障,并且定期演练,没演练过的容灾等于没有。