Bakka's Blog

业务背景

事件溯源

事件溯源模式 - Azure Architecture Center

如何把复杂的状态修改良好地编排呢?思考这个问题的过程中,其实不断地贴近了事件溯源的实现。

用事件中心,对事件进行定义,并封装状态,使调用者无需关心底层的实现,将复杂状态操作良好地编排在一起。

解耦不是万能良药,并非无脑地用 hooks 封装各种 UI 逻辑然后组装到一个编排者里。如果几个状态相互关联,那么 hooks 将发生大量的相互调用、传递,反而把状态的复杂给隐式化了。对于可维护性而言,这样反而更差。

数据副本(状态管理)

默认的 AI 设计把它分成三个副本:客户端的存储事实、已同步的快照、最新状态,然后围绕它们去做同步、增量更新、乐观更新、处理并发等一堆复杂度。

后来我让它改为只有一个客户端数据:不需要一个事实,只有一个承载本地数组的数据,然后做增量处理,通过一些 ID 字段以及准确收集发生修改的数据,把这些都简化了。

数据副本是一个很常见的场景,基本上来说有几个常见用途:

  • 用空间换时间的缓存
  • 分散故障带来的风险
  • 数据隔离,避免相互影响

但是它显然会带来成本:数据同步复杂度、一致性策略、存储成本等。这些问题延伸开来会产生大量边界条件,十分难以处理。

因为数据的不一致一定会在某个时机发生,同步需要时间,又会有异步回写、并发写入、断网刷新等现实世界的奇怪条件,所以这将难以处理。

这里思考的主题就是:什么时候副本是必要的抽象,什么时候副本只是为了弥补状态设计的不清晰?

不要随意为语义区分去创建副本

我犯的一个错误是:为了区分语义而去创建副本,带来了不少额外的工程负担。

语义上的区分,必须服务于某种不可推导的能力,否则就是人为增加状态。React 官方一直强调不要存储可以推导的数据。

把编辑器改成一份文档加操作队列之后,复杂度会明显下降:实际上是在减少可变状态(mutable state),把很多原本需要维护一致性的副本,变成了可以由已有信息推导出来的结果。

为什么一定要分一个客户端真相、客户端草稿、客户端已同步基线、客户端最新修改?

直接舍弃客户端抽象,用于承载客户端的草稿;舍弃已同步基线,直接使用本地的持久化数据,以服务端作为权威,简化很多客户端的派生状态,也能实现功能。

例如 Notion 的草稿。如果它支持自动保存、多设备打开、放弃草稿、发布、恢复历史草稿,那么确实需要存储客户端间也不一致的状态。这里需要一个对象去承载它:

published
draft

这确实是两种不同的数据,因为:

  • published 不能由 draft 推导
  • draft 不能由 published 推导

它们分别代表两个真实存在的业务实体。

但如果满足:

currentState = serverState + pendingOperations

那么 serverState 其实就是可推导状态。这种时候,我们只需要处理客户端和服务端的不一致,为什么还要徒增数据副本来处理客户端这里的不一致呢?

一些场景不一定需要 diff

直觉上的增量都是计算出来的:通过不同的状态去 diff,然后得出差异。但实际上并不只有这一个方法,为了增量而去增加状态也不一定合理。

ID 不止能标识身份

聊天室的增量同步依赖 syncId,它标识操作发生的时机。只需要对比本机的最大操作时机和远端的最大操作时机,就能计算出本地还未同步的操作。因为只要获取了操作所修改的消息结果,就可以把这些新的消息同步下来;没有被操作的消息注定和过去一致。

这像是乐观锁的实现:发出请求时携带 ID,可以用于检测服务端的并发冲突,并提示或失败。

所以有时比起复制状态或者设计复杂算法,可以考虑是否能使用额外字段来事半功倍。

编辑器数据的复杂度演进参考

场景核心模型为什么
Markdown 本地编辑单 State没有同步问题
自动保存编辑器State + Pending Operation操作本身就是变化
离线编辑State + Durable Operation Log恢复未提交修改
多端同步State + Version + Pending Operation判断基线
多人协作State + Operation History + Version合并并发修改
Figma 级State + CRDT/OT + Version Vector解决分布式一致性

这里的编辑器由于和消息流的特殊性,设计采用类似「单一事实源 + 增量日志」,保证修改路径都被捕获。

local state
    |
    v
change tracking
    |
    v
mutation queue
    |
    v
server

而这种设计其实就是偏向于数据冗余了:

               server
                  |
                  v
           syncedSnapshot
                  |
                  v
clientFact ---------------- latestState
    |
    v
local edits

适配器和深模块

对于解耦的一些思想:

  • 深模块(Deep Module)解决的是「复杂性耦合」
  • 适配器(Adapter)解决的是「接口耦合」

适配器把数据的归一化、校验等接口逻辑集中管理,从业务里解脱出来。接口变更时只需要修改适配器层,而不用改业务端的调用。

深模块也是增加接口的层级,把一些浅接口的组合隔离模块化,通过封装实现逻辑解耦。

异常可靠性保证

应用随时可能崩溃。

HTTP 超时不代表服务端没有成功。

其他用户可能同时修改相同消息。

修改 UI
  -> 写入 SQLite pending
  -> 发送 HTTP
  -> 服务端提交
  -> 收到响应
  -> 本地结算

应用可能在任意两个节点之间失败,因此会产生下面这些具体 case:

具体场景用户期望需要的数据
修改后、HTTP 发送前崩溃修改不能丢失,重启后也不能擅自发送ordered changes、recovered
服务端成功,但响应丢失retry 不能重复插入clientId、unknown
服务端明确失败保留本地内容,允许 retry/discardordered changes、failed
别人抢先修改了消息不能覆盖别人,同时保留自己的内容expectedSyncId、conflict
一个批次部分成功成功部分确认,只保留失败部分有顺序的批次 changes
SQLite 写入失败UI 修改不消失,但禁止发送远端persistenceFailed
未发送操作被 undo只取消本地 pending,不请求服务端operationCause、批次状态
已确认操作被 undo生成 inverse 请求,并正常处理冲突operationCause、当前 syncId

响应丢失需要 clientId 来实现幂等

用户插入消息 A:

clientId = 8421

服务端已经成功创建 messageId = 550,但响应在网络中丢失。客户端现在不能判断服务端是否成功,因此必须记录:

changes: insert A
clientId: 8421
pendingReason: unknown

用户 retry 时继续使用 8421。服务端发现这个 insert 已经执行过,返回 replayed,避免创建第二条消息。

并发编辑与乐观锁

本地已知:

messageId 10
syncId 7
content "第一稿"

本地意图:

expectedSyncId 7
content "第二稿"

其他用户已经更新服务端:

syncId 8
content "第三稿"

服务端通过 expectedSyncId = 7 发现冲突。客户端必须同时保留「第三稿」这个事实和「第二稿」这个本地意图,才能避免覆盖别人又不丢失用户内容。

真正驱动设计的产品保证

  1. 用户修改不丢
  2. 未知结果不重复执行
  3. 并发修改不互相覆盖
  4. 失败后仍然可以明确地 retry 或 discard

字段的重要性也可以据此划分:

  • ordered changesclientIdexpectedSyncIdpending reason:解决正确性问题
  • operationCause:解决 Undo/Redo 功能
  • sourceSurface:主要解决编辑器延迟发送、聊天室立即发送等调度差异,不属于核心正确性字段

如果产品可以接受崩溃丢修改、重复插入、覆盖并发修改,并且不需要恢复和部分成功,那么 room_message_pending 确实可以不要。它存在是为了兑现上述保证。