Harbor

Tìm kiếm bài viết mạng xã hội

KhóInverted indexIndex real-timeXếp hạngTiếng ViệtGiống Facebook Search, tìm bài trên X (Twitter)

Hiểu đề

Tìm kiếm bài viết mạng xã hội là gì? Ô tìm kiếm bài viết trong một mạng xã hội: gõ "bão số 3" hay "quan an ngon q1" và nhận các bài liên quan, ưu tiên bài mới và nhiều like. Thách thức là kho bài tăng hàng chục triệu mỗi ngày, lượt like đổ vào liên tục và người dùng gõ tiếng Việt lúc có dấu lúc không.

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

  1. 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ả
  2. 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"
  3. 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
  4. 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ả)
  5. 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

Post
Bài viết gốc: post_id, author_id, text, visibility (PUBLIC/FRIENDS/ONLY_ME), created_at, updated_at, deleted. Nguồn sự thật, nằm ở kho bài của hệ thống feed.
Like
Một lượt like: (post_id, user_id, created_at). Khóa chính kép để like lại là idempotent.
PostCounter
Số like/comment hiện tại của một bài, do dịch vụ đếm giữ; search chỉ nhận bản gom lại.
SearchDoc
Bản ghi trong search index: post_id, text đã phân tích (có dấu, bỏ dấu, tách từ), author_id, visibility, created_at, like_bucket, version.
Friendship
Quan hệ bạn bè, dùng để lọc bài chế độ FRIENDS lúc tìm kiếm.
MethodEndpointGhi 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/postsTạ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}/likesLike, 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.

  1. 1Client gọi GET /v1/search/posts?q=quan an ngon q1
  2. 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)
  3. 3Xem cache Redis cho phần kết quả công khai của câu truy vấn phổ biến
  4. 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
  5. 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.

  1. 1Client POST /v1/posts, Post API ghi bài + outbox trong 1 transaction
  2. 2Relay (hoặc CDC đọc WAL) đẩy post_created vào queue/Kafka, key = post_id để giữ thứ tự từng bài
  3. 3Indexer gom sự kiện trong 1 s, gửi bulk request vào index, dùng updated_at làm external version
  4. 4Index refresh mỗi 1 s nên bài mới tìm được sau khoảng 2-5 s
  5. 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.

  1. 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
  2. 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
  3. 3Index tính điểm: BM25 × gauss(created_at, scale 3 ngày) × log(1 + like_bucket), lấy top 200 ứng viên
  4. 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.

Người dùng
10.000 DAU
Tải đỉnh
≈ 2,3 req/s tìm kiếm đỉnh
Chi phí ước lượng
≈ 50–120 USD/tháng (ước lượng)

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.

Người dùngNgười dùngDịch vụAPI Node.jsDatabasePostgreSQLTìm kiếmIndex GINHTTPSbài, liketo_tsquerycập nhật tsvector

Ướ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ầnChọnSố lượngCấu hìnhVì sao
APINode.js (NestJS) trên Cloud Run22 vCPU · 4 GB RAM2,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.
DatabasePostgreSQL managed12 vCPU · 8 GB · 50 GB SSDBà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 indexCột tsvector (unaccent + simple) + index GIN, pg_trgm cho gõ sai1 index≈ 0,9 GB/nămBài mới tìm được ngay sau khi commit, không có độ trễ đồng bộ.
Xếp hạngts_rank × hệ số tuổi bài × log(1 + like_count) trong SQL-Chạy trong câu truy vấnSố 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.
BackupSnapshot tự động của dịch vụ managed1/ngàyGiữ 7 ngàyIndex nằm trong DB nên khôi phục DB là khôi phục luôn tìm kiếm.
Thay đổi so với mức trước
  • Đ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
Nút thắt tiếp theo

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ầnMức 1Mức 2Mức 3
Người dùng10.000 DAU1 triệu DAU50 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ầnChư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ầnChư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ầnChưa cần
Backup
Snapshot tự động của dịch vụ managed
1/ngày × Giữ 7 ngày
Chưa cầnChưa cần
Search APIChư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
ElasticsearchChưa cần
Elastic Cloud / OpenSearch Service, index theo tháng
3 node × 8 vCPU · 32 GB · 500 GB SSD
Chưa cần
QueueChưa cần
Outbox + Amazon SQS / Google Pub/Sub
1 × Managed, có dead-letter queue
Chưa cần
IndexerChư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
RedisChưa cần
ElastiCache / Memorystore
1 primary + 1 replica × 2 vCPU · 4 GB
Chưa cần
ES hotChưa cầnChưa cần
Elasticsearch, index theo ngày, ILM
14 node × 8 vCPU · 32 GB · 1 TB NVMe
ES warmChưa cầnChưa cần
Elasticsearch, index gộp theo tuần
9 node × 8 vCPU · 32 GB · 4 TB HDD
ES master + coldChưa cầnChư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
KafkaChưa cầnChưa cần
MSK / Confluent
3 broker (1/AZ) × Topic posts 6 partition, likes 12 partition, RF 3
Gom likeChưa cầnChưa cần
Flink job, cửa sổ 60 s theo post_id
3 TaskManager × 4 vCPU · 8 GB
Redis ClusterChưa cầnChưa cần
ElastiCache, cache truy vấn công khai
1 primary + 2 replica × 4 vCPU · 8 GB
Kho bài + bộ đếmChưa cầnChư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?

Cách chưa tốt
Một bảng Postgres với index GIN cho mọi bài

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)

Cách tốt
Elasticsearch, chia theo document bằng hash(post_id) vào N shard cố định

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"

Cách tốt nhất
Index theo ngày + tầng hot/warm/cold, hỏi từ mới đến cũ

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?

Cách chưa tốt
API ghi DB rồi gọi index đồng bộ, cập nhật like_count trong index mỗi lần like

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

Cách tốt
Outbox/CDC → queue → indexer ghi theo lô; like gom theo cửa sổ thời gian

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)

Cách tốt nhất
Hai luồng tách riêng: nội dung có version, like chỉ cập nhật khi vượt mốc

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?

Cách chưa tốt
Sắp xếp thuần theo created_at hoặc thuần theo like_count

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í)

Cách tốt
function_score: BM25 × decay theo tuổi bài × log(1 + like)

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

Cách tốt nhất
Xếp hạng hai giai đoạn: index lấy ứng viên rẻ, service xếp lại bằng tín hiệu thật

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"

Cách chưa tốt
Analyzer chuẩn: tách theo khoảng trắng, giữ nguyên dấu

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

Cách tốt
Lowercase + asciifolding, index hai field (có dấu, bỏ dấu), ưu tiên khớp cụm

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

Cách tốt nhất
Tách từ bằng từ điển tiếng Việt + field bỏ dấu + synonym + n-gram cho chữ dính liền

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

Nên nói
  • “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ả.”
Bẫy hay gặp
  • 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

Elasticsearch
Elasticsearch là search engine phân tán dựa trên inverted index. Bài này giải thích analyzer (kể cả tiếng Việt), shard và replica, near-real-time refresh, cách đồng bộ từ database chính và khi nào Postgres full-text là đủ.
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.
Change data capture (CDC)
Change data capture đọc mọi thay đổi trong database và phát thành luồng sự kiện. Bài này so sánh CDC dựa trên log, trigger và polling, giải thích Debezium với Kafka, quan hệ với transactional outbox, các use case và những bẫy về thứ tự và schema.
Sharding
Chia dữ liệu theo chiều dọc và chiều ngang, cách chọn shard key và tránh hotspot, so sánh range, hash và directory, resharding, query và transaction xuyên shard, khi nào chưa nên shard và một Postgres chịu được tới đâu.
Scale ghi
Khi lượng ghi vượt sức một database: gom batch, ghi bất đồng bộ qua hàng đợi, chọn storage tối ưu cho ghi, chia dữ liệu theo key, xử lý partition nóng bằng salting, sharded counter và tổng hợp trước. Kèm con số cho từng quy mô.
Apache Flink
Flink là engine xử lý stream có trạng thái, đọc event từ Kafka, gom theo cửa sổ thời gian và cho kết quả chính xác kể cả khi event đến muộn hoặc máy chết. Bài này giải thích event time, watermark, các loại window, state và checkpoint, exactly-once với Kafka, và so sánh với Kafka Streams, Spark.

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