CRDT và Real-time Collaboration 2026 - Kiến trúc Đồng bộ Multi-User kiểu Figma, Notion với Yjs, Automerge, WebSocket và Presence/Awareness

Posted on: 4/17/2026 7:11:25 AM

Table of contents

  1. 1. Vì sao real-time collaboration đã trở thành mặc định trong UX 2026
    1. Real-time không phải chỉ là chat
  2. 2. Hành trình từ Google Wave đến CRDT trưởng thành
  3. 3. OT vs CRDT - so sánh chuyên sâu cho người chọn công nghệ
    1. Quy tắc chọn nhanh
  4. 4. CRDT lý thuyết - state-based vs op-based, và vì sao YATA thắng
    1. 4.1. State-based CRDT (CvRDT - Convergent)
    2. 4.2. Operation-based CRDT (CmRDT - Commutative)
    3. 4.3. List/Text CRDT - YATA (Yjs) và RGA (Automerge)
  5. 5. Yjs - kiến trúc nội bộ, shared types và update format
    1. 5.1. Shared types và composability
    2. 5.2. Binary update format và sync protocol
      1. Tombstone không bao giờ biến mất
    3. 5.3. Awareness protocol - presence và cursor
  6. 6. Automerge 3 - JSON-first, columnar storage và sync protocol
  7. 7. Kiến trúc production - bốn pattern phổ biến nhất 2026
    1. 7.1. Pattern A - Monolithic WebSocket node giữ state trong RAM
    2. 7.2. Pattern B - Stateless WebSocket node + Redis pub/sub
    3. 7.3. Pattern C - Actor model (Orleans / Erlang / Cloudflare Durable Objects)
    4. 7.4. Pattern D - Managed service (Liveblocks / PartyKit / Pusher)
      1. Quy tắc lựa chọn pattern
  8. 8. Persistence - snapshot, log compaction và versioning
    1. 8.1. Versioning và time travel
  9. 9. Scaling - sharding theo room, GC tombstone và backpressure
    1. 9.1. Sharding theo room
    2. 9.2. GC tombstone
    3. 9.3. Backpressure khi user gõ quá nhanh
  10. 10. Bảo mật - auth, room permission và end-to-end encryption
    1. 10.1. JWT handshake
    2. 10.2. Room permission
    3. 10.3. End-to-end encryption với CRDT
      1. E2EE đánh đổi awareness server-side
  11. 11. Sáu anti-pattern thường gặp khi triển khai CRDT production
  12. 12. Checklist go-live cho hệ thống real-time collaboration 2026
  13. 13. Tương lai - AI agent là CRDT peer thứ N
  14. 14. Kết luận
  15. 15. Tài liệu tham khảo

1. Vì sao real-time collaboration đã trở thành mặc định trong UX 2026

Mười năm trước, một sản phẩm SaaS có nút "Save" được coi là bình thường. Năm 2026 thì ngược lại - một sản phẩm vẫn còn nút Save bị xem là cũ. Người dùng đã quen với Figma, Notion, Linear, Google Docs, Miro, FigJam: bạn gõ - đối tác thấy ngay; bạn kéo một khối - cả phòng họp thấy con trỏ của bạn di chuyển; bạn offline mười phút rồi bật lại - không có cửa sổ "conflict" nào hỏi bạn chọn version nào. Đằng sau trải nghiệm đó là một họ thuật toán có gốc từ những năm 2000 nhưng chỉ thực sự production-ready trong khoảng năm năm gần đây: CRDT - Conflict-free Replicated Data Types.

Bài viết này là một cẩm nang chuyên sâu cho engineer đang xây dựng hoặc đánh giá một hệ thống collaboration thật. Chúng ta sẽ đi qua bốn lớp: lý thuyết (CRDT là gì, khác gì với Operational Transformation), thực thi (Yjs, Automerge 3 - hai thư viện đang chiếm lĩnh thị trường), kiến trúc backend (transport WebSocket, presence/awareness, persistence, scaling), và cuối cùng là các anti-pattern cùng checklist go-live cho team đang chọn công nghệ năm 2026.

~85%SaaS B2B hạng A 2026 có ít nhất một surface đa người dùng real-time
100msngưỡng trễ end-to-end để cảm giác "real-time" với cursor và keystroke
0số lần Yjs cần một hộp thoại "merge conflict" - đó là điểm mạnh kiến trúc
~10xchi phí RAM trên backend khi giữ document state per room so với stateless API

Real-time không phải chỉ là chat

Có ba lớp real-time hay bị nhập nhằng: broadcast (chat, notification - SignalR/Socket.io làm tốt), shared state (presence, cursor, "ai đang xem cùng" - Liveblocks/Phoenix Channels), và collaborative document (text, JSON, drawing - Yjs/Automerge). Bài viết này tập trung vào lớp thứ ba, lớp tốn công nhất nhưng cũng tạo khác biệt sản phẩm rõ nhất.

2. Hành trình từ Google Wave đến CRDT trưởng thành

Hiểu lý do CRDT chiến thắng OT trong nhiều use case 2026 thì cần nhìn đường đi của 25 năm. Nhiều design choice của Yjs và Automerge là phản ứng trực tiếp với thất bại của các hệ thống thế hệ trước.

1989 - Operational Transformation (OT) ra đời
Ellis & Gibbs đề xuất OT trong GROVE editor. Ý tưởng: mỗi op gửi đến server được "transform" để bù trừ với các op concurrent đã xảy ra. OT đòi hỏi central server làm trọng tài thứ tự.
2009 - Google Wave và bài học OT
Wave là hệ thống real-time text editing tham vọng nhất thời điểm đó. Đóng cửa sau 18 tháng. Một trong các lý do kỹ thuật: thuật toán Jupiter OT khó implement đúng, không dễ extend cho rich text. Google Docs sau này dùng OT đơn giản hơn và central server.
2011 - Shapiro et al định nghĩa CRDT
Bài báo "Conflict-free Replicated Data Types" của Shapiro, Preguiça, Baquero, Zawirski mô tả nền tảng toán học cho CRDT: state-based (CvRDT) và operation-based (CmRDT), kèm chứng minh đặc tính eventual consistency mà không cần central coordinator.
2016 - WOOT, Logoot, RGA và bùng nổ thuật toán list CRDT
Hàng loạt thuật toán list/text CRDT ra đời: WOOT, Logoot, LSEQ, Treedoc, RGA. Hiệu năng còn rất xa Google Docs nhưng đã chứng minh tính khả thi: peer-to-peer text editing không cần server.
2018 - YATA và Yjs trưởng thành
Kevin Jahns công bố YATA (Yet Another Transformation Approach) và thư viện Yjs. YATA đơn giản hơn RGA, tracking cấu trúc bằng linked list với origin/leftOrigin. Yjs là CRDT đầu tiên đạt benchmark gần Google Docs về tốc độ và memory.
2020 - Automerge và "local-first software"
Bài báo "Local-first software" của Ink & Switch cùng với Automerge gây tiếng vang lớn. Tư tưởng: dữ liệu sống ở client, sync qua CRDT, server chỉ là relay. Triết lý ngược với SaaS truyền thống.
2023 - Automerge 2.0 viết lại bằng Rust
Automerge 2 dùng columnar binary format, viết core bằng Rust, đưa hiệu năng lên ngang Yjs. Tích hợp browser qua WebAssembly. Chính thức production-ready cho JSON document arbitrary cấu trúc.
2024 - Liveblocks, PartyKit, ElectricSQL hệ sinh thái thương mại
Layer hosted xuất hiện: Liveblocks bán "collaboration as a service" trên nền Yjs, PartyKit là edge server cho multiplayer, ElectricSQL đưa CRDT xuống lớp Postgres replication. Real-time bắt đầu trở thành commodity.
2025-2026 - AI agent trở thành CRDT peer
Bài viết của ElectricSQL về "AI agents as CRDT peers" cho thấy hướng đi tiếp theo: agent ghi vào tài liệu cùng người dùng qua chính cơ chế Yjs, không có race condition, không cần handshake riêng. Real-time collaboration nay không chỉ giữa người với người.

3. OT vs CRDT - so sánh chuyên sâu cho người chọn công nghệ

Đây là quyết định kiến trúc đầu tiên và quan trọng nhất. Đừng tin vào câu "CRDT luôn tốt hơn" - thực tế Google Docs vẫn dùng OT, Quip dùng OT, Etherpad dùng OT. CRDT thắng ở vài bài toán, OT thắng ở vài bài toán khác. Bảng dưới đây là so sánh thẳng thắn dựa trên trải nghiệm production thật.

Tiêu chíOperational Transformation (OT)CRDT (Yjs / Automerge)
Trọng tài thứ tựCần central serverKhông cần (peer-to-peer khả thi)
Offline editingKhó, phải re-transform khi reconnectDễ, merge tự nhiên khi online lại
Bộ nhớ documentChỉ snapshot hiện tạiCần lưu metadata (tombstone, timestamp logic)
Complexity thuật toánCao (transform function khó đúng cho rich text)Trung bình (định nghĩa op + merge rule rõ ràng)
Rich text formattingQuill OT, ShareDB OT đã trưởng thànhYjs Y.XmlFragment, Automerge Rich Text mới ổn định
Undo/Redo per userCần custom logic phức tạpYjs UndoManager hỗ trợ sẵn
Throughput peakCao nếu server tối ưu (Google Docs đạt được)Cao, nhưng cần GC tombstone
Dễ reason về correctnessKhó, transform property khó verifyDễ hơn, có proof toán học cho convergence
Use case mạnh nhấtServer-centric, document chỉ tồn tại online (Google Docs)Local-first, offline-capable, peer-to-peer (Linear, Figma)
Use case yếu nhấtMobile offline, peer-to-peerDocument siêu lớn (>100MB) - tombstone phồng

Quy tắc chọn nhanh

Nếu sản phẩm bạn cần (1) offline-first, (2) cho mobile, (3) muốn dùng lại editor open source (Tiptap, Slate, Lexical, ProseMirror), hoặc (4) có nhu cầu peer-to-peer trong tương lai - chọn CRDT. Nếu bạn cần (1) chỉ online, (2) nguồn lực dày, (3) đã có team OT lâu năm, hoặc (4) document siêu lớn nhưng ít concurrent op - OT vẫn là lựa chọn an toàn. Năm 2026 mặc định cho team mới là CRDT.

4. CRDT lý thuyết - state-based vs op-based, và vì sao YATA thắng

CRDT có hai họ chính. Hiểu khác biệt giúp bạn đọc source Yjs hoặc Automerge mà không lạc.

4.1. State-based CRDT (CvRDT - Convergent)

Mỗi replica giữ toàn bộ state, định nghĩa một merge function phải có ba tính chất: commutative (a+b = b+a), associative ((a+b)+c = a+(b+c)), idempotent (a+a = a). Nếu thoả ba tính chất, mọi replica gộp state với nhau theo thứ tự bất kỳ vẫn ra cùng kết quả - đó là eventual consistency.

Ví dụ kinh điển: G-Counter (grow-only counter). Mỗi replica giữ một map {nodeId: localCount}. Giá trị counter là tổng tất cả localCount. Merge là max element-wise. Tính chất: nếu hai replica increment cùng lúc rồi sync, kết quả luôn đúng tổng.

Ưu điểm state-based: đơn giản, không cần causal ordering. Nhược điểm: phải gửi cả state mỗi lần sync, không phù hợp document lớn. Vì thế trong production hầu như không ai dùng pure state-based cho text/JSON document.

4.2. Operation-based CRDT (CmRDT - Commutative)

Replica gửi operation thay vì state. Yêu cầu: op phải commute (đổi thứ tự apply vẫn cùng kết quả) và transport phải reliable + at-most-once + causal order (op cha đến trước op con).

Ví dụ OR-Set (observed-remove set): khi add element, gắn unique id; khi remove, ghi nhớ những id nào đã remove. Concurrent add và remove cùng element thì add thắng (vì remove chỉ xoá id đã observe).

Op-based hiệu quả hơn về băng thông nhưng yêu cầu tầng transport mạnh hơn. Yjs và Automerge đều thuộc nhóm op-based với optimization: log op được nén thành binary update có thể đóng gói lại thành "snapshot" hoặc "delta".

4.3. List/Text CRDT - YATA (Yjs) và RGA (Automerge)

Bài toán list CRDT khó nhất: hai user cùng insert ký tự ở vị trí 5, làm sao quyết định ai trước? Không thể dùng index số (vì index thay đổi sau insert). Giải pháp: gắn mỗi ký tự một identifier ổn định (ID = nodeId + clock), insert được mô tả là "chèn ký tự X bên phải Y", sau đó dùng tie-breaking rule khi cùng vị trí.

graph LR
    subgraph U1["User A gõ "X" sau "He""]
        A1["H"] --> A2["e"] --> A3["X"]
    end
    subgraph U2["User B gõ "Y" sau "He""]
        B1["H"] --> B2["e"] --> B3["Y"]
    end
    subgraph MERGE["Sau merge - YATA tie-break theo clientID"]
        M1["H"] --> M2["e"] --> M3["X (A.5)"] --> M4["Y (B.7)"]
    end
    style A3 fill:#e94560,color:#fff
    style B3 fill:#4CAF50,color:#fff
    style M3 fill:#e94560,color:#fff
    style M4 fill:#4CAF50,color:#fff
Hai concurrent insert được sắp xếp deterministic theo (origin, clientID, clock)

YATA của Yjs đơn giản hơn RGA: mỗi item có origin (ID của ký tự bên trái khi tạo), rightOrigin (ký tự bên phải khi tạo), tie-break bằng (clientID, clock). Khi merge, item mới được "lồng" giữa origin và rightOrigin theo rule deterministic. Hiệu quả: O(N) cho insert thông thường, có thể tối ưu xuống gần O(1) nhờ index hash.

5. Yjs - kiến trúc nội bộ, shared types và update format

Yjs là CRDT phổ biến nhất 2026 cho text. Nó không phải là editor, không có UI, mà là shared data model: bạn cấu trúc data của bạn bằng Y.Map, Y.Array, Y.Text, Y.XmlFragment - mọi thay đổi tự động sync với mọi peer khác.

graph TB
    subgraph CLIENT["Yjs Client (Browser/Node)"]
        DOC["Y.Doc
(root container)"] TYPES["Shared Types
Y.Text / Y.Array / Y.Map / Y.XmlFragment"] STORE["DocStore
(Item list, indexed by clientID)"] ENCODER["Update Encoder
(binary, lib0)"] AWARE["Awareness Protocol
(presence, cursor, user)"] end subgraph TRANSPORT["Provider (transport agnostic)"] WS["y-websocket"] WEBRTC["y-webrtc"] REDIS["y-redis"] IDB["y-indexeddb (persistence)"] end subgraph BACKEND["Backend"] SYNCSERVER["Sync Server
(broadcast updates)"] DB[("Persistence
Postgres / S3 / LevelDB")] PUBSUB["Redis Pub/Sub
(cross-node)"] end DOC --> TYPES --> STORE --> ENCODER DOC --> AWARE ENCODER --> WS ENCODER --> WEBRTC ENCODER --> REDIS ENCODER --> IDB AWARE --> WS WS --> SYNCSERVER SYNCSERVER --> DB SYNCSERVER --> PUBSUB PUBSUB --> SYNCSERVER style DOC fill:#e94560,color:#fff style ENCODER fill:#e94560,color:#fff style SYNCSERVER fill:#2c3e50,color:#fff
Yjs tách biệt rõ data model, encoder, transport và persistence - mỗi tầng thay được

5.1. Shared types và composability

Bạn lồng được shared type vào nhau: Y.Map<string, Y.Array<Y.Map>> mô tả một board Trello hoàn chỉnh - map cột → array thẻ → map field thẻ. Mỗi thay đổi sub-tree được encode thành update tối thiểu, không cần re-broadcast cả board.

// Cấu trúc một Notion-like document
import * as Y from 'yjs'

const doc = new Y.Doc()
const blocks = doc.getArray('blocks')

const heading = new Y.Map()
heading.set('type', 'heading')
heading.set('text', new Y.Text('CRDT 2026'))
blocks.push([heading])

const paragraph = new Y.Map()
paragraph.set('type', 'paragraph')
paragraph.set('text', new Y.Text('Hello collaborative world'))
blocks.push([paragraph])

// Mọi user khác sẽ tự động thấy 2 block này khi sync

5.2. Binary update format và sync protocol

Update của Yjs là binary tối ưu: VarInt cho số, dictionary encoding cho ký tự lặp, run-length encoding cho id liên tiếp. Một paragraph 1000 ký tự sau khi gõ tuần tự chỉ chiếm ~150 bytes update vì các ID liên tiếp được nén thành một range.

Sync protocol có hai bước (sync step 1 và step 2): client gửi state vector (map clientID → max clock đã thấy), server trả về diff update (chỉ những op client chưa thấy). Đây là lý do Yjs sync nhanh ngay cả khi document lớn: client A có 1MB state, mở lại sau khi offline 5 phút, sync chỉ tốn vài KB nếu không có nhiều thay đổi.

Tombstone không bao giờ biến mất

Khi xoá ký tự, Yjs không xoá thật mà đánh dấu deleted. Tombstone giữ lại ID để các op mới đến muộn vẫn neo được vào đúng vị trí. Document sửa nhiều lần liên tục có thể phồng lên đáng kể. Chiến lược production: snapshot định kỳ rồi Y.encodeStateAsUpdate(doc) tạo update mới chỉ chứa state hiện tại, các tombstone cũ không còn cần neo bị nén lại.

5.3. Awareness protocol - presence và cursor

Awareness là một concept tách biệt khỏi document: nó là ephemeral state (cursor position, selection range, "user X đang xem"). Không persist, không có tombstone, được expire sau ~30 giây nếu không có heartbeat.

// Presence ở client
import { Awareness } from 'y-protocols/awareness'
const awareness = new Awareness(doc)
awareness.setLocalStateField('user', { name: 'Anh Tu', color: '#e94560' })
awareness.setLocalStateField('cursor', { anchor: 120, head: 145 })

awareness.on('change', () => {
  for (const [clientId, state] of awareness.getStates()) {
    if (clientId === doc.clientID) continue
    renderRemoteCursor(clientId, state.user, state.cursor)
  }
})

6. Automerge 3 - JSON-first, columnar storage và sync protocol

Automerge 3 là đối thủ chính của Yjs. Triết lý khác: Yjs ưu tiên text editor, Automerge ưu tiên JSON document arbitrary. Nếu app của bạn không phải editor mà là dữ liệu cấu trúc (kanban board, todo list, configuration sync), Automerge cho cảm giác "JSON object thường" tự nhiên hơn.

Tiêu chíYjsAutomerge 3
Ngôn ngữ coreJavaScript (có port C++/Rust)Rust (browser qua WASM)
API styleShared types (Y.Map, Y.Text, ...)JSON proxy + change function
Text performanceTốt nhất trên benchmarkNgang ngửa từ v3, vẫn chậm hơn nhẹ
JSON arbitrary nestingLồng được nhưng cần khai báoTự nhiên như object thường
Storage formatBinary update listColumnar binary (nén tốt hơn)
Sync protocolState vector exchangeHeads-based + bloom filter
Multi-languageJavaScript chính, Rust port (yrs)Rust core, JS/Python/Swift binding chính thức
Editor ecosystemTiptap, Slate, ProseMirror, Quill, Lexical, Monaco, CodeMirrorCần custom integration cho hầu hết editor
Khi nào chọnRich text editor là core (Notion, Linear)JSON document arbitrary, mobile native, multi-language stack
// Automerge 3 - cảm giác JSON object thường
import { next as Automerge } from '@automerge/automerge'

let doc = Automerge.from({
  todos: [],
  filter: 'all'
})

doc = Automerge.change(doc, d => {
  d.todos.push({ id: 1, text: 'Học CRDT', done: false })
  d.todos.push({ id: 2, text: 'Refactor backend', done: false })
})

// Sync với peer khác
const sync = Automerge.initSyncState()
const [nextDoc, nextSync, message] = Automerge.generateSyncMessage(doc, sync)
// gửi message qua WebSocket / HTTP / bất cứ transport nào

7. Kiến trúc production - bốn pattern phổ biến nhất 2026

Code client là phần dễ. Backend là nơi 90% lỗi sản xuất xảy ra. Có bốn pattern kiến trúc bạn sẽ chọn giữa, mỗi pattern có trade-off rõ.

7.1. Pattern A - Monolithic WebSocket node giữ state trong RAM

Mỗi document được "pin" về một node duy nhất. Client connect đến node đó qua WebSocket. Node giữ Y.Doc trong memory, broadcast update giữa client connect cùng node. Persist vào disk định kỳ (snapshot mỗi 30s).

graph LR
    C1["Client 1"] --> WS1["WS Node A
(Y.Doc room1)"] C2["Client 2"] --> WS1 C3["Client 3"] --> WS2["WS Node B
(Y.Doc room2)"] LB["Load Balancer
(sticky by roomId)"] --> WS1 LB --> WS2 WS1 --> DB[("Snapshot Store
S3 / Postgres")] WS2 --> DB style WS1 fill:#e94560,color:#fff style WS2 fill:#e94560,color:#fff
Mỗi room "ghim" về một node - đơn giản, hiệu quả cho start-up

Phù hợp: dưới 100k concurrent user, dưới 10k room đồng thời, document không quá lớn. Vấn đề: node restart làm mất presence, khó scale ngang vì cần sticky session, cold start chậm khi load document từ disk.

7.2. Pattern B - Stateless WebSocket node + Redis pub/sub

Mỗi node WebSocket không "own" room nào cố định. Update đến từ client → node decode → gửi qua Redis pub/sub channel doc:{roomId} → mọi node subscribe channel đó nhận và broadcast cho client của mình. State document được giữ trong Redis (hoặc một node leader theo Raft).

graph TB
    subgraph CLIENTS["Clients"]
        C1["Client 1"]
        C2["Client 2"]
        C3["Client 3"]
        C4["Client 4"]
    end
    subgraph NODES["WebSocket Nodes (stateless)"]
        N1["Node A"]
        N2["Node B"]
        N3["Node C"]
    end
    subgraph SHARED["Shared State"]
        REDIS[("Redis
Pub/Sub + Stream
doc:{roomId}")] BLOB[("Persistence
Postgres / S3
snapshot + log")] end C1 --> N1 C2 --> N2 C3 --> N2 C4 --> N3 N1 <--> REDIS N2 <--> REDIS N3 <--> REDIS REDIS --> BLOB style REDIS fill:#e94560,color:#fff style BLOB fill:#2c3e50,color:#fff
Pattern phổ biến cho production scale - mọi node bình đẳng, scale ngang dễ

Phù hợp: trên 100k concurrent user, deployment Kubernetes nhiều node, cần restart node không ảnh hưởng client. Vấn đề: Redis trở thành SPOF (cần Redis Cluster/Sentinel), chi phí băng thông Redis lớn nếu không filter, state document cần cơ chế leader-based để tránh xung đột write.

7.3. Pattern C - Actor model (Orleans / Erlang / Cloudflare Durable Objects)

Mỗi room là một actor (grain trong Orleans, GenServer trong Phoenix, Durable Object trong Cloudflare Workers). Hệ thống actor đảm bảo single-writer per room - không có race condition. Client connect được route đến đúng actor, actor giữ state trong RAM, persist async.

Cloudflare Durable Objects là implementation hoàn thiện nhất cho web hiện nay: mỗi document = một Durable Object, chạy trên edge gần user, persist vào storage SSD của Cloudflare. Liveblocks và PartyKit đều xây trên ý tưởng tương tự.

Phù hợp: ứng dụng global cần độ trễ thấp, team chấp nhận lock-in vào platform. Vấn đề: chi phí cao hơn pattern B, debug khó nếu chưa quen actor model.

7.4. Pattern D - Managed service (Liveblocks / PartyKit / Pusher)

Bạn không xây backend. Liveblocks lo phần WebSocket, persistence, auth, presence. Bạn trả USD/month theo MAU. Trade-off rõ: nhanh-launch, ít kỹ thuật, nhưng vendor lock-in và chi phí tăng tuyến tính khi scale.

Quy tắc lựa chọn pattern

Start-up giai đoạn idea-validation → Pattern D (Liveblocks). Sau Series A, MAU > 100k → migrate sang Pattern B (Redis). Có ngân sách và team mạnh → Pattern C (Durable Objects/Orleans). Pattern A chỉ dùng cho prototype hoặc internal tool dưới 1000 user.

8. Persistence - snapshot, log compaction và versioning

Một sai lầm phổ biến: persist mọi update Yjs vào Postgres mỗi khi đến. Sau một tháng, table doc_updates có 50 triệu row, query load document mất 10 giây. Cách đúng là kết hợp append-only log với periodic snapshot.

graph LR
    UPD["Update đến
(binary, ~100B)"] --> APPEND["Append vào
log table"] APPEND --> CHECK{Log size
>= threshold?} CHECK -->|No| END1[Done] CHECK -->|Yes| MERGE["Apply tất cả update
vào Y.Doc trong RAM"] MERGE --> SNAP["Y.encodeStateAsUpdate
=> snapshot binary"] SNAP --> WRITE["Write snapshot vào
doc_snapshot table/S3"] WRITE --> DELETE["Delete log cũ
(trước snapshot)"] DELETE --> END2[Done] style MERGE fill:#e94560,color:#fff style SNAP fill:#4CAF50,color:#fff
Log + snapshot - cân bằng giữa write rẻ và read nhanh

Schema gợi ý cho Postgres:

-- Append-only log, write rất nhanh
CREATE TABLE doc_update (
    id BIGSERIAL PRIMARY KEY,
    doc_id UUID NOT NULL,
    update_data BYTEA NOT NULL,        -- binary update Yjs
    client_id BIGINT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_update_doc ON doc_update(doc_id, id);

-- Snapshot định kỳ - load nhanh
CREATE TABLE doc_snapshot (
    doc_id UUID PRIMARY KEY,
    snapshot BYTEA NOT NULL,           -- encodeStateAsUpdate
    last_update_id BIGINT NOT NULL,    -- log id cuối được snapshot
    state_vector BYTEA NOT NULL,       -- để sync diff
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Khi load: doc_snapshot.snapshot + doc_update WHERE id > last_update_id

Threshold thường dùng: snapshot mỗi khi log đạt 100 update hoặc 1MB tổng size. Snapshot lớn hơn 5MB nên chuyển sang S3 và lưu URL trong Postgres.

8.1. Versioning và time travel

Yjs hỗ trợ snapshot có thể được "freeze" thành version, sau đó so sánh hai version để render diff. Y.snapshot(doc) trả về object nhỏ chỉ chứa state vector; cộng với log update có thể tái dựng document tại bất kỳ điểm nào trong quá khứ. Đây là cơ chế đứng sau "Page History" của Notion hay "Version History" của Figma.

9. Scaling - sharding theo room, GC tombstone và backpressure

9.1. Sharding theo room

Document trong real-time collaboration không cần global consistency - chỉ cần consistency trong một room. Đây là tính chất rất đẹp cho scaling: bạn shard hoàn toàn theo roomId. Mỗi shard có thể là một consumer group, một Redis cluster, một Durable Object.

Hash function gợi ý: shard = consistent_hash(roomId) % N với N là số node WebSocket. Khi N thay đổi (auto-scale), consistent hashing giúp chỉ một phần nhỏ room phải migrate.

9.2. GC tombstone

Document càng sống lâu, tombstone càng nhiều. Yjs không tự GC vì op đến muộn vẫn cần neo. Cách tiếp cận thực tế: định kỳ tạo "compaction snapshot" - không xoá tombstone hoàn toàn nhưng nén chúng vào một block duy nhất. Production mature hơn dùng Yjs document v2 (đang preview 2026) hỗ trợ "permanent delete" sau ngưỡng thời gian an toàn (>1 ngày = không còn op trễ).

9.3. Backpressure khi user gõ quá nhanh

Một keyboard auto-typing 100 ký tự/giây tạo 100 update/giây. Nhân với 50 user trong cùng room = 5000 message/giây phải broadcast. Backpressure pattern:

  • Debounce client: Yjs Y.Transaction gộp nhiều thay đổi thành một update.
  • Batch server: chờ 50ms rồi gộp tất cả update đến trong khoảng đó thành một broadcast.
  • Drop awareness: cursor update có thể drop nếu client xử lý không kịp - không persist nên không sao.

10. Bảo mật - auth, room permission và end-to-end encryption

Chưa nói đến CRDT, một WebSocket server bình thường đã có hai vấn đề auth quen thuộc: ai được connect, và sau khi connect thì ai được join room nào. Với CRDT, có thêm vấn đề thứ ba: ai được apply update gì.

10.1. JWT handshake

WebSocket không có header sau khi upgrade. Hai cách phổ biến: gửi JWT trong query string khi connect (wss://server/yjs?token=xxx), hoặc dùng connection cookie. Server verify token, gắn userId vào connection state, sau đó mọi message từ connection đó được kiểm tra theo userId.

10.2. Room permission

Khi client subscribe room doc:{roomId}, server kiểm tra userId có quyền không. Cache permission trong Redis với TTL 60s để tránh hit DB mỗi message. Khi quyền thay đổi (admin revoke), publish event permission:revoked:{userId}:{roomId} để mọi node disconnect connection liên quan.

10.3. End-to-end encryption với CRDT

Đây là lợi thế lớn của CRDT: vì merge là deterministic và không cần server hiểu nội dung, bạn có thể mã hoá update ở client bằng key mà server không biết. Server chỉ relay binary blob. Pattern phổ biến: room key được derive từ password chia sẻ, mỗi update Yjs encrypt bằng AES-GCM trước khi gửi qua WebSocket.

E2EE đánh đổi awareness server-side

Khi bạn encrypt update, server không thể chạy logic dựa trên nội dung (search, mention notification, full-text indexing). Mọi tính năng đó phải chuyển về client hoặc dùng một relay được uỷ quyền giải mã. Cân nhắc trước khi đi E2EE.

11. Sáu anti-pattern thường gặp khi triển khai CRDT production

  1. Persist mỗi update trực tiếp vào Postgres không snapshot. Bảng phình lên, query load document chậm dần. Phải có snapshot + log compaction từ ngày đầu.
  2. Quên broadcast update qua Redis pub/sub khi scale ngang. User trên node A gõ, user trên node B không thấy. Test với load balancer multi-instance từ đầu.
  3. Sticky session quá lâu. User mất kết nối, reconnect đến node khác, mất 5 giây để load document từ DB. Pattern B (stateless + Redis) tránh được.
  4. Không debounce update. Một paste 50KB text tạo 50000 op nhỏ thay vì một transaction. Luôn wrap thay đổi lớn trong doc.transact(() => ...).
  5. Awareness leak. Disconnect không dọn awareness state, khiến user thấy con trỏ "ma" của user đã rời room. Phải xử lý cleanup trong onClose handler.
  6. Không có quota per room. Một user gửi 1MB text qua WebSocket làm OOM một node. Đặt giới hạn message size (ví dụ 256KB), giới hạn connection per user (10), giới hạn document size (50MB).

12. Checklist go-live cho hệ thống real-time collaboration 2026

Hạng mụcYêu cầu
Chọn CRDTĐã đo benchmark Yjs vs Automerge cho document mẫu thật của bạn (10MB, 50 user, 1000 op/s)
Editor integrationTiptap/ProseMirror/Slate/Lexical chọn xong, plugin Yjs đã verify cho rich text feature cần dùng
TransportWebSocket có ping/pong heartbeat 30s, reconnect exponential backoff, fallback sang long-polling khi proxy chặn WS
PersistenceAppend log + snapshot mỗi 100 update, snapshot lớn lưu S3, có script compact log offline
ScalingStateless WS node + Redis pub/sub, sharding theo roomId, auto-scale theo CPU và connection count
AuthJWT trong query string, refresh token trước khi expire, room permission cache TTL 60s
AwarenessCursor + selection broadcast, expire 30s, cleanup khi disconnect
BackpressureDebounce 50ms server-side, drop cursor khi quá tải, message size limit 256KB
ObservabilityOpenTelemetry trace cho mỗi sync round, metric: connection count, doc size, update/s, snapshot lag
Disaster recoveryBackup snapshot mỗi giờ, có thể replay log từ S3 trong 24 giờ qua, RPO < 1 phút
VersioningTime travel UI cho user, snapshot tagged theo "named version", có thể fork document từ version cũ
TestLoad test 10k WS connection đồng thời, chaos test (kill random node, network partition), property-based test cho merge convergence

13. Tương lai - AI agent là CRDT peer thứ N

Năm 2026 mang đến một góc nhìn mới: nếu human user là một CRDT peer, vì sao AI agent không là một peer? Bài viết của ElectricSQL về "AI agents as CRDT peers" chỉ ra điều này tự nhiên hơn nhiều so với việc thiết kế protocol RPC riêng cho agent ghi vào document.

Concrete: Claude agent gen ra một paragraph, áp vào Y.Text giống như user gõ. Nếu user đang gõ cùng lúc, Yjs merge tự động - không có "AI override user" hay "user override AI". Cả hai cùng tồn tại như hai peer bình đẳng. Đây là pattern Notion AI và Linear AI đang đi theo, và là hướng phát triển sạch nhất cho generative agent trong tài liệu nhiều người dùng.

Khi viết hệ thống collaboration mới năm 2026, hãy thiết kế từ đầu cho ba loại peer: human, AI agent, integration bot. Cả ba đều ghi qua cùng một CRDT layer với cùng một mô hình permission. Đó là kiến trúc bền cho thập kỷ tiếp theo.

14. Kết luận

Real-time collaboration không còn là tính năng "nice to have" cho SaaS năm 2026 - nó là kỳ vọng baseline. CRDT đã giải quyết được phần khó nhất (merge convergence) bằng toán học, để lại cho engineer phần thực dụng: chọn thư viện đúng (Yjs cho editor, Automerge cho JSON arbitrary), thiết kế backend đúng (Pattern B nếu scale, Pattern D nếu cần launch nhanh), persist đúng (snapshot + log), và chống các anti-pattern dễ mắc.

Đừng đợi đến khi product đã ship six months mới retrofit collaboration - chi phí migrate sau đó luôn cao hơn 5-10x so với thiết kế từ đầu. Cũng đừng over-engineer: một startup ba người không cần Pattern C ngay; một Liveblocks subscription 99 USD/month đủ để validate idea trước khi đầu tư backend.

Ba câu hỏi cần trả lời trước khi bắt đầu: (1) Document của bạn chủ yếu là text hay JSON? (chọn Yjs vs Automerge). (2) Bạn cần offline-first hay online-only? (chọn CRDT vs OT). (3) Scale dự kiến trong 12 tháng tới là bao nhiêu MAU? (chọn pattern backend). Trả lời được ba câu đó, mọi quyết định kỹ thuật còn lại trở nên rõ ràng.

15. Tài liệu tham khảo