域名 / IP 被墙了怎么办?前端多入口容灾的完整落地方案
零、这篇和常见写法的差别在哪
网上讲「被墙了怎么办」的文章,绝大多数停在两个层面:
- 有的是运营视角:域名池、换域名、备案、CDN 分散。这类内容对「运营治理」很到位,但默认了一个前提——前端能掌控的资源只有域名。
- 有的是协议视角:ECH、IP 直连、TLS 指纹。这类内容技术密度高,但往往不谈资产流水线、重试预算、众包探测这些「跑起来之后才会痛」的事。
这篇想做的是把两层拼起来,并补上 2026 年才成立的两个新事实。所以全文按五个层来组织,每层只解决一件事:

上面一层挂了,下面一层还在;下面一层也挂了,还有触达层。这个结构决定了后面所有代码的归属——写代码之前先想清楚这段逻辑属于哪一层,不属于任何一层的,就是过度设计。
一、先搞清楚「被墙」到底是哪一种
「打不开」在用户眼里是一个现象,在网络层面至少有四种完全不同的形态。手段要对症,否则全是白做。

| 形态 | 机制 | 前端能观察到的现象 | 有效手段 | 无效手段 |
|---|---|---|---|---|
| DNS 污染 / 劫持 | 解析返回错误 IP 或空应答 | 连到错误地址、证书报错、NXDOMAIN | 换域名;DoH 解析;IP 直连 | 同域名重试 |
| IP 封锁(黑洞) | 目标 IP 丢包 | 请求长时间挂起直到超时 | 换 IP;共享 IP 承载;对冲竞速 | 同 IP 下换域名 |
| SNI / TCP RST | 按域名识别后重置连接 | 握手阶段被重置,ERR_CONNECTION_RESET |
ECH 加密 SNI;IP 直连;换域名 | 换 IP 不换域名 |
| 平台 / 浏览器拦截 | 微信、QQ、安全库把域名标红 | 出现拦截提示页,而非网络错误 | 换域名;域名提前预热养信誉 | 任何网络层手段 |
由此得到两条硬规则,后面所有设计都要服从它们:
- 每个域名背后的 IP 必须相互独立。 十个域名全指向同一个 IP,IP 一封全灭,域名池形同虚设。
- 证书、注册商、权威 DNS 尽量分散。 一张证书的 SAN 里塞一堆域名,一个被标记就整批关联。
合规说明:面向中国大陆用户的加速域名需要完成 ICP 备案,域名池里每个域名都应当是合规、可备案的业务域名。本文讨论的是提升业务可用性与网络路径多样性。
二、2026 年两个新事实(这是本文和旧文最大的分歧点)
2.1 IP 地址证书已经 GA,「IP 直连兜底」这条路重新打开了
很多文章(包括很新的)会写:浏览器无法直连 IP 并指定域名 SNI,所以「IP 直连兜底」走不通。
前半句是对的,后半句在 2026 年 1 月 15 日之后不成立了。
Let's Encrypt 官方公告:IP 地址证书已 General Availability,支持 IPv4 和 IPv6,有效期 160 小时(约 6.7 天),在 ACME 客户端里选 shortlived profile 即可签发。
这条改变了一个基础事实:
- 连接 IP 字面量时,ClientHello 里不携带 SNI(RFC 6066 明确规定 HostName 里不允许出现 IP 字面量)。所以「按域名识别后 RST」这套阻断,对着 IP 连接没有可匹配的字段。
- DNS 环节整个消失。DNS 污染对你无效,因为压根没查 DNS。
- 证书校验能过(IP SAN 匹配),所以
fetch能正常发、能读 body、能走 CORS——不像自签名证书那样被浏览器直接拦掉。
注意区分两件事:
| 想做的事 | 能不能做 | 说明 |
|---|---|---|
| 连 IP,但伪装成某个域名的 SNI | ✗ | 浏览器 JS 做不到,也确实不需要 |
| 连 IP,不带 SNI,用 IP-SAN 证书 | ✓ | 这才是「IP 直连兜底」的正确形态 |
2.2 ECH 落地了,但必须绑 DoH
Cloudflare 文档写明:Free 套餐默认开启 ECH,外层 SNI 统一为 cloudflare-ech.com,中间人只看到「你在访问 Cloudflare 上的某个站」,看不到具体域名。Chrome 117+ / Firefox 118+ 支持,Android 17 已把它做进系统层并默认开启 ECH GREASE(连「这台机器开了 ECH」这个特征都被抹平)。
但有个前提必须记住,Cloudflare 文档自己写了:ECH 的密钥来自 DNS 的 HTTPS 资源记录(HTTPS RR)。所以:
ECH 必须配 DoH。 否则明文 DNS 被污染或被丢弃,你连 ECH 配置都拉不到,ECH 等于没开。
也要认边界:阻断方可以直接封 cloudflare-ech.com 这个外层 SNI,或者封 Cloudflare 的 IP 段。所以 ECH 是「提高成本」,不是「免疫」。
三、传输层:五条互相补位的出口

