业务背景
事件溯源
事件溯源模式 - 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/discard | ordered 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 发现冲突。客户端必须同时保留「第三稿」这个事实和「第二稿」这个本地意图,才能避免覆盖别人又不丢失用户内容。
真正驱动设计的产品保证
- 用户修改不丢
- 未知结果不重复执行
- 并发修改不互相覆盖
- 失败后仍然可以明确地 retry 或 discard
字段的重要性也可以据此划分:
ordered changes、clientId、expectedSyncId、pending reason:解决正确性问题operationCause:解决 Undo/Redo 功能sourceSurface:主要解决编辑器延迟发送、聊天室立即发送等调度差异,不属于核心正确性字段
如果产品可以接受崩溃丢修改、重复插入、覆盖并发修改,并且不需要恢复和部分成功,那么 room_message_pending 确实可以不要。它存在是为了兑现上述保证。