跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 案例:協同編輯

案例:協同編輯

好幾個人同時編輯同一份文件,每個人都要即時看到別人的修改,最後還要完全一致:OT(Operational Transformation)和 CRDT(Conflict-free Replicated Data Type)兩種做法。

流程
重新連線權限操作游標附加定期載入SQL 與 NoSQL文件與權限(關聯式)Redis 與 Memcached游標與在線編輯者輪詢、長輪詢與 WebSocketWebSocket閘道協作伺服器每份文件一台事件驅動與串流處理操作日誌快照程式離線裝置(本機 CRDT)物件儲存快照

協同編輯讓每個人的按鍵立刻出現在自己的畫面上,再想辦法讓所有人最後看到同一份文字。線上時由一台伺服器替文件排順序並轉換晚到的操作;離線時改用每個字都有編號的 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 ordered
type 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 / sHcatscHat / scHat
插入 vs 刪除cart在 3 插入「S」刪除 0 的「c」arSt / artSarSt / arSt
刪除 vs 插入card刪除 3 的「d」在 0 插入「s」scar / scadscar / scar
刪除 vs 刪除boat刪除 1 的「o」刪除 1 的「o」bt / btbat / bat

每個人先套用自己的修改,再套用對方的。直接套用時,位置指的是已經不存在的那版文字:三種情況兩邊最後看到不同的字;兩人刪同一個字母時,還會多刪掉一個無辜的字母(「bt」)。轉換之後兩邊一致,也沒有多丟任何字。

② 兩個人、四回合、四種做法

合併方式
4
  1. 開始hello world
  2. 第 1 回合A: hll worlLYB: hll worlYL
  3. 第 2 回合A: hllX wworlYB: hllX wowrlY
  4. 第 3 回合A: hllX wjwrlY(B 相同)
  5. 第 4 回合A: QhlUXL jrlYB: QhllU Lwjwl
這場兩邊一致
否
這場丟失的字
3
200 場中不一致
200

照位置直接套用對方的操作,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 On = 4n = 8n = 16n = 32成長倍數:實測(理論)
OT 合併O(n²)321285122,048×64 (×64)

線上時每次只差幾個操作,轉換很便宜;但離線很久回來,兩邊各 32 個修改就要轉換 2,048 次,是各 4 個時的 64 倍。這是離線編輯常改用 CRDT 的原因之一。

和其他做法比

兩邊不一致丟失的字留下的墓碑需要
直接套用200 / 200 (100.0%)2560無
最後存檔勝出0 / 200 (0.0%)10710時鐘
OT 轉換0 / 200 (0.0%)00中央排序的伺服器
序列 CRDT0 / 200 (0.0%)0983每個字的編號與墓碑

同樣 200 場、共 3206 次按鍵。只有 OT 和 CRDT 同時做到一致又不丟字;OT 要伺服器排序,CRDT 要多存編號和墓碑。

真實世界裡的它

  • Google Docs 用 OT:每份文件的修改都經過伺服器排序,再廣播給其他人。
  • Figma 的多人編輯借用了 CRDT 的想法,但仍由伺服器做最後決定。
  • Yjs 和 Automerge 是開源的 CRDT 函式庫,常用在離線優先(local-first)的應用。

取捨與陷阱

  • 用「最後存檔勝出」處理同時編輯:一定一致,但每回合都在丟另一個人的修改(上面量得到)。
  • OT 的轉換函式要對每一對操作都正確,情況一多(格式、表格、移動)很容易漏掉一種,兩邊就悄悄分歧。
  • CRDT 的墓碑和編號會一直累積;要定期壓縮,而壓縮需要確定所有副本都已經看過那些刪除。
  • 兩種做法都只保證「大家看到一樣的字」,不保證那段文字說得通:兩人同時改同一個字,結果可能是兩個字黏在一起。