| 出口 | 做法 | 覆盖的死角 | 边界 |
|---|---|---|---|
| A 域名 + ECH | Cloudflare 开 ECH,客户端配 DoH | 抗 SNI 阻断 | 外层 SNI 被整体封、CF IP 段被封时失效 |
| B IP + IP-SAN 证书 | LE 短有效期证书 + nginx default_server |
抗 DNS 污染 + 抗 SNI | IP 本身被封时失效 |
| C 泛域名随机子域 | *.example.com 通配证书,随机串轮换 |
抗「按精确域名封」 | 主域被整体封时失效 |
| D 端口池 | 443 / 8443 / 2053 / 2083 / 2087 / 2096 | 抗端口阻断 | 单端口策略封禁时逐个试 |
| E 共享 IP 承载 | 大 CDN 共享 IP、Serverless 默认域名 | 抗单 IP 封锁 | 封了会连坐大量正常业务,成本高 |
为什么必须并列而不是主备:ECH 挡不住「外层 SNI 被整体封」;IP 证书挡不住「IP 本身被封」。两者死角不重叠,所以是并列的两条腿,由竞速层决定这一轮走哪条。
3.1 IP-SAN 证书签发
# 首次
acme.sh --set-default-ca --server letsencrypt
acme.sh --issue --standalone -d 203.0.113.7 --profile shortlived
# 装到 nginx 并自动 reload
acme.sh --install-cert -d 203.0.113.7 \
--key-file /etc/le/ip.key \
--fullchain-file /etc/le/ip.fullchain.pem \
--reloadcmd "nginx -s reload"
--profile shortlived 是硬要求:Let's Encrypt 规定 IP 证书必须是短有效期,不带这个参数请求会被拒。
3.2 nginx 侧要接受「没有 SNI 的连接」
关键在 default_server。少了它,无 SNI 的连接会被另一个 server 块抢走,然后证书不匹配。
map $http_origin $cors_origin {
default "";
"~^https://(app\.example\.com|shell\d+\.example\.net)$" $http_origin;
}
server {
listen 443 ssl default_server;
listen 8443 ssl default_server;
server_name _;
ssl_certificate /etc/le/ip.fullchain.pem;
ssl_certificate_key /etc/le/ip.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_early_data on;
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Headers "authorization,content-type,x-idempotency-key" always;
add_header Access-Control-Allow-Methods "GET,POST,PUT,PATCH,DELETE,OPTIONS" always;
add_header Access-Control-Max-Age 86400 always;
add_header Vary Origin always;
if ($request_method = OPTIONS) { return 204; }
location = /__health {
return 204;
add_header Access-Control-Allow-Origin * always;
add_header Cache-Control no-store always;
}
location / { proxy_pass http://127.0.0.1:3000; }
}
四、寻址层:签名地址包 + 多通道并行拉取

4.1 为什么必须签名
地址包会被放在第三方公共存储(对象存储、jsDelivr、GitHub raw、IPFS 网关)上。这些地方的写入权限一旦出问题,攻击者就能往包里塞自己的服务器地址,全量用户被导向钓鱼站。所以包必须签名,公钥硬编码在应用里,并且内置两把(当前 + 下一把)以便轮换。
4.2 包结构
{
"v": 7,
"iat": "2026-11-01T00:00:00Z",
"exp": "2026-11-08T00:00:00Z",
"minShell": "1.4.0",
"probe": "https://probe-a.example.net/__health",
"exits": [
{
"id": "cf-ech",
"kind": "domain",
"host": "app.example.com",
"ports": [443, 2053, 2083, 2087, 2096, 8443],
"ech": true,
"asn": "AS13335",
"weight": 100,
"note": "需 DoH,抗 SNI 阻断"
},
{
"id": "ip-a",
"kind": "ip",
"host": "203.0.113.7",
"ports": [443, 8443],
"certType": "ip-san",
"certNotAfter": "2026-11-06T12:00:00Z",
"asn": "AS64500",
"weight": 80,
"note": "无 SNI,抗 DNS 污染"
}
],
"sig": "base64url(ECDSA-P256-SHA256 over canonical JSON without sig)"
}
payload / sig 分离还是整体签,两种都行,但签名端和验签端必须用完全一致的序列化。下文示例用「去掉 sig 字段后 JSON.stringify」。
4.3 服务端签名
// scripts/sign-pool.mjs
import { sign } from 'node:crypto'
import { writeFileSync } from 'node:fs'
// 一次性:generateKeyPairSync('ec', { namedCurve: 'P-256' })
// 私钥离线保管,公钥 JWK 写入前端包
const privateKey = /* 从离线介质读取 PEM */ null
const payload = JSON.stringify({
v: 21,
iat: Date.now(),
exp: Date.now() + 7 * 24 * 3600 * 1000,
probe: 'https://probe-a.example.net/__health',
exits: [
{ id: 'cf-ech', kind: 'domain', host: 'app.example.com', ports: [443, 8443], ech: true, weight: 100, asn: 'AS13335' },
{ id: 'ip-a', kind: 'ip', host: '203.0.113.7', ports: [443, 8443], certType: 'ip-san', weight: 80, asn: 'AS64500' },
],
})
// WebCrypto 验的是 IEEE P1363 格式(r||s),这里必须指定,否则验签必失败
const sig = sign('sha256', Buffer.from(payload), {
key: privateKey,
dsaEncoding: 'ieee-p1363',
}).toString('base64url')
writeFileSync('pool.json', JSON.stringify({ payload, sig }))
dsaEncoding: 'ieee-p1363' 这一行是最容易踩的坑:Node 默认输出 DER 编码的签名,而 WebCrypto 的 verify 期望 r||s 的裸格式。两者不匹配的表现是「服务端签得好好的,前端每次都验签失败」,且报错信息毫无提示。
4.4 客户端验签
// src/lib/bootstrap.ts
const PUB_JWK = { kty: 'EC', crv: 'P-256', x: '…', y: '…' } // 硬编码,随发版固定
export type Exit = {
id: string
kind: 'domain' | 'ip'
host: string
ports: number[]
ech?: boolean
certType?: string
certNotAfter?: string
weight: number
asn?: string
}
export type Bundle = {
v: number; iat: number; exp: number; minShell?: string
probe: string; exits: Exit[]; sig: string
}
function canonical(b: Bundle): Uint8Array {
const { sig, ...rest } = b
return new TextEncoder().encode(JSON.stringify(rest))
}
export async function verifyBundle(raw: unknown): Promise<Bundle | undefined> {
const b = raw as Bundle
if (!b?.exits?.length || !b.sig) return undefined
const key = await crypto.subtle.importKey(
'jwk', PUB_JWK, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'],
)
const sig = Uint8Array.from(
atob(b.sig.replace(/-/g, '+').replace(/_/g, '/')),
(c) => c.charCodeAt(0),
)
const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, sig, canonical(b))
if (!ok) return undefined
if (Date.now() > b.exp) return undefined // 过期包直接丢,防止旧池复活
return b
}
用 P-256 而不是 Ed25519 的原因:P-256 在 WebCrypto 里全平台无短板。地址包这条链路上任何一次验签失败都是致命的,不值得为省几行代码去赌浏览器版本。
4.5 多通道并行 + 取最大版本号
// src/lib/channels.ts
import { verifyBundle, type Bundle } from './bootstrap'
import { dbp } from './db'
const CHANNELS: Array<() => Promise<unknown>> = [
// ① 本地缓存:离线时唯一可用,且它证明过自己能用,优先级最高
async () => (await (await dbp).get('meta', 'bundle'))?.value,
// ② 应用内置种子:打包进 shell,永不失效
async () => (await import('../seed.json')).default,
// ③ 公共只读存储:多放几处,任一存活即可
async () => (await fetch('https://cdn.jsdelivr.net/gh/USER/REPO@main/pool.json', { cache: 'no-store' })).json(),
async () => (await fetch('https://raw.githubusercontent.com/USER/REPO/main/pool.json', { cache: 'no-store' })).json(),
async () => (await fetch('https://BUCKET.r2.dev/pool.json', { cache: 'no-store' })).json(),
// ④ DoH 查 TXT:走加密 DNS,绕开污染
async () => {
const r = await fetch(
'https://cloudflare-dns.com/dns-query?name=_boot.app.example.com&type=TXT',
{ headers: { accept: 'application/dns-json' }, cache: 'no-store' },
)
const j = await r.json()
const txt = (j?.Answer ?? []).map((a: any) => a.data.replace(/^"|"$/g, '')).join('')
return txt ? JSON.parse(txt) : undefined
},
]
export async function resolveBundle(): Promise<Bundle | undefined> {
const results = await Promise.allSettled(CHANNELS.map((f) => f()))
const bundles = (await Promise.all(
results.map((r) => (r.status === 'fulfilled' ? verifyBundle(r.value) : undefined)),
)).filter(Boolean) as Bundle[]
if (!bundles.length) return undefined
// 取版本号最高的,而不是「第一个成功的」
const best = bundles.sort((a, b) => b.v - a.v)[0]
await (await dbp).put('meta', { key: 'bundle', value: best })
return best
}
两个细节是刻意的:
- 并行是为了「部分通道被墙时仍能拉到」。
- 取最大 v 而不是取第一个成功的,是为了防止一个存活但陈旧的通道把出口池拖回旧状态。这个 bug 非常阴险,表现为「换了新出口,但一部分用户永远停在旧地址」。用
Promise.any也能收敛(靠版本回退校验),但会多一个刷新周期的窗口,不如直接全等。
TXT 通道的容量限制:单条 TXT 字符串 ≤ 255 字节,所以大包要切成 p1.pool.example.com ~ p4.pool.example.com 多条,客户端拼起来再验签。建议 TXT 只用来下发精简版列表 + 签名,作为第三、第四条通道,而不是唯一通道。
五、判定层:先分清「哪种墙」,再决定换哪条腿

