Harbor

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à đủ.

15 phút đọcCông nghệ chủ chốt

Elasticsearch là gì và mô hình cốt lõi

Elasticsearch (ES) là một search engine phân tán xây trên thư viện Lucene (Grokking ch.21 dùng ES và Lucene làm ví dụ cho tài liệu được đánh chỉ mục).1 Bạn gửi vào các document JSON, ES phân tích nội dung text, xây chỉ mục, và cho phép tìm kiếm full-text, lọc, sắp xếp theo độ liên quan, và tính aggregation (đếm theo nhóm, histogram). Độ trễ vài chục mili giây trên hàng trăm triệu document là ước tính của khoá học, cần đo.2

Thành phần trung tâm là inverted index. Thay vì lưu "document 1 chứa những từ gì", ES lưu ngược lại: "từ pho xuất hiện trong document 1, 7, 42". Grokking ch.21 mô tả nó như một cấu trúc giống HashMap từ term tới danh sách document kèm tần suất và vị trí của term trong từng document; ưu điểm là hỗ trợ full-text search và khỏi đếm lại từ khi truy vấn, nhược điểm là tốn thêm dung lượng và công cập nhật khi thêm, sửa, xoá document.3 Redis in Action §7.1.1 cũng giải thích inverted index và dùng SET/ZSET của Redis để dựng nó.4 Khi tìm "phở bò", ES lấy hai danh sách của pho và bo, giao hoặc hợp chúng, rồi chấm điểm. Trong Grokking, kết quả được sắp theo tần suất term trong document;5 BM25 (term xuất hiện nhiều trong document nhưng hiếm trong toàn bộ tập thì điểm cao) là kiến thức thêm của khoá học.2

Khái niệm ESTương đương gần đúng trong SQL
IndexTable
DocumentRow (JSON)
MappingSchema, kiểu từng field
ShardMột partition của index, là một Lucene index độc lập (tương ứng document partitioning trong Grokking ch.21)6
ReplicaBản sao của shard trên node khác (replication trong Grokking ch.21)7

Đặc điểm cần nhớ: ES là kho phụ để tìm kiếm, không nên là nguồn sự thật. Nó không có transaction nhiều document, ghi chỉ hiện ra sau một khoảng refresh, và việc đổi mapping thường đòi reindex toàn bộ (khuyến nghị thực hành của khoá học).2

Kiến trúc cluster

Một cluster ES gồm nhiều node với vai trò khác nhau (vai trò cụ thể của ES là kiến thức sản phẩm;2 ý tưởng cluster manager theo dõi sức khoẻ node bằng heartbeat và chia partition có trong Grokking ch.21):6

  • Master node (nên có 3 node master-eligible): quản lý metadata, quyết định shard nằm ở đâu. Không phục vụ query nặng.
  • Data node: giữ shard, chạy indexing và search.
  • Coordinating node: nhận request, gửi tới các shard liên quan, gộp kết quả. Mọi node đều có thể làm vai trò này.

Một query đi theo hai pha. Pha query: coordinating node gửi tới một bản (primary hoặc replica) của mỗi shard, mỗi shard trả về top N id và điểm. Pha fetch: coordinating node gộp, chọn top N toàn cục, rồi lấy nội dung các document đó. Ý tưởng chạy tìm kiếm song song trên từng index nhỏ rồi một merger gộp và sắp xếp kết quả giống mô tả "Searching" trong Grokking ch.21.5

Đang vẽ sơ đồ…

Shard và replica. Số primary shard chọn khi tạo index và không đổi được nếu không split hoặc reindex. Document được đưa vào shard theo hash(_routing) mod số_shard, mặc định _routing là id (Grokking: có thể dùng hàm băm để gán document vào partition).6 Replica tăng khả năng đọc và chịu lỗi: Grokking nói replication factor 3 là đủ, một bản là primary và hai bản là replica, đặt rải ở các availability zone.7

Near-real-time. Document mới ghi nằm trong buffer bộ nhớ và translog. Mỗi refresh_interval (mặc định cỡ 1 giây), ES tạo một segment mới và lúc đó document mới tìm được, nên độ trễ hiển thị cỡ giây. Khi import hàng loạt, tăng refresh_interval hoặc tắt hẳn để indexing nhanh hơn. Segment nhỏ được merge dần ở nền, việc này tốn CPU và I/O. Đây là cơ chế nội bộ của ES, chưa có trong sách của thư viện.2 Grokking cũng tách việc dựng index khỏi việc phục vụ query để indexing không ảnh hưởng search, và ghi nhận việc cập nhật document làm tốn công cập nhật index.8

Mapping, analyzer và tiếng Việt

