Tìm kiếm bài viết mạng xã hội
Hiểu đề
Yêu cầu chức năng
- Người dùng có thể tìm bài viết theo từ khóa, gõ có dấu hoặc không dấu đều ra kết quả
- Kết quả mặc định xếp theo độ liên quan kết hợp độ mới và lượt like; có thể chọn "Mới nhất"
- Bài vừa đăng xuất hiện trong kết quả tìm kiếm sau vài giây; bài bị xóa biến mất sau vài giây
- Người dùng chỉ thấy bài mình có quyền xem (công khai, hoặc chế độ bạn bè khi là bạn của tác giả)
- Kết quả phân trang theo kiểu cuộn vô hạn
Yêu cầu phi chức năng
- Tìm kiếm p95 < 300 ms khi tìm trong 7 ngày gần nhất, < 1,5 s khi tìm toàn bộ lịch sử
- Bài mới tìm được sau ≤ 10 s; số like dùng để xếp hạng được phép trễ 1-2 phút
- Chịu được 1 tỷ like/ngày và 25 triệu bài mới/ngày ở quy mô lớn mà không làm chậm tìm kiếm
- 0 lần lộ bài riêng tư cho người không có quyền
- Độ sẵn sàng 99,9%; index hỏng có thể dựng lại hoàn toàn từ kho bài gốc
Ngoài phạm vi: Tìm người, trang, nhóm (chỉ tìm bài viết); Gợi ý khi đang gõ (typeahead); Tìm theo nội dung ảnh/video; Quảng cáo chèn trong kết quả, cá nhân hóa sâu bằng mô hình ML lớn.
Thực thể và API
| Method | Endpoint | Ghi chú |
|---|---|---|
| GET | /v1/search/posts?q=&sort=top|recent&range=7d|all&cursor= | Trả 20 bài kèm cursor (search_after), không dùng offset để trang sâu không chậm. |
| POST | /v1/posts | Tạo bài. Ghi kho bài và outbox trong cùng transaction, sự kiện post_created đi tới indexer. |
| PATCH | /v1/posts/{id} | Sửa nội dung hoặc quyền xem. Sự kiện post_updated kèm version để index không ghi đè bản mới bằng bản cũ. |
| DELETE | /v1/posts/{id} | Xóa mềm; sự kiện post_deleted gỡ bài khỏi index. |
| POST | /v1/posts/{id}/likes | Like, idempotent theo (post_id, user_id). Phát sự kiện like vào Kafka cho bộ đếm và search. |
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ể tìm bài viết theo từ khóa
Search API chuẩn hóa câu truy vấn, hỏi search index để lấy danh sách post_id đã xếp hạng, lọc quyền xem ngay trong truy vấn, rồi lấy nội dung bài từ kho bài. Index chỉ cần trả id và điểm, nội dung hiển thị luôn lấy từ nguồn sự thật.
- 1Client gọi GET /v1/search/posts?q=quan an ngon q1
- 2Search API chuẩn hóa: chữ thường, tách từ, tạo cả dạng có dấu và bỏ dấu, mở rộng viết tắt (q1 → quận 1)
- 3Xem cache Redis cho phần kết quả công khai của câu truy vấn phổ biến
- 4Hỏi index: khớp text AND (visibility = PUBLIC OR author_id IN danh sách bạn của người tìm), sắp theo điểm
- 5Nhận 20 post_id + điểm, lấy nội dung và số like hiện tại theo lô từ kho bài, trả về kèm cursor
Nguồn: Grokking Modern System Design · ch.21 Distributed Search ↗ · bài "Indexing" · mục "Inverted index", "Searching from an inverted index" ; Grokking Modern System Design · ch.21 Distributed Search ↗ · bài "Design of a Distributed Search System" · mục "Distributed indexing and searching" ; Grokking Modern System Design · ch.16 Distributed Cache ↗ · mục "Writing policies" ; Chưa có nguồn trong thư viện: chuẩn hóa tiếng Việt và bỏ dấu
2) Bài mới, bài sửa, bài xóa phản ánh vào kết quả sau vài giây
Post API không ghi thẳng vào search index. Nó ghi bài và một bản ghi outbox trong cùng transaction, relay đẩy sự kiện vào queue, indexer đọc và ghi vào index theo lô. Nhờ vậy DB và index không lệch nhau khi một bên lỗi.
- 1Client POST /v1/posts, Post API ghi bài + outbox trong 1 transaction
- 2Relay (hoặc CDC đọc WAL) đẩy post_created vào queue/Kafka, key = post_id để giữ thứ tự từng bài
- 3Indexer gom sự kiện trong 1 s, gửi bulk request vào index, dùng updated_at làm external version
- 4Index refresh mỗi 1 s nên bài mới tìm được sau khoảng 2-5 s
- 5post_deleted thì xóa doc; Search API vẫn kiểm tra lại cờ deleted khi lấy nội dung
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (transactional outbox, transaction log tailing, tr. 97–100) ; Grokking Modern System Design · ch.21 Distributed Search ↗ · bài "Design of a Distributed Search System" · mục "Indexing with replicas" ; Grokking Modern System Design · ch.17 Distributed Messaging Queue (đệm giữa producer và consumer) ↗
3) Kết quả xếp theo độ liên quan, độ mới và lượt like
Điểm cuối cùng là tích của điểm khớp từ khóa (BM25), hệ số giảm dần theo tuổi bài và hệ số log theo lượt like. Lượt like không cập nhật vào index theo từng cú click mà được gom lại rồi đẩy theo mốc.
- 1Sự kiện like đi vào Kafka, bộ gom (Flink hoặc consumer) cộng dồn theo post_id mỗi 60 s
- 2Khi số like của bài vượt mốc (1, 5, 10, 50, 100, 500...) thì cập nhật like_bucket trong index
- 3Index tính điểm: BM25 × gauss(created_at, scale 3 ngày) × log(1 + like_bucket), lấy top 200 ứng viên
- 4Search API xếp lại 200 ứng viên bằng số like thật từ bộ đếm và quan hệ bạn bè, trả trang đầu
Nguồn: Grokking Modern System Design · ch.21 Distributed Search ↗ · mục "Searching from an inverted index" (xếp theo độ liên quan) ; Grokking Modern System Design · ch.24 Sharded Counters ↗ · mục "Problem statement" (cập nhật like dồn dập cần gom) ; Chưa có nguồn trong thư viện: công thức BM25 và hàm decay
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.
Mạng xã hội nhỏ cho một cộng đồng. Tìm kiếm chạy ngay trong Postgres bằng full-text (tsvector + unaccent + index GIN), không cần hệ thống tìm kiếm riêng.
Ước lượng
- Tìm: 10.000 × 2 lượt / 86.400 ≈ 0,23 req/s, đỉnh ×10 ≈ 2,3 req/s
- API: ceil(2,3 / (1.000 × 0,6)) = 1 → lấy tối thiểu 2 instance để dự phòng
- Bài mới: 10.000 × 0,5 = 5.000 bài/ngày × 1 KB = 5 MB/ngày × 365 ≈ 1,8 GB/năm
- Index GIN ≈ 50% dữ liệu: 1,8 GB × 0,5 ≈ 0,9 GB/năm, nằm gọn trong 8 GB RAM
- Like: 10.000 × 20 / 86.400 ≈ 2,3 ghi/s, UPDATE like_count trực tiếp vẫn nhẹ
Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · mục "Requests estimation", "Storage requirements" ; Grokking Modern System Design · ch.21 Distributed Search ↗ · bài "Requirements of a Distributed Search System's Design" · mục "Resource estimation" ; Quy tắc ước lượng của khoá học · /hoc/chon-server (600 req/s mỗi instance, 60% CPU, Redis ×1,3, kích thước index/shard và năng lực Elasticsearch là giả định của khoá học, cần load test)
Bảng hạ tầng
| Thành phần | Chọn | Số lượng | Cấu hình | Vì sao |
|---|---|---|---|---|
| API | Node.js (NestJS) trên Cloud Run | 2 | 2 vCPU · 4 GB RAM | 2,3 req/s tìm kiếm cộng phần còn lại của app vẫn xa mức 600 req/s một instance. |
| Database | PostgreSQL managed | 1 | 2 vCPU · 8 GB · 50 GB SSD | Bài, like, bạn bè và full-text index cùng một chỗ; vài GB dữ liệu là rất nhỏ. |
| Full-text index | Cột tsvector (unaccent + simple) + index GIN, pg_trgm cho gõ sai | 1 index | ≈ 0,9 GB/năm | Bài mới tìm được ngay sau khi commit, không có độ trễ đồng bộ. |
| Xếp hạng | ts_rank × hệ số tuổi bài × log(1 + like_count) trong SQL | - | Chạy trong câu truy vấn | Số bài khớp mỗi truy vấn chỉ vài trăm nên tính điểm trong DB vẫn nhanh. |
| Backup | Snapshot tự động của dịch vụ managed | 1/ngày | Giữ 7 ngày | Index nằm trong DB nên khôi phục DB là khôi phục luôn tìm kiếm. |
- Điểm xuất phát: một API, một Postgres, tìm kiếm bằng full-text có sẵn
- Bỏ dấu bằng unaccent để gõ không dấu vẫn ra bài
- Lọc quyền xem bằng WHERE ngay trong câu SQL
Khi kho bài lên hàng trăm triệu, index GIN không còn vừa RAM, mỗi bài mới làm ghi chậm đi và các truy vấn từ phổ biến quét hàng triệu dòng. Cần tách tìm kiếm ra một cụm riêng.
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 | 1 triệu DAU | 50 triệu DAU |
| Chi phí | ≈ 50–120 USD/tháng (ước lượng) | ≈ 2.500–5.000 USD/tháng (ước lượng, phần lớn là cụm Elasticsearch) | ≈ 30.000–60.000 USD/tháng (ước lượng, phần lớn là 26 node Elasticsearch) |
| API | Node.js (NestJS) trên Cloud Run 2 × 2 vCPU · 4 GB RAM | Chưa cần | Chưa cần |
| Database | PostgreSQL managed 1 × 2 vCPU · 8 GB · 50 GB SSD | PostgreSQL managed (HA) 1 primary + 1 read replica × 4 vCPU · 16 GB · 500 GB SSD | Chưa cần |
| Full-text index | Cột tsvector (unaccent + simple) + index GIN, pg_trgm cho gõ sai 1 index × ≈ 0,9 GB/năm | Chưa cần | Chưa cần |
| Xếp hạng | ts_rank × hệ số tuổi bài × log(1 + like_count) trong SQL - × Chạy trong câu truy vấn | Chưa cần | Chưa cần |
| Backup | Snapshot tự động của dịch vụ managed 1/ngày × Giữ 7 ngày | Chưa cần | Chưa cần |
| Search API | Chưa cần | Node.js (NestJS), container autoscale 2–4 (autoscale) × 2 vCPU · 4 GB RAM | Node.js trên Kubernetes, 3 AZ 6 pod (HPA 6–12) × 2 vCPU · 4 GB mỗi pod |
| Elasticsearch | Chưa cần | Elastic Cloud / OpenSearch Service, index theo tháng 3 node × 8 vCPU · 32 GB · 500 GB SSD | Chưa cần |
| Queue | Chưa cần | Outbox + Amazon SQS / Google Pub/Sub 1 × Managed, có dead-letter queue | Chưa cần |
| Indexer | Chưa cần | Node.js consumer, bulk 1 s, gom like 60 s 2 × 1 vCPU · 2 GB RAM | Node.js consumer, bulk 1 s, external version 4 pod × 2 vCPU · 4 GB |
| Redis | Chưa cần | ElastiCache / Memorystore 1 primary + 1 replica × 2 vCPU · 4 GB | Chưa cần |
| ES hot | Chưa cần | Chưa cần | Elasticsearch, index theo ngày, ILM 14 node × 8 vCPU · 32 GB · 1 TB NVMe |
| ES warm | Chưa cần | Chưa cần | Elasticsearch, index gộp theo tuần 9 node × 8 vCPU · 32 GB · 4 TB HDD |
| ES master + cold | Chưa cần | Chưa cần | 3 master chuyên trách + searchable snapshot trên S3 3 node + 1 bucket × 4 vCPU · 8 GB; ≈ 13,7 TB/năm trên S3 |
| Kafka | Chưa cần | Chưa cần | MSK / Confluent 3 broker (1/AZ) × Topic posts 6 partition, likes 12 partition, RF 3 |
| Gom like | Chưa cần | Chưa cần | Flink job, cửa sổ 60 s theo post_id 3 TaskManager × 4 vCPU · 8 GB |
| Redis Cluster | Chưa cần | Chưa cần | ElastiCache, cache truy vấn công khai 1 primary + 2 replica × 4 vCPU · 8 GB |
| Kho bài + bộ đếm | Chưa cần | Chưa cần | Dịch vụ sẵn có của hệ thống feed (ScyllaDB/Redis) dùng chung × Lấy nội dung và số like theo lô |
Đà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) Index chứa hàng tỷ bài, hàng chục TB: chia shard thế nào và bài cũ để ở đâu?
Tốt ở quy mô nhỏ, nhưng 9 tỷ bài một năm làm index GIN vượt xa RAM và ổ của một node. Mỗi bài mới phải cập nhật GIN, ghi chậm dần và VACUUM nặng.
Đánh đổi: Không cần thêm hệ thống nào, nhưng chỉ chạy tốt tới vài chục triệu bài.
Nguồn: Chưa có nguồn trong thư viện: GIN index của Postgres không có trong sách ; Seven Databases in Seven Weeks ↗ (.zip) · ch.2 PostgreSQL · mục "Full-Text Search" (tìm kiếm văn bản trong Postgres)
Mỗi shard là một inverted index nhỏ chứa một phần bài viết; truy vấn gửi tới mọi shard rồi gộp top-k. Grokking ch.21 so sánh với chia theo term (mỗi node giữ một nhóm từ) và chọn chia theo document vì truy vấn nhiều từ không phải giao posting list qua mạng, còn từ phổ biến như "hà nội" không dồn hết vào một node.
Đánh đổi: Mọi truy vấn chạm mọi shard, kể cả dữ liệu 5 năm trước; muốn đổi số shard phải reindex toàn bộ, xóa bài cũ phải delete-by-query tốn kém.
Nguồn: Grokking Modern System Design · ch.21 Distributed Search ↗ · bài "Design of a Distributed Search System" · mục "Distributed indexing and searching" (Document partitioning và Term partitioning) ; Grokking Modern System Design · ch.21 Distributed Search ↗ · mục "Replication"
Mỗi ngày một index (khoảng 37,5 GB ở quy mô lớn, vừa một shard). 7 ngày gần nhất nằm ở node hot SSD, có nhiều bản sao để đỡ tải đọc. Từ ngày 8 tới 1 năm, index được gộp theo tuần (shrink + force merge) và chuyển sang node warm ổ lớn rẻ hơn. Quá 1 năm thì chuyển sang snapshot trên S3, tìm được nhưng chậm. Search API hỏi tầng hot trước, chỉ hỏi warm/cold khi người dùng chọn "mọi thời gian" hoặc hot chưa đủ kết quả. Xóa dữ liệu cũ chỉ là xóa nguyên index.
Đánh đổi: Tìm bài cũ chậm hơn hẳn và phải quản lý vòng đời index (ILM), alias, việc gộp index; bài cũ bỗng viral vẫn nằm ở tầng warm.
Nguồn: Grokking Modern System Design · ch.21 Distributed Search ↗ · mục "Replication", "Replication factor and replica distribution" ; Chưa có nguồn trong thư viện: index theo ngày, tầng hot/warm/cold, shrink, snapshot S3
2) Bài mới và 35.000 like/s đi vào index real-time thế nào mà không làm sập cụm tìm kiếm?
Ghi hai nơi trong một request: một bên lỗi là DB và index lệch nhau vĩnh viễn. Mỗi lần cập nhật một doc, Elasticsearch phải đánh index lại cả doc và tạo segment mới, 35.000 lần mỗi giây là cụm chỉ còn lo merge segment.
Đánh đổi: Dễ code nhất nhưng vừa mất nhất quán vừa giết hiệu năng index.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (transactional outbox, transaction log tailing, tr. 97–100) (ghi hai nơi không nguyên tử gây lệch) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages
Bài viết đi qua outbox hoặc CDC đọc WAL (Microservices Patterns §3.3.7) nên không mất sự kiện. Indexer gom 1 s rồi gửi bulk. Like được cộng dồn theo post_id mỗi 60 s, mỗi bài chỉ cập nhật index tối đa 1 lần/phút.
Đánh đổi: Số like trong index trễ 1 phút; bài viral vẫn bị cập nhật mỗi phút dù thứ hạng không đổi.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (transactional outbox, transaction log tailing, tr. 97–100) ; Grokking Modern System Design · ch.24 Sharded Counters ↗ · mục "Problem statement", "Detailed design" (gom cập nhật bộ đếm)
Nội dung bài đi qua Kafka, key = post_id để giữ thứ tự, indexer dùng updated_at làm external version nên sự kiện đến muộn không ghi đè bản mới. Like đi qua bộ gom (Flink), chỉ khi bài vượt mốc log (1, 5, 10, 50, 100...) mới cập nhật like_bucket; ước tính chỉ khoảng 5% lượt like gây ra một lần cập nhật. Số like chính xác dùng ở bước xếp lại top 200 lấy từ bộ đếm, không lấy từ index. Khi đổi analyzer hoặc hỏng index thì replay Kafka hoặc quét kho bài để dựng lại.
Đánh đổi: Thêm Kafka và một job streaming phải vận hành; thứ hạng trong index chỉ gần đúng nên bắt buộc có bước xếp lại.
Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Strict ordering" ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages ; Chưa có nguồn trong thư viện: thư viện không có sách về Elasticsearch/Kafka/Flink; chỉ nêu khái niệm cơ bản (external version, like_bucket)
3) Xếp hạng kết hợp độ liên quan, độ mới và lượt like thế nào?
Theo thời gian thì trang đầu toàn bài spam vừa đăng có chứa từ khóa. Theo like thì luôn là vài bài viral cũ, kém liên quan.
Đánh đổi: Đơn giản, dễ giải thích, nhưng cảm giác kết quả "sai" ngay với người dùng.
Nguồn: Grokking Modern System Design · ch.21 Distributed Search ↗ · mục "Searching from an inverted index" (kết quả nên sắp theo độ liên quan thay vì một tiêu chí)
BM25 đo độ khớp từ khóa. Hàm gauss giảm điểm theo tuổi bài, ví dụ còn một nửa sau 3 ngày. Dùng log cho like để bài 1 triệu like không lấn át hết bài 100 like liên quan hơn. Tab "Mới nhất" chỉ sắp theo created_at trong các bài khớp.
Đánh đổi: Trọng số chỉnh tay; hàm điểm chạy trên mọi doc khớp nên tốn CPU với từ khóa phổ biến.
Nguồn: Lý thuyết · BM25: điểm của tài liệu D với truy vấn Q là tổng theo từng từ q của IDF(q) × f × (k1 + 1) ÷ (f + k1 × (1 − b + b × |D| ÷ avgdl)), với f là số lần q xuất hiện trong D, |D| là độ dài D, avgdl là độ dài trung bình, k1 và b là tham số (thường k1 khoảng 1,2 đến 2, b khoảng 0,75). Từ hiếm có IDF cao, và độ lặp lại có hiệu ứng bão hòa. Chưa có nguồn trong thư viện: Elasticsearch, Kafka, Flink; function_score và hàm decay chỉ nêu khái niệm
Giai đoạn 1 trong index: BM25 + decay + like_bucket, chỉ lấy top 200 mỗi shard rồi gộp. Giai đoạn 2 ở Search API: dùng số like/comment thật, tốc độ tăng like trong 1 giờ qua, tác giả là bạn hay người theo dõi, độ tin cậy của tác giả, rồi đa dạng hóa để một tác giả không chiếm cả trang. Log lượt click để chỉnh trọng số bằng A/B test.
Đánh đổi: Thêm 20-50 ms và phải giữ snapshot kết quả theo cursor để cuộn trang không bị trùng hay nhảy thứ tự.
Nguồn: Grokking Modern System Design · ch.21 Distributed Search ↗ · mục "Searching from an inverted index" (tìm ứng viên rồi xếp hạng) ; Chưa có nguồn trong thư viện: xếp hạng hai giai đoạn, đa dạng hóa và A/B test
4) Tách từ tiếng Việt: "hà nội", "ha noi", "hanoi" đều phải tìm ra bài, "học sinh" không được ra bài "sinh học"
Tiếng Việt viết theo âm tiết, một từ có thể gồm 2-3 âm tiết. Tách theo khoảng trắng thì "học sinh" thành hai token độc lập nên bài "sinh học" cũng khớp. Gõ không dấu thì gần như không ra gì.
Đánh đổi: Có sẵn, không phải cấu hình, nhưng chất lượng kết quả rất kém.
Nguồn: Grokking Modern System Design · ch.21 Distributed Search ↗ · bài "Indexing" · mục "Inverted index" (tách tài liệu thành từ/term rồi lập chỉ mục) ; Chưa có nguồn trong thư viện: đặc thù âm tiết tiếng Việt
Field text giữ dấu, field text.folded bỏ dấu. Truy vấn tìm trên cả hai, khớp đúng dấu được điểm cao hơn. Thêm shingle 2 âm tiết hoặc match_phrase để "học sinh" đúng thứ tự được cộng điểm.
Đánh đổi: Bỏ dấu gây nhập nhằng ("ban" khớp bạn, bán, bàn); index to thêm khoảng 1,5-2 lần vì nhiều field.
Nguồn: Grokking Modern System Design · ch.21 Distributed Search ↗ · bài "Indexing" · mục "Inverted index" ; Chưa có nguồn trong thư viện: asciifolding và shingle của Elasticsearch
Field chính dùng bộ tách từ tiếng Việt dựa trên từ điển (ví dụ plugin coccoc-tokenizer hoặc VnCoreNLP chạy trước khi index) để "học sinh" là một token. Thêm field bỏ dấu, field shingle âm tiết, bộ synonym cho viết tắt và teencode (ko → không, hn → hà nội, q1 → quận 1) và edge n-gram để "hanoi" khớp "ha noi". Điểm: đúng từ ghép có dấu > đúng từ ghép bỏ dấu > khớp âm tiết rời.
Đánh đổi: Plugin phải cài lên mọi node, đổi analyzer là phải reindex; từ điển và danh sách viết tắt cần cập nhật liên tục theo cách người dùng gõ.
⚠ Chưa có nguồn trong thư viện: coccoc-tokenizer, VnCoreNLP, synonym và edge n-gram không có trong sách; chỉ nêu ý tưởng cơ bản
Trong phỏng vấn
- “Index chỉ là bản sao để tìm; nguồn sự thật là kho bài, nên tôi đồng bộ qua outbox/Kafka và luôn dựng lại được index.”
- “Tôi chia theo document và theo ngày: phần lớn truy vấn chỉ cần 7 ngày gần nhất nên chỉ tầng hot cần SSD và nhiều bản sao.”
- “Like không cập nhật index theo từng click; tôi gom lại, chỉ cập nhật khi vượt mốc, rồi xếp lại top 200 bằng số like thật.”
- “Quyền xem được lọc ngay trong truy vấn index, không lọc sau khi đã lấy trang kết quả.”
- Cập nhật doc trong Elasticsearch cho mỗi lượt like.
- Ghi DB và index trong cùng request (dual write) mà không có outbox.
- Dùng analyzer mặc định cho tiếng Việt, quên trường hợp gõ không dấu và từ ghép.
- Lọc quyền xem sau khi phân trang làm trang thiếu kết quả hoặc lộ bài riêng tư qua cache.
Đọ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.