浏览器拿不到区分「墙」和「服务器宕机」的直接信号,但时序特征 + 多源对照能推出高置信度结论。
| 观测到的事实 | 推断 | 下一步 |
|---|---|---|
失败耗时 < 1.5s,TypeError: Failed to fetch |
sni-blocked |
优先 kind: 'ip' 的出口(无 SNI) |
| 失败耗时 > 8s 直到主动 abort | ip-blocked |
换 ASN / 换端口池 / 走共享 IP |
| 解析成功,但连到的 IP 与池里记录不一致 | dns-poisoned |
先切 DoH,再重试域名出口 |
| 第三方探针通,自有入口全灭 | origin-down |
判定为自己的出口问题,全力切换 |
| 第三方探针也全灭 | offline |
与墙无关,走纯离线模式 + 队列 |
| 域名出口全灭、IP 出口存活 | partial |
降权域名出口,权重转移给 IP 出口 |
// src/lib/classify.ts
export type Diagnosis =
| 'healthy' | 'origin-down' | 'dns-poisoned'
| 'sni-blocked' | 'ip-blocked' | 'offline'
export async function classify(probeUrl: string): Promise<Diagnosis> {
if (!navigator.onLine) return 'offline'
// 多源对照:三个互不相关的第三方探针,谁通用谁
const thirdParty = await Promise.any([
fetch('https://www.gstatic.com/generate_204', { mode: 'no-cors', cache: 'no-store' }),
fetch('https://cp.cloudflare.com/', { mode: 'no-cors', cache: 'no-store' }),
fetch('https://detectportal.firefox.com/success.txt', { mode: 'no-cors', cache: 'no-store' }),
]).then(() => true).catch(() => false)
if (!thirdParty) return 'offline'
const t0 = performance.now()
try {
await fetch(probeUrl, { cache: 'no-store', signal: AbortSignal.timeout(10_000) })
return 'healthy'
} catch {
const dt = performance.now() - t0
if (dt < 1500) return 'sni-blocked' // 快速重置
return 'ip-blocked' // 慢速超时
}
}
最容易被误判的一处:把 navigator.onLine 当真理。连着 WiFi、但出站全被丢包时它仍返回 true。必须靠真实探测来判定。
探针选择要贴合你的用户群。面向中国大陆用户时,
gstatic/cloudflare/firefox这几个本身就常年不可达,会让探针永远返回「离线」,把「站点被封」误判成「用户断网」。此时应换成你所在用户群体一定可达的探针(例如https://www.baidu.com/favicon.ico)。
六、取数层:竞速、对冲、熔断,三个必须分清的分支

被墙里最常见的是黑洞型:请求不报错,只是一直挂着。传统「超时 3s → 换下一个」体验极差。正确做法是对冲请求:800ms 没响应就并行打下一个,谁先回谁赢。
6.1 三种「失败」走三条不同的路
| 失败类型 | 含义 | 动作 |
|---|---|---|
| 超时 / RST / DNS 失败 / 502 / 504 | 入口不通 | 换出口重试(有意义) |
429 / 503(带 Retry-After) |
后端明确在忙 | 不换出口,守着退避,展示降级 UI |
200 但 content-type 是 HTML |
多半是拦截页/挑战页 | 当失败处理,不要当 JSON 解析,绝不写缓存 |
第三条是容灾代码最容易自己制造事故的地方:被墙时最常见的「成功响应」就是运营商或平台的拦截页,HTTP 200、内容是 HTML。如果你用 CacheableResponsePlugin({ statuses: [200] }),会把拦截页当成首页缓存下来,然后用户被永久锁在那个页面上。
6.2 重试预算
额外请求 ≤ 总请求的 10%(且每分钟至少 3 次)。 故障期流量本来就紧张,竞速会把请求放大 3~4 倍,必须有预算桶把波浪宽度压住。
// 预算桶:额外请求不超过总请求的 10%
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 + 60_000 } }
const spend = () => { tick(); if (B.spent < Math.max(3, B.reqs * 0.1)) { B.spent++; return true } return false }
6.3 多出口取数(含修正后的 abort 语义)
// src/lib/fetch.ts
import type { Exit } from './bootstrap'
const breakers = new Map<string, { fails: number; openUntil: number }>()
const keyOf = (e: Exit) => `${e.kind}:${e.host}`
function isOpen(e: Exit) {
const b = breakers.get(keyOf(e))
return !!b && b.openUntil > Date.now()
}
function markFail(e: Exit) {
const k = keyOf(e)
const b = breakers.get(k) ?? { fails: 0, openUntil: 0 }
b.fails++
if (b.fails >= 2) { b.openUntil = Date.now() + 60_000; b.fails = 0 }
breakers.set(k, b)
}
function markOk(e: Exit) { breakers.delete(keyOf(e)) }
type Hit = { res: Response; exit: Exit }
async function hit(
e: Exit, port: number, path: string, init: RequestInit, timeoutMs: number, ac: AbortController,
): Promise<Hit> {
const timer = setTimeout(() => ac.abort(), timeoutMs)
try {
const res = await fetch(`https://${e.host}:${port}${path}`, {
...init,
signal: ac.signal,
credentials: 'omit', // 跨 origin 靠 Bearer,不靠 Cookie
mode: 'cors',
cache: 'no-store',
headers: { ...init.headers, 'x-exit': e.id },
})
if (res.status === 429 || res.status === 503) {
const err: any = new Error('OVERLOADED')
err.retryAfter = Number(res.headers.get('retry-after')) || 5
err.status = res.status
throw err
}
if (!res.ok) throw new Error(`HTTP ${res.status}`)
// 拦截页冒充成功:200 但 content-type 是 HTML
const ct = res.headers.get('content-type') ?? ''
if (!ct.includes('json')) throw new Error('NOT_JSON')
return { res, exit: e }
} finally {
clearTimeout(timer)
}
}
/**
* 竞速取数。关键点:
* - Promise.any(不是 race):race 会被最快失败抢占
* - 胜出的那条不能被 abort:否则 body 流会断
* - 429/503 不是「入口不通」,不参与换出口,直接向调用方抛出
*/
export async function raceFetch(
path: string,
init: RequestInit,
exits: Exit[],
opts: { timeoutMs?: number; wave?: number } = {},
): Promise<Response> {
const { timeoutMs = 4000, wave = 4 } = opts
const candidates = exits
.filter((e) => !isOpen(e))
.flatMap((e) => e.ports.map((p) => ({ e, p })))
.sort((a, b) => b.e.weight - a.e.weight)
.slice(0, wave)
if (!candidates.length) throw new Error('NO_EXIT')
const acs = candidates.map(() => new AbortController())
let winner = -1
const tasks = candidates.map(({ e, p }, i) =>
hit(e, p, path, init, timeoutMs, acs[i]).then(
(h) => { if (winner < 0) winner = i; markOk(e); return h },
(err) => { markFail(e); throw err },
),
)
try {
const h = await Promise.any(tasks)
return h.res
} catch (err: any) {
// 任一出口明确「后端在忙」,就不要报成「全部失败」
const overloaded = (err?.errors ?? []).find((x: any) => x?.message === 'OVERLOADED')
if (overloaded) throw overloaded
throw new Error('ALL_EXITS_FAILED')
} finally {
// 关键:跳过胜出者
acs.forEach((a, i) => { if (i !== winner) a.abort() })
}
}
这里的三个修正点值得单独说明,因为它们都是真实项目里翻过车的:
Promise.race换Promise.any:race会被第一个失败抢占,导致「最快的出口刚 RST 就把整个请求判死」。- 胜出者不被 abort:
fetch拿到响应头就 resolve,body 还没读完。此时 abort 会让 body 流报错,调用方拿到的是AbortError而不是数据。这个 bug 只在「竞速成功」的路径上出现,而竞速成功恰恰是容灾生效的时刻。 - 429/503 不参与换出口:如果车道 0 在 800ms 返回 429,同时对冲的车道 1 马上要成功,直接 reject 会把通的路作废。所以把它当「候选结论」,等其他车道跑完或成功。
七、离线层:彻底连不上时,为什么还能打开

