JsonStream 设计笔记:为什么这么设计
本文回答四个问题:帧头为什么长这样;压缩/加密怎么取舍;背压复杂在哪;pub/sub 与 req/res 混用一条连接时边界怎么划。帧格式的完整定义见 protocol.md,本文只谈动机与代价。
1. 帧头:对着 WebSocket 抄作业,再还回去一点
1.1 WebSocket 帧结构回顾(RFC 6455 §5.2)
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+最小 2 字节(FIN + opcode + MASK + 7 位长度),长度 >125 时扩展到 4/10 字节,客户端帧还要 4 字节掩码。它省字节的手段是变长编码:7 位不够就换 16 位,再不够换 64 位。
1.2 我们抄了什么、还回了什么
| WebSocket 惯例 | JsonStream 的处理 | 为什么 |
|---|---|---|
| opcode 区分帧用途 | 保留(Type 字段,8bit) | 连接复用的通用解,没理由不用 |
| FIN 位分片大消息 | 换形保留(独立 FRAGMENT 类型 + FlagFragmented 位,protocol §3.4) | v0.1.x 翻案:生产不能「包体过大就拒传」,任意大小的单体消息是正式版需要的能力。不用 WebSocket 的 FIN+continuation 形式:续段是独立帧类型且不带元数据/变换标志(两者只随开帧走一次),帧形更简单;运行不可穿插由唯一写循环保证,接收端免块序号。原「16 MiB 内一帧装得下、省掉状态机」的决策与此处的翻案理由都留了档 |
| MASK 客户端掩码 | 不做 | 掩码是为了防代理缓存投毒(中间盒按内容猜测包边界的历史包袱),专用客户端/服务端直连场景无此威胁 |
| 7/16/64 位变长长度 | 改为固定 32 位长度前缀 | 变长编码省的字节(多数帧 <125B 时省 2~6B)换来解码端分支状态机。固定 4B 让"读完头 → 两次 ReadFull"成为无条件操作;16 MiB 上限同时是内存防线 |
| ping/pong 控制帧 | 保留(独立 Type + 读空闲超时) | 与 RFC 6455 §5.5 的 keepalive 惯例对齐 |
净效果:帧头 14B(WebSocket 典型消息 2~6B)。我们用每帧多花约 10 字节,换掉两个状态机(变长解码、掩码往返);分片重组后来作为大消息能力补回(见上表翻案),但形态比 WebSocket 简单——续段是独立 FRAGMENT 类型且不带元数据。对 JSON 业务消息(普遍数百字节起)这 10B 是 ❤️% 的税;对心跳这类小帧则是 14B vs 6B 的浪费——所以心跳帧不带 Metadata、payload 为空,把它压到 14B 就是下限,接受。
1.3 和 RSocket 的帧头对比
RSocket 帧头 6B(Stream ID 4B + Type/Flags 2B),带长度字段的帧再加 4B = 10B,思路与我们几乎一致(定长 + 类型 + Stream ID + 长度前缀),只是它把类型压缩到 6bit 并把 Flags 挤进同一字段。我们的 14B 多出的 8B 是 Magic(2B)与 Version(1B)+ Reserved(1B)——Magic 是给 TCP 复用端口探测/误连场景一个立即判废的机会,Version 是握手期快速失败的依据。这两个字段 RSocket 没有,因为它运行在 Reactor Netty/WebSocket 之上,传输层已替它做了识别;我们直接裸奔 TCP,这两个字节是自付的保险费。
2. 交互模型:对着 RSocket 抄,再合并两个入口
2.1 RSocket 的交互模型
RSocket 定义四种交互原语,每种是独立的帧类型:
| RSocket | 语义 | JsonStream 对应 |
|---|---|---|
REQUEST_RESPONSE | 1 请求 → 1 响应(next+complete 合一) | REQUEST(无 Stream/Channel 位) → 单帧 RESPONSE |
REQUEST_STREAM | 1 请求 → N 响应 + COMPLETE | REQUEST(Flags.Stream) → N×RESPONSE + COMPLETE |
REQUEST_FNF | 单向,零响应(连错误都不回) | ONEWAY |
REQUEST_CHANNEL | 双向 N↔M | REQUEST(Flags.Channel) |
三个值得说明的借鉴:
- 响应帧自带终结语义。请求/响应不追加 COMPLETE 帧——一次交互少一个 RTT 内的帧和一次状态转换。这是 RSocket 相对"gRPC 流必须显式 end"更激进、也更适合小消息场景的地方,原样继承。
- CANCEL 是流的一部分。客户端取消时发 CANCEL,服务端尽早停止计算——背压解决"生产快于消费",CANCEL 解决"消费者根本不要了",两者共同构成流控的完整语义。HTTP/1.1 没有对应物(只能断连接),这是 RSocket 相对 HTTP 系最大的实用性增量之一。
- 错误既可以是流级的,也可以是连接级的。Stream ID=0 的 ERROR 等价于 RSocket 的
ERROR(CONNECTION),协议状态不可信时唯一正确动作是断开重连。
2.2 我们改了什么:用 Flags 合并请求入口
RSocket 把三种请求拆成三个帧类型;JsonStream 用一个 REQUEST 类型 + Flags.Stream/Flags.Channel 位表达。差异的理由与代价:
- 理由:本协议的帧类型表需要容纳 MQTT 式的 SUBSCRIBE/UNSUBSCRIBE、心跳、恢复等控制帧,类型空间(虽然 8bit 够用)和解析分支数都希望克制;Flags 位让"请求家族"共享同一套路由 Metadata 读取逻辑。
- 代价(要诚实):RSocket 收到 REQUEST_STREAM 帧那一刻就知道交互模式,而我们要等路由 Metadata 解出来、查到服务端 handler 注册类型后才能校验模式。中间件如果只看帧头、不看 Metadata,无法预判这是一次性请求还是无限流——对"按模式做准入控制"的网关不够友好。v1 接受这个代价(Flags 声明 + handler 校验不一致回
ERROR(PROTOCOL)兜底),如果真出现该场景,升级路径是把 Stream/Channel 位提为独立帧类型,语义完全等价。
3. 压缩/加密的参数化:帧内自描述,协商只管默认值
关键决策:每帧的 Flags 声明本帧是否压缩/加密,解码管线看帧不看协商结果。
- 这样心跳帧可以永远裸奔、大 payload 才压缩,单连接内混合存在;协商结果(CONNACK)只决定"默认怎么做",而不是"只能怎么做"。
- 与 TLS 的取舍:TLS 加密的是整个 TCP 流,一防窃听二防篡改三防注入,且密钥有协商体系;本协议的 Flags.Encrypted 是应用层端到端加密的占位——PSK + AES-256-GCM 演示了"参数化"本身(题面要求的是"可选",不是"替代 TLS")。诚实的生产建议:外层套 TLS 做传输安全,Flags.Encrypted 留给"经过中间件仍要保密"的端到端场景(与 Signal/MLS 的思路同构,与 RSocket 把安全完全下放给传输层不同——那是它的部署环境决定的)。
- 压缩的取舍:flate 无字典压缩对重复性高的 JSON(日志、状态同步)收益明显,对已压缩/高熵载荷是纯浪费。v1 用最简单的策略——发送方全有或全无(Config 决定),没有做"压缩率不达标就回退明文"的自适应,因为那需要每帧编码两次或边压边判断,复杂度收益不成比例。改进路径:CONNACK 协商 zstd 字典(JSON 场景收益最大的单项优化)。
- 顺序必须是先压后加:GCM 输出在统计上近似随机字节,对它压缩得率为零;反过来先加后压等于白白付 28B 加密开销(12B nonce + 16B tag)还保留明文可压缩的冗余。
4. Metadata 与 Payload 分离:路由可读,业务不透明
把 route/topic 放在明文 Metadata 而不是 JSON payload 里,模仿的是 RSocket 的 composite metadata(路由扩展不侵入数据帧):
- 中间件(鉴权、限流、metrics、网关路由)不解码 payload 就能工作;
- 加密后 payload 是不透明字节,路由信息若混进去就只能整帧解密才能路由——等于强迫中间件持有密钥。
代价:Metadata 恒为明文,路由名本身泄露给链路上的观察者("哪条连接在订阅哪个主题"是可以被旁路统计的);且 Metadata 不参与压缩,高频长路由名有小额开销。缓解:路由名收敛为短标识;需要隐藏时把路由名纳入加密范围(那就要放弃中间件可读性,二选一,没有免费午餐)。
5. 背压:credit 而不是 LEASE,复杂度花在刀刃上
5.1 三种流控模型的对照
| 模型 | 代表 | 语义 | 复杂度 |
|---|---|---|---|
| 滑动窗口 | TCP / HTTP/2 | 按字节授权,双端维护窗口 | 高(字节级记账) |
| credit 按条数 | JsonStream、Reactive Streams request(n) | 授权 N 条消息,交付后归还 | 中 |
| 租约按时间 | RSocket LEASE | 授权"未来 TTL 内最多 N 条"的速率 | 低,但只约束速率不约束在途量 |
选 credit 的原因:题面要的是背压("生产快于消费时把压力传回去"),credit 直接表达"我还没消费完,你别再发";LEASE 表达的是准入速率,防的是滥用而非背压(RSocket 自己也靠订阅方的 request(n) 做真正的流控,LEASE 是外层闸门)。滑动窗口按字节记账对 JSON 消息场景过度工程——我们按条计数就够,且"一条消息"恰好是业务方心智里的单位。
5.2 credit 的真实复杂度(为什么它是"可选")
- 发送侧阻塞点。额度归零时
Emit()/Send()必须阻塞——意味着 handler 的执行 goroutine 挂起,服务端需要为每个活跃流挂起一个 goroutine 或引入队列,这是全协议里唯一"协议层反向影响应用层并发模型"的机制。 - 归还时机即语义。收到帧 ≠ 归还,交付给应用才算——归还太早背压失效(队列在应用层暗中堆积,回到无背压世界),太晚吞吐骤降。v1 选"交付即归还、批量补发",即 HTTP/2 WINDOW_UPDATE 的惯例位置。
- 死锁风险。双工模式下双方同时阻塞在对方的 credit 上,若无连接级共享窗口管理会互相等待;v1 用连接级共享窗口 + credit 无论如何最终会归还(读循环保证交付)规避。
- 粒度困境。连接级窗口实现简单、全局内存有界,但一个慢订阅会拖住同连接上其他流(队头阻塞的流控版);per-stream 窗口公平但要维护 N 份状态。v1 选连接级并声明,这是"背压可选"的另一半理由:它不是免费能力,不该默认强加给所有用户。
请求/响应与 ONEWAY 不纳入 credit:单帧即终结的交互没有"连续生产"可言,强行套 credit 只会白加一个 RTT。
6. pub/sub 与 req/res 混用一条连接:边界划在"流"上
两种模式共享一条 TCP 连接、同一套帧头,边界必须清晰,否则出现"这帧属于谁"的歧义。v1 的划分规则:
- 一切皆流。req/res 是生命周期极短的流;订阅是以 SUBACK 为起点、以 UNSUBSCRIBE/断连为终点的长流。每帧必带 Stream ID(控制帧除外),归属由 ID 唯一确定——不存在"广播帧"这种无主帧。
- 主题是双向命名空间,方向由发起方决定。C→S 的 PUBLISH 打到服务端
HandlePublish注册的 handler(客户端当生产者);S→C 的 PUBLISH 投递到该连接的订阅(服务端当生产者)。同一个帧类型两个方向语义对偶,避免"上行主题/下行主题"两套类型。 - 错误边界:订阅建立失败(鉴权、配额)用 ERROR 回应 SUBSCRIBE 的流;投递失败(订阅方处理回调出错)不回帧——投递是服务端发起的动作,回帧会形成"响应的响应",方向倒置。这条不对称要在文档里写死,否则实现者必然在这里犹豫。
- 生命周期边界:订阅随会话保留、重连后自动重订续投;请求/响应流断连后同样保留等待重放。两者的恢复策略一致(§8 的 at-least-once),不因模式不同而分裂——分裂意味着客户端要维护两套恢复逻辑,成本不成比例。恢复成功时未终结流的 handler 随会话迁移到新连接继续执行(客户端上行在新流表命中);放任 handler 悬空会让它白产帧进保留队列,还可能把队列撑爆(overflowed 连坐会话里其他流)。
- 背压边界:两种模式的下行投递共享连接级 credit 窗口(§5.2 的取舍)——这是设计决策,发送侧闸门的接入现状见 protocol §7.9「实现状态」(当前覆盖流式与双工的
RESPONSE,订阅广播路径尚未接入)。窗口共享意味着 pub/sub 的高频广播与某个流式响应互相影响吞吐——接受,因为 per-stream 窗口的公平性收益在面试题规模下不值得其状态成本。
所以 req/res 与 pub/sub 的边界不在连接上,在流上;不在类型上,在方向与生命周期上。混用的收益是一条 TCP、一次握手、一套心跳与恢复;混用的代价是共享流控与队头阻塞,两边都在本文档里挑明了。
7. 缺点清单(自评)
不设防地列一遍这套设计的坏处,供评审质询:
- 帧头 14B:比 WebSocket(2~6B)厚,心跳类小帧的相对开销最大;Magic/Version 在传输层已有 TLS ALPN 的部署里是重复保险。
- 分片重组不是免费的:单条消息可到 MaxMessageSize(默认 64 MiB,protocol §3.4),重组期间缓冲常驻、运行中间同流新数据帧会被拒——一条大消息会占住接收端一份同尺寸缓冲,且与同连接其他流共享队头阻塞(这是共享连接固有的,不是分片新引入的,但它让"传大文件"从「不可用」变成「可用但贵」,应用层该自己权衡切块粒度)。
- 恢复不是零丢失,也无力判重:重放只覆盖断开期间产生的下行帧(进入保留队列的);断连瞬间已在 TCP 在途的帧不保证送达、也不重放——恢复窗口内"可能丢最后一帧",且无 per-frame 序号,客户端无法判重。对非幂等操作(下单、扣款)只能靠业务 ID 幂等兜底(§8.3)。
- 上行不缓存:断连瞬间客户端发出的请求会丢失,客户端"重连成功但刚才那个请求发出去过没有"是未定义状态,应用层需要自行设计重试(这其实是把难题诚实地下放,但下放也是没解决)。
- 连接级 credit 的队头阻塞:慢流拖快流(§5.2)。
- 服务端会话保留是有状态内存:4 MiB/会话 × N 连接的保留队列,水平扩容时"重连必须回到原实例"(粘性 LB)或引入会话共享存储,这是比协议本身更贵的工程问题——RSocket 的 resume 在集群场景同样栽在这里,不是我们独有的坑,但必须提前说明。
- PSK 不解决密钥分发:题面只要求"可选加密",真正的安全故事(TLS、轮换、前向保密)在 v1 之外。
8. 实现层已知优化空间
诚实列出已识别但未做的性能项(已做的:flate 编解码器按帧池化——每帧新建 writer 实测 ~1.3ms/~800KB 垃圾,池化后压缩路径 4.1 倍提速):
- 帧缓冲不池化:
appendTo(nil)与变换管线的中间bytes.Buffer每帧分配。池化需要把缓冲生命周期钉进异步写循环(帧在 sendCh 里排队),收益在百 ns 级,复杂度在生命周期审计,暂不值得。 - 小帧写合并:心跳/CREDIT/重放帧各自一次 socket write;
writev或发送侧攒批在高扇出 pub/sub 场景可省系统调用。 - GCM 吞吐依赖 AES-NI:基准机器(有 AES-NI)干净实测 ~5µs/~1.3KiB,无硬件加速的平台慢数倍,生产部署应确认 CPU 特性或在 TLS 之下免用应用层加密。
- 结构体字段布局未按对齐重排(fieldalignment 提示 Client/Config/flow 等 10 个结构可省 8~32 字节/实例):基准瓶颈在网络 RTT(干净实测 ~114µs),内存布局的影响在基准噪声之下;按对齐重排会打散按语义分组的字段排列,降低可读性,不做。
9. 测试策略:100% 覆盖是怎么凑出来的,以及哪些不是凑的
语句覆盖率 100%(go test -coverprofile 实测;语句计数随工具链版本不同——1.24 与 stable 两腿实测均为 100.0%,故只记百分比不记分母,含 fuzz/契约/集成全绿,CI 有门禁)。数字是结果不是目标,这里记录三类真正值得说的手段:
- 测试接缝是生产代码里唯一的"为测试而设":
jsonMarshal、aesNewCipher、gcmNew、randRead、resubAckTimeout五个包级 var。这些构造在合法入参下不会失败,其错误分支是防御性的——不改成就只能永远测不到,或者拿到错误的输入去硬凑。接缝之外零改动:分支可达性靠并发时序构造,不动一行生产逻辑。 - 确定性并发技巧:双分支
select的随机性是覆盖率的头号敌人,解法是消灭"另一个就绪分支"——填满sendCh(容量 256)让发送只剩关闭分支;让请求卡在select之前的发送步骤,再预布置"响应 + COMPLETE"双就绪后放行,保证 drain 分支必然到达。写失败用 SO_Linger(0) 的 RST(FIN 后缓冲区写仍会成功,RST 才是真失败);写超时用 1 MiB 载荷塞满回环缓冲。剩下的真随机分支(如先收到响应还是先收到终结帧)用 20~100 次迭代把漏检概率压到 2^-20 以下;示例侧的窄窗竞态先做"铺满窗口"的构造再套用同一纪律(见 §9.1 与 DESIGN.md §11.3)。 - race detector 的两条纪律:它按向量时钟判定,墙钟错开但无 happens-before 边照样报——sleep 治不了竞态,只有 channel/spawn 边能。由此推出全局桩的顺序约束:先写桩后 spawn 的 goroutine 读桩天然有序(spawn 边),反向则永远无序,所以桩测试必须先于一切泄漏握手 goroutine 的测试执行(见 client_server_internal_test.go 文件首注释)。
覆盖率回答的是"这段代码执行过没有",不是"它对不对"——所以还有第四层专管信任边界上的对端字节:五个 fuzz 靶把不变量写成断言(任意字节不 panic、parse∘encode 恒等、meta 往返恒等、握手 JSON 线上往返恒等且协商是 wire 形态的纯函数、解压不超上限、恶意握手后合法客户端仍可用),CI 里跑固定预算的挖掘冒烟(靶名自动发现、每靶 20s、不带 -race,细节 DESIGN §11.4)。前 3 类手段凑的是那 100% 数字,这一层防的是 100% 覆盖也照样能错的输入:README bug 清单 1、2 就是它抓的,而那两条路径今天全被测试执行过。
已知边界:默认顺序下桩安全(先写桩后 spawn 的读经 spawn 边有序),-shuffle=on 实测约 1/3 排列会翻车——服务端 handleConn 收尾(错误帧/CONNACK 编码)的读桩跨越测试边界,与后续测试的桩写入无 happens-before。这是全局桩与异步收尾的固有属性,彻底互斥需要给生产代码加锁,为非契约场景(Go 默认不开 shuffle,CI 亦不开)不值得。
9.1 示例的测试方法学(与库包不同的一类)
examples/ 的测试现在与库包同为 100.0%(最后 5 条窄竞态分支的逐条构造记在 DESIGN.md §11.3),但方法学与库包不同,单独记录:
- 示例是文档,所以先改代码再改测试,而不是相反。为了让示例可测而加的钩子必须保持"读起来像正常的工程代码":服务端抽出的
options/parseOptions/serve(ctx)是任何真实服务都会有的形状(配置结构体 + 依赖注入 + ctx 停机),ln字段与runOptions.callTTL的注释都写明了"只为测试存在"的理由。反过来,如果为了让覆盖率好看而给示例加 sleep、加测试专用分支,或把双工 handler 改写成一次性收发的形态,那是在破坏示例作为文档的价值。这条纪律的例外只有一处,而且是先实证后改口:客户端Publish的出口经 90 轮时序扫描确认"纯竞态抓不住"之后,才加了runOptions.beforePublish检查点(演示恒为 nil),见 DESIGN.md §11.3。 - 写测试时顺手修了示例的一个真 bug:服务端双工 handler 原来把
Receive返回的io.EOF(对端正常半关闭)原样抛给框架,框架会当成 handler 出错、向一条已经正常结束的流补一个ERROR(INTERNAL)帧。补上errors.Is(err, io.EOF)判断后返回 nil。这条与覆盖率无关,是被"读示例当作 API 范例"这件事逼出来的——这正是给示例写测试的额外收益。 - 示例的测试自己就是 API 用法示范:
examples/server的测试用真实客户端把五种交互原语各走一遍(请求/响应、流式与中途取消、双工、单向、发布订阅),顺带验证 handler 的三条错误路径(载荷不是对象、n越界、对端取消导致Emit失败)。这是"示例不会随库演进而腐烂"的唯一可靠办法。 - 用可控的阻塞点代替 sleep 造时序:
Stream之后的错误出口无法用"掐断连接"稳定命中(run 是微秒级流水,连接死亡插不进那个窗口)。改用的办法是让服务端停在客户端必然会阻塞的地方——range只发一帧就挂住,示例的读循环于是卡在第二次Next(有已知超时上限),测试在这个窗口正中掐断连接,后续步骤必然撞上死连接。这是"造时序"而不是"赌时序"。同类拦停点还能从对端侧挪到客户端侧:把 std log 输出临时换成条件拦停器,示例主 goroutine 就泊在自己写的某条日志行上(恰在两条业务语句之间),任意相邻语句之间都插得进掐线——Stream/SendOneWay两个错误出口由此确定性化。 - 连发把硬币做厚:窄窗竞态单次命中率约 0.2% 不够纪律用时,先找能铺满窗口两侧的构造再谈加大轮数——对尚未注册流的 ERROR 帧会被
lookupFlow静默忽略,于是"盲发 2048 帧"的每一帧都是免费轮次,连发尾段横贯注册时刻、µs 级的帧间隔小于检查窗口,命中率从 0.2% 抬到 ≥80%(Channel.Send出口)。 - 观察即锚点:任何"测试看到 X 才行动"的构造,X 必须是已经收到的帧而不是推断出的本地时刻。
Publish出口的终版把掐线锚在对端收到 ONEWAY 之后并向钩子确认——此前用"钩子里睡 50ms 当作帧已上线"的推断版,在 -race 下约 1/8 轮次迟到帧被 kill 关 socket 吞掉,正面证据就此变成运气。 - 确实需要等的时候就说清在等什么:ONEWAY/PUBLISH 的 handler 跑在独立 goroutine 里且不回执,测试无从观测,所以显式
time.Sleep(200ms)并注释说明"服务端 handler 的错误分支发生在它自己的 goroutine 里"。这是有意识接受的粗糙,不是遗漏。