案例:協同編輯
好幾個人同時編輯同一份文件,每個人都要即時看到別人的修改,最後還要完全一致:OT(Operational Transformation)和 CRDT(Conflict-free Replicated Data Type)兩種做法。
協同編輯讓每個人的按鍵立刻出現在自己的畫面上,再想辦法讓所有人最後看到同一份文字。線上時由一台伺服器替文件排順序並轉換晚到的操作;離線時改用每個字都有編號的 CRDT。選一個流程一步步看;點方塊進到元件的主題。
type Op = { kind: "ins" | "del" | "noop"; pos: number; ch?: string; site: string }; // a, rewritten to apply after b, when both were made on the same text.function transform(a: Op, b: Op): Op { if (a.kind === "noop" || b.kind === "noop") return a; if (a.kind === "ins" && b.kind === "ins") { const first = a.pos < b.pos || (a.pos === b.pos && a.site < b.site); return first ? a : { ...a, pos: a.pos + 1 }; } if (a.kind === "ins") return a.pos <= b.pos ? a : { ...a, pos: a.pos - 1 }; if (b.kind === "ins") return a.pos < b.pos ? a : { ...a, pos: a.pos + 1 }; if (a.pos === b.pos) return { ...a, kind: "noop" }; return a.pos < b.pos ? a : { ...a, pos: a.pos - 1 };} type Id = [number, string]; // [counter, site]: unique, and orderedtype Char = { id: Id; after: Id | null; ch: string; deleted: boolean }; const key = (id: Id | null) => id ? id[0] + "@" + id[1] : "root";const newer = (a: Id, b: Id) => a[0] > b[0] || (a[0] === b[0] && a[1] > b[1]); class Replica { chars = new Map<string, Char>(); children = new Map<string, Char[]>(); removed = new Set<string>(); // includes deletes received before inserts clock = 0; constructor(public site: string) {} text(): string { return this.visible().map((c) => c.ch).join(""); } visible(): Char[] { const out: Char[] = []; const stack = [...(this.children.get("root") ?? [])].reverse(); while (stack.length) { const c = stack.pop()!; if (!c.deleted) out.push(c); for (const child of [...(this.children.get(key(c.id)) ?? [])].reverse()) stack.push(child); } return out; } insertAt(pos: number, ch: string): Char { const visible = this.visible(); const after = pos > 0 ? visible[pos - 1].id : null; this.clock += 1; const c: Char = { id: [this.clock, this.site], after, ch, deleted: false }; this.integrate(c); return { ...c }; // what gets sent to the other replicas } // Anchor order is deterministic; deletion wins over duplicate inserts. integrate(c: Char): void { if (c.deleted) this.remove(c.id); if (this.chars.has(key(c.id))) return; this.clock = Math.max(this.clock, c.id[0]); const copy = { ...c, deleted: c.deleted || this.removed.has(key(c.id)) }; this.chars.set(key(c.id), copy); const list = this.children.get(key(c.after)) ?? []; let i = 0; while (i < list.length && newer(list[i].id, c.id)) i++; list.splice(i, 0, copy); this.children.set(key(c.after), list); } deleteAt(pos: number): Id { const target = this.visible()[pos]; this.remove(target.id); return target.id; } remove(id: Id): void { this.removed.add(key(id)); const c = this.chars.get(key(id)); if (c) c.deleted = true; }}① 轉換的四種情況
| 情況 | 原文 | A(大寫) | B | 直接套用:A/B | 轉換後:A/B |
|---|---|---|---|---|---|
| 插入 vs 插入 | cat | 在 1 插入「H」 | 在 0 插入「s」 | scHat / sHcat | scHat / scHat |
| 插入 vs 刪除 | cart | 在 3 插入「S」 | 刪除 0 的「c」 | arSt / artS | arSt / arSt |
| 刪除 vs 插入 | card | 刪除 3 的「d」 | 在 0 插入「s」 | scar / scad | scar / scar |
| 刪除 vs 刪除 | boat | 刪除 1 的「o」 | 刪除 1 的「o」 | bt / bt | bat / bat |
每個人先套用自己的修改,再套用對方的。直接套用時,位置指的是已經不存在的那版文字:三種情況兩邊最後看到不同的字;兩人刪同一個字母時,還會多刪掉一個無辜的字母(「bt」)。轉換之後兩邊一致,也沒有多丟任何字。
② 兩個人、四回合、四種做法
- 開始hello world
- 第 1 回合A: hll worlLYB: hll worlYL
- 第 2 回合A: hllX wworlYB: hllX wowrlY
- 第 3 回合A: hllX wjwrlY(B 相同)
- 第 4 回合A: QhlUXL jrlYB: QhllU Lwjwl
照位置直接套用對方的操作,200 場裡有 200 場兩邊最後不一樣,還有 256 個沒人刪的字不見了——兩邊都覺得自己是對的,也沒有任何地方會報錯。
固定亂數種子:在「hello world」上跑 200 場,每場 4 回合;每回合 A(打大寫)和 B(打小寫)各按 1~3 個鍵(七成是插入),看不到對方,然後交換。「丟失」是最後文字裡少了、但沒有人刪過的字;「最後存檔勝出」每回合由較晚存檔的一方勝出。
模型假設與範圍
- 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
- 單行文字、唯一且不可變的字元 ID、插入最終會送達;CRDT 接受亂序、重送和先刪後插,缺錨點的後代暫時不可見。未包含富文字、游標、永久遺失或 tombstone GC;OT 的輪次同步不能推廣成任意網路協定。
什麼時候用
- 好幾個人要同時改同一份東西,而且每個人都要立刻看到自己的按鍵:文件、試算表、白板、程式碼。
- 一律線上、可以有一台伺服器替每份文件排順序時,OT 加上操作日誌最簡單直接。
- 需要離線編輯或點對點同步、沒有中央伺服器可以依靠時,用 CRDT。
和其他主題的關係
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| OT:轉換一個操作 k 是它之後已套用的操作數;線上時 k 很小 | O(k) | O(k) |
| OT:合併兩段離線修改 兩邊各 k 個操作,每對都要轉換 | O(k²) | O(k²) |
| CRDT:整合一個字 雜湊表找 ID,錨點樹的同層陣列插入需 O(s);s 是同錨點子節點數,t 是墓碑數 | O(s + 1) | O(n + t) |
| CRDT:依位置插入/讀出文字 走訪錨點樹,保留墓碑但不輸出;n 含尚未接上錨點的未刪字元 | O(n + t) | O(n + t) |
空間:O(n + t + p),CRDT 留著字元 ID 與墓碑;p 是先收到但插入尚未到達的刪除 ID。OT 另存操作日誌
Big O 實測:n 變大時步數怎麼長
數的是:兩邊各 n 個離線修改要合併時,轉換函式被呼叫的次數
| Big O | n = 4 | n = 8 | n = 16 | n = 32 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|---|
| OT 合併 | O(n²) | 32 | 128 | 512 | 2,048 | ×64 (×64) |
線上時每次只差幾個操作,轉換很便宜;但離線很久回來,兩邊各 32 個修改就要轉換 2,048 次,是各 4 個時的 64 倍。這是離線編輯常改用 CRDT 的原因之一。
和其他做法比
| 兩邊不一致 | 丟失的字 | 留下的墓碑 | 需要 | |
|---|---|---|---|---|
| 直接套用 | 200 / 200 (100.0%) | 256 | 0 | 無 |
| 最後存檔勝出 | 0 / 200 (0.0%) | 1071 | 0 | 時鐘 |
| OT 轉換 | 0 / 200 (0.0%) | 0 | 0 | 中央排序的伺服器 |
| 序列 CRDT | 0 / 200 (0.0%) | 0 | 983 | 每個字的編號與墓碑 |
同樣 200 場、共 3206 次按鍵。只有 OT 和 CRDT 同時做到一致又不丟字;OT 要伺服器排序,CRDT 要多存編號和墓碑。
真實世界裡的它
- Google Docs 用 OT:每份文件的修改都經過伺服器排序,再廣播給其他人。
- Figma 的多人編輯借用了 CRDT 的想法,但仍由伺服器做最後決定。
- Yjs 和 Automerge 是開源的 CRDT 函式庫,常用在離線優先(local-first)的應用。
取捨與陷阱
- 用「最後存檔勝出」處理同時編輯:一定一致,但每回合都在丟另一個人的修改(上面量得到)。
- OT 的轉換函式要對每一對操作都正確,情況一多(格式、表格、移動)很容易漏掉一種,兩邊就悄悄分歧。
- CRDT 的墓碑和編號會一直累積;要定期壓縮,而壓縮需要確定所有副本都已經看過那些刪除。
- 兩種做法都只保證「大家看到一樣的字」,不保證那段文字說得通:兩人同時改同一個字,結果可能是兩個字黏在一起。