Analyzer quyết định text được cắt thành term như thế nào. Nó gồm character filter, tokenizer và token filter. Khái niệm tương ứng trong sách: Redis in Action §7.1.1 mô tả parsing, tokenization và danh sách stop word trước khi đánh chỉ mục;9 chi tiết analyzer của ES thì chưa có sách.2 Ví dụ analyzer standard biến "Phở Bò Tái" thành phở, bò, tái.

Với tiếng Việt có hai vấn đề (phần tiếng Việt là kiến thức của khoá học, thư viện không có sách về xử lý tiếng Việt):10

  1. Dấu. Người dùng hay gõ không dấu: "pho bo". Thêm filter asciifolding để phở thành pho. Nên giữ cả hai phiên bản (một sub-field có dấu, một không dấu) để kết quả có dấu đúng được điểm cao hơn.
  2. Từ ghép. Tiếng Việt viết cách theo âm tiết. "Hà Nội" là một từ nhưng bị cắt thành hà và nội. Hai cách xử lý: dùng plugin tách từ tiếng Việt (có các plugin cộng đồng dựa trên bộ tách từ như coccoc-tokenizer), hoặc đơn giản hơn là dùng match_phrase và shingle (ghép 2 âm tiết liền nhau thành một term) để cụm từ liền nhau được điểm cao.
PUT /posts
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "analysis": {
      "analyzer": {
        "vi_folded": {
          "tokenizer": "standard",
          "filter": ["lowercase", "asciifolding"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "title":   { "type": "text", "fields": { "folded": { "type": "text", "analyzer": "vi_folded" } } },
      "city":    { "type": "keyword" },
      "created": { "type": "date" },
      "likes":   { "type": "integer" }
    }
  }
}

Query tìm cả có dấu và không dấu, lọc theo thành phố:

GET /posts/_search
{
  "query": {
    "bool": {
      "must":   { "multi_match": { "query": "pho bo", "fields": ["title^2", "title.folded"] } },
      "filter": [ { "term": { "city": "hanoi" } } ]
    }
  },
  "size": 20
}

Phần filter không tính điểm và được cache (đặc tính của ES),2 nên đặt điều kiện chính xác (thành phố, trạng thái, khoảng ngày) vào đây. Field keyword dùng cho lọc, sort và aggregation; field text dùng cho tìm full-text.

Sai lầm hay gặp: Phân trang sâu bằng from: 100000. Mỗi shard phải lấy 100.020 kết quả rồi gộp. Dùng search_after với một sort ổn định thay thế. Nguyên lý giống phân trang bằng skip lớn ở MongoDB: sách nói skip phải tìm rồi bỏ qua mọi kết quả nên chậm, và nên tính truy vấn trang kế từ kết quả trang trước.11

Đồng bộ từ database chính

Dữ liệu gốc nằm ở PostgreSQL hoặc MySQL. ES chỉ là bản sao để tìm. Có ba cách đẩy dữ liệu sang. Microservices Patterns ch.7 nói CQRS view có thể dùng Elasticsearch cho text query, được cập nhật bằng cách nghe event từ phía ghi;12 ch.3 §3.3.7 mô tả hai cách phát event tin cậy: transactional outbox (bảng OUTBOX trong cùng transaction + message relay) và transaction log tailing (có nhắc Debezium đẩy thay đổi của Postgres/MySQL/MongoDB vào Kafka).13

CáchCách làmƯuNhược
Dual writeApp ghi DB rồi ghi ESĐơn giảnGhi DB thành công, ghi ES lỗi là lệch dữ liệu, không có retry chuẩn
OutboxTrong cùng transaction, app ghi thêm một dòng vào bảng outbox. Worker đọc outbox và index vào ESKhông mất sự kiện, retry đượcThêm bảng và worker
CDCDebezium đọc WAL/binlog, đẩy sự kiện vào Kafka, consumer index vào ESKhông cần sửa code ghi, thứ tự theo khóaThêm hạ tầng Kafka và Debezium
Đang vẽ sơ đồ…

Consumer nên index theo kiểu idempotent: sách nói broker thường chỉ đảm bảo giao ít nhất một lần nên message có thể trùng, cần handler idempotent hoặc ghi lại id đã xử lý,14 và view cần xử lý cập nhật đồng thời, sự kiện lặp.15 Cụ thể với ES: dùng id của DB làm _id, và dùng version với version_type: external (ví dụ lấy updated_at hoặc LSN) để sự kiện cũ đến muộn không ghi đè bản mới (tính năng ES, chưa có sách).2 Cần thêm một job đối soát định kỳ hoặc khả năng reindex toàn bộ từ DB, vì sớm muộn cũng có lúc lệch; sách cũng nói view CQRS cần cơ chế dựng lại từ đầu, và broker không giữ event vô hạn nên phải đọc cả event đã lưu trữ.16 Chi tiết hơn ở Change Data Capture.

Mẹo (thực hành của khoá học2): Để đổi mapping không downtime, tạo index mới posts_v2, reindex vào đó, rồi chuyển alias posts từ v1 sang v2. App luôn đọc ghi qua alias.

Capacity và sizing

Các con số là giả định của khoá học (ước lượng ban đầu, cần đo trên dữ liệu thật); sách của thư viện không có số liệu sizing của ES.2 Bài toán chia tập dữ liệu lớn thành nhiều partition vì index quá lớn so với RAM một máy có trong Grokking ch.21.17

Chỉ sốƯớc lượng
Node data điển hình8 vCPU · 32 GB RAM, heap JVM ~50% RAM (tối đa ~31 GB), phần còn lại cho page cache
Kích thước shard10–50 GB mỗi shard
Số shard mỗi nodeVài chục, tránh hàng nghìn shard nhỏ
Dung lượng indexThường 1–1,5 lần dữ liệu thô JSON, chưa tính replica
IndexingVài nghìn tới vài chục nghìn doc/s mỗi node, tùy kích thước doc và analyzer
Query đơn giảnVài trăm tới vài nghìn QPS mỗi node, p95 vài chục ms

Ví dụ tìm kiếm bài viết: 500 triệu bài, mỗi bài trung bình 1 KB sau khi index.

500.000.000 × 1 KB ≈ 500 GB primary
Shard 40 GB → ~13 shard, làm tròn 15 primary
1 replica → 1 TB tổng
Mỗi node giữ ~300 GB đĩa an toàn → 4 node data, thêm 1 cho dư → 5 node
Query peak 3.000 QPS, mỗi node ~800 QPS → 4–5 node là vừa

Cộng thêm 3 master node nhỏ (2 vCPU · 4 GB). Để đĩa luôn dưới ~75% vì ES ngừng cấp shard khi vượt ngưỡng watermark (giả định/khuyến nghị của khoá học).2

Khi nào dùng và khi nào không

Nhu cầuLựa chọn hợp lý
Tìm kiếm trong vài triệu dòng, tiếng Anh hoặc không dấu đơn giảnPostgres full-text (tsvector + index) hoặc pg_trgm là đủ (Seven Databases in Seven Weeks ch.2 Day 3 trình bày tsvector/tsquery và trigram)18
Tìm theo tiền tố, autocomplete nhỏPostgres pg_trgm, Redis sorted set (Redis in Action §6.1 autocomplete)19, hoặc trie trong RAM
Full-text trên hàng trăm triệu document, relevance tốt, facet, typo toleranceElasticsearch / OpenSearch
Log và observabilityES/OpenSearch, hoặc Loki, ClickHouse rẻ hơn khi chỉ cần lọc
Tìm theo ý nghĩa, embeddingVector database, hoặc kNN của ES
Tìm theo vị trí đơn giảnPostGIS, Redis GEO; ES có geo_point nếu cần kết hợp với text
Nguồn sự thật, transactionKhông dùng ES

Postgres full-text là đủ khi: dữ liệu dưới khoảng vài chục triệu dòng (ngưỡng ước lượng của khoá học), chỉ cần ranking cơ bản, không cần facet phức tạp, và bạn muốn tránh thêm một hệ thống phải đồng bộ. Lợi ích lớn nhất của cách này là kết quả tìm kiếm luôn nhất quán với dữ liệu, không có độ trễ đồng bộ (vì không có bản sao thứ hai, so với độ trễ view CQRS trong Microservices Patterns §7.2.4).2018

Vận hành ở quy mô nhỏ và lớn

Nhỏ. Dùng managed: Elastic Cloud hoặc Amazon OpenSearch Service (giá tuỳ nhà cung cấp). Ở giai đoạn này, cân nhắc nghiêm túc việc ở lại với Postgres.

Vừa. Tách master riêng, theo dõi heap usage, GC pause, search latency, indexing rate, số segment, rejected thread pool. Đặt snapshot định kỳ lên S3.

Lớn. Dùng kiến trúc hot-warm-cold cho dữ liệu theo thời gian (log): index mới trên node SSD mạnh, index cũ chuyển sang node rẻ hơn, quá hạn thì xóa bằng ILM (index lifecycle management). Index theo ngày hoặc dùng rollover theo kích thước để giữ shard 10–50 GB. Chi phí chủ yếu nằm ở RAM và đĩa của replica, nên với log có thể giảm replica của index cũ xuống 0 nếu đã có snapshot.

Phần vận hành bên trên là kinh nghiệm của khoá học.2 Sự cố phổ biến: mapping explosion (mỗi key JSON động thành một field mới), shard quá nhiều làm master quá tải, query wildcard đầu chuỗi *abc quét toàn bộ term, và split brain ở cluster cũ cấu hình sai số master.

Điểm nói trong phỏng vấn

  • ES là kho phụ cho tìm kiếm, nguồn sự thật vẫn là DB. Nói rõ cách đồng bộ: outbox hoặc CDC qua Kafka, index idempotent theo _id và version.
  • Nói về độ trễ hiển thị ~1 giây do refresh, và chấp nhận được với tìm bài viết, Yelp, news feed search.
  • Sizing: shard 10–50 GB, node 8 vCPU · 32 GB, heap ≤ 31 GB, replica 1.
  • Với tiếng Việt: asciifolding cho không dấu, sub-field, và tách từ ghép bằng plugin hoặc shingle.
  • Biết khi nào không cần ES: Postgres full-text cho tập nhỏ, kết quả luôn nhất quán.
  • Nếu đề bài hỏi tự xây search như Grokking ch.21: crawler, indexer, searcher; chia index theo document (mỗi shard giữ tập document riêng, query fan-out tới tất cả) thay vì theo term.6 Sách còn nêu bước cải tiến: tách indexer khỏi searcher, tính index một lần ở primary rồi copy file index sang replica thay vì mỗi replica tự tính lại.8

Trong phỏng vấn: Khi người phỏng vấn hỏi "nếu ES chết thì sao", trả lời: tìm kiếm suy giảm nhưng ghi dữ liệu vẫn chạy vì đi vào DB; khi ES hồi phục, consumer đọc tiếp từ offset Kafka hoặc outbox, không mất dữ liệu.

Nguồn trích dẫn

  1. Grokking Modern System Design · ch.21 Distributed Search · bài "Indexing" · mục "Build a searchable index". ↩

  2. Chưa có nguồn trong thư viện: thư viện không có sách về Elasticsearch/Lucene; các chi tiết vận hành, API và sizing của ES chỉ nêu khái niệm cơ bản, mọi con số là ước lượng của khoá học cần đo lại. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12

  3. Grokking Modern System Design · ch.21 Distributed Search · bài "Indexing" · mục "Inverted index", "Advantages of using an inverted index", "Disadvantages of using an inverted index". ↩

  4. Grokking Modern System Design · ch.21 Distributed Search · bài "Design of a Distributed Search" · mục "Distributed indexing and searching" (Document partitioning, Term partitioning, Indexing). ↩ ↩2 ↩3 ↩4

  5. Grokking Modern System Design · ch.21 Distributed Search · bài "Design of a Distributed Search" · mục "Replication" và "Replication factor and replica distribution". ↩ ↩2

  6. Grokking Modern System Design · ch.21 Distributed Search · bài "Problems with the proposed design" · mục "Solution" và "Separate the indexing and search". ↩ ↩2

  7. Redis in Action · ch.7 · §7.1.1 Basic search theory (parsing, tokenization, stop words; Listing 7.1). ↩

  8. Chưa có nguồn trong thư viện: asciifolding, tách từ tiếng Việt và shingle là kỹ thuật thực hành của khoá học; thư viện không có sách về xử lý ngôn ngữ tiếng Việt. ↩

  9. MongoDB: The Definitive Guide 2e (file .zip, tải về giải nén) · ch.4 Querying · mục "Avoiding Large Skips" (tr. 70). ↩

  10. Microservices Patterns (file .zip, tải về giải nén) · ch.7 · §7.3.1 Choosing a view datastore, bảng 7.1 "Query-side view stores" (text search bằng Elasticsearch, tr. 238). ↩

  11. Microservices Patterns (file .zip, tải về giải nén) · ch.3 · §3.3.7 Transactional messaging (Transactional outbox, Transaction log tailing, Debezium). ↩

  12. Microservices Patterns (file .zip, tải về giải nén) · ch.3 · §3.3.6 Handling duplicate messages. ↩

  13. Microservices Patterns (file .zip, tải về giải nén) · ch.7 · §7.3.2 Data access module design (Handling concurrency, Idempotent event handlers). ↩

  14. Microservices Patterns (file .zip, tải về giải nén) · ch.7 · §7.3.3 Adding and updating CQRS views (tr. 241–242). ↩

  15. Grokking Modern System Design · ch.21 Distributed Search · bài "Design of a Distributed Search" · mục "Indexing on a centralized system" (index quá lớn cho một máy). ↩

  16. Seven Databases in Seven Weeks (file .zip, tải về giải nén) · ch.2 PostgreSQL · §2.4 Day 3: Full-Text and Multidimensions (Fuzzy Searching, Try a Trigram, Full-Text Fun, TSVector and TSQuery). ↩ ↩2

  17. Redis in Action · ch.6 Application components in Redis · §6.1 Autocomplete (tr. 111–116). ↩

  18. Microservices Patterns (file .zip, tải về giải nén) · ch.7 · §7.2.4 The drawbacks of CQRS (Dealing with the replication lag, tr. 236). ↩