这一层是整个方案里唯一能在「彻底被墙」状态下生效的机制。SW 拦截导航并返回缓存壳——这个过程完全没有网络参与,也不做 DNS 解析。
前提是三条同时成立,缺一条这条链路就断:
- 用户在被封之前访问过,SW 已安装完成;
- app shell 已预缓存完毕;
- 存储没有被浏览器回收(要靠
storage.persist()提前申请)。
7.1 三块存储各管一件事
| 存储 | 装什么 | 为什么不能混用 |
|---|---|---|
| Cache API | HTTP 响应原文(app shell、静态资源、可缓存的 GET) | 响应是「原文」,后端改字段结构后很难安全复用 |
| IndexedDB | 已归一化的业务快照 + outbox 写队列 | 能按业务键精确失效、能做 schema 迁移 |
| LocalStorage | 只放 < 4KB 的开关位(主题、上次路由) | 同步 API 阻塞主线程、5MB 上限、无事务 |
OPFS 单独说一句:SW 里没有 createSyncAccessHandle()(只有 dedicated worker 有),只能用异步 API,而异步 API 慢,所以 SW 里别指望用 OPFS 做高频小写入。它适合大二进制、WASM 模块、离线媒体。
配额是共享的:Cache API / IDB / OPFS 共用同一个 origin quota,必须统一回收。用量 > 80% 时只清运行时缓存,预缓存与 outbox 一律保留。
7.2 Service Worker
// src/sw.ts
/// <reference lib="webworker" />
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'
import { registerRoute, NavigationRoute, setCatchHandler } from 'workbox-routing'
import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<{ url: string; revision: string | null }>
}
const PREFIX = 'c-' // 运行时缓存统一前缀,便于回收
cleanupOutdatedCaches()
precacheAndRoute(self.__WB_MANIFEST)
const isShell = async (res: Response) => {
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"') // 只认真正的应用壳
}
// 导航:SW 启动与网络请求并行(Navigation Preload),失败回退迁移页
const navStrategy = new NetworkFirst({
cacheName: `${PREFIX}nav`,
networkTimeoutSeconds: 3,
plugins: [new CacheableResponsePlugin({ statuses: [200] })],
})
registerRoute(new NavigationRoute(async (params) => {
const event = params.event as FetchEvent
const preload = await event.preloadResponse?.catch(() => undefined)
if (preload) return preload
try {
const res = await navStrategy.handle(params)
// 200 但不是应用壳 → 多半是拦截页,别缓存它
if (!(await isShell(res.clone()))) {
const cached = await caches.match('/offline.html')
if (cached) return cached
}
return res
} catch {
return (await caches.match('/offline.html')) ?? Response.error()
}
}))
setCatchHandler(async ({ request }) => {
if (request.destination === 'document') {
return (await caches.match('/offline.html')) ?? Response.error()
}
return Response.error()
})
// 静态资源:长缓存
registerRoute(
({ request, url }) =>
url.origin === self.location.origin &&
['script', 'style', 'font', 'image'].includes(request.destination),
new CacheFirst({
cacheName: `${PREFIX}static`,
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({ maxEntries: 300, maxAgeSeconds: 60 * 60 * 24 * 30 }),
],
}),
)
// API GET:SWR,但排除实时通道与个人凭证
registerRoute(
({ request, url }) =>
request.method === 'GET' &&
url.pathname.startsWith('/api/') &&
!url.pathname.startsWith('/api/live/') &&
!url.pathname.startsWith('/api/me'),
new StaleWhileRevalidate({
cacheName: `${PREFIX}api`,
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 }),
],
}),
)
要点:
- 缓存名不带 build id。带了的项目每次发版白屏一次:新 SW 激活时旧缓存全清,而新预缓存还没落盘。运行时缓存用固定名,预缓存交给 Workbox 按 revision 管理。
sw.js与index.html必须Cache-Control: no-cache,否则更新通道会被缓存卡死。CacheableResponsePlugin的 statuses 别加0。0是给 opaque 响应(跨域no-cors)用的,只用 CORS 请求时加上它会把失败的跨域请求缓存成空壳。- opaque 响应读不到 body,
res.json()直接抛。跨域接口必须 CORS。
7.3 存储持久化与配额回收
// src/lib/storage.ts
export async function guardStorage() {
// 越早申请越好:Chrome 对「高参与度站点 / 已安装 PWA」自动给,Firefox 会弹窗
if (navigator.storage?.persist) await navigator.storage.persist()
const est = await navigator.storage.estimate()
const pct = est.quota ? ((est.usage ?? 0) / est.quota) * 100 : 0
if (pct > 80) {
// 只清运行时缓存,预缓存与 outbox 一律保留
for (const n of await caches.keys()) if (n.startsWith('c-')) await caches.delete(n)
}
}
7.4 kill switch
SW 出 bug 会把用户锁死,所以必须有一份「清缓存 + 注销自己」的自毁通道,且要能远程触发。
/* 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));
})()));
顺序是先删缓存再 unregister,反了会让页面在「无 SW 且无缓存」的状态下多撑一次请求。
7.5 写队列(outbox)
被墙期间写入不能丢、不能重。三件套:幂等键 + 指数退避 + navigator.locks。
// src/lib/outbox.ts
import { dbp, type OutboxItem } from './db'
const MAX_ATTEMPTS = 8
export async function enqueue(item: Omit<OutboxItem, 'id' | 'createdAt' | 'attempts' | 'nextAt' | 'status'>) {
const db = await dbp
const full: OutboxItem = {
...item,
id: crypto.randomUUID(), // 同时作为 Idempotency-Key
createdAt: Date.now(),
attempts: 0,
nextAt: 0,
status: 'pending',
}
await db.put('outbox', full)
return full
}
async function sendOne(it: OutboxItem): Promise<'done' | 'conflict' | 'retry' | 'dead'> {
const res = await fetch(it.url, {
method: it.method,
headers: {
'content-type': 'application/json',
'x-idempotency-key': it.id, // 服务端必须按它去重
...(it.baseVersion ? { 'if-match': it.baseVersion } : {}),
},
body: it.body === undefined ? undefined : JSON.stringify(it.body),
})
if (res.ok) return 'done'
if (res.status === 409) return 'conflict' // 版本冲突,交给上层合并
if (res.status >= 500 || res.status === 429) return 'retry'
if (res.status >= 400) return 'dead' // 4xx 重试没意义
return 'retry'
}
export async function flushOutbox() {
if (!navigator.onLine) return
const db = await dbp
const run = async () => {
const all = await db.getAllFromIndex('outbox', 'by-status', 'pending')
const now = Date.now()
for (const it of all.filter((x) => x.nextAt <= now)) {
await db.put('outbox', { ...it, status: 'inflight' })
let outcome: Awaited<ReturnType<typeof sendOne>>
try { outcome = await sendOne(it) } catch { outcome = 'retry' }
if (outcome === 'done') { await db.delete('outbox', it.id); continue }
if (outcome === 'dead' || outcome === 'conflict') {
await db.put('outbox', { ...it, status: 'dead' }); continue
}
const attempts = it.attempts + 1
if (attempts >= MAX_ATTEMPTS) { await db.put('outbox', { ...it, status: 'dead', attempts }); continue }
// 指数退避 + 抖动,避免恢复瞬间全量重放打爆后端
const backoff = Math.min(2 ** attempts * 1000, 5 * 60 * 1000)
await db.put('outbox', {
...it, status: 'pending', attempts,
nextAt: Date.now() + backoff + Math.random() * 1000,
})
}
}
// 多标签页同时在线时只允许一个标签 flush,防重复提交
if (navigator.locks?.request) {
await navigator.locks.request('outbox-flush', { ifAvailable: true }, async (lock) => {
if (!lock) return
await run()
})
} else {
await run()
}
}
addEventListener('online', () => void flushOutbox())
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void flushOutbox()
})
setInterval(() => void flushOutbox(), 30_000)
navigator.locks 这一步最容易被漏掉:没有它,用户开三个标签页、网络一恢复,同一条订单会被提交三次。幂等键能救数据,但救不了后端 QPS。
不要依赖 Background Sync:Chrome/Edge/Opera/Samsung 有,Safari 全系(含 iOS 26/27)和 Firefox 全系没有。只能当加速器,主力必须是上面这套「IDB outbox + 定时器 + 可见性事件」。
八、触达层:让用户不依赖任何单一域名也能找到你
前面所有手段都假设「用户手里有一个能用的入口域名」。首次访问的用户没有 SW、没有缓存、没有池,只能靠这一层。
| 渠道 | 做法 |
|---|---|
| 独立导航页 | 极简静态页,托管在与主站完全不同的服务商 + 注册商,永远指向当前可用入口 |
| App / 客户端内 | 内置地址包 + 内置导航页入口,启动即竞速 |
| 公众号 / 社群 / 邮件 / 短信 | 事先沟通「如打不开请访问导航页 xxx」 |
| 搜索引擎品牌词 | 维护品牌词搜索结果指向最新入口 |
| 单文件 portable.html | 零外部请求、全内联的兜底页,用户可保存、转发、U 盘拷 |
| 用户收藏的老域名 | 靠 SW 兜底页自动迁移 |
8.1 迁移时登录态怎么办
localStorage 与 Cookie 都按域名隔离,新域名不会带着旧域名的登录态,并且绝不能把 token 放进 URL。
| 做法 | 体验 | 说明 |
|---|---|---|
| 新域名重新登录 | 一般 | 最简单,适合低频场景 |
| 一次性迁移码 | 好 | 旧域名生成 60 秒有效、单次使用的 ticket,跳转新域名后换新 token |
| 统一 Bearer + 静默刷新 | 好 | 配合 refresh token 的服务端校验 |
迁移码必须放 fragment(#t=...)而不是 query:
// 旧域名(还能打开时)
location.replace(`https://new.example.net/#t=${ticket}`)
// 新域名,读取后立刻清掉
const t = new URLSearchParams(location.hash.slice(1)).get('t')
history.replaceState(null, '', location.pathname)
?t=xxx 会进 Nginx access log、浏览器历史、以及跨域时的 Referer 头;# 后面的内容这三处都拿不到。
九、服务端与网络侧:让每个域名「死得独立」
这部分不写前端代码,但决定了域名池能不能真正生效。
- IP 隔离:每个入口域名绑定不同 CDN 厂商或不同 IP 段。能做到「一个 IP 段被封只死一个域名」,才是真多活。
- 回源多线路:CDN 回源到源站的 IP 不暴露;源站安全组只放行回源网段,并校验回源密钥头。
- 隐藏源站:清理历史 DNS 记录、证书透明度日志、邮件头里泄露的源站 IP;泄露过就换 IP。
- 证书分散:不同域名独立证书,不要塞同一张 SAN。
- DNS 分散:两家权威 DNS,TTL 60~300s,启用健康检查自动摘除。
- 健康端点:
/__health返回 204,极轻,不走业务逻辑,不要让它回源数据库。 - 域名预热:备用域名平时承接 1%~5% 的真实流量。新域名信誉为零,突然启用更容易被「平台拦截」而不是「网络封锁」,而平台拦截换 IP、换端口全都救不回来。
- 域名流水线:采购 → 备案 → 配置 → 预热 → 上线 → 监测 → 摘除 → 替换。被墙是常态不是意外,域名要像耗材一样管理。
# 源站:只信任回源网段,并校验回源密钥头
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; }
location = /__health { return 204; add_header Access-Control-Allow-Origin * always; add_header Cache-Control no-store always; }
}
十、落地顺序与验收

10.1 实施顺序
| 优先级 | 事项 | 为什么在这个位置 |
|---|---|---|
| P0 | 故障判定 classify() + RUM 上报 |
没有诊断,后面所有切换都是瞎切 |
| P0 | 域名资产流水线(独立 IP / 证书 / DNS 分散) | 没有这个,域名池是空中楼阁 |
| P0 | 签名池 + 多通道下发 + 内置种子 | 纯离线状态下「寻址」要能成立 |
| P1 | SW 预缓存 app shell + storage.persist() |
单位成本最低、收益最大的一条 |
| P1 | IP-SAN 证书出口 + nginx default_server |
让「IP 直连」这条路真的能走 |
| P1 | 竞速 + 对冲 + 熔断 + 重试预算 | 对付黑洞型故障的核心 |
| P1 | outbox + 幂等键 + navigator.locks |
被墙期间的写入不能丢、不能重 |
| P2 | 众包探测 → 聚合 → 自动摘除 → 下发 | 把发现和处置时间压到分钟级 |
| P2 | 独立导航页 + 单文件 portable.html |
首次访问用户的最后一道保险 |
10.2 验收清单
| # | 注入方式 | 期望结果 |
|---|---|---|
| 1 | 断网后点桌面图标 | app shell 从 SW 缓存打开,无任何网络错误页 |
| 2 | hosts 把主域名指向 10.0.0.1(DNS 污染) |
判定 dns-poisoned,自动切 IP 出口,接口恢复 |
| 3 | iptables 对该 IP:443 REJECT(SNI/RST) |
判定 sni-blocked,≤1.5s 切到 IP 证书出口 |
| 4 | iptables 对该 IP 全端口 DROP(黑洞) |
判定 ip-blocked,切到另一个 ASN 的出口 |
| 5 | 关闭主出口 443,只留 8443 | 端口池生效,走 8443,不报错 |
| 6 | 篡改 pool.json 的 exits[0].host |
验签失败,包被丢弃,沿用本地缓存包 |
| 7 | 三标签页离线各写 1 次,恢复网络 | 服务端收到 3 条,不是 9 条 |
| 8 | mock 返回 200 + 拦截 HTML | 不被缓存成首页,API 不当 JSON |
| 9 | 断开到被封域名的全部网络 | SW 返回迁移页,≤5s 跳到可用入口,无跳转循环 |
| 10 | IP 证书剩 2 小时 | 面板告警,该出口权重降为 0 |
| 11 | 清空浏览器数据后手工打开 portable.html |
显示最新地址列表 + 二维码,可复制 |
| 12 | 发布一个故意写错的 SW | kill-switch 能清理并恢复,页面回到可用状态 |
验收标准(可量化):
- 主域名 IP 黑洞时,用户额外延迟 ≤ 1.5s。
- 入口域名被封,老用户 ≤ 5s 自动迁移,且无跳转循环。
- 任意一个域名、一个 CDN、一个池通道失效,业务不报错。
- 故障期间客户端额外请求量 ≤ 正常请求量的 1.1 倍。
- 从「域名被判定被封」到「新池下发到客户端」平均时长有明确 SLA(例如 ≤ 10 分钟)。
iptables这类注入建议在 docker 双容器里做,不碰你真机网络。
十一、常见的坑
- 多个域名指向同一个 IP:IP 一封全灭,域名池形同虚设。
- 所有域名放进同一张证书:一个被标记,整批被关联。
- 只靠 Service Worker:首次访问的用户零保护;必须配合触达层与池下发。
- 配置/池与业务同域下发:主域被封时更新通道一起死。
- 池不签名:劫持任一通道就能把用户导向恶意域名。
- 同域名重试:对 IP 黑洞和域名被封都没用,只会增加等待。
- 过载时还换域名重试:
429/503换出口 = 把压力放大到所有入口。 - 缓存了拦截页/劫持页当首页:写缓存前必须校验应用壳标记。
- 备用域名没预热:临时启用的新域名信誉为零,更容易被平台拦截。
- 迁移时把 token 放进 URL:会进日志、历史、Referer;用单次迁移码,放 fragment 并立即清除。
- 上报没有防刷:被人刷假数据,好域名被自动摘除。
navigator.onLine当真理:连着 WiFi 但出站被封时仍为true。Promise.race当竞速:会被最快失败抢占;用Promise.any。- 胜出者被 abort:body 流会断,且只在成功路径上出问题。
- Node 签名用默认 DER 编码:WebCrypto 验签必失败;要
dsaEncoding: 'ieee-p1363'。 - 依赖 Background Sync:Safari / Firefox 全系没有,主力必须是 IDB outbox。
- 探针选在大陆不可达的域名:
gstatic/cloudflare/firefox常年不通,会把「站点被封」误判成「用户断网」。 - 容灾代码自己没有容灾:兜底逻辑抛异常比没有兜底更糟,每个分支都要能安全返回。
十二、结语
被墙场景的本质是:你无法保证某个域名或 IP 永远可用,只能保证「换得快、换得到、换得安全」。
- 换得到:地址包、独立导航页、多渠道触达,保证用户总能拿到一个新入口;
- 换得快:竞速选线 + 对冲请求 + SW 迁移页,保证用户感知不到或只感知几秒;
- 换得安全:签名 + 防回滚 + 防刷 + 预算控制,保证「换」这个动作本身不成为攻击面。
再加两句 2026 年的新事实:
- IP-SAN 证书 GA 之后,「IP 直连兜底」重新可用。连 IP、不带 SNI、配 IP 证书,同时干掉 DNS 污染和 SNI 阻断,代价是 6.7 天一轮的证书续期。
- ECH 是性价比最高的一类手段,但必须绑 DoH,且挡不住外层 SNI 被整体封。
最后:每个域名背后的 IP 必须独立,域名池才有意义;容灾要能指回具体故障,并且定期演练——没演练过的容灾等于没有。
附录:参考资料
- Let's Encrypt:6-day and IP Address Certificates are Generally Available(2026-01-15)
- Let's Encrypt:Six-Day and IP Address Certificates Available in Certbot(2026-03-11)
- Cloudflare Docs:ECH Protocol
- The Hacker News:Android 17 Adds OS-Wide ECH
- caniuse:Background Sync API(Safari / Firefox 不支持)
- GoogleChrome/workbox Releases
- TanStack Query:#8696 Offline first Persist 讨论
相关文档
评论与补充0
有问题或想补充的,写在下面 · 选中正文文字再点「报告错误」会自动引用还没有评论。有疑问、补充,或者发现错字,都可以写在下面。