Thuật toán Ứng dụng — DP, String, Big Data & hơn nữa/Consistent hashing — thêm/bớt node ít xáo trộn
30/66
Bài 30 / 66~22 phútThuật toán phân tánMiễn phí lượt xem

Consistent hashing — thêm/bớt node ít xáo trộn

Ánh xạ key và node lên vòng tròn hash; thêm/bớt node chỉ di chuyển K/N key thay vì rehash toàn bộ. Virtual node cân bằng tải.

TL;DR: Modulo hashing thông thường (hash(key) % N) buộc gần như toàn bộ key phải remap khi N thay đổi. Consistent hashing giải quyết bằng cách chiếu cả key lẫn node lên một vòng tròn (ring) có không gian hash cố định; key thuộc về node đầu tiên gặp theo chiều kim đồng hồ. Thêm hoặc bớt một node chỉ ảnh hưởng đến ~K/N key thay vì toàn bộ K key. Virtual node (mỗi node vật lý giữ nhiều điểm trên ring) san đều tải và xử lý rebalance khi node chết. Đây là nền tảng của Amazon Dynamo, Apache Cassandra, và nhiều CDN.

Bạn đang thiết kế cache layer cho một trang thương mại điện tử phục vụ 50 triệu request/ngày. Cache cluster ban đầu có 4 node. Khi traffic tăng đột biến dịp sale, team muốn thêm node 5 — nhưng cách thêm node xác định liệu có xảy ra "cache stampede" làm sập hệ thống hay không. Bài này giải thích tại sao cách thêm node quan trọng hơn số node.

1. Vấn đề của modulo hashing

Cách đơn giản nhất để phân phối K key vào N node là: node = hash(key) % N. Với N=4:

keyhash(key)hash % 4node
"user:1001"14833node3
"order:555"20480node0
"cart:888"30713node3

Hoạt động tốt khi N cố định. Nhưng khi team thêm node thứ 5 (N trở thành 5):

  • "user:1001": hash % 5 = 3 → vẫn node3 (may mắn)
  • "order:555": hash % 5 = 3 → đổi từ node0 sang node3 (cache miss!)
  • "cart:888": hash % 5 = 1 → đổi từ node3 sang node1 (cache miss!)

Phân tích tổng quát: khi N đổi thành N+1, xác suất hash(key) % N = hash(key) % (N+1) rất thấp — trung bình khoảng N/(N+1) key phải remap, tức ~80% khi N=4. Với 50 triệu key trong cache, 40 triệu request đột ngột đánh thẳng vào database — đây là cache stampede.

Vì sao stampede nguy hiểm

Khi hàng triệu cache miss xảy ra đồng thời, tất cả đều fallback xuống database. Database không được thiết kế để chịu tải đó → latency tăng vọt → timeout → retry → vòng lặp chết. Đây là một trong những nguyên nhân phổ biến nhất gây outage tại scale lớn.

2. Consistent hashing — ánh xạ lên vòng tròn

Ý tưởng cốt lõi: thay vì ánh xạ key vào một trong N bucket cố định, ta ánh xạ cả key lẫn node lên một không gian hash vòng tròn (thường là [0, 2^32-1] hoặc [0, 2^64-1]).

Gọi không gian này là ring. Cả key và node identifier đều được hash vào ring:

  • Node được đặt tại vị trí hash(node_id) trên ring.
  • Key k thuộc về node đầu tiên theo chiều kim đồng hồ từ vị trí hash(k).
flowchart LR
    subgraph Ring["Vòng tròn hash (0 → 2^32-1)"]
        direction TB
        N0(["Node A\n(vị trí 12°)"])
        N1(["Node B\n(vị trí 120°)"])
        N2(["Node C\n(vị trí 240°)"])
        K1["key1\n(hash=15°)"]
        K2["key2\n(hash=130°)"]
        K3["key3\n(hash=250°)"]
    end
    K1 -->|"→ Node A"| N0
    K2 -->|"→ Node C"| N2
    K3 -->|"→ Node A\n(wrap around)"| N0

Khi thêm Node D tại vị trí 200° trên ring, chỉ key nằm trong cung từ 120° đến 200° (tức vùng vừa được Node D "phủ") cần chuyển từ Node C sang Node D. Mọi key khác không bị ảnh hưởng.

Khi bớt Node B (tại 120°), chỉ key của Node B chuyển sang node kế tiếp theo chiều kim đồng hồ (Node C). Phần còn lại của ring bất biến.

3. Pseudocode — cấu trúc dữ liệu ring

Ring cần hỗ trợ hai thao tác hiệu quả: (1) thêm/bớt node, (2) tra node chủ của key. Cả hai đều thực hiện tốt với sorted array hoặc balanced BST lưu các vị trí trên ring.

-- Khởi tạo ring rỗng
function initRing():
    ring <- SortedMap()        -- position -> node_id
    return ring

-- Thêm một node vật lý vào ring
function addNode(ring, node_id):
    pos <- hash(node_id) mod RING_SIZE    -- vị trí trên ring
    ring.insert(pos, node_id)
    -- Kết quả: key trong cung (predecessor(pos), pos] chuyển sang node_id

-- Bớt một node vật lý
function removeNode(ring, node_id):
    pos <- hash(node_id) mod RING_SIZE
    ring.delete(pos)
    -- Kết quả: key của node_id chuyển sang successor(pos)

-- Tra node chủ của key
function lookup(ring, key):
    pos <- hash(key) mod RING_SIZE
    successor <- ring.ceilingEntry(pos)   -- node đầu tiên >= pos
    if successor = null:
        return ring.firstEntry().node_id   -- wrap around về đầu ring
    return successor.node_id
// Time: O(log N) mỗi thao tác  Space: O(N)

ceilingEntry(pos) trả về entry nhỏ nhất không nhỏ hơn pos — đây là thao tác "tìm successor" trên sorted map, O(log N) với balanced BST.

3.1 Phân tích số key bị ảnh hưởng

Với K key phân phối đều và N node, mỗi node giữ trung bình K/N key. Khi thêm node mới (N → N+1), nó "chiếm" cung từ predecessor đến vị trí của nó — trung bình 1/(N+1) tổng số key (ring chia đều thành N+1 phần, mỗi phần chiếm 1/(N+1)). Xấp xỉ thực tế: ~K/N key dời (vì các node hiện tại giữ K/N, node mới "lấy" từ người hàng xóm), nhưng con số chính xác là K/(N+1). So với modulo hashing remap ~N/(N+1) ≈ 83% key (với N=5), consistent hashing chỉ remap ~1/(N+1) ≈ 17%.

4. Virtual node — cân bằng tải và xử lý heterogeneous cluster

Ring thuần túy có hai vấn đề:

  1. Phân bố không đều: hash function không đảm bảo N node chia đều ring thành N cung bằng nhau — một node có thể giữ 30%, node khác chỉ giữ 5%.
  2. Hot spot khi node chết: khi Node B chết, toàn bộ tải của nó đổ về đúng một node kế tiếp thay vì trải đều.

Virtual node (còn gọi là vnodes) giải quyết cả hai: mỗi node vật lý được ánh xạ vào V điểm trên ring thay vì 1 điểm, bằng cách hash "node_id#0", "node_id#1", ..., "node_id#(V-1)".

-- Thêm node với V virtual nodes
function addNodeWithVnodes(ring, node_id, V):
    for i <- 0 to V-1:
        vnode_id <- node_id + "#" + i
        pos <- hash(vnode_id) mod RING_SIZE
        ring.insert(pos, node_id)          -- lưu node vật lý, không phải vnode
// Time: O(V log N)  Space: O(V*N) trên ring

Với V lớn (Cassandra bản cũ mặc định 256 vnode/node; từ Cassandra 4.0 năm 2021 giảm còn 16 — xem CASSANDRA-13701 — vì 256 gây overhead repair/bootstrap), mỗi node vật lý chiếm ~V/tổng-vnode phần ring — xấp xỉ phân phối đều theo luật số lớn.

flowchart TB
    subgraph Ring2["Ring với virtual nodes (V=3)"]
        direction LR
        A0["A#0"] --> B0["B#0"] --> C0["C#0"] --> A1["A#1"] --> B1["B#1"] --> C1["C#1"] --> A2["A#2"] --> B2["B#2"] --> C2["C#2"] --> A0
    end
    A0 & A1 & A2 -->|"vật lý"| NA(["Node A"])
    B0 & B1 & B2 -->|"vật lý"| NB(["Node B"])
    C0 & C1 & C2 -->|"vật lý"| NC(["Node C"])

Khi Node B chết, các vnode của nó (B#0, B#1, B#2) phân tán trên ring — tải của chúng chuyển sang A và C xen kẽ, không dồn hết vào một node.

Heterogeneous cluster: node mạnh hơn (RAM cao hơn) được gán V lớn hơn, nhận nhiều key hơn tỉ lệ với sức mạnh.

5. Pitfall

Pitfall 1 — Quên wrap-around khi tìm successor

-- SAI: chỉ tìm tiến, bỏ sót trường hợp key nằm sau node cuối cùng trên ring
function lookupBuggy(ring, key):
    pos <- hash(key) mod RING_SIZE
    return ring.ceilingEntry(pos).node_id   -- null nếu không có node nào >= pos
-- ĐÚNG: nếu không có node nào >= pos, wrap về node đầu tiên
function lookupCorrect(ring, key):
    pos <- hash(key) mod RING_SIZE
    node <- ring.ceilingEntry(pos)
    if node = null:
        node <- ring.firstEntry()   -- wrap around
    return node.node_id

Bỏ sót wrap-around khiến ~1/N key (những key nằm sau node cuối trên ring) không được phục vụ — silent bug rất khó debug vì chỉ xảy ra với một phần nhỏ key.

Pitfall 2 — Hash node bằng địa chỉ IP thay vì tên ổn định

-- SAI: dùng IP, thay IP (scale-in/out trên cloud) → node hash sang vị trí khác
addNode(ring, "10.0.1.5")

-- ĐÚNG: dùng tên logic ổn định (hostname, node_id cố định)
addNode(ring, "cache-node-3")

Nếu node được thay thế bằng máy mới có IP khác, hash vào vị trí khác trên ring → vị trí cũ bị orphan, vị trí mới thêm như node mới → không nhận lại đúng key cũ.

Pitfall 3 — V quá nhỏ → phân bố vẫn lệch

-- V=1 (không có virtual node) với N=4 node:
-- Một node có thể chiếm 40% ring, node khác chỉ 10% — phụ thuộc vào hash
-- V=100 trở lên mới đạt phân bố tương đối đều theo CLT

Với V=1, xác suất cao có node bị "hot" (chiếm cung dài). Cassandra dùng V=16 (mặc định từ 4.0; bản cũ 256); memcached clients thường dùng V=150-200. Đánh đổi: V lớn hơn → phân bố đều hơn nhưng bộ nhớ ring lớn hơn O(V*N) và repair/bootstrap chậm hơn.

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

  • Hash function: consistent hashing xây trên hash function phân phối đều (MurmurHash3, xxHash thường dùng trong production). Hiểu collision rate và avalanche effect giúp chọn hash đúng cho ring.
  • Quorum — nhất quán đọc/ghi: trong thực tế, consistent hashing quyết định key nào vào node nào; quorum quyết định cần bao nhiêu node xác nhận để đảm bảo consistency. Hai kỹ thuật cùng nhau tạo nền tảng của Dynamo.
  • Gossip protocol: Cassandra dùng gossip để các node tự phát hiện topology ring thay đổi (node join/leave) mà không cần coordinator trung tâm.
  • Case study Cassandra & etcd: xem cách Cassandra triển khai consistent hashing + vnodes trong thực tế, kể cả cách handle node failure và repair.
  • Crypto hash: khác với hash function thông thường, crypto hash (SHA-256) đảm bảo collision-resistance mạnh hơn nhưng chậm hơn — không phù hợp cho ring lookup hot path; dùng cho data integrity (Merkle tree).

📚 Deep Dive

📚 Deep Dive — Nguồn gốc & tham khảo

Bài báo gốc:

  • Karger et al. (1997) — "Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web", ACM STOC. Đề xuất thuật toán gốc cho distributed caching; chứng minh mỗi node chịu O(1/N) key và thêm/bớt node chỉ remap O(1/N) key với xác suất cao.
  • DeCandia et al. (2007) — "Dynamo: Amazon's Highly Available Key-value Store", SOSP. Mô tả chi tiết cách Amazon dùng consistent hashing + virtual node + quorum trong production Dynamo (tiền thân của DynamoDB và Cassandra).

Sách:

  • Designing Data-Intensive Applications (Kleppmann), Chapter 6 "Partitioning" — trình bày consistent hashing trong bối cảnh partitioning schemes so với range partitioning.

Implementation thực tế:

  • Cassandra token ring: mỗi node tự chọn token (vị trí trên ring) theo cấu hình num_tokens (mặc định 16 từ Cassandra 4.0; bản cũ 256). Thêm node mới → Cassandra tự tính toán và streaming data segment tương ứng.
  • ketama (libketama): thư viện C triển khai consistent hashing cho memcached clients, dùng MD5 làm hash function và V=160 điểm/server.

Tóm tắt

  • Modulo hashing hash(key) % N remap ~N/(N+1) key khi N thay đổi — gây cache stampede ở scale lớn.
  • Consistent hashing chiếu key và node lên ring; mỗi key thuộc node đầu tiên theo chiều kim đồng hồ.
  • Thêm/bớt node chỉ ảnh hưởng ~K/N key (chỉ cung lân cận), không phải toàn bộ K key.
  • Lookup dùng ceilingEntry trên sorted map: O(log N). Bắt buộc xử lý wrap-around.
  • Virtual node (V điểm/node vật lý) giải quyết phân bố lệch và hot spot khi node chết.
  • Nền tảng của Amazon Dynamo, Apache Cassandra, ketama (memcached), nhiều CDN.

Tự kiểm tra

Tự kiểm tra
Q1
Với N=5 node dùng modulo hashing và K=1.000.000 key, thêm node thứ 6 khiến trung bình bao nhiêu key phải remap? So với consistent hashing (không dùng vnode)?

Modulo hashing: một key giữ nguyên node khi hash % N == hash % (N+1) — xảy ra với xác suất N/(N+1) = 5/6 ≈ 83%. Vậy ~17% key giữ nguyên, ~83% phải remap → khoảng 830.000 key bị remap.

Consistent hashing: node mới chiếm ~1/(N+1) = 1/6 ≈ 16.7% ring. Trung bình chỉ ~167.000 key remap — gấp 5 lần ít hơn. Và quan trọng hơn: các key *khác* không bị ảnh hưởng gì, không gây cache miss dây chuyền.

Q2
Tại sao virtual node giúp khi một node vật lý bị chết, thay vì chỉ dùng 1 điểm/node?

Với 1 điểm/node, khi Node B chết, toàn bộ tải của nó (có thể 20-40% ring nếu phân bố lệch) đổ vào đúng một node kế tiếp — node đó có thể quá tải ngay lập tức.

Với V virtual nodes, các vnode của Node B rải đều khắp ring. Khi Node B chết, mỗi vnode chuyển tải sang một node kế tiếp khác nhau — tải được phân tán đều cho tất cả N-1 node còn lại. Mỗi node còn lại chỉ nhận thêm ~1/(N-1) tải, thay vì một node nhận thêm ~100% tải của node chết.

Q3
Pseudocode lookup dùng ceilingEntry(pos). Nếu sorted map chứa N node, thao tác này có độ phức tạp bao nhiêu? Dùng cấu trúc dữ liệu nào để đạt đó?

ceilingEntry(pos) tìm entry nhỏ nhất không nhỏ hơn pos — đây là thao tác tìm kiếm nhị phân trên tập có thứ tự, đạt O(log N).

Cấu trúc dữ liệu phù hợp: balanced BST (Red-Black Tree, AVL Tree) — Java TreeMap, C++ std::map. Các thao tác insert/delete/ceilingEntry đều O(log N). Sorted array cũng dùng được với binary search O(log N) nhưng insert/delete O(N) — phù hợp khi ring ít thay đổi.

Q4
Tại sao không dùng SHA-256 làm hash function cho ring lookup, dù SHA-256 phân phối rất đều?

SHA-256 là cryptographic hash — được thiết kế để chống tấn công (collision resistance, preimage resistance), đổi lấy tốc độ: SHA-256 chỉ đạt ~300-500 MB/s trên CPU thông thường.

Ring lookup xảy ra trên hot path của mọi request — cần hash nhanh, không cần security. MurmurHash3 hay xxHash đạt 5-10 GB/s, nhanh hơn SHA-256 khoảng 10-20 lần. Phân phối đủ đều cho ring mà không tốn chi phí crypto. SHA-256 phù hợp cho data integrity (Merkle tree, content-addressed storage) — không phải routing.

Q5
Một cluster Cassandra có 3 node, mỗi node có num_tokens=256. Ring có bao nhiêu điểm? Thêm node thứ 4 (cũng 256 token), trung bình bao nhiêu token chuyển sang node mới?

3 node × 256 token = 768 điểm trên ring.

Thêm node thứ 4 với 256 token: tổng thành 1024 điểm. Node mới chiếm 256/1024 = 25% ring. Trung bình 256 token mới này lấy từ các node cũ → mỗi node cũ mất khoảng 256/3 ≈ 85 token (tương đương ~85/768 ≈ 11% tổng key/node). Đây là quá trình Cassandra gọi là streaming — data tương ứng được copy từ các node cũ sang node mới trước khi node mới chính thức online.

Q6
Consistent hashing giải quyết vấn đề remap, nhưng nó có đảm bảo consistency (tính nhất quán dữ liệu đọc/ghi) không? Cần thêm gì?

Không. Consistent hashing chỉ giải quyết routing — key nào vào node nào. Nó không đảm bảo rằng khi đọc key k, bạn sẽ thấy giá trị mới nhất được ghi.

Để đảm bảo consistency, cần thêm replication + quorum: key được replicate lên N node (N node kế tiếp trên ring trong Dynamo/Cassandra), và đọc/ghi chờ quorum (R+W > N). Đây chính là nội dung bài tiếp theo — consistent hashing và quorum là hai kỹ thuật bổ sung nhau, không phải thay thế nhau.

Q7
Nếu hash function dùng cho ring có avalanche effect kém (bit thay đổi ít khi input thay đổi nhỏ), điều gì xảy ra với phân bố node trên ring?

Avalanche effect kém có nghĩa là các input gần nhau (như node_id#0, node_id#1) cho ra hash gần nhau. Hậu quả: các vnode của cùng một node vật lý cluster lại gần nhau trên ring thay vì rải đều.

Kết quả: một số cung ring không có vnode nào, cung khác dày đặc → một số node vật lý giữ ít key hơn nhiều so với node khác (mất ý nghĩa của virtual node). Đây là lý do MurmurHash3 và xxHash được chọn cho ring — chúng có avalanche effect tốt, đảm bảo input tương tự cho output hoàn toàn khác nhau, vnode rải đều trên ring.

Bài tiếp theo: Quorum — nhất quán đọc/ghi với R+W>N

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