2.3 数据协议:操作事件的统一格式

接入层最后、也是最关键的一块:统一事件协议。笔、擦、成员变更、撤销,都得长一个样,客户端才能一致解析、服务端才能一致广播、日志才能一致重放。协议乱,整个协作就散架。本篇定下白板的事件格式与编号规则。

一条事件长什么样

字段 含义 示例
seq 房间级自增序号 1024
type 事件类型 stroke / erase / member
actor 操作人 u_88
ts 客户端时间戳 1700000000
op 具体操作负载 {pts:[...], color, size}

关键在 seq 是房间级自增:同一房间所有事件按 seq 排好序,回放、撤销、冲突合并全凭它。

几种事件类型

type 负载 用途
stroke 坐标点序列 + 样式 画线
erase 擦除范围 擦除
undo 反向到某 seq 撤销
member 进 / 出 成员变更
snapshot 快照引用 快速恢复

每种事件都要能被「重放」,日志就是这些事件的有序集合,这是可回放的根基。

为什么 seq 要单调递增

多人同时画时,各人本地都能先画自己的笔(乐观),但要给全局排序,必须让服务端给每笔打一个全局单调递增的 seq: | 情况 | 只靠本地时间戳 | 用全局 seq | |---|---|---| | 两笔几乎同时 | 顺序随机、乱序 | 服务端定序 | | 断线重连补数据 | 无法判断谁新谁旧 | 按 seq 对齐 | | 撤销 | 不知撤到哪 | 撤到某 seq |

协议编码与压缩

白板事件高频,payload 别用「一行一 JSON 序列化」——字段名重复太多。常用做法:proto/二进制 + 字段省略,坐标用定点或相对增量编码,颜色用枚举值。把「颜色红」编码成一个字节,比每次传字符串省得多。协议层做足,带宽和延迟都受益。

踩坑提示

最容易翻车的是把「客户端时间戳」当排序依据:各终端时钟不同步,用 ts 排序会把「后画的」排到前面。必须用服务端全局 seq 定序,ts 只当展示时间。再一个坑:事件负载要幂等——重发、重连补齐时同一 seq 只生效一次,否则补数据会把一笔画两遍。

事件协议的完整定义

把字段定下来只是第一步,真正的工程量在「字段约束」上。seq 永远由服务端分配,客户端提交时不带 seq,只带本地临时 ID;ts 由客户端填,服务端不改它,只用作展示时间。各字段的归属必须写死在协议文档里,不然每加一个端就会乱一次:

字段 谁产生 可空 约束
seq 服务端 否 房间内严格自增
type 客户端 否 枚举值
actor 服务端 否 从 token 解析,禁止客户端传
ts 客户端 否 毫秒时间戳
op 客户端 否 必须与 type 匹配
clientId 客户端 是 临时 ID,去重用

画线事件的负载怎么省

白板最高频的就是画线,一秒上千个坐标点,编码方式直接决定带宽账单。对比三种编码的体积:

编码方式 一条 20 点笔画的体积 解码成本
纯 JSON 文本 约 1.2KB 无
字段省略 + 定点坐标 约 300B 低
二进制协议 约 120B 中

体积差近十倍。进阶做法是相对增量编码:第一个点存绝对坐标,后续点只存相对前一点的偏移,配合变长整数压缩,体积还能再砍一半。颜色也别传字符串,常用色枚举成一个字节即可。对协议设计感兴趣的同学,Go IM 实战:手搭一个即时消息系统 里有一套可复用的消息协议设计,思路相通。

事件如何落日志

协议定了,存储要对齐。事件流写入要保证三点:有序、只增追加、可回放。

// 服务端:分配 seq 并追加日志
async function applyEvent(roomId, event) {
  const seq = await redis.incr(`room:${roomId}:seq`);
  event.seq = seq;
  await redis.xadd(`room:${roomId}:log`, '*', 'd', JSON.stringify(event));
  publish(roomId, event);    // 先落盘再广播,顺序不能反
}

这里有一个取舍:先落盘再广播,失败的事件不会被别人看到,但延迟多了几毫秒;先广播再落盘则可能广播成功而日志丢了,重连补齐时缺这条。生产实践一致选前者——宁可慢几毫秒,也不能让日志和广播对不上。

撤销事件的正确姿势

撤销不是「删除」,而是「反向操作」:落一条 undo 事件,指明撤到哪个 seq。渲染层按日志重放,遇到 undo 就跳过被撤的笔画。删除式撤销会让日志出现空洞,seq 一断,重连补齐就没法定位了。

撤销方式 日志形态 补齐复杂度
删除式 seq 有空洞 高,需额外索引
反向操作式 日志只增 低,重放即可
快照式 定期压缩 中,需维护版本

日志压缩的时机也有讲究:日志超过一万条就做一次快照,把旧日志归档到对象存储。这块设计想深入的同学可以看 历史回放的日志压缩实践,思路在事件流系统里是通用的。

快照事件的定位

很多时候快照被当成「另一个东西」单独存,其实它就是一条普通事件,只是负载指向了一个外部地址。新成员进房、断线太久回来,服务端直接在日志里找到最近一条 snapshot 事件,按它把画板渲染出来,再补之后的增量即可。快照事件本身也占一个 seq,这样它天然就在日志的序里,不用额外维护「当前快照是第几条」这种旁路状态。

场景 快照怎么给
新成员进房 发最近一条 snapshot + 之后增量
短断线重连 只发增量
长断线重连 发 snapshot + 少量增量

注意快照生成要异步:主流程收到「做快照」信号后丢给后台任务,做完再以一条 snapshot 事件追加进日志。同步做会阻塞事件写入,高峰期直接卡住整个房间的笔。

协议的版本演进

协议一定会改,关键是怎么改得「不撕裂」。原则有三条:新字段只增不改、旧字段永不删、type 只扩不缩。客户端遇到不认识的字段要跳过而不是报错,遇到不认识的 type 要静默丢弃并上报一个遥测计数——这样服务端能知道哪些老客户端还在用旧协议。

演进操作 危险程度 注意事项
加新字段 低 老端跳过即可
改字段含义 高 等于悄悄换协议
删旧字段 高 老端解析报错
加新 type 中 老端静默丢弃并遥测

灰度流程也固定下来:先让 5% 的客户端带上新版本协议跑一周,看遥测里的未知字段计数和错误率,没问题再全量。这套「可向前兼容」的协议演进手法,在 Go 微服务实战:手搭一个服务治理框架 的接口治理一节有更系统的论述。

另外要给协议配套一份「字典文档」:每个 type 对应哪些 op 字段、枚举值分别是什么,都写清楚放在仓库里。文档与代码不一致是常态,所以让它由协议定义文件自动生成,而不是人手维护。想看自动化协议文档的落地方式,可以参考 数据协议详解 的工具链一节。把协议当代码一样管理,协作白板的重放与合并才永远有据可依。

白板服务的参数不在多,在每一项都对应一个可观测的现象。

小结

统一事件协议 = 稳定字段 + 全局 seq + 可重放 + 可压缩。它是协作白板的地基:笔照它画、撤销照它退、日志照它存。协议定清楚,后面冲突合并才有得讲。

延伸阅读