Harbor

Soạn thảo tài liệu cùng lúc

KhóOT / CRDTWebSocketPhiên theo tài liệuSnapshot + op logGiống Google Docs, Notion, Microsoft Word Online

Hiểu đề

Soạn thảo tài liệu cùng lúc là gì? Trình soạn thảo trên web cho phép nhiều người sửa cùng một tài liệu cùng lúc, thấy chữ và con trỏ của nhau gần như ngay lập tức. Ví dụ một nhóm 5 người cùng viết báo cáo, mỗi người một phần nhưng thỉnh thoảng sửa chung một đoạn.

Yêu cầu chức năng

  1. Người dùng có thể tạo, mở và sửa tài liệu văn bản (chữ, định dạng cơ bản, ảnh)
  2. Nhiều người (tối đa 100 người sửa) cùng sửa một tài liệu và thấy thay đổi của nhau gần như ngay
  3. Người dùng thấy ai đang mở tài liệu, con trỏ và vùng chọn của họ
  4. Người dùng có thể xem lịch sử phiên bản và khôi phục bản cũ

Yêu cầu phi chức năng

  • Gõ phím hiện ngay trên máy mình (áp dụng cục bộ, không chờ mạng)
  • Thay đổi của một người hiện ở máy người khác p95 < 200 ms khi cùng region
  • Mọi bản sao hội tụ về cùng một nội dung; 0 thao tác bị mất sau khi server đã xác nhận
  • Mở tài liệu 100 KB p95 < 1 s
  • Độ sẵn sàng 99,95%; mất mạng vẫn gõ được và đồng bộ lại khi có mạng

Ngoài phạm vi: Bảng tính, slide; Gợi ý chính tả, gõ tắt, dịch; Bình luận và gợi ý sửa (suggestion mode) chi tiết; Nhập/xuất DOCX, PDF.

Thực thể và API

Document
Tài liệu: id, tiêu đề, chủ sở hữu, version hiện tại, con trỏ tới snapshot mới nhất.
Operation
Một thay đổi đã được server xếp thứ tự: doc_id, version, user_id, client_id, client_seq, nội dung (insert/delete/format kèm vị trí), base_version.
Snapshot
Toàn bộ nội dung tài liệu tại một version, lưu dạng blob trên S3/GCS.
Permission
Quyền của user trên tài liệu: owner, editor, commenter, viewer.
Presence
Ai đang mở tài liệu, màu, vị trí con trỏ/vùng chọn. Chỉ nằm trong RAM, không lưu bền.
MethodEndpointGhi chú
POST/v1/docsTạo tài liệu mới, trả docId.
GET/v1/docs/{id}Trả snapshot mới nhất, version của snapshot và các op sau đó; kiểm tra quyền trước.
WS/v1/docs/{id}/sessionClient gửi {baseVersion, clientSeq, ops}; server trả ack kèm version, đẩy op của người khác và presence.
GET/v1/docs/{id}/history?cursor=Danh sách phiên bản đã gộp theo thời gian và người sửa.
POST/v1/docs/{id}/restoreBody {version}. Tạo op mới đưa nội dung về bản cũ, lịch sử vẫn giữ nguyên.
POST/v1/docs/{id}/sharesChia sẻ cho email với quyền xem/sửa.

Thiết kế tổng thể

Đi lần lượt từng yêu cầu chức năng. Mục tiêu ở đây là chạy đúng, việc tối ưu để dành cho phần đào sâu.

1) Người dùng có thể mở và sửa tài liệu

Mở tài liệu là một request REST lấy snapshot cộng các op phía sau. Sau đó client mở WebSocket tới đúng session server đang giữ tài liệu đó, vì mọi op của một tài liệu phải đi qua một nơi duy nhất để có thứ tự chung.

  1. 1Client gọi GET /v1/docs/{id}, API kiểm tra quyền
  2. 2API đọc snapshot mới nhất từ blob store và các op sau snapshot từ op log
  3. 3Client dựng tài liệu cục bộ, nhớ version hiện tại
  4. 4Client mở WS; load balancer định tuyến theo docId tới session server sở hữu tài liệu

Nguồn: Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Design of Google Docs", mục "Components" và "Workflow" ; Grokking Modern System Design · ch.36 WhatsApp ↗ · bài "Detailed Design of WhatsApp", mục "Connection with a WebSocket server"

2) Nhiều người cùng sửa và thấy thay đổi của nhau

Client áp op ngay trên máy mình rồi gửi lên kèm baseVersion. Session server biến đổi op theo các op đã có từ baseVersion (OT), gán version tiếp theo, ghi bền rồi mới ack và phát cho người khác.

  1. 1Client gộp phím trong 100–200 ms thành một op, áp cục bộ, gửi {baseVersion, op}
  2. 2Session server lấy các op từ baseVersion đến hiện tại, transform op mới qua từng op đó
  3. 3Gán version = hiện tại + 1, ghi vào op log
  4. 4Ack cho người gửi, phát op đã transform tới các client khác của tài liệu
  5. 5Client khác transform op nhận được qua các op cục bộ chưa được ack rồi áp vào

Nguồn: Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Concurrency in Collaborative Editing", mục "Operational transformation" (positional index, hội tụ) ; Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Design of Google Docs", mục "Workflow" (WebSocket + operations queue, nơi xử lý xung đột) ; Microservices Patterns ↗ (.zip) · ch.6 · §6.1.2 Overview of event sourcing (tr. 186–193)

3) Người dùng thấy ai đang mở và con trỏ của họ

Presence là dữ liệu tạm, mất cũng không sao. Nó đi cùng kết nối WS tới session server, được gộp và phát lại, không ghi DB.

  1. 1Client gửi vị trí con trỏ/vùng chọn tối đa 1 lần mỗi 100 ms khi có thay đổi
  2. 2Session server cập nhật bảng presence trong RAM của tài liệu
  3. 3Mỗi 100 ms server phát một gói presence gộp cho các client
  4. 4Client ngắt kết nối hoặc quá 30 s không heartbeat thì bị xóa khỏi danh sách

Nguồn: Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Concurrency in Collaborative Editing", ghi chú cuối bài (WebSocket cho phép hiển thị con trỏ của cộng tác viên) ; Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Evaluation of Google Docs' Design", mục "Latency" ; Chưa có nguồn trong thư viện: chu kỳ 100 ms và heartbeat 30 s là giả định của khoá học

4) Người dùng xem lịch sử và khôi phục bản cũ

Op log là lịch sử đầy đủ, snapshot giúp dựng nhanh một version bất kỳ. Khôi phục là tạo một op mới, nên người đang sửa vẫn đồng bộ bình thường.

  1. 1Client gọi GET /history, API trả các mốc phiên bản đã gộp
  2. 2Xem bản cũ: lấy snapshot gần nhất trước version đó rồi áp op tới version cần
  3. 3Khôi phục: tính diff giữa nội dung hiện tại và bản cũ, gửi thành op mới qua session server

Nguồn: Microservices Patterns ↗ (.zip) · ch.6 · §6.1.2 Overview of event sourcing và §6.1.5 Using snapshots to improve performance (tr. 186–197) ; Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Design of Google Docs", mục "Components" (blob storage)

Hạ tầng theo quy mô

Cùng một sản phẩm, ba mức người dùng. Chọn từng mức để xem sơ đồ, phép tính và cấu hình server thay đổi thế nào.

Người dùng
10.000 DAU (công cụ nội bộ hoặc startup)
Tải đỉnh
≈ 1.000 kết nối, 400 op/s
Chi phí ước lượng
≈ 100–250 USD/tháng (ước lượng)

Hai session server Node giữ tài liệu trong RAM, LB băm docId để chọn server. Postgres giữ cả metadata lẫn op log, snapshot nằm trên S3.

Người dùngTrình duyệtCân bằng tảiLB hash docIdDịch vụAPI RESTDịch vụSession serverLưu trữS3 snapshotDatabasePostgreSQLHTTPS + WSmở tài liệuWS theo docIdđọc snapshotquyền, op tailop logghi snapshot

Ước lượng

  • Giả định: lúc đỉnh 10% DAU đang mở tài liệu, 20% trong số đó đang gõ, mỗi người gõ ≈ 2 op/s (đã gộp phím), op ≈ 100 B
  • Kết nối: 10.000 × 10% = 1.000 WebSocket → ceil(1.000 / 20.000) = 1 → 2 session server
  • Op: 1.000 × 20% × 2 = 400 op/s đỉnh; phát cho trung bình 3 người khác + 1 ack = 1.600 tin/s
  • Op log: 10.000 × 600 s gõ/ngày × 2 = 12.000.000 op × 100 B = 1,2 GB/ngày; giữ thô 30 ngày = 36 GB
  • Snapshot: 12.000.000 / 1.000 = 12.000 lần × 100 KB = 1,2 GB/ngày lên S3

Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · bài "Examples of Resource Estimation", mục "Number of servers required", "Storage requirements" ; Quy tắc ước lượng của khoá học · /hoc/chon-server (500 req/s mỗi vCPU, chạy 60% CPU, Redis ×1,3; tỉ lệ người đang mở, tốc độ gõ, kích thước op và tin/s mỗi vCPU là giả định của khoá học)

Bảng hạ tầng

Thành phầnChọnSố lượngCấu hìnhVì sao
Load balancerNginx / Envoy, hash theo docId1 (managed hoặc 2 VM)Hỗ trợ WebSocketMọi kết nối của một tài liệu tới cùng session server.
API RESTNode.js (NestJS)21 vCPU · 2 GB RAMMở tài liệu 10.000 × 10 / 86.400 ≈ 1,2 req/s, rất nhẹ; 2 để dự phòng.
Session serverNode.js + ws, OT trong RAM22 vCPU · 4 GB RAM1.000 kết nối và 1.600 tin/s là nhẹ; 2 để còn chỗ chuyển tài liệu khi 1 server chết.
DatabasePostgreSQL managed1 primary + 1 standby2 vCPU · 8 GB · 100 GB SSD400 op/s ghi theo lô; 36 GB op log nóng; unique (doc_id, version) chặn ghi trùng.
Blob storeS3 / GCS1Snapshot và ảnh trong tài liệuChỉ giữ snapshot mới nhất và một bản mỗi ngày cho mỗi tài liệu.
Thay đổi so với mức trước
  • Điểm xuất phát: OT trong RAM của session server, mỗi tài liệu một chủ
  • Op log và metadata chung một Postgres, snapshot trên S3
  • Presence chỉ nằm trong RAM, chưa cần Redis
Nút thắt tiếp theo

Khi lên hàng chục nghìn op/s, Postgres phải nhận hàng chục nghìn hàng mỗi giây và bảng op tăng hàng trăm GB mỗi ngày. Việc đổi chủ tài liệu bằng hash ở LB cũng bắt đầu gây lỗi khi scale server lên xuống thường xuyên.

Cấu hình và chi phí là ước lượng để học và trình bày trong phỏng vấn. Xem cách tính ở bài Chọn server.

So sánh nhanh ba mức

Thành phầnMức 1Mức 2Mức 3
Người dùng10.000 DAU (công cụ nội bộ hoặc startup)1 triệu DAU50 triệu DAU (Grokking ch.38 dùng 80 triệu)
Chi phí≈ 100–250 USD/tháng (ước lượng)≈ 3.000–8.000 USD/tháng (ước lượng)≈ 150.000–350.000 USD/tháng (ước lượng)
Load balancer
Nginx / Envoy, hash theo docId
1 (managed hoặc 2 VM) × Hỗ trợ WebSocket
Envoy, định tuyến theo bảng chủ tài liệu
2 × 2 vCPU · 4 GB RAM
Chưa cần
API REST
Node.js (NestJS)
2 × 1 vCPU · 2 GB RAM
Node.js (NestJS), autoscale
2–4 (autoscale) × 2 vCPU · 4 GB RAM
Node.js trên Kubernetes
29 pod (HPA) × 2 vCPU · 4 GB mỗi pod
Session server
Node.js + ws, OT trong RAM
2 × 2 vCPU · 4 GB RAM
Node.js + ws, OT trong RAM
10 × 2 vCPU · 4 GB RAM
Node.js trên Kubernetes
300 pod (chia 3 region) × 4 vCPU · 8 GB mỗi pod
Database
PostgreSQL managed
1 primary + 1 standby × 2 vCPU · 8 GB · 100 GB SSD
PostgreSQL managed (HA), op log partition theo ngày
1 primary + 1 standby + 1 read replica × 16 vCPU · 64 GB · 2 TB SSD
Chưa cần
Blob store
S3 / GCS
1 × Snapshot và ảnh trong tài liệu
S3 / GCS
1 × Snapshot + ảnh
Chưa cần
CDNChưa cần
CloudFront / Cloud CDN
1 × JS/CSS của editor, ảnh trong tài liệu
Chưa cần
RedisChưa cần
ElastiCache / Memorystore
1 primary + 1 replica × 2 vCPU · 4 GB
Chưa cần
Compaction workerChưa cần
Node.js job chạy đêm
2 × 2 vCPU · 4 GB RAM
Chưa cần
Global LB + CDNChưa cầnChưa cần
Cloud CDN + Global LB, 3 region
1 × Định tuyến kết nối về region đang giữ tài liệu
Shard mapChưa cầnChưa cần
etcd (hoặc ZooKeeper)
3 node mỗi region × 2 vCPU · 8 GB · SSD
Kafka op logChưa cầnChưa cần
MSK / Confluent
9 broker (3 mỗi AZ) × 512 partition, RF 3, acks=all, transactional.id theo shard ảo
Op archiverChưa cầnChưa cần
Node.js/Go consumer trên Kubernetes
32 pod × 2 vCPU · 4 GB mỗi pod
Op storeChưa cầnChưa cần
ScyllaDB (hoặc Bigtable), partition theo doc_id
24 node (8 mỗi AZ) × 8 vCPU · 64 GB · 4 TB NVMe mỗi node
Metadata + quyềnChưa cầnChưa cần
PostgreSQL shard theo doc_id (Citus)
8 shard × (1 primary + 1 standby) × 8 vCPU · 32 GB · 1 TB SSD
Redis ClusterChưa cầnChưa cần
ElastiCache Redis Cluster
3 shard × (1 primary + 1 replica) × 4 vCPU · 16 GB mỗi node

Đào sâu

Mỗi câu hỏi đi từ cách đơn giản đến cách người phỏng vấn mong đợi ở cấp senior.

1) Hai người cùng sửa một câu cùng lúc. Giải quyết xung đột thế nào: OT hay CRDT?

Cách chưa tốt
Gửi cả tài liệu, bản lưu sau thắng (last-write-wins)

Mỗi lần lưu ghi đè toàn bộ nội dung. Người lưu sau xóa mất chữ của người lưu trước mà không ai biết.

Đánh đổi: Chỉ dùng được khi một người sửa tại một thời điểm.

Nguồn: Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Concurrency in Collaborative Editing", mục "Concurrency issues" (hai người sửa cùng lúc cho kết quả sai nếu không có cơ chế xử lý xung đột)

Cách tốt
Khóa theo đoạn văn

Ai bấm vào đoạn nào thì khóa đoạn đó, người khác chỉ xem. Đơn giản, không cần thuật toán hội tụ. Cách này hợp với bảng tính vì mỗi ô là một đơn vị độc lập (Grokking ch.38, mục "Concurrency in Collaborative Editing").

Đánh đổi: Trải nghiệm kém với văn bản: hai người muốn sửa cùng đoạn dù không đụng nhau vẫn phải chờ, và khóa bị treo khi người giữ mất mạng.

Nguồn: Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Concurrency in Collaborative Editing", mục "Techniques for conflict resolution" (đoạn "Why can't we use locks to synchronize between users?": khóa hợp với Google Sheets, kém với văn bản)

Cách tốt nhất
OT với server trung tâm xếp thứ tự cho mỗi tài liệu

Op mang vị trí theo index. Server là nơi duy nhất ra thứ tự; op đến với baseVersion cũ được transform qua các op đã xếp trước nó, ví dụ hai người cùng chèn ở vị trí 10 thì một chèn bị dời ra sau. Client chỉ có tối đa một op đang chờ ack và một bộ đệm, nên chỉ cần transform giữa client và server, đơn giản hơn nhiều so với OT ngang hàng. Google Docs, Etherpad dùng hướng này.

Đánh đổi: Phải có server trung tâm cho từng tài liệu và hàm transform cho mọi cặp loại op (chèn, xóa, định dạng) rất dễ sai; offline lâu thì phải transform một đoạn dài khi kết nối lại.

Nguồn: Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Concurrency in Collaborative Editing", mục "Operational transformation" (lock-free, positional index, ví dụ hai insert ở vị trí 10) ; Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Evaluation of Google Docs' Design", mục "Consistency" (server tập trung cho thứ tự cuối cùng của các sự kiện)

Cách tốt nhất
CRDT (Yjs, Automerge): mỗi ký tự có id duy nhất

Mỗi ký tự mang id (siteId, counter) và chèn tương đối theo id ký tự bên cạnh nên op giao hoán và idempotent, áp theo thứ tự nào cũng hội tụ. Không cần server ra thứ tự; server chỉ chuyển tiếp và lưu. Hợp với offline lâu và đồng bộ ngang hàng.

Đánh đổi: Metadata lớn: ký tự đã xóa vẫn để lại dấu (tombstone) nên tài liệu nặng hơn vài lần và cần gom rác; phân quyền và kiểm tra op ở server khó hơn.

Nguồn: Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Concurrency in Collaborative Editing", mục "Conflict-free Replicated Data Type (CRDT)" (id duy nhất mỗi ký tự, offline rồi hội tụ, có thể peer-to-peer) ; Chưa có nguồn trong thư viện: tombstone và gom rác của CRDT sách không nêu chi tiết; chỉ giữ ý cơ bản

2) Mọi op của một tài liệu phải qua một nơi. Gắn tài liệu vào session server thế nào?

Cách chưa tốt
Round-robin, server nào cũng nhận op và đồng bộ qua DB

Mỗi server đọc version từ DB, transform rồi ghi. Hai server xử lý hai op cùng lúc thì phải khóa hàng tài liệu trong DB cho mỗi phím gõ.

Đánh đổi: Khóa DB trên mỗi op làm trễ tăng mạnh và DB thành nút thắt.

Nguồn: Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Evaluation of Google Docs' Design", mục "Scalability" (nhiều WebSocket server, mỗi tài liệu một operations queue)

Cách tốt
Sticky theo docId bằng consistent hashing ở load balancer

LB băm docId để mọi kết nối của một tài liệu tới cùng server (consistent hashing). Server giữ tài liệu trong RAM và xếp thứ tự không cần khóa.

Đánh đổi: Khi thêm/bớt server, một phần tài liệu đổi chủ; nếu server cũ còn sống thêm vài giây thì có lúc hai server cùng nhận op của một tài liệu.

Nguồn: Grokking Modern System Design · ch.8 Load Balancers ↗ · bài "Advanced Details of Load Balancers" (thuật toán LB) ; Grokking Modern System Design · ch.9 Databases ↗ · bài "Data Partitioning", mục "Consistent hashing" ; Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Evaluation of Google Docs' Design", mục "Scalability"

Cách tốt nhất
Owner duy nhất qua lease có epoch + fencing ở op log

Tài liệu thuộc một shard ảo; owner giữ lease trong hệ đồng thuận (etcd/ZooKeeper), mỗi lần đổi chủ thì epoch tăng. Op ghi vào log kèm epoch, và log từ chối ghi từ epoch cũ (với Postgres là unique (doc_id, version) cộng kiểm tra epoch; với Kafka là transactional.id theo shard để producer cũ bị fence). Server mới nhận shard thì nạp snapshot + op tail rồi phục vụ tiếp; client tự nối lại và gửi lại op chưa được ack. Grokking ch.38 cũng gợi ý mỗi tài liệu một hàng đợi op riêng.

Đánh đổi: Thêm một hệ đồng thuận phải vận hành; tài liệu rất hot (100 người sửa, 10.000 người xem) vẫn dồn vào một server nên phải tách người xem sang tầng phát lại riêng.

Nguồn: Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Evaluation of Google Docs' Design", mục "Scalability" (mỗi tài liệu một queue, forward op vào queue của tài liệu) ; Grokking Modern System Design · ch.9 Databases ↗ · bài "Data Partitioning", mục "Request routing" › "ZooKeeper" ; Chưa có nguồn trong thư viện: lease có epoch và fencing token không được sách nào trong thư viện mô tả; giữ lại như kỹ thuật cơ bản

3) Lưu op và snapshot thế nào để vừa bền, vừa mở nhanh, vừa có lịch sử?

Cách chưa tốt
Ghi lại toàn bộ tài liệu sau mỗi lần gõ

Tài liệu 100 KB, 2 op/s mỗi người gõ là 200 KB/s ghi cho mỗi người. Không còn lịch sử từng thay đổi.

Đánh đổi: Tốn băng thông và IO gấp hàng nghìn lần kích thước thay đổi thật.

Nguồn: Microservices Patterns ↗ (.zip) · ch.6 · §6.1.1 The trouble with traditional persistence (tr. 185–186) ; Microservices Patterns ↗ (.zip) · ch.6 · §6.1.2 Overview of event sourcing (tr. 186–193)

Cách tốt
Op log chỉ ghi thêm trong Postgres, khóa (doc_id, version)

Mỗi op một hàng ~100 B, khóa chính (doc_id, version) chặn hai op cùng version. Mở tài liệu thì đọc snapshot rồi áp op phía sau.

Đánh đổi: Không có snapshot định kỳ thì tài liệu sửa lâu năm phải áp hàng triệu op khi mở; bảng op tăng hàng trăm GB mỗi ngày ở quy mô lớn.

Nguồn: Microservices Patterns ↗ (.zip) · ch.6 · §6.1.2 Overview of event sourcing (lưu chuỗi event thay vì trạng thái, tr. 186–193) ; Microservices Patterns ↗ (.zip) · ch.6 · §6.1.3 Handling concurrent updates using optimistic locking (tr. 193–194)

Cách tốt nhất
Op log + snapshot mỗi 1.000 op + gộp op cũ, ack sau khi ghi bền

Đây là event sourcing kèm snapshot (Microservices Patterns §6.1.5). Session server gom op của nhiều tài liệu, ghi một lô mỗi 20 ms rồi mới ack và phát, nên op đã ack không bao giờ mất. Cứ 1.000 op thì lưu snapshot lên blob store; mở tài liệu chỉ cần snapshot cộng tối đa 1.000 op. Op cũ hơn 7–30 ngày được gộp thành các mốc phiên bản theo giờ/ngày để lịch sử vẫn xem được mà dung lượng giảm mạnh.

Đánh đổi: Ack trễ thêm tới 20 ms và phải chạy job gộp; lịch sử cũ chỉ còn độ chi tiết theo mốc.

Nguồn: Microservices Patterns ↗ (.zip) · ch.6 · §6.1.5 Using snapshots to improve performance (chỉ cần snapshot cộng các event sau nó, tr. 195–197) ; Chưa có nguồn trong thư viện: việc gộp op cũ thành mốc phiên bản theo giờ/ngày là thiết kế của khoá, sách chỉ nêu snapshot

4) Hiện con trỏ và danh sách người đang xem thế nào khi một tài liệu có hàng nghìn người mở?

Cách chưa tốt
Ghi vị trí con trỏ vào DB mỗi lần di chuyển

Con trỏ đổi liên tục khi gõ và bấm chuột. Ghi DB cho dữ liệu chỉ sống vài giây là lãng phí và chậm.

Đánh đổi: Tải ghi lớn hơn cả op thật, trong khi dữ liệu không cần bền.

Nguồn: Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Concurrency in Collaborative Editing", ghi chú cuối bài (WebSocket cho phép hiển thị con trỏ) ; Chưa có nguồn trong thư viện: presence là dữ liệu tạm, không cần ghi bền; đây là quyết định thiết kế của khoá

Cách tốt
Gửi presence qua cùng WebSocket, phát mỗi thay đổi

Presence ở trong RAM session server, ai di chuyển con trỏ thì phát cho mọi người khác. Đủ tốt với nhóm vài người.

Đánh đổi: N người mở thì mỗi lần di chuyển sinh N tin; 1.000 người mở là bùng nổ N² tin.

Nguồn: Grokking Modern System Design · ch.38 Google Docs ↗ · bài "Concurrency in Collaborative Editing", ghi chú cuối bài (WebSocket để hiển thị con trỏ cộng tác viên) ; Grokking Modern System Design · ch.18 Pub-sub ↗ · bài "Introduction to Pub-sub", mục "Use cases of pub-sub"

Cách tốt nhất
Presence tạm trong RAM, gộp mỗi 100 ms, vị trí neo theo nội dung, giới hạn người hiển thị

Server gộp mọi thay đổi presence thành một gói mỗi 100 ms. Con trỏ lưu dạng vị trí tương đối (neo vào op/ký tự) để không lệch khi người khác chèn chữ phía trước. Chỉ phát con trỏ của người đang sửa; người chỉ xem hiển thị thành số đếm và vài avatar. Danh sách 'ai đang mở' cho trang ngoài lưu Redis có TTL 30 s, làm mới bằng heartbeat.

Đánh đổi: Khi session server chuyển chủ, presence mất vài giây rồi tự dựng lại; chấp nhận được vì đây là dữ liệu tạm.

Nguồn: Redis in Action ↗ · ch.3 · §3.7 Other commands › "Expiring keys" (tr. 61–62) ; Chưa có nguồn trong thư viện: gộp gói presence mỗi 100 ms và neo vị trí theo ký tự là thiết kế của khoá, sách không đề cập

Trong phỏng vấn

Nên nói
  • “Mọi op của một tài liệu đi qua một chủ duy nhất để có thứ tự chung; tôi dùng OT kiểu client-server vì chỉ cần transform giữa client và server.”
  • “Client áp op ngay trên máy mình, server ack sau khi đã ghi bền, nên gõ không chờ mạng mà op đã ack không mất.”
  • “Lưu op log chỉ ghi thêm cộng snapshot mỗi 1.000 op; lịch sử và khôi phục đều dựa trên đó, khôi phục là một op mới.”
  • “Presence là dữ liệu tạm trong RAM, gộp mỗi 100 ms, không ghi DB.”
Bẫy hay gặp
  • Lưu cả tài liệu sau mỗi phím gõ hoặc dùng last-write-wins.
  • Cho nhiều server cùng nhận op của một tài liệu mà không có fencing khi đổi chủ.
  • Ack cho client trước khi op được ghi bền.
  • Ghi con trỏ vào DB và phát presence của mọi người xem cho mọi người.

Đọc thêm

Cập nhật realtime
Đẩy thay đổi tới client trong vài trăm mili giây: chọn giữa polling, long polling, SSE, WebSocket, rồi fan-out qua nhiều gateway, xử lý reconnect và backpressure.
Consistent hashing
Consistent hashing chia key cho nhiều node sao cho khi thêm hoặc bớt node chỉ một phần nhỏ key phải di chuyển. Bài này đi từ vấn đề của hash modulo tới hash ring, virtual node, replication dọc vòng và một cài đặt TypeScript ngắn.
Kafka
Kafka là log phân tán bền vững: topic chia partition, consumer đọc theo offset và có thể đọc lại. Bài này đi qua thứ tự theo partition, consumer group, replication với ISR và acks, giới hạn của exactly-once, cách chọn số partition và so sánh với SQS, Pub/Sub, RabbitMQ.
Cassandra
Cassandra là database wide-column phân tán, ghi rất nhanh nhờ LSM tree, mở rộng gần tuyến tính bằng ring và consistent hashing. Bài này giải thích partition key, clustering column, cách mô hình hoá theo câu truy vấn, tunable consistency và những cái bẫy như tombstone.
ZooKeeper
ZooKeeper là dịch vụ điều phối cho hệ phân tán: bầu leader, lưu cấu hình, service discovery và distributed lock dựa trên cây znode, node ephemeral và sequential, đồng thuận bằng ZAB. Bài này giải thích cách nó hoạt động, vì sao Kafka chuyển sang KRaft, và khi nào chọn etcd hay Consul.
Redis
Redis là kho key-value trong RAM với nhiều cấu trúc dữ liệu: dùng làm cache, bộ đếm, leaderboard, hàng đợi, GEO và pub/sub. Bài này đi qua mô hình dữ liệu, persistence, replication, Cluster, Lua và cách tính RAM.

Sách trong thư viện của bạn. Số chương có thể lệch nhẹ so với bản PDF.