Notion 把區塊編輯從「後寫覆蓋」換成 RGA CRDT
Notion Blog · 2025-07(正式上線)
原本的問題
Notion 的頁面由許多獨立的 block 記錄組成,不同 block 之間本來就能被不同人同時編輯而互不干擾。但只要兩個人同時編輯同一個 block,伺服器會依照請求抵達的先後順序覆蓋內容,也就是傳統的「後寫覆蓋」(last write wins, LWW)。官方文章舉的例子是 Emma 和 Charlie 各自基於同一份、尚未看到對方修改的內容繼續編輯,兩邊都不知道對方也在改;伺服器收到兩筆更新後,只認「誰後到」,先到的那份修改會被完整蓋掉。
這不是理論風險,而是實際會發生的資料遺失:使用者在同一個段落打字,另一個協作者的修改可能整段消失,且沒有任何合併或提示。
採用的方法
Notion 改採以Replicated Growable Array(RGA)為基礎的序列型 CRDT,取代整份 block 內容的覆蓋式更新。RGA 用一棵樹結構保存「所有曾經被插入過的字元」,每個字元是樹上一個節點,擁有唯一且穩定的 ID,格式是 SessionID@LamportClock(文章例子如 A@1),其中 session ID 區分不同的用戶端連線,Lamport clock 提供邏輯時間戳記,讓兩端各自產生的操作在合併時有明確的先後關係可以排序,而不是比對「伺服器收到的時間」。
刪除操作不會真的移除節點,而是標記成墓碑(tombstone)並保留在樹裡,因為可能還有離線或尚在傳輸中的操作依賴這些 ID 去定位插入點;直接刪除節點會讓那些操作找不到錨點而出錯。
為了支援粗體、超連結這類格式標記,Notion 引入了 Peritext 演算法的做法:把格式標記錯定在字元「前」或「後」的間隙位置,而不是錯定在字元索引上,並區分「可延伸」(例如粗體,游標打在邊界會延續格式)與「不可延伸」(例如超連結,邊界外的字不該套用)兩種標記類型,讓並發插入時格式範圍能正確擴張或不擴張。
最難處理的是同一個 block 被同時分割的情況——例如兩人同時在同一段中間按下 Enter 把段落切成兩塊。Notion 為此新增三個概念:文字所屬的「text slice」組成「text slice tree」;跨越分割後仍能追蹤原 block 血緣的穩定識別「text instance」;以及用來在分裂後快速定位一段文字歸屬哪個 slice 的「search label」——每次分割就在標籤後面附加 L 或 R,但實際儲存時不是直接存 LRL 這種字串比對,而是用一種精簡編碼表示。
| 項目 | 舊方法(LWW) | 新方法(RGA + Peritext) |
|---|---|---|
| 合併同一 block 的並發編輯 | 後到的更新整份覆蓋,另一方修改遺失 | 逐字元合併,雙方修改都保留 |
| 刪除處理 | 直接移除,無法對齊離線操作 | 保留為 tombstone,離線操作仍能定位 |
| 格式標記(粗體/連結) | 依附字元索引,並發插入易錯位 | 依附字元間隙錨點,依 Peritext 規則擴張 |
| 同一 block 被同時分割 | 未特別處理 | text slice + text instance + search label 追蹤 |
實際效果
這套系統在 2025 年 7 月正式上線,Notion 稱之為全球最大規模的 CRDT 部署之一,目前每分鐘要處理數百萬筆 CRDT 操作。而 search label 改用精簡編碼取代直接字串儲存後,儲存效率提升了五倍。
對正在做即時協作編輯器的團隊來說,這篇文章點出的關鍵不是「要不要用 CRDT」,而是單一序列 CRDT 撐不起真實的文件編輯——block 分割、富文本格式、離線操作重放,每一種情境都需要額外的資料結構(text instance、search label、Peritext 錨點)去補上序列 CRDT 本身沒解決的問題。只把 RGA 套用在單一字元陣列上,遇到「同時分割同一段」這種操作就會出問題,需要另外設計像 search label 這樣的機制才能維持一致性。
原始來源:Notion Blog:How Notion handles concurrent editing with CRDTs、Ink & Switch:Peritext