问题的根源:Cloudflare 很快,但在中国不够快
我的个人站点托管在 Cloudflare 上,使用了它的全球 CDN。在欧美访问,首页那张几 MB 的高清背景图几乎秒开。但只要切到国内网络——无论是电信、联通还是移动——延迟就会肉眼可见地上升,有时甚至要等好几秒。
这并不是 Cloudflare 的技术有问题,而是物理现实决定的:Cloudflare 在中国大陆没有边缘节点。所有请求必须跨境回源到香港、新加坡或更远的节点,中间经过国际链路的拥塞和不可预测的路由绕行 [5][8]。
传统思路无非是:再买一个国内 CDN、搞双栈部署、把图片放到国内 OSS 上——这些方案能做到,但要么花钱,要么复杂。我不想引入额外的供应商,也不想把数据搬来去。
我想要的是一套方案:继续用 Cloudflare,但让中国访客的加载速度和欧美访客一样快。
破局思路:访客之间互相传,CDN 只负责「第一个种子」
核心想法来自 BitTorrent 的古老智慧:下载者同时也是上传者 [1]。如果第一位中国访客从 Cloudflare 下载了图片(忍受这一次慢),之后他就能把这张图传给下一位访客——第二位访客不再需要跨境下载,直接从同城的另一个浏览器拿数据。
用 P2P 的术语说:
这个架构一旦跑起来,访问量越大、在线种子越密集、国内用户的 P2P 命中率就越高。Cloudflare 只负责服务「冷启动」——为第一个种子提供数据。之后的传播走的是国内用户之间的局域网级直连,物理上绕开了国际链路。
架构全景:五个组件的协同
Text
┌─────────────────────────────────────────────────────────┐
│ Cloudflare 生态 │
│ │
│ ┌──────────┐ ┌───────────────┐ ┌──────────────┐ │
│ │ Pages │ │ D1 数据库 │ │ TURN 服务 │ │
│ │ (静态站点)│ │ (信令大厅) │ │ (NAT 穿透) │ │
│ └────┬─────┘ └───────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
└───────┼─────────────────┼────────────────────┼──────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────┐
│ 访客浏览器 │
│ │
│ Service Worker Cache ← 0ms 命中 │
│ PeerJS WebRTC ← 多源 P2P 下载(同国优先) │
│ fetch() CDN ← 安全回退 │
└─────────────────────────────────────────────────┘
Cloudflare Pages
托管整个站点,包括这个嵌入了 P2P 逻辑的 HTML 文件。没有任何后端服务,纯静态。
Cloudflare D1 — 信令大厅的数据库
这是整套系统的「Tracker」,也是 GeoIP 国家标签的存储中枢。D1 是 Cloudflare 的 serverless SQLite 数据库,基于 SQLite 引擎构建,为 Workers 提供原生 SQL 查询能力,且支持全球读副本就近响应 [14]。
我的信令 Worker(部署在 Cloudflare Workers 上)背后连着一个 D1 数据库。表结构如下——
Sql
CREATE TABLE peers (
peer_id TEXT PRIMARY KEY,
status TEXT, -- 'COMPLETE' | 'JOIN'
country TEXT, -- ☆ 国家标签,如 'CN' 'US' 'JP'
last_seen INTEGER -- Unix 时间戳
);
关键在于 country 字段。每个浏览器节点在注册时,信令 Worker 通过 Cloudflare 请求头中携带的 CF-IPCountry 字段获取访客所属国家代码 [2][3],并将其连同 Peer ID 和状态一并写入 D1。
CF-IPCountry 是 Cloudflare 免费提供的 GeoIP 能力——它基于 Cloudflare 的全球 IP 地理数据库,在每个 HTTP 请求的头部自动附加访客 IP 所对应的两位国家代码(如 CN、US、JP)。无需额外付费,无需第三方 GeoIP 库,信令 Worker 直接从请求头读取即可 [2][3]。
选择 D1 而不是自建数据库的原因:它和我的站点一样跑在 Cloudflare 上,零运维;SQLite 兼容的 SQL 语义让我不需要学新的查询方言;免费额度绰绰有余——一个只维护几百条 peer 记录的表,读写量几乎可以忽略不计 [14]。
Cloudflare TURN — NAT 打洞
WebRTC 建立 P2P 连接的前提是两个浏览器能找到彼此。但在真实网络中,绝大多数用户都在 NAT 后面(家庭路由器、公司防火墙、移动网络 CG-NAT)。需要一个 STUN/TURN 服务器来协助建立连接 [4][12][15]。
Cloudflare 提供了 rtc.live.cloudflare.com 的 TURN 服务,通过 API 动态下发 ICE Server 配置。每次页面加载时,代码会向这个 endpoint POST 请求,拿到一组带有时效的 STUN 和 TURN 服务器列表,喂给 PeerJS 的 RTCPeerConnection 配置。
STUN 负责告诉浏览器「你在公网上的地址长什么样」,两个浏览器分别获知自己的公网映射地址后,就能尝试直连打洞。对于少数 STUN 打不通的极端对称 NAT,TURN 会作为中继转发流量 [12][15]——虽然这不如直连快,但保证了可达性。
PeerJS — WebRTC 的友好封装
原生 WebRTC API 是出了名的啰嗦:RTCPeerConnection 要管 SDP offer/answer、ICE candidate 收集和交换、DataChannel 的创建和 buffer 管理。PeerJS 把这些都包成了一个简洁的 facade:
Javascript
const peer = new Peer(myId, { config: { iceServers } });
// 做种:监听别人的连接
peer.on('connection', conn => {
conn.on('data', msg => { /* 处理分片请求 */ });
});
// 下载:主动连接种子
const conn = peer.connect(seedId);
conn.on('open', () => { /* 开始请求分片 */ });
一句 peer.connect() 背后,PeerJS 完成了:获取本地 ICE candidate → 通过信令通道发给对方 → 接收对方 candidate → 建立 DTLS 加密的 DataChannel [7]。我只关心通道打开后传什么数据。
Service Worker Cache API — 极速二次命中
图片下载完成后(无论来自 P2P 还是 CDN 回退),都会存入 Cache API。下次同一个浏览器打开页面时,根本不会走网络——直接从缓存取 blob,0ms 渲染背景图。
这是整个「Type A」路径的逻辑:缓存命中 → 秒开 → 同时节点以 COMPLETE 状态注册到 D1 信令大厅,立即开始做种服务他人。
GeoIP 国家标签体系:为什么要把用户「标上国籍」
这是整套系统最精巧的设计之一。一句话概括:P2P 传输很快,但前提是种子和下载者在同一张网络上。国际链路仍然是慢的。
如果不做国家区分,可能出现这样的尴尬场景:
P2P 不能解决跨国的物理延迟问题。它只能解决同一网络区域内「最后一公里」的分发效率。 因此必须引入地理位置感知的节点匹配——也就是 GeoIP 国家标签 + 同国优先调度 [10][13]。
国家标签从哪里来
浏览器端代码自己没有可靠的 GeoIP 能力(navigator.geolocation 需要用户授权且精度不稳定)。但我的信令 Worker 跑在 Cloudflare 上,而 Cloudflare 为每一个经过它代理的 HTTP 请求自动附加 CF-IPCountry 请求头 [2]。
在 Worker 代码中只需要一行:
Javascript
const country = request.headers.get('CF-IPCountry') || 'XX';
然后这个国家代码伴随节点的 Peer ID 一起存入 D1 的 peers 表。
Sql
INSERT INTO peers (peer_id, status, country, last_seen)
VALUES (?, 'JOIN', ?, unixepoch())
ON CONFLICT(peer_id) DO UPDATE SET last_seen = unixepoch();
浏览器端展示归属国
在 HTML 代码的 initNetwork() 函数中,信令初始化请求的返回值包含了 country 字段,通过 console.log 打印出来:
Javascript
const initResp = await signal(State.hasCache ? 'COMPLETE' : 'JOIN');
console.log(
`%c[P2P] 节点就绪 | 我的ID: ${State.myPeerId} | 归属国: ${initResp?.country || 'XX'} | 状态: ${State.status}`,
"color: #ff8c00; font-weight: bold; font-size: 13px;"
);
这让每个访客在开发者控制台里就能看到自己的节点国籍标签,调试时一目了然。
同国优先调度:种子列表的智能排序
真正发挥作用的地方在信令 Worker 的 WANT 处理逻辑中。当一个节点发出 WANT 请求(表示「我需要下载,给我推荐种子」),Worker 查询 D1 并做排序:
Sql
-- 信令 Worker 处理 WANT 请求时的查询逻辑(伪代码)
SELECT peer_id, country
FROM peers
WHERE status = 'COMPLETE'
AND last_seen > unixepoch() - 30 -- 30 秒内有心跳的活跃节点
ORDER BY
CASE WHEN country = ? THEN 0 ELSE 1 END, -- ☆ 同国优先
last_seen DESC
LIMIT 20;
查询参数 ? 填入的是请求者自己的国家代码(同样从 CF-IPCountry 头部获取)。
排序逻辑的核心是一条 SQL:
Sql
CASE WHEN country = 'CN' THEN 0 ELSE 1 END
如果种子的 country 等于请求者的国家代码,排到前面(权重 0);否则排到后面(权重 1)。然后再按 last_seen 降序,确保更活跃的种子在同组内优先 [13]。
效果就是:一个中国访客发起 WANT 请求,返回的种子列表中,所有 country = 'CN' 的节点排在前面,日本、美国等外国种子排在后面。
前端也做了二次保险
浏览器端代码在收到邻居列表后还有一层 RTT 探测。之前提到只有延迟在 30-300ms 之间的节点才会被纳入下载池——这个机制天然也会过滤掉跨国的高延迟节点,相当于在前端做了一道隐式的同区域筛选 [5]。
信令 Worker 的排序 + 前端 RTT 过滤 = 双重保险,确保下载优先走国内直连,跨国连接只在无国内种子可用时作为后备。
下载引擎详解:真正的 BT 分片调度
这部分是代码的核心——一个轻量级的 BitTorrent 下载引擎,跑在浏览器里。
分片:把图片切成 128KB 的小块
一张 5MB 的图片被切为固定大小 128KB 的分片(piece)。为什么是 128KB?这是对 WebRTC DataChannel 消息体大小上限和并发调度粒度的折中——太小导致头部开销比例过高,太大不利于多源并行。
分片数 = ceil(图片总字节数 / 131072)。每个分片由一个索引号(0-based uint32)标识,在传输时嵌入数据包的前 4 字节。
邻居筛选:RTT 探针,只连快的不连慢的
信令大厅返回的候选种子列表(已按同国优先排序),不是全部都用。我先对每个候选做一次轻量级 RTT 测量:
Javascript
conn.send({ type: 'PING', t: performance.now() });
// 对方收到后立即回 PONG,携带原始时间戳
// RTT = 当前时间 - 携带的时间戳
只有 RTT 落在 30ms~300ms 之间的节点才会被正式连接。300ms 以上的要么在国际链路上要么网络质量太差,拉进来会拖慢整个 swarm。同时最多连接 3 个活跃种子——这个数字是实测中总吞吐和连接管理开销的甜点。
这里的精妙之处:由于信令 Worker 已经把同国种子排在前面,RTT 探测的前几个候选大概率是同国节点。对于中国用户,排在前面的都是 CN 标签的国内种子,RTT 探测通过率极高。外国种子排在后面,甚至可能在前 3 个连接名额被国内种子占满后根本不会被探测到。这是 GeoIP 标签和 RTT 过滤的协同效应。
动态分片调度:work stealing 机制
这是下载引擎的心脏。我维护一个全局分片状态数组:
Text
分片 0: idle │ 分片 1: downloading(种子A) │ 分片 2: done │ 分片 3: idle │ …
调度逻辑每秒轮询一次:
Text
时间线示意(3 个同国种子并发下载 9 个分片):
种子 A (CN): [0]→✓ [3]→✓ [6]→✓ ─── 完成
种子 B (CN): [1]→✓ [4]→⏳(超时!) → 夺回,种子 A 接替 [4]→✓ [7]→✓
种子 C (CN): [2]→✓ [5]→✓ [8]→✓ ───
这种设计让慢节点不会成为瓶颈。快节点自动接管慢节点未完成的工作,整体下载时间趋近于最快的那一个源。
组装:把所有碎片拼回一张图
所有分片到达后,按索引排序,用 Blob API 拼装:
Javascript
const blob = new Blob(pieceData, { type: 'image/png' });
然后通过 CSS 自定义属性注入页面背景:
Javascript
document.documentElement.style.setProperty('--p2p-bg', `url(${URL.createObjectURL(blob)})`);
配合 CSS 的 transition: background-image 0.6s ease-in-out,背景图的出现有一个优雅的淡入过渡,用户体验上就是从模糊到清晰的渐变呈现。
三条路径的完整决策树
每次页面加载,系统走如下决策链:
Text
┌──────────────────┐
│ 检查 Cache API │
└────────┬─────────┘
│
┌──────────────┴──────────────┐
│ 命中 │ 未命中
▼ ▼
┌─────────────────┐ ┌──────────────────────┐
│ Type A: 0ms秒开 │ │ 初始化 PeerJS + 信令 │
│ 以COMPLETE注册 │ │ 注册时标注国家标签 │
└─────────────────┘ └──────────┬───────────┘
┌────────────┴────────────┐
│ 从 D1 查询种子列表 │
│ ORDER BY 同国优先 │
└────────────┬────────────┘
│
┌──────────────────┴──────────────────┐
│ 有合格种子 │ 无 / RTT 超限
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ Type B: P2P多源 │ │ Type C: CDN 安全回退 │
│ 优先同国直连下载 │ │ fetch() 跨境下载 │
└────────┬─────────┘ └──────────┬───────────┘
│ │
└──────────────┬───────────────────┘
│
▼
┌──────────────────┐
│ 存入 Cache │
│ 状态 → COMPLETE │
│ 以本国标签注册为种子│
└──────────────────┘
Type C 是至关重要的安全网。 P2P 不可用时(无在线种子、无同国种子且跨国 RTT 不合格),系统不会报错或白屏,而是静默回退到传统 CDN 下载。这意味着 100% 的可用性——第一个访客永远能看到图,即使当时没有任何种子在线。
为什么这套架构针对中国做了精准优化
回到最初的问题:Cloudflare 在中国大陆没有边缘节点,跨境访问慢。
这套 P2P + GeoIP 方案本质上是在 Cloudflare 之上构建了一层地理感知的覆盖网络:
更妙的是,D1 的全球读副本配合 Cloudflare 的 Anycast 网络,信令请求本身也是就近响应的——中国访客查询种子列表,Worker 请求打到 Cloudflare 在亚太的节点,D1 读副本就近返回数据,延迟远低于直接访问欧美源站 [14]。
简单说:Cloudflare 的 CDN 只被用来「播种」——把数据送到每个国家的第一个用户手里。之后的传播发生在同国用户之间,完全绕开了国际链路。GeoIP 标签 + 同国优先排序就是这个覆盖网络的「路由协议」。
实际效果
上线后观察了几周:
指标
|
部署前
|
部署后
|
国内访客首页大图首屏时间
|
3-8s(波动大)
|
Type A 0ms / Type B 0.5-2s / Type C 3-8s(仅首次)
|
CDN 流量(该图片)
|
100% 走 Cloudflare
|
~15-20% 走 CDN,其余由缓存+P2P 消化
|
中国 P2P 命中率
|
—
|
70-85%(种子池建立后)
|
同国种子匹配率
|
—
|
>95%(CN 用户匹配到 CN 种子)
|
跨国误匹配
|
—
|
<5%(仅在国内种子池为空时发生)
|
用户上传开销
|
—
|
每做种一次上传约 5MB,远低于观看一分钟视频的流量
|
最关键的用户体验变化:回头客秒开,新访客在种子池存在时也接近秒开。同国优先调度让国内用户几乎总是在和国内种子通信,下载速度跑满本地宽带。 那张大图不再是页面加载的瓶颈。
代码核心片段解读
完整逻辑都在开头那个 HTML 文件里,约 350 行。这里拆解几个关键片段:
信令通信(读写 D1 的 Worker)
Javascript
async function signal(action = 'HEARTBEAT') {
const res = await fetch(SIGNALING_URL, {
method: 'POST',
body: JSON.stringify({ peerId: State.myPeerId, action })
});
return await res.json();
}
SIGNALING_URL 指向部署在 Cloudflare Workers 上的信令服务,它背后连接 D1。action 可以是 JOIN(新节点注册,Worker 从请求头读取 CF-IPCountry 写入 D1)、COMPLETE(我有完整图片,我是种子)、WANT(给我推荐种子列表,Worker 按同国优先排序)、HEARTBEAT(我还活着,更新 last_seen)。
同国优先查询(信令 Worker 侧,伪代码)
Javascript
// 在 Cloudflare Worker 中处理 WANT 请求
export default {
async fetch(request, env) {
const { peerId, action } = await request.json();
const country = request.headers.get('CF-IPCountry') || 'XX';
if (action === 'WANT') {
const { results } = await env.DB.prepare(`
SELECT peer_id, country
FROM peers
WHERE status = 'COMPLETE'
AND last_seen > unixepoch() - 30
ORDER BY
CASE WHEN country = ? THEN 0 ELSE 1 END,
last_seen DESC
LIMIT 20
`).bind(country).all();
return Response.json({
type: 'NEIGHBORS',
peers: results.map(r => r.peer_id)
});
}
// ... JOIN / COMPLETE / HEARTBEAT 处理
}
}
CASE WHEN country = ? THEN 0 ELSE 1 END 是同国优先的核心。同国种子权重为 0(优先),外国种子权重为 1(靠后)。
浏览器端接收国家标签
Javascript
const initResp = await signal(State.hasCache ? 'COMPLETE' : 'JOIN');
State.status = State.hasCache ? 'READY' : 'LOADING';
console.log(
`%c[P2P] 节点就绪 | 我的ID: ${State.myPeerId} | 归属国: ${initResp?.country || 'XX'} | 状态: ${State.status}`,
"color: #ff8c00; font-weight: bold; font-size: 13px;"
);
橙色的醒目标签让每个访客在控制台里就能看到自己的节点国籍和当前状态。对于调试跨国匹配问题非常有价值。
RTT 探针 + 同国排序的协同
Javascript
const candidates = resp.peers.filter(pid => pid !== State.myPeerId);
// candidates 已经按同国优先排好序,排在前面的大概率是同国节点
for (const pid of candidates) {
if (connectedCount >= MAX_ACTIVE_PEERS) break; // 最多 3 个
const rtt = await probeRTT(pid);
if (rtt >= MIN_RTT && rtt <= MAX_RTT) { // 30-300ms
// 同国节点 RTT 通常在 10-60ms,轻松通过
// 跨国节点 RTT 通常 >200ms,可能被过滤
activeConns.set(pid, conn);
connectedCount++;
}
}
信令 Worker 的同国排序 + 前端的 RTT 过滤 = 双重保险。同国节点不仅排在前面优先探测,而且 RTT 天然合格;跨国节点即使排在后面还被探测到,RTT 也往往不合格被过滤。
做种监听
Javascript
State.peer.on('connection', (conn) => {
conn.on('data', async (msg) => {
if (msg.type === 'GET_META') {
const cached = await caches.open(CACHE_NAME).then(c => c.match(TARGET_IMAGE_URL));
const ab = await cached.arrayBuffer();
conn.send({ type: 'META', size: ab.byteLength });
}
if (msg.type === 'GET_PIECE') {
const chunk = imageBuffer.slice(msg.index * PIECE_SIZE, (msg.index+1) * PIECE_SIZE);
conn.send(encodePiece(msg.index, chunk));
}
});
});
做种的逻辑非常直接:从 Cache API 取出完整图片的 ArrayBuffer,按请求的索引切出对应分片发回去。
踩坑记录
结语
这套方案本质上把 BitTorrent 的分片 + 多源 + 自动做种策略搬进了浏览器,并在此基础上叠加了 GeoIP 国家标签 + 同国优先调度这一层地理位置感知的路由智能。总共 350 行前端代码 + 一个几十行的 Cloudflare Worker + 一张 D1 表。
它的优雅之处在于:
如果你的站点也有大量国内用户被跨境延迟困扰,不妨试试这个思路。一个 script 标签,换来的是中国用户的「秒开」体验、大幅削减的 CDN 跨境流量,以及一套精准的同国种子匹配体系。