“A、B 同时改同一条线的颜色,不是“谁后保存谁覆盖”的 UI 问题,而是分布式复制状态下的并发写冲突。要同时做到收敛、不丢意图、低延迟,核心是:操作化 + 因果排序 + 冲突合并/变换 + 本地乐观更新 + 异步广播”。


1. 人话版:图纸就像多人同时编辑的 Google Docs

A 和 B 各自手里有一份图纸副本。
A 把线从红改蓝,B 把同一条线从红改绿。
网络不会按你点鼠标的顺序到达服务器,甚至可能延迟、重复、乱序、断线。

所以你不能靠“最后保存覆盖”。你要设计一套规则:

  1. 最终所有人看到的图纸一样:收敛性。
  2. 不能因为网络乱序,把不该丢的修改丢掉:不丢意图。
  3. A 一画,B 很快看到,不能等保存:低延迟。

关键认知:
收敛性不等于不丢意图。
LWW,也就是 Last-Writer-Wins,最后写入赢,可以保证收敛,但它会丢并发输家的意图。
CRDT 可以保证收敛,但它不自动理解业务意图。
OT 可以做意图变换,但复杂、依赖中心服务器和正确变换函数。


2. 把问题形式化:线、操作、时钟、因果

一条线可以表示为:

1
2
3
4
5
6
7
Line {
id: "line1",
color: "red",
width: 2,
points: [...],
version: ...
}

A 的操作:

1
2
3
4
5
6
7
8
9
10
OpA = {
id: "opA",
actor: "A",
hlc: [100, 0, "A"],
deps: { ... }, // 因果依赖
type: "setColor",
target: "line1",
from: "red",
to: "blue"
}

B 的操作:

1
2
3
4
5
6
7
8
9
10
OpB = {
id: "opB",
actor: "B",
hlc: [100, 0, "B"],
deps: { ... },
type: "setColor",
target: "line1",
from: "red",
to: "green"
}

判断并发还是因果:

  • 如果 B 的操作依赖里包含 A 的 opA,说明 B 是在看到 A 改成蓝色之后才改绿色。
    这不是并发,B 是后续操作,最终必须绿色。
  • 如果 A、B 都基于红色,互相不知道对方,说明是并发同属性写。
    这时两个颜色不可能同时成为最终单值颜色。必须选策略。

时钟推荐用 HLC,混合逻辑时钟:
(physicalTime, logicalCounter, actorId)。
它比纯物理时间可靠,又接近人类时间,适合 LWW。


3. 三个目标到底是什么意思

3.1 收敛性

最终所有副本状态一致。

数学上要求你的合并/应用函数满足:

  • 交换律:先应用 A 再 B,和先 B 再 A,结果一样。
  • 结合律:分组顺序不影响结果。
  • 幂等:同一个操作重复应用多次,结果一样。

或者你用一个中心服务器给所有操作排全序,然后广播。
这样大家按同一个顺序应用,也能收敛。

3.2 不丢意图

不是“蓝色和绿色都显示出来”才叫不丢意图。
不丢意图至少包含四层:

  1. 因果后续必须赢:
    B 看到 A 改蓝后,再改绿,最终必须绿。

  2. 非冲突并发都保留:
    A 改颜色,B 改线宽,最终颜色是 A 的,线宽是 B 的。

  3. 并发同冲突有确定性策略:
    A 改蓝,B 改绿,最终按规则选一个,比如 LWW、优先级、图层锁、用户投票。

  4. 冲突可解释、可恢复:
    如果产品要求不丢,可以用多值寄存器保留 {blue, green},UI 提示“有冲突”,让用户选。

所以面试时你要说:
“不丢意图”不是数学性质,而是业务语义。协议保证收敛,业务定义冲突怎么解。

3.3 低延迟

目标:A 改完,本地立刻变蓝,B 在 100ms 内看到。
不能等服务器保存。

做法:

  • 本地乐观更新:先改 UI。
  • 同时生成操作,放进 pending 队列。
  • WebSocket 长连接发服务器。
  • 服务器排序、广播。
  • 客户端收到远程操作,和本地 pending 做变换/合并,再重放。

4. 方案一:中心服务器 + OT

OT,Operational Transformation,操作变换。
Google Docs 经典方案。

流程:

  1. A 本地先改蓝,发 OpA。
  2. B 本地先改绿,发 OpB。
  3. 服务器先收到 A,应用后文档颜色是蓝。
  4. 服务器收到 B,发现 B 基于红色,但现在已经是蓝色。
  5. 服务器对 B 做变换:
1
2
原始 OpB: setColor(line1, red -> green)
变换后: setColor(line1, blue -> green)
  1. 服务器广播变换后的 B。
  2. A 收到后,本地从蓝变绿。
  3. 最终 A、B 都看到绿色,收敛。

这里 A 的蓝色意图被覆盖了。
如果业务认为“并发同属性写,最后写入赢”,这是合理的。
如果业务要求“不丢意图”,就不能只做 LWW,要用多值或冲突提示。

OT 的关键难点:

  • 变换函数必须正确。
  • 要满足 TP1/TP2 性质。
  • 复杂操作,比如移动、删除、分组、撤销,变换会爆炸。
  • 通常需要中心服务器做权威排序。

面试加分说法:
“OT 强在意图变换和中心权威,弱在变换函数复杂、离线差。CRDT 强在自动收敛和离线,弱在元数据大、业务冲突不自动解决。”


5. 方案二:CRDT

CRDT,Conflict-free Replicated Data Type,无冲突复制数据类型。
它通过数据结构本身保证合并收敛。

常见类型:

5.1 LWW Register

最后写入赢寄存器。

值:

1
2
3
4
5
{
value: "green",
hlc: [100, 1, "B"],
actor: "B"
}

合并规则:

1
2
3
4
merge(a, b) =
a.hlc > b.hlc ? a :
b.hlc > a.hlc ? b :
a.actor > b.actor ? a : b

对 A 蓝、B 绿:
假设 B 的 HLC 更大,最终绿色。
所有人一致。
缺点:A 的蓝色被丢掉。适合“最后编辑赢”的属性,比如颜色、线宽、标题。

5.2 MV Register

多值寄存器。

规则:

  • 如果一个值因果依赖另一个值,新的覆盖旧的。
  • 如果两个值并发,两个都保留。

对 A 蓝、B 绿并发,最终值集合:

1
{ blue, green }

UI 可以显示:

1
这条线有冲突颜色:蓝色 / 绿色,请选择

这就是“不丢意图”的强方案。
缺点:产品体验复杂,需要冲突解决 UI。

5.3 Yjs / Automerge

Yjs 是工业界常用 CRDT 库。
Y.Map 对同一个 key 的并发 set 通常是 LWW。
Y.Array、Y.Text 用 RGA/YATA 这类序列 CRDT。
Automerge 也类似。

图形编辑器常见做法:

  • 对象 ID 用稳定 ID。
  • 属性如颜色、线宽用 LWW。
  • 点序列、文本用 CRDT 序列。
  • 删除用墓碑。
  • 存在感、光标不持久化,节流广播。

6. A/B 同线改色的完整推演

场景:A 改蓝,B 改绿,都是并发。

方案 1:LWW

最终取 HLC 最大:

1
2
A: (100, 0, A)
B: (100, 1, B)

B 赢,最终绿色。
A、B 都看到绿色。
收敛性满足。
不丢意图:非冲突不丢,并发同属性冲突按规则丢一个。
低延迟:本地先变,最终一致。

方案 2:MV Register

最终:

1
color = { blue, green }

A、B 都看到冲突。
收敛性满足。
不丢意图满足。
低延迟满足。
但产品需要用户选择。

方案 3:OT

服务器先应用 A,蓝。
B 到达后变换为 blue -> green。
最终绿。
收敛性满足。
意图:如果产品规则是“后到覆盖”,合理。
如果产品要求保留 A,需要额外冲突日志。

方案 4:业务优先级

比如:

  • 图层锁:锁定图层的人赢。
  • 角色权限:设计师赢过访客。
  • 手动冲突:进入待解决列表。

协议只保证收敛,业务定义谁赢。


7. 低延迟架构怎么设计

一个可落地的实时协同架构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
客户端 A
├─ 本地状态
├─ 乐观更新
├─ pending op 队列
└─ WebSocket
│
▼
中心服务器
├─ 鉴权
├─ 操作排序 seq / HLC
├─ OT 变换 或 CRDT 合并
├─ 操作日志 op log
├─ 快照 snapshot
└─ 广播
│
▼
客户端 B
├─ 接收远程 op
├─ 与 pending 变换/合并
├─ 重放 pending
└─ 更新 UI

关键工程点:

  • WebSocket / WebTransport,二进制协议,protobuf/flatbuffers。
  • 增量操作,不要每次传整张图。
  • 批量、压缩、心跳、断线重连。
  • 状态向量同步:新客户端先拉快照,再拉缺失 op。
  • 操作日志定期压缩成快照。
  • 光标、选区、在线状态用临时消息,不写 op log。
  • 本地乐观更新,服务器确认后清理 pending。
  • 冲突提示 UI。

目标延迟:

  • 本地:0ms 感知。
  • 同区域远端:50-150ms。
  • 跨区域:150-300ms,可用边缘节点优化。

8. 面试回答模板:这样讲,面试官知道你不是背题

如果面试官问:“A、B 同时把同一条线从红改蓝、红改绿,怎么保证收敛、不丢意图、低延迟?”

你可以这样答:

我会先把它定义成分布式复制状态的并发写问题。
文档模型是对象树,每个对象有稳定 ID。操作带 opId、actorId、HLC、因果依赖 deps。
传输用 WebSocket 二进制增量,服务器权威排序并持久化 op log,定期快照。客户端本地乐观更新,先改 UI,再异步发操作。
对 A/B 同线颜色,先判断因果:如果 B 依赖 A,则 B 是后续,最终绿。
如果 A、B 并发,同属性单值冲突。默认策略可以用 LWW Register,按 HLC + actorId 打破平局,最终选一个,所有人收敛。
如果产品要求不丢意图,就用 MV Register 保留 {blue, green},UI 提示冲突让用户选。
非冲突操作,比如 A 改颜色、B 改线宽,会字段级合并,都保留。
低延迟靠本地乐观更新、WebSocket 广播、服务器全序和客户端重放 pending。
如果选型:中心权威 + OT 适合复杂文本和强服务端控制;CRDT 适合离线、多端、P2P、自动合并。实际项目常用混合:结构用 CRDT,属性用 LWW,存在感不持久化。

这个回答里有:定义、模型、协议、冲突语义、低延迟、选型、工程落地。
这就是 25k+ 的味道。


9. 高薪加分点

你要能继续往下讲:

  • OT 的 TP1/TP2:变换函数正确性,保证不同顺序应用结果一致。
  • CRDT 的半格:merge 满足交换、结合、幂等。
  • HLC:解决物理时钟不可靠。
  • 因果一致性:版本向量、状态向量。
  • 撤销重做:OT inverse,CRDT undo manager。
  • 权限与审计:op log 天然可审计。
  • 性能:快照、日志压缩、增量同步、二进制协议。
  • AI 结合:AI 可以生成操作、建议冲突解决,但 AI 操作也必须走同一套一致性层,不能绕过协议。
  • 失败模式:网络分区、重复消息、乱序、离线编辑、恶意客户端、时钟漂移。

10. 常见坑

  1. 只背“CRDT 解决冲突”:错。CRDT 只保证收敛,不保证业务意图。
  2. 认为 LWW 一定错:不一定。颜色、标题这类单值属性,LWW 很常见。
  3. 认为收敛就等于体验一致:不是。UI 延迟、撤销、冲突提示都影响体验。
  4. 用纯物理时间戳排序:客户端时钟不可靠,要用 Lamport/HLC。
  5. 忽略因果:B 看到 A 后改绿,和 B 没看到 A 就改绿,语义完全不同。
  6. 忽略离线:CRDT 更适合离线,OT 通常依赖中心服务器。
  7. 忽略操作日志和快照:没有它们,新用户同步和恢复会很慢。

11. 最终学习检验:你能做到这些,就算精通

  • 能白板画出:客户端、WebSocket、服务器、op log、快照、广播、pending 重放。
  • 能写出 Op 结构:opId、actor、hlc、deps、target、prop、old、new。
  • 能写出 LWW merge 和 MV Register merge。
  • 能讲清 OT 对 A/B 同线改色的变换过程。
  • 能对比 OT 和 CRDT 的优缺点。
  • 能解释:收敛、不丢意图、低延迟分别靠什么保证。
  • 能回答追问:离线怎么办?撤销怎么办?冲突 UI 怎么做?服务器单点怎么办?
  • 能说出:协议保证收敛,业务定义冲突语义。

一句话总结:
收敛靠全序或 CRDT 半格;不丢意图靠因果检测、非冲突合并和冲突策略;低延迟靠本地乐观更新和异步增量广播。
A 改蓝、B 改绿,最终要么 LWW 选一个,要么 MV Register 保留冲突,要么 OT 变换后覆盖。没有银弹,只有权衡。你能把权衡讲清楚,面试就能拿高薪。