Hệ thống Dữ liệu Phân tán/Secondary index phân mảnh — local (document) vs global (term)
17/38
Bài 17 / 38~12 phútPhân mảnh (partitioning)Miễn phí lượt xem

Secondary index phân mảnh — local (document) vs global (term)

Index phụ khó phân mảnh vì nó cắt ngang cách chia dữ liệu chính. Hai cách: local (document-partitioned) rẻ ghi đắt đọc, global (term-partitioned) ngược lại.

TL;DR: Secondary index (tra theo trường khác ngoài khoá chính, ví dụ tìm mọi xe màu đỏ) khó phân mảnh vì trường phụ cắt ngang cách chia dữ liệu theo khoá chính. Có hai cách trái ngược: local index (document-partitioned) đẩy chi phí sang đọc — mỗi partition tự index dữ liệu của mình nên ghi rẻ, nhưng một truy vấn phải hỏi mọi partition rồi gộp (scatter/gather). Global index (term-partitioned) đẩy chi phí sang ghi — index phân mảnh theo giá trị trường phụ nên đọc chỉ chạm một partition, nhưng một document phải cập nhật nhiều partition index, thường bất đồng bộ.

Hai bài trước chia dữ liệu theo khoá chính và lo co giãn. Nhưng "tìm theo khoá chính" chỉ là một nửa nhu cầu; nửa kia là "tìm theo thuộc tính" — và đó là lúc secondary index vào cuộc, mang theo một đánh đổi mới đặc trưng cho hệ phân tán.

Nhớ lại Inverted index

Bạn đã học inverted index: ánh xạ từ một term (giá trị) tới danh sách document chứa nó — nền của tìm kiếm theo nội dung. Secondary index phân mảnh chính là câu hỏi "đặt inverted index đó ở đâu khi document rải trên nhiều node": cùng chỗ với document (local) hay gom theo term (global)?

1. Analogy — thư viện: mỗi phòng tự làm mục lục vs một mục lục chung

Sách chia ra nhiều phòng theo mã sách (khoá chính). Độc giả muốn "mọi sách chủ đề lịch sử" — một thuộc tính khác. Hai cách làm mục lục theo chủ đề:

  • Mỗi phòng tự làm mục lục của phòng mình (local): phòng nào cũng có sổ "sách lịch sử trong phòng này". Thêm một cuốn vào phòng → chỉ sửa mục lục phòng đó (rẻ). Nhưng độc giả hỏi "mọi sách lịch sử toàn thư viện" → phải đi hỏi tất cả các phòng rồi gộp.
  • Một mục lục chung, chia theo chủ đề (global): có "quầy Lịch sử" giữ danh sách mọi sách lịch sử ở mọi phòng. Độc giả hỏi → tới đúng quầy Lịch sử, xong (rẻ). Nhưng thêm một cuốn có nhiều chủ đề → phải chạy cập nhật nhiều quầy (quầy Lịch sử, quầy Chiến tranh...), và quầy có thể ở phòng khác.
Thư việnSecondary index
Mỗi phòng tự làm mục lụcLocal (document-partitioned) index
Thêm sách chỉ sửa mục lục phòng đóGhi rẻ (1 partition)
Hỏi "mọi sách LS" = đi mọi phòngĐọc = scatter/gather
Quầy chung theo chủ đềGlobal (term-partitioned) index
Hỏi tới đúng quầy, xongĐọc rẻ (1 partition/term)
Thêm sách cập nhật nhiều quầyGhi đắt (nhiều partition, async)
💡 Cách nhớ

Local = mục lục theo phòng (thêm rẻ, tìm phải đi khắp). Global = quầy theo chủ đề (tìm nhanh, thêm phải cập nhật nhiều quầy). Đọc-nhiều → global; ghi-nhiều → local.

flowchart TB
  subgraph LOCAL["Local index (theo document)"]
    LR["Doc 'red' rai o P1,P2,P3"] --> LQ["Query red = hoi CA P1,P2,P3 (scatter)"]
  end
  subgraph GLOBAL["Global index (theo term)"]
    GR["Term 'red' gom tai P_red"] --> GQ["Query red = chi hoi P_red"]
  end

2. Local index — vì sao ghi rẻ mà đọc phải scatter?

Mỗi partition giữ secondary index chỉ trên các document của chính partition đó. Index "đồng vị" (co-located) với dữ liệu.

Ghi rẻ. Thêm/sửa một document chỉ đụng partition chứa nó: cập nhật dữ liệu + cập nhật index cục bộ, tất cả trong một partition. Không cần nói chuyện với partition khác.

Thử đoán trước

Local index: mỗi partition chỉ index document của chính nó, mà dữ liệu chia theo khoá chính (car_id), không theo màu. Vậy nếu cụm có 20 partition thay vì 3, một truy vấn "mọi xe đỏ" phải hỏi bao nhiêu trong số 20 partition đó — lần theo lý do "màu không liên quan gì tới cách chia theo car_id" để tự suy ra con số, đừng đoán chung chung "nhiều hay ít".

