3.4 历史回放:操作日志与撤销
白板最后一块灵魂能力:能回放、能撤销。白板是「只增不改」的事件流,回放就是按日志重放,撤销就是重放到某点再反向应用。本篇讲日志怎么记、回放怎么放、撤销怎么退得对。
为什么「日志驱动」而非「截图驱动」
| 方式 | 能做什么 | 代价 |
|---|---|---|
| 定时截图 | 只看静态快照 | 不能倒放、不能并 |
| 操作日志 | 重放、撤销、分享 | 要维护有序事件流 |
白板要「倒着退」「看某时刻」,只能靠日志——每笔、每次撤、每次成员变更都入流。
日志长什么样
| 字段 | 作用 |
|---|---|
| seq | 房间级自增,回放靠它排 |
| type | stroke / erase / undo / member |
| actor | 谁做的 |
| 负载 | 操作数据 |
| ts | 展示时间 |
日志只增不改,回放 = 从起点按 seq 重放;撤销 = 插入一条 undo 事件,指向被撤的 seq。
回放的两种「放」
| 放法 | 场景 | 做法 |
|---|---|---|
| 实时追 | 观众看现场 | 边收边放 |
| 时移 | 看历史某刻 | 重放到目标 seq 停下 |
时移回放要靠 seq 精确定位——这就是「全局 seq」的价值,没有 seq 连「退到哪」都说不清。
撤销怎么退得对
撤销分两层: | 层 | 含义 | 做法 | |---|---|---| | 个人撤销 | 撤我最后一笔 | 记我最后 seq,反操作 | | 整板回退 | 回到某时刻 | 重放到目标 seq |
关键:撤销本身也是一条事件(undo),要入日志、可再反撤销(redo),且要幂等——重复发 undo 不能多退。
日志存储与裁剪
日志会涨,不能无限存: | 策略 | 说明 | |---|---| | 分级 | 细粒度日志存热,合并成「里程碑」存冷 | | 快照锚点 | 每隔 N 笔打一个快照,回放从最近快照起 | | 裁剪 | 超过保存期合并 / 转 COS |
「日志 + 快照锚点」是回放与存储的平衡:细粒度保正确,锚点省冷存。
踩坑提示
最坑是「撤销用了客户端时间戳」:多人场景靠 ts 退会退错位置,必须靠 seq 定位。再一个坑:redo 被并发笔破坏——你 A 的笔、我 B 的笔同时发生,A 反撤销 B 的笔就乱了,撤销范围要限定「自己的笔」,不能碰别人的,否则越撤越乱。
小结
回放靠「有序日志 + 快照锚点」,撤销靠「undo 事件 + seq 定位 + 幂等」。把「退」变成日志里的一条可重放事件,白板才算真正「可回放、可撤销」。
日志写入要保证什么
操作日志是回放与撤销的地基,写入有三个硬要求:
async function append(boardId, ev) {
const seq = await redis.incr(`seq:${boardId}`); // 原子取号
ev.seq = seq;
await redis.rpush(`log:${boardId}`, JSON.stringify(ev)); // 有序推入
return seq;
}
- 原子性:
incr保证并发取号不重不跳; - 有序性:同一房间的日志按 seq 单调递增;
- 持久性:落盘或主从同步之后才允许返回成功。
| 要求 | 手段 | 不做的后果 |
|---|---|---|
| 原子取号 | Redis incr | seq 重复 |
| 有序落盘 | rpush 再落盘 | 回放错乱 |
| 快照锚点 | 每 N 笔打快照 | 冷启动慢 |
快照锚点怎么打
纯重放日志,房间画了一万笔就要重放一万次——新人进房间等到天黑。锚点做法:每隔 500 笔(或 2 分钟)把当前「板面状态」序列化成一份快照,日志只保留锚点之后的增量。加载时先取最近锚点,再追放增量,几秒就能开板。
| 存储 | 内容 | 特点 |
|---|---|---|
| 快照 | 板面全量状态 | 冷存便宜,读取慢 |
| 增量日志 | 锚点后的事件 | 热存快,容量有限 |
| 冷归档 | 超期事件 | 审计与取证用 |
撤销的幂等与边界
撤销最容易写错,记住三条:
- undo 事件带 targetSeq(指向被撤的那笔),重复到达时先查「这个 seq 是否已被撤」;
- 撤销只对自己的笔生效,跨成员撤销在并发下必然打架;
- redo 不是「反向 undo」,而是一条独立的 redo 事件,同样要幂等。
{ type: "undo", actor: "u-1001", targetSeq: 128, undoId: "und-9a" }
undoId 是幂等键:无论这条 undo 被重放多少次,效果只等于一次。
回放的工程细节
- 观众模式:客户端只订阅日志增量,按 seq 边收边画;
- 时移:先取目标 seq 对应的快照,再追放到目标 seq 停下;
- 倍速:按事件原生 ts 间隔重放即可,无需额外时间轴。
回放要做好,日志的顺序就不能含糊。日志型系统的更多玩法,可参考 Go 日志管线实战;订单流水同样是「只增日志」,见 Java 订单中心。
成员变更也要入日志
只记笔的日志是不完整的:A 画了一半断了线,重连时如果不知道「断线期间房间发生了什么」,补齐就会错位。所以成员进入 / 离开、权限变更、甚至快照锚点本身,都要作为 member / meta 事件入流。回放时这些事件用来还原「那一刻谁在场、谁能编辑」,而不仅仅是画面。
| 事件 | type | 回放作用 |
|---|---|---|
| 成员进入 | member | 恢复在场名单 |
| 成员离场 | member | 停止其编辑态 |
| 快照锚点 | meta | 载入锚点状态 |
| 权限变更 | meta | 还原只读 / 可编辑 |
回放的存储成本
日志是只增的,成本随房间寿命线性涨。粗算一下:一分钟 20 笔、每笔 200 字节,一小时的房间就是 1.4MB 纯日志;千级房间一天就是几十 GB。这就是为什么「快照 + 增量 + 冷归档」三段式几乎是唯一解:热日志只保最近锚点之后,老日志压成 COS 冷存,回放超过 30 天的板子时再慢慢拉。存储层的分库分表思路,可参考 MySQL 分库分表实战;对象存储的冷热分级,看 Go 海量存储实战。
实验环境可以蹲一个 腾讯云云产品合集特惠,把 Redis 热存与 COS 冷存一起配齐。
分享只读链接的回放
白板的「分享给没装客户端的人看」也靠回放:生成一条只读链接,观众打开后走时移回放——不用建 WebSocket 长连,直接 HTTP 分段拉日志 + 快照即可。粗略实现:
// 观众端按页拉取
async function loadFrom(seq) {
const res = await fetch(`/api/board/${id}/log?from=${seq}&limit=500`);
const { ops, next } = await res.json();
ops.forEach(apply); // 按序应用
if (next) loadFrom(next); // 继续追
}
| 观众类型 | 拉取方式 | 实时性 |
|---|---|---|
| 在线协作者 | WebSocket 增量 | 秒级 |
| 只读观众 | HTTP 分段拉日志 | 几秒到分钟 |
| 归档查看 | 快照 + 历史静态图 | 按需 |
回放做好了,白板才从「画完即散」变成「可复盘、可分享、可追责」的资产。更多接口设计的实践,可参考 PHP 接口实战。
