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 接口实战。

延伸阅读