Đọc theo trường phụ đắt. Truy vấn "mọi xe màu đỏ" — xe đỏ rải khắp mọi partition (vì partition chia theo khoá chính, không theo màu). Nên phải gửi truy vấn tới mọi partition, mỗi partition tra index cục bộ của nó, rồi gộp kết quả (scatter/gather). Chậm và chịu tail latency (chờ partition chậm nhất).

Local index (partition theo car_id):
  P1: index color: {red: [c3, c8], blue: [c1]}
  P2: index color: {red: [c11], green: [c5]}
  P3: index color: {red: [c20, c22]}
  Query "red" -> hoi CA P1,P2,P3 -> gop [c3,c8,c11,c20,c22]

Đây là cách phổ biến nhất (Elasticsearch, nhiều store) vì ghi cục bộ đơn giản và bền.

3. Global index — phân mảnh theo term (đọc rẻ, ghi đắt)

Index được coi như dữ liệu riêng và phân mảnh theo giá trị trường phụ (term), độc lập với cách chia dữ liệu chính. Ví dụ: mọi entry index cho color=red nằm trên một partition (chọn theo hash(red)), color=blue trên partition khác.

Thử đoán trước

Với global index phân theo term, truy vấn "mọi xe đỏ" chỉ chạm một partition (đọc rẻ). Bạn thêm một xe mới có 3 trường được index (color=red, brand=toyota, city=hanoi). Đoán con số chính xác: nó phải cập nhật bao nhiêu partition của index (gợi ý: đếm theo số trường được index, không phải trả lời chung chung "nhiều"), và lần theo việc các partition đó nằm ở node khác nhau để đoán vì sao cập nhật thường KHÔNG thể gói gọn trong một transaction đồng bộ.

Đọc rẻ. Truy vấn "mọi xe đỏ" tới đúng partition giữ term red — một chỗ, không scatter. Range query trên term cũng có thể nhanh nếu index sắp theo term.

Ghi đắt và phức tạp. Một document có nhiều trường được index (color=red, brand=toyota), và các term đó nằm trên các partition khác nhau. Nên một lần ghi document phải cập nhật nhiều partition index — thường ở các node khác với node chứa document. Vì cập nhật đồng bộ nhiều partition là 2PC đắt (Module 4), thực tế thường cập nhật bất đồng bộ: index có thể trễ so với dữ liệu (đọc index ngay sau ghi có thể chưa thấy) — một dạng replication lag (Module 2) cho index.

Global index (partition theo term):
  P_red:    red -> [c3, c8, c11, c20, c22]   (moi xe do o day)
  P_blue:   blue -> [c1]
  P_toyota: toyota -> [c8, c22]
  Query "red" -> chi hoi P_red (nhanh).
  Ghi 1 xe do+toyota -> cap nhat P_red VA P_toyota (nhieu node, async).

4. Pitfall — chọn global index rồi giả định index luôn khớp dữ liệu

Pitfall — đọc global index ngay sau ghi và tin nó đã cập nhật

Vì global index cập nhật bất đồng bộ, có một khoảng index chưa khớp với dữ liệu.

❌ Ghi xe moi (color=red) -> bao "da luu" -> ngay sau doc index "moi xe do"
   -> global index P_red CHUA duoc cap nhat (async) -> xe vua them KHONG hien
   -> tuong mat du lieu / bug

❌ Chon LOCAL index cho bang doc-nhieu theo truong phu, tra van nong
   -> moi query scatter toi MOI partition -> cham + tai lon vo ich

Hệ quả: hoặc kết quả tìm kiếm thiếu tạm thời (global async) bị hiểu là mất dữ liệu, hoặc đọc chậm triền miên (local cho workload đọc-nhiều).

Hướng đúng: chọn theo tỉ lệ đọc/ghi và yêu cầu độ tươi. Ghi-nhiều, đọc theo trường phụ thưa → local (ghi rẻ, chịu scatter khi đọc). Đọc theo trường phụ rất nhiều, chịu được index trễ chút → global (đọc rẻ, chấp nhận ghi phức tạp + eventual consistency của index). Và với global, thiết kế app biết index có thể trễ — đừng giả định vừa ghi là tìm thấy ngay.

5. 📚 Deep Dive

📚 Deep Dive — Partitioning and secondary indexes

Ghi chú: DDIA Ch6 nhấn: đây là cùng một đánh đổi đọc/ghi lặp lại — chỉ khác lần này áp lên chính index. Không có index phân tán "vừa ghi rẻ vừa đọc rẻ".

6. Liên hệ các bài khác

  • Bài 01 — Range vs hash: secondary index khó vì trường phụ cắt ngang cách chia chính (theo khoá chính) — nối thẳng từ cách partition dữ liệu.
  • Module 2 — Replication lag: global index async có "index lag" giống replication lag — đọc index ngay sau ghi có thể chưa thấy.
  • Module 4 — Transaction phân tán: vì sao global index không cập nhật đồng bộ mọi partition — đồng bộ nhiều partition là 2PC đắt.
  • Inverted index (Thuật toán): cấu trúc index bên dưới — bài này quyết đặt nó ở đâu khi phân tán.

7. Tóm tắt

  • Giá trị một trường phụ không tôn trọng ranh giới partition vốn chia theo khoá chính — đây là gốc rễ khiến secondary index khó.
  • Local (document-partitioned) = mỗi phòng tự làm mục lục riêng: thêm sách rẻ, nhưng tìm theo chủ đề phải lục khắp mọi phòng rồi gộp lại.
  • Global (term-partitioned) = một quầy chung theo chủ đề: tìm nhanh (đúng một quầy), nhưng thêm sách phải chạy cập nhật nhiều quầy — thường trễ vài giây tới vài phút.
  • Chọn local khi ghi nhiều/đọc theo trường phụ thưa; chọn global khi đọc theo trường phụ dày, chấp nhận độ trễ cập nhật index.
  • Anti-pattern: tin global index luôn khớp ngay sau ghi (async → có khoảng trễ tạm thời); hoặc dùng local cho workload đọc-nhiều theo trường phụ (scatter/gather chậm triền miên).

8. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao secondary index khó phân mảnh, trong khi partition theo khoá chính thì đơn giản?

Partition chính chia dữ liệu theo khoá chính, nên tra theo khoá chính đi thẳng tới đúng partition. Nhưng secondary index tra theo một trường khác (màu, tag), và giá trị trường đó cắt ngang cách chia chính: các document có cùng màu rải khắp mọi partition (vì chúng khác khoá chính).

Nên không có chỗ tự nhiên để đặt index phụ: đặt cùng document (local) thì kết quả rải khắp → đọc phải gộp; gom theo giá trị (global) thì một document đụng nhiều nơi → ghi phải phân tán. Chính sự "cắt ngang" này tạo ra đánh đổi mà partition khoá chính không có.

Q2
Trong local (document-partitioned) index: vì sao ghi rẻ nhưng đọc theo trường phụ đắt?

Ghi rẻ: mỗi partition chỉ index document của chính nó, nên thêm/sửa một document chỉ đụng partition chứa nó — cập nhật cả dữ liệu lẫn index cục bộ trong một partition, không nói chuyện với partition khác.

Đọc đắt: kết quả cho một giá trị trường phụ (mọi xe đỏ) nằm rải khắp mọi partition (vì partition chia theo khoá chính, không theo màu). Nên phải scatter truy vấn tới mọi partition, mỗi nơi tra index cục bộ, rồi gather gộp lại — chậm và chịu tail latency (chờ partition chậm nhất).

Q3
Trong global (term-partitioned) index: giải thích vì sao đọc rẻ nhưng ghi đắt, và vì sao ghi thường bất đồng bộ.

Đọc rẻ: index phân mảnh theo term, nên mọi kết quả cho một term (mọi xe đỏ) gom trên một partition → truy vấn tới đúng đó, không scatter.

Ghi đắt: một document có nhiều trường được index (color=red, brand=toyota), các term đó nằm trên các partition khác nhau (thường ở node khác với node chứa document). Nên một lần ghi phải cập nhật nhiều partition index ở nhiều node.

Async vì cập nhật đồng bộ nhiều partition = 2PC (Module 4), rất đắt và blocking. Thực tế chấp nhận cập nhật bất đồng bộ → index có thể trễ so với dữ liệu (một dạng lag), đổi lấy ghi không bị chặn.

Q4
Một app rao vặt: đăng tin rất nhiều (ghi cao), người xem thỉnh thoảng lọc theo 'khu vực'. Chọn local hay global index cho trường khu vực? Vì sao?

Chọn local (document-partitioned). Lý do: workload ghi cao (đăng tin liên tục) → ưu tiên ghi rẻ, mà local chỉ cập nhật index cục bộ của partition chứa tin — không đụng node khác. Lọc theo khu vực chỉ thỉnh thoảng, nên đọc scatter/gather (đắt hơn) xảy ra ít, chấp nhận được.

Global sẽ bắt mỗi lần đăng tin cập nhật nhiều partition index ở nhiều node (ghi đắt) để đổi lấy đọc nhanh — nhưng đọc vốn thưa nên lợi ích đó không đáng cái giá ghi. Nguyên tắc: local cho ghi-nhiều/đọc-phụ-thưa, global cho đọc-phụ-nhiều.

Q5
Vì sao 'index lag' của global index là họ hàng của replication lag (Module 2), và app cần lưu ý gì?

Vì global index cập nhật bất đồng bộ: sau khi ghi document, các partition index (ở node khác) được cập nhật sau một khoảng — y như follower async trễ hơn leader (replication lag). Trong khoảng đó, index chưa khớp dữ liệu.

App cần lưu ý: đừng giả định vừa ghi là tìm thấy ngay qua index. Đọc index ngay sau ghi có thể thiếu bản vừa thêm (giống vi phạm read-your-writes). Thiết kế trải nghiệm chịu được "kết quả tìm kiếm cập nhật sau vài giây", hoặc với thao tác cần thấy ngay thì đọc thẳng dữ liệu (không qua global index) — đúng tinh thần phân loại đọc theo mức nhất quán ở Module 2.

Bài tiếp theo: Request routing — tìm đúng node giữ dữ liệu

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên