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. Vì sao real-time collaboration đã trở thành mặc định trong UX 2026
- 2. Hành trình từ Google Wave đến CRDT trưởng thành
- 3. OT vs CRDT - so sánh chuyên sâu cho người chọn công nghệ
- 4. CRDT lý thuyết - state-based vs op-based, và vì sao YATA thắng
- 5. Yjs - kiến trúc nội bộ, shared types và update format
- 6. Automerge 3 - JSON-first, columnar storage và sync protocol
- 7. Kiến trúc production - bốn pattern phổ biến nhất 2026
- 8. Persistence - snapshot, log compaction và versioning
- 9. Scaling - sharding theo room, GC tombstone và backpressure
- 10. Bảo mật - auth, room permission và end-to-end encryption
- 11. Sáu anti-pattern thường gặp khi triển khai CRDT production
- 12. Checklist go-live cho hệ thống real-time collaboration 2026
- 13. Tương lai - AI agent là CRDT peer thứ N
- 14. Kết luận
- 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.
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.
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 server | Không cần (peer-to-peer khả thi) |
| Offline editing | Khó, phải re-transform khi reconnect | Dễ, merge tự nhiên khi online lại |
| Bộ nhớ document | Chỉ snapshot hiện tại | Cần lưu metadata (tombstone, timestamp logic) |
| Complexity thuật toán | Cao (transform function khó đúng cho rich text) | Trung bình (định nghĩa op + merge rule rõ ràng) |
| Rich text formatting | Quill OT, ShareDB OT đã trưởng thành | Yjs Y.XmlFragment, Automerge Rich Text mới ổn định |
| Undo/Redo per user | Cần custom logic phức tạp | Yjs UndoManager hỗ trợ sẵn |
| Throughput peak | Cao nếu server tối ưu (Google Docs đạt được) | Cao, nhưng cần GC tombstone |
| Dễ reason về correctness | Khó, transform property khó verify | Dễ hơn, có proof toán học cho convergence |
| Use case mạnh nhất | Server-centric, document chỉ tồn tại online (Google Docs) | Local-first, offline-capable, peer-to-peer (Linear, Figma) |
| Use case yếu nhất | Mobile offline, peer-to-peer | Document 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
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
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í | Yjs | Automerge 3 |
|---|---|---|
| Ngôn ngữ core | JavaScript (có port C++/Rust) | Rust (browser qua WASM) |
| API style | Shared types (Y.Map, Y.Text, ...) | JSON proxy + change function |
| Text performance | Tốt nhất trên benchmark | Ngang ngửa từ v3, vẫn chậm hơn nhẹ |
| JSON arbitrary nesting | Lồng được nhưng cần khai báo | Tự nhiên như object thường |
| Storage format | Binary update list | Columnar binary (nén tốt hơn) |
| Sync protocol | State vector exchange | Heads-based + bloom filter |
| Multi-language | JavaScript chính, Rust port (yrs) | Rust core, JS/Python/Swift binding chính thức |
| Editor ecosystem | Tiptap, Slate, ProseMirror, Quill, Lexical, Monaco, CodeMirror | Cần custom integration cho hầu hết editor |
| Khi nào chọn | Rich 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
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
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
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.Transactiongộ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
- 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.
- 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.
- 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.
- 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(() => ...). - 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
onClosehandler. - 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ục | Yê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 integration | Tiptap/ProseMirror/Slate/Lexical chọn xong, plugin Yjs đã verify cho rich text feature cần dùng |
| Transport | WebSocket có ping/pong heartbeat 30s, reconnect exponential backoff, fallback sang long-polling khi proxy chặn WS |
| Persistence | Append log + snapshot mỗi 100 update, snapshot lớn lưu S3, có script compact log offline |
| Scaling | Stateless WS node + Redis pub/sub, sharding theo roomId, auto-scale theo CPU và connection count |
| Auth | JWT trong query string, refresh token trước khi expire, room permission cache TTL 60s |
| Awareness | Cursor + selection broadcast, expire 30s, cleanup khi disconnect |
| Backpressure | Debounce 50ms server-side, drop cursor khi quá tải, message size limit 256KB |
| Observability | OpenTelemetry trace cho mỗi sync round, metric: connection count, doc size, update/s, snapshot lag |
| Disaster recovery | Backup snapshot mỗi giờ, có thể replay log từ S3 trong 24 giờ qua, RPO < 1 phút |
| Versioning | Time travel UI cho user, snapshot tagged theo "named version", có thể fork document từ version cũ |
| Test | Load 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
- Yjs - Shared data types for building collaborative software (GitHub)
- Yjs Documentation - Introduction và shared types
- y-websocket - Websocket connector và awareness protocol
- Automerge - JSON CRDT library (GitHub)
- CRDT Benchmarks - so sánh Yjs, Automerge, ShareDB
- crdt.tech - Tổng hợp paper, implementation và resources về CRDT
- Local-first software (Ink & Switch) - bài báo nền tảng triết lý local-first
- How CRDTs make multiplayer text editing part of Zed's DNA - Zed Blog
- Architectures for Central Server Collaboration - Matthew Weidner
- AI agents as CRDT peers - building collaborative AI with Yjs (ElectricSQL)
- Liveblocks Yjs - hosted CRDT infrastructure
- PartyKit Documentation - edge multiplayer platform
Background Jobs trên .NET 10 năm 2026 - Hangfire, Quartz.NET và MassTransit: Scheduler, Retry, Distributed Lock và Outbox Pattern cho Workflow Bất đồng bộ Production
Passkeys & WebAuthn 2026 - Thay thế Password với FIDO2, Platform Authenticator và Phishing-resistant Auth trên .NET 10 và Vue
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.