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 + 可重放 + 可压缩。它是协作白板的地基:笔照它画、撤销照它退、日志照它存。协议定清楚,后面冲突合并才有得讲。
