Skip to content
ToiCFPublic

About

基于 Cloudflare Workers 的 WebSocket TCP 中继,主打 BYOB 读取、上传机会性合包与下载 grain 聚合。

Resources

Stars

196 stars

Watchers

1 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 

Repository files navigation

GrainTCP

GrainTCP.js 是一个基于 Cloudflare Workers 的 WebSocket TCP relay 实现,重点不在附加协议功能,而在于围绕真实代理路径收敛小包传输优化。

核心能力

当前代码主线包含:

  • 普通 TCP 首包解析
  • WebSocket early data 接入
  • request.fetcher.connect() TCP 出站
  • 并发拨号竞争首个成功连接
  • 显式关闭 WebSocket 压缩协商
  • 上传侧机会性 grain 合包写入
  • 下载侧复用同一 grain 核,再加下载门控
  • BYOB 读取转发

代码主路径

Relay over WebSocket
  → 解析 UUID / 目标地址 / 端口
  → `request.fetcher.connect()` 并发建立 TCP
  → 上传侧机会性收纳 + 合包后写入
  → 下载侧大包直发 / 小包复用同一 grain 核后回传

出站接口

当前代码最突出的点之一,就是最先把 request.fetcher.connect() 这条灰接口直接引入到实际 TCP relay 主线里使用,而不是继续停留在公开 cloudflare:sockets.connect() 的常规路径上。

方式 类型 当前结论
cloudflare:sockets.connect() 公开 API 正式、稳定、直接出公网 TCP
request.fetcher.connect() undocumented 灰接口 当前代码实际使用;同样可直接出公网 TCP,但接口形态与可用性更依赖平台实现

需要区分的一点:

  • 当前代码用的是 request 级 fetcher.connect()
  • 不是公开 service binding 那条 Fetcher.connect() 路径

从 workerd 源码层面看,这两条入口最终都会落到同一套底层 TCP 建连实现上;差异主要不在“另一套 socket 引擎”,而在 JS 层入口、fetcher 归属和通道来源。

对当前这份代码来说,request.fetcher.connect() 的意义主要有两点:

  1. 它不是公开导入 cloudflare:sockets 模块的常规写法,而是 直接在 request 级 JS 上下文里取出的灰接口
  2. 在代码特征上,这条路径相比常规公开 socket 入口更小

但要分清:

  • 这更接近 入口形态差异
  • 不是已经被源码或实测完全证明成“底层能力高一档的新 socket”

当前可以确认的是:

  • request.fetcher.connect() 确实能直接出公网 TCP
  • 它在底层仍与公开 connect() 共享同类建连路径
  • 它作为 JS 层新引入的灰接口,代码特征比常规公开 socket 模块更小

设计重点

1. UUID 热路径

与以往所有代码不同,这一版没有把 UUID 校验继续留在请求期做循环逐字节比对,而是把 UUID 预解码前移到模块初始化阶段:先转成 16 字节,再拆成固定标量常量。

这样请求真正进入请求热路径后,UUID 这一步只剩下两件事:

  • 长度不足直接返回
  • 16 个字节做固定位置直接比较,失败立刻返回

这条路径的目的很明确:把一次性工作留在初始化,把请求热路径压成最短的固定宽度判断。对 workerd / V8 这类运行时来说,这比在每次请求里保留循环、偏移计算和额外分支更容易收紧成稳定热路径。

本地 Node v20.19.4 基准:

路径 以往处理方式 当前实现 谁更快
UUID 命中匹配 53.26 ns/op 9.44 ns/op 当前实现约 5.6x
UUID 尾部错误快速拒绝 51.63 ns/op 11.00 ns/op 当前实现约 4.7x
完整首包有效解析 220.67 ns/op 187.09 ns/op 当前实现约 1.18x
完整首包错误快速拒绝 52.56 ns/op 23.14 ns/op 当前实现约 2.3x

这里的 UUID 路径已经压到了 纳秒级匹配 + 亚微秒级完整解析,四项热路径对比里当前实现全部更快。

2. 上传侧机会性合包

当前主线不再保留早期那种偏重的“显式队列状态机”。

现在上传侧保留的核心只有三件事:

  • 先把收到的 WebSocket 小块收进一个很薄的 grain 收纳核
  • drain 时把连续小块尽量 bundle 成更少的实际写入
  • 当前轮写完后,如果队列还没空,就立刻继续下一轮 drain

也就是说,它已经从“带额外边界、额外拒收、额外参数层”的上传队列,收敛成:

collect -> bundle -> writer.write()

这里的重点已经不是“做一个复杂上传系统”,而是把真正有用的那颗机会性合包核留下来:

  • 小包不要一条一条立刻写
  • 同轮连续到达的小块,尽量并成更少的 writer.write()
  • 其余和主逻辑无关的重队列外壳都剔掉

这条提炼后的上传路径,优化目标也更纯粹:

  • 减少高频小 writer.write() 调用
  • 减少 per-message 调度次数
  • 把原始很多条细碎 message 压成更少的实际写入

当前实现对应:

  • grain 收纳核:先收,再并
  • upPack = 20KB:当前单次合包目标
  • drain 递归:上一轮写完后,若仍有积压就继续写

这里说的“机会性”,意思不是固定等待,也不是强行憋包,而是:

  • 同一轮 drain 前后如果还有连续小块到达,就顺手继续并
  • 没有连续 backlog 时,就直接把当前这一块交给 writer.write()

也正因为它不是硬等待模型,所以它同时保留了两头:

  • 默认路径尽量接近直写
  • 一旦遇到 tiny packet storm,上限又能明显拉高

本地 Node v20.19.4 回放数据可以直接说明这一点。

固定 512B 小包、4096 条原始 message、upPack = 20KB:

模式 原始 message 实际输出次数 最大单次输出 减少量 优化倍数
直接逐条写出 4096 4096 512B 0 1.0x
当前机会性合包 4096 103 20480B 3993 39.8x

随机 mixed 小包样本、4096 条原始 message、总字节 4,767,543:

模式 实际输出次数 聚合输出次数 聚合吃入条数 减少量 优化倍数
直接逐条写出 4096 0 0 0 1.0x
旧定时合包对照 3606 393 883 490 1.14x
当前机会性合包 2280 1217 3033 1816 1.8x

换句话说,这条上传侧主线并不是只追求“平均更平滑”,而是实打实地在不引入重状态机的前提下,把上行 tiny message 风暴压成更少的实际 writer.write() 次数。

这条路优化的是 JS 层写入次数和调度形态。

它的价值就是 削减高频小包带来的固定成本,而不是再造一层重型上传状态机。

3. 下载侧大包直发与小包 grain 合包

下载侧分成两部分:

  • 已定型的是大包直发主线
  • 继续优化的是 <32KB 小包如何进入同一颗 grain 核

当前代码的下行主线很明确:

  • 大包:tx.reap() → ws.send(v) → 立刻换新 BYOB buffer
  • 小包:v.slice() 脱离原读缓冲 → 进入 grain 核 → 由下载门控决定何时 flush

大包直发已经定型,原因很直接:

  • ws.send(Uint8Array) 底层不是立刻深拷贝,而是把当前 view 对应的 backing store 挂进 outgoingMessages 异步队列
  • 所以大包如果先 slice() 再发,只会平白多一次 JS copy
  • direct send 之后,这块读缓冲又不能继续复用,所以必须立刻换新 buffer

大包路径现在就是一句话:

direct send,随后立刻换新 buffer

这也是它比旧式“先大缓冲、再定时 flush”的下载写法更省开销的原因:

  • 旧写法会额外维护一整套大缓冲、offset、timer、resume、flush 状态
  • 大块数据常常先复制,再 send
  • 进入大文件阶段后,还会继续塞进 JS 缓冲再等定时器发出

而当前这版的大包路径只保留两个必要动作:

  • 直接 send
  • 换新 buffer

少了一次大块复制,也少了一层额外的 JS 发送系统。

真正重要的变化在小包这一半:下载侧不再维持一套和上传侧完全无关的小包模型,而是直接复用同一类 grain 收纳/合包核心,再在外面包一层下载专用门控。

也就是:

上传侧: collect -> bundle -> writer.write()
下载侧: collect -> bundle -> gate(ripen/reap) -> ws.send()

共享的是同一类基础动作:

  • 先收纳连续小块
  • 能并就并
  • 最后把更少的块送出去

下载侧额外保留的,只是它自己的门控语义:

  • 小包还在连续增长时,再给一点点观察窗口
  • 如果缓冲接近上限,就立刻发
  • 如果增长停了,就把当前这一批发出去

这里的 32KB 不是“必须攒满才发”的目标,而是 聚合上限:

  • >= 32KB 直接发
  • < 32KB 先进入 grain 核,再交给下载门控
  • 缓冲接近上限、同轮连续小包折叠完成,或极短 quiet-window 结束后就发

所以它不是简单缓存,而是 共享 grain 核 + 下载门控 的轻量聚合。

这部分针对的是 高频小 frame 场景:把很多 <32KB 小块压成更少的 ws.send() 次数,减少 frame 数、调度次数和 runtime 队列压力。

本地 Node v20.19.4 的下行小包回放也能把这一点量化出来。

固定 512B 小包、4096 条原始下行块:

模式 实际输出次数 最大单次输出 减少量 优化倍数
旧版 64 32768B 4032 64.0x
新版 64 32768B 4032 64.0x

这一组说明的是:在极端连续 tiny packet storm 下,两者都会稳定顶到 32KB cap flush,所以纯 fixed 样本主要验证的是上限本身,没有把差异拉开。

更接近真实代理响应的是 mixed 小包样本。4096 条、512..8192B、总字节 10,309,167:

模式 实际输出次数 聚合输出次数 聚合吃入条数 减少量 优化倍数 最大单次输出
旧版 2721 1102 2477 1375 1.51x 21686B
新版 2030 1147 3223 2076 2.02x 32768B

这组 mixed 样本对应的结论更接近当前主线:

  • 新版的小包压缩力度更高,实际输出次数从 2721 进一步压到 2030
  • 按输出次数算,新版相比旧版又多做了约 1.34x 的削减
  • 按减少量算,新版把 saved 从 1375 提到 2076,约是旧版的 1.51x

它不是完整流控,也不是背压系统。WebSocket.send() 仍然没有可用的 drain / backpressure 信号;客户端慢的时候,runtime 内部队列仍然会继续增长。这个聚合器能做的,是尽量减小高频小 frame 对 send() 队列的放大。

当前把 dnPack 收在 32KB,是因为更小的 2KB / 4KB / 8KB 虽然也会做一点合并,但还停留在原始小包尺度里,只有 32KB 才能把小包 frame 数压到另一个数量级。

当前门控的关键不是长延时,而是:

  • dnTail = 512B:尾部接近满时直接发
  • dnQr = 4:连续增长观察的最多追加轮次

chunk = 64KB 也只是当前通用默认档,不是源码硬上限。它的含义更接近“普通混合流量下更稳的工程档位”;在明确的大文件 bulk 场景里,更大的读块仍然可能成立。

下载侧也没有继续走重型 JS 队列路线。ws.send() 底层本来就有自己的异步发送队列,JS 层再额外堆一整套大缓冲、timer 和状态机,通常只会多一次合并拷贝和更多调度开销。当前主线保留的是轻量小包聚合,不再重做一层下载发送系统。

简化模型:

T_direct = 32KB
N_down ≈ B_down / E_down
Cost_down ≈ N_down * (C_loop + F_big * C_rebuf) + P_small * B_down * k_slice
Q_ws_out' ≈ R_sock_read - R_ws_drain

含义:

  • T_direct:大小包分界;>= 32KB 直发,< 32KB 进入轻量聚合
  • E_down:下行平均交付块大小;越大,N_down 越小,read + send + loop 固定成本越低
  • P_small:走小包分支的字节占比;越高,slice() 复制成本越高
  • F_big:走大包直发分支的迭代占比;越高,补新 BYOB buffer 的次数越多
  • Q_ws_out:ws.send() 之后 runtime 内部的隐藏发送队列;当 R_sock_read > R_ws_drain 时继续增长

当前主参数:

  • dnPack = 32KB
  • dnTail = 512B
  • dnQr = 4

4. 并发拨号

当 concur = 4 时,会同时发起多路 TCP 建连,谁先成功就使用谁,其余连接关闭。主要用于改善部分入口下的首连成功率和首包速度。

这里保留并发拨号,不是随意设定的常数,而是因为 同一目标在 Cloudflare runtime / 路由层本身就存在黑盒抖动。即使目标服务器不变、入口不变,单路 connect() 的胜者 RTT 也会因为边缘调度、路径选择和 runtime 波动出现几毫秒级差异;并发竞速的意义,就是直接吃掉这一段波动,取当前轮里最快的那条。

现有实测也支持这一点:同目标下,胜者拨号中位数可以从 concur=1 的 33ms 压到 concur=2/3/4 的 30/30/29ms 左右。也就是说,这里的并发不是为了堆复杂重试,而是为了在 CF 这一层做一次黑盒择快,直接降低 RTT。

当前 4 对应的是 Workers / Pages 部署下的默认配置。如果把这份代码改成 Snippets 形态使用,并发拨号应手动收回到 1,不要继续保留 4。

同时也要记住边界:这类竞速只适合 单轮 保留,不能在同一个 request 里继续叠多轮 fallback / retry / second race。原因不是逻辑复杂度,而是 request 级连接配额和 delayed-close 会先把收益吃掉。

5. 禁用 WebSocket 压缩协商

当前代码在握手响应里显式把 Sec-WebSocket-Extensions 置空,目的不是“省一个 header”,而是主动避免 relay 主路径进入 WebSocket 压缩协商。

原因也很直接:

  • relay 主路径传的是原始 TCP 字节流,很多真实场景里内部本来就是 TLS/HTTP2/QUIC 等高熵数据
  • 这类数据在 Worker 这一层再做 WS 压缩,通常换不来稳定收益
  • 反过来却会把压缩/解压、状态维护和 frame 处理的 CPU 成本继续抬高

换句话说,这里不是简单追求“字节看起来更小”,而是优先控制 relay 热路径里的 CPU 消耗。结合本地工程记录,更稳的结论是:

  • 即使某些场景下表面吞吐看起来会更大
  • WS 压缩仍会把 CPU 开销明显放大

因此当前主线把它直接视为负优化处理,不把 WS 压缩放进默认传输路径。

当前配置

变量 意义 默认值
id UUID 2523c510-9ff0-415b-9582-93949bfae7e3
chunk BYOB 读取块大小 64 * 1024
dnPack 下载侧 grain 聚合上限 32 * 1024
dnTail 下载侧尾部阈值 512
dnQr 下载侧连续增长观察轮次 4
upPack 上传侧合包目标 20 * 1024
maxED early data 上限 8 * 1024
concur 并发拨号数;Workers / Pages 默认 4,Snippets 手动改 1 4

这些值对应的是当前主线路径下的收敛结果,不是随意占位参数。

文件

文件 说明
GrainTCP.js Worker 主实现:首包解析、TCP 出站、统一 grain 核、BYOB 转发

相关链接

Stargazers over time

Stargazers over time

About

基于 Cloudflare Workers 的 WebSocket TCP 中继,主打 BYOB 读取、上传机会性合包与下载 grain 聚合。

Resources

Stars

196 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors