WebRTC 边缘加速:用 P2P + GeoIP 国家标签驯服 Cloudflare 的中国访问速度

问题的根源:Cloudflare 很快,但在中国不够快

我的个人站点托管在 Cloudflare 上,使用了它的全球 CDN。在欧美访问,首页那张几 MB 的高清背景图几乎秒开。但只要切到国内网络——无论是电信、联通还是移动——延迟就会肉眼可见地上升,有时甚至要等好几秒。

这并不是 Cloudflare 的技术有问题,而是物理现实决定的:Cloudflare 在中国大陆没有边缘节点。所有请求必须跨境回源到香港、新加坡或更远的节点,中间经过国际链路的拥塞和不可预测的路由绕行 [5][8]

传统思路无非是:再买一个国内 CDN、搞双栈部署、把图片放到国内 OSS ——这些方案能做到,但要么花钱,要么复杂。我不想引入额外的供应商,也不想把数据搬来去。

我想要的是一套方案:继续用 Cloudflare,但让中国访客的加载速度和欧美访客一样快。


破局思路:访客之间互相传,CDN 只负责「第一个种子」

核心想法来自 BitTorrent 的古老智慧:下载者同时也是上传者 [1]。如果第一位中国访客从 Cloudflare 下载了图片(忍受这一次慢),之后他就能把这张图传给下一位访客——第二位访客不再需要跨境下载,直接从同城的另一个浏览器拿数据。

P2P 的术语说:

  • Seeder(种子):已经持有完整图片的访客节点,持续为其他人提供上传
  • Leecher(下载者):新来的访客,从多个种子手中并发下载

这个架构一旦跑起来,访问量越大、在线种子越密集、国内用户的 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 所对应的两位国家代码(如 CNUSJP)。无需额外付费,无需第三方 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 打不通的极端对称 NATTURN 会作为中继转发流量 [12][15]——虽然这不如直连快,但保证了可达性。

PeerJS — WebRTC 的友好封装

原生 WebRTC API 是出了名的啰嗦:RTCPeerConnection 要管 SDP offer/answerICE 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。下次同一个浏览器打开页面时,根本不会走网络——直接从缓存取 blob0ms 渲染背景图。

这是整个「Type A」路径的逻辑:缓存命中秒开同时节点以 COMPLETE 状态注册到 D1 信令大厅,立即开始做种服务他人。


GeoIP 国家标签体系:为什么要把用户「标上国籍」

这是整套系统最精巧的设计之一。一句话概括:P2P 传输很快,但前提是种子和下载者在同一张网络上。国际链路仍然是慢的。

如果不做国家区分,可能出现这样的尴尬场景:

  • 一个日本访客需要下载图片,信令大厅给他推荐了一个中国种子
  • 尽管中国种子数据齐全,但中日之间的国际链路延迟高达 150-300ms,跨洋传输速度远不如同国节点 [5]
  • WebRTC 连接虽然能建立,但实际吞吐量被国际拥塞链路严重制约

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 │ …

调度逻辑每秒轮询一次:

  1. 先到先分配:种子连上后立即被指派一个 idle 分片
  2. 完成即接替:一个分片下载完后,该种子立刻拿下下一个 idle 分片
  3. 卡死夺回:如果一个分片被指派超过 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 之上构建了一层地理感知的覆盖网络

  • 第一个中国访客:走 Type C,从 Cloudflare 跨境下载。慢一次,但图片被缓存到本地。注册时被标记为 CN
  • 第二个中国访客:走 Type B。信令 Worker 查询 D1SQL CASE WHEN country = 'CN' THEN 0 ELSE 1 END 把第一个访客排在种子列表首位。WebRTC 直连大概率是同运营商同城内网直连,延迟可能只有十几毫秒,吞吐跑满。
  • N 个中国访客CN 种子池越来越密集,P2P 命中率逼近 100%。同国排序确保下载永远优先走国内链路,几乎没有人再需要跨境下载。
  • 日本/美国访客:同理,信令 Worker 会自动将他们匹配到 JP  US 标签的种子,避免被中国种子拖慢。

更妙的是,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 上的信令服务,它背后连接 D1action 可以是 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,按请求的索引切出对应分片发回去。


踩坑记录

  1. GeoIP 的精度取舍CF-IPCountry 只提供国家级别(两位代码),不能精确到城市或省份。对于中国这种幅员辽阔的国家,「同国」标签不能区分北京和广州用户。但由于中国国内的骨干网延迟通常也在 30-60ms 以内 [5]RTT 探测都能通过,实际吞吐表现良好。如果未来需要更细粒度的区域匹配(比如同省、同运营商),可以考虑用 ASN 信息(CF-IPASN)或引入第三方 GeoIP 数据库做城市级标记。
  2. VPN 用户的标签偏差:如果用户开着 VPNCF-IPCountry 返回的是 VPN 出口 IP 的国家,而非用户真实所在地。这可能导致一个实际在中国的用户被标为 US,从而被匹配到美国种子——反而变慢了。好在这种情况不多,而且 RTT 探测会自然过滤掉高延迟的跨国连接,用户最终大概率回退到 Type C 直连 CDN,不至于白屏。
  3. WebRTC DataChannel 二进制传输PeerJS 默认的 serialization JSON,不适合传二进制。我用 conn.send(packet.buffer) 直接发送 ArrayBuffer,接收端通过 msg instanceof ArrayBuffer 判断类型,完全绕过了序列化开销。
  4. 分片索引编码:每个分片包的前 4 字节是 uint32 little-endian 的索引号(用 DataView 写入),之后是数据体。接收端解包后按索引放入 pieceData 数组。
  5. 连接上限为什么是 3:实测 3 个并发 DataChannel 已经能跑满大部分用户的下载带宽。继续增加连接数,总吞吐提升很小,反而增加调度复杂度和浏览器内存压力。尤其在同国优先策略下,3 个同国种子的并发能力绰绰有余。
  6. D1 信令 Worker 的过期清理:用 last_seen 时间戳 + Worker scheduled handlerCron Trigger)定期清理超过 30 秒没心跳的节点,防止「僵尸种子」(用户关闭了标签页但没来得及注销)污染种子列表。
  7. 隐私WebRTC 直连会暴露真实 IP [6]。我的站点是非敏感内容,用户 IP 可见性没有实际危害。GeoIP 标签只记录到国家级别,不存储具体 IP 地址。如果有需要,可以将所有连接强制走 TURN relay,隐藏真实 IP

结语

这套方案本质上把 BitTorrent 的分片 + 多源 + 自动做种策略搬进了浏览器,并在此基础上叠加了 GeoIP 国家标签 + 同国优先调度这一层地理位置感知的路由智能。总共 350 行前端代码 + 一个几十行的 Cloudflare Worker + 一张 D1 表。

它的优雅之处在于:

  • 经济模型自洽:每个下载者自动变成同国种子,系统随用户增长而变强
  • 地理自适应路由:中国访客优先匹配中国种子,自动绕开国际链路瓶颈
  • 无额外成本D1 免费额度绰绰有余,TURN 按量计费但用量极小,CF-IPCountry 免费
  • 零运维:没有服务器要管,Cloudflare 托管一切
  • 完美降级P2P 失败静默回退 CDN100% 可用性

如果你的站点也有大量国内用户被跨境延迟困扰,不妨试试这个思路。一个 script 标签,换来的是中国用户的「秒开」体验、大幅削减的 CDN 跨境流量,以及一套精准的同国种子匹配体系。

← 返回知识库