Soạn thảo tài liệu cùng lúc
Hiểu đề
Yêu cầu chức năng
- 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)
- 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
- Người dùng thấy ai đang mở tài liệu, con trỏ và vùng chọn của họ
- 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
| Method | Endpoint | Ghi chú |
|---|---|---|
| POST | /v1/docs | Tạ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}/session | Client 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}/restore | Body {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}/shares | Chia 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.
- 1Client gọi GET /v1/docs/{id}, API kiểm tra quyền
- 2API đọc snapshot mới nhất từ blob store và các op sau snapshot từ op log
- 3Client dựng tài liệu cục bộ, nhớ version hiện tại
- 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.
- 1Client gộp phím trong 100–200 ms thành một op, áp cục bộ, gửi {baseVersion, op}
- 2Session server lấy các op từ baseVersion đến hiện tại, transform op mới qua từng op đó
- 3Gán version = hiện tại + 1, ghi vào op log
- 4Ack cho người gửi, phát op đã transform tới các client khác của tài liệu
- 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.
- 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
- 2Session server cập nhật bảng presence trong RAM của tài liệu
- 3Mỗi 100 ms server phát một gói presence gộp cho các client
- 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.
- 1Client gọi GET /history, API trả các mốc phiên bản đã gộp
- 2Xem bản cũ: lấy snapshot gần nhất trước version đó rồi áp op tới version cần
- 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.
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.
Ướ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ần | Chọn | Số lượng | Cấu hình | Vì sao |
|---|---|---|---|---|
| Load balancer | Nginx / Envoy, hash theo docId | 1 (managed hoặc 2 VM) | Hỗ trợ WebSocket | Mọi kết nối của một tài liệu tới cùng session server. |
| API REST | Node.js (NestJS) | 2 | 1 vCPU · 2 GB RAM | Mở tài liệu 10.000 × 10 / 86.400 ≈ 1,2 req/s, rất nhẹ; 2 để dự phòng. |
| Session server | Node.js + ws, OT trong RAM | 2 | 2 vCPU · 4 GB RAM | 1.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. |
| Database | PostgreSQL managed | 1 primary + 1 standby | 2 vCPU · 8 GB · 100 GB SSD | 400 op/s ghi theo lô; 36 GB op log nóng; unique (doc_id, version) chặn ghi trùng. |
| Blob store | S3 / GCS | 1 | Snapshot và ảnh trong tài liệu | Chỉ giữ snapshot mới nhất và một bản mỗi ngày cho mỗi tài liệu. |
- Đ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
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ần | Mức 1 | Mức 2 | Mức 3 |
|---|---|---|---|
| Người dùng | 10.000 DAU (công cụ nội bộ hoặc startup) | 1 triệu DAU | 50 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 |
| CDN | Chưa cần | CloudFront / Cloud CDN 1 × JS/CSS của editor, ảnh trong tài liệu | Chưa cần |
| Redis | Chưa cần | ElastiCache / Memorystore 1 primary + 1 replica × 2 vCPU · 4 GB | Chưa cần |
| Compaction worker | Chưa cần | Node.js job chạy đêm 2 × 2 vCPU · 4 GB RAM | Chưa cần |
| Global LB + CDN | Chưa cần | Chư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 map | Chưa cần | Chưa cần | etcd (hoặc ZooKeeper) 3 node mỗi region × 2 vCPU · 8 GB · SSD |
| Kafka op log | Chưa cần | Chưa cần | MSK / Confluent 9 broker (3 mỗi AZ) × 512 partition, RF 3, acks=all, transactional.id theo shard ảo |
| Op archiver | Chưa cần | Chưa cần | Node.js/Go consumer trên Kubernetes 32 pod × 2 vCPU · 4 GB mỗi pod |
| Op store | Chưa cần | Chư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ền | Chưa cần | Chưa cần | PostgreSQL shard theo doc_id (Citus) 8 shard × (1 primary + 1 standby) × 8 vCPU · 32 GB · 1 TB SSD |
| Redis Cluster | Chưa cần | Chư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?
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)
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)
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)
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?
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)
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"
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ử?
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)
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)
Đâ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ở?
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á
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"
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
- “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.”
- 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
Sách trong thư viện của bạn. Số chương có thể lệch nhẹ so với bản PDF.