Bài 7 giải quyết tầng đọc rất rẻ. Nhưng nó không giúp gì cho hai thứ: throughput ghi — mọi lệnh ghi vẫn dồn về một primary — và dung lượng — mọi replica đều chứa trọn bộ dữ liệu. Khi một trong hai thứ đó vượt giới hạn một máy, không còn cách nào ngoài chia dữ liệu ra: sharding.
Đây là bước đắt nhất trong toàn bộ series, và cái giá không phải tiền mà là độ phức tạp vĩnh viễn: mất transaction xuyên shard, mất JOIN, phân trang trở nên khó, và mọi truy vấn không mang shard key đều thành quét toàn bộ.
Hai phép đo trong bài đáng chú ý. Thứ nhất, tỉ lệ dữ liệu phải di trú khi thêm một shard: modulo hashing từ 4 lên 5 shard làm 79,97% dữ liệu phải chuyển chỗ, còn consistent hashing chỉ 18,78% — và cả hai khớp gần như hoàn hảo với công thức lý thuyết. Thứ hai, một shard key lệch làm p99 tệ hơn 10,3 lần trong khi p50 lại tốt hơn — hotspot sống hoàn toàn ở phần đuôi.
node trên
sysdesign-hashring.js với 100.000 key, không cần Docker.Giới hạn 1 CPU mỗi shard không phải chi tiết trang trí — xem mục 8.2, nơi việc thiếu nó khiến lab đo ra kết quả ngược hẳn.
8.1 Khi nào buộc phải shard — và vì sao nên là bước cuối
Thứ tự ưu tiên đúng, từ rẻ tới đắt. Mỗi bước chỉ nên đi khi bước trước đã hết tác dụng:
| Bước | Giải quyết được | Chi phí độ phức tạp |
|---|---|---|
| 1 · Tối ưu query và thêm index | Phần lớn vấn đề "database chậm" trong thực tế | Gần như không |
| 2 · Cache (Bài 5) | Tải đọc; mỗi số 9 hit ratio chia 10 tải database | Thấp, nhưng thêm bài toán vô hiệu hoá |
| 3 · Read replica (Bài 7) | Throughput đọc | Trung bình: lag và read-your-writes |
| 4 · Scale dọc primary | Throughput ghi, tới trần phần cứng | Thấp, nhưng có trần và trần đó đắt |
| 5 · Sharding | Throughput ghi và dung lượng, không còn trần | Cao và VĨNH VIỄN — không có đường quay lại dễ dàng |
Ba dấu hiệu cho thấy bạn thật sự cần shard, chứ không phải chỉ cảm thấy cần:
| Dấu hiệu | Vì sao replica và cache không giúp được |
|---|---|
| Primary bão hoà I/O ghi, không phải CPU | Replica nhân tải đọc chứ không chia tải ghi; cache không đứng trước đường ghi |
| Dữ liệu vượt dung lượng ổ đĩa lớn nhất bạn mua được | Mỗi replica đều chứa trọn bộ dữ liệu, nên thêm replica không thêm dung lượng |
| Thời gian backup và restore đã dài tới mức không chấp nhận được | Đây là dấu hiệu bị bỏ qua nhiều nhất. Database 5 TB nghĩa là RTO (Recovery Time Objective — thời gian tối đa bạn cam kết để khôi phục sau sự cố) của bạn tính bằng nhiều giờ, dù hệ thống đang chạy hoàn hảo |
JOIN, mất transaction xuyên shard, phân trang thành bài toán riêng, mọi truy vấn không mang
shard key thành scatter-gather (phải hỏi tất cả shard rồi gộp kết quả lại, nên
độ trễ bằng shard chậm nhất và tải nhân với số shard) — để đổi lấy một lợi ích có thể không bao giờ cần
đến.Câu hỏi kiểm tra rất đơn giản: bạn đã đo và biết chính xác cái gì đang bão hoà chưa? Nếu câu trả lời là "chưa, nhưng chắc là database" thì bước tiếp theo là đi đo, không phải đi shard. Rất nhiều lần thứ đang bão hoà là một truy vấn thiếu index, và một dòng
CREATE INDEX giải quyết xong vấn đề mà sharding phải
trả giá cả năm.
8.2 Chọn shard key — và một phép đo suýt cho kết luận ngược
Shard key tốt phải đạt cả ba điều, và điều thứ ba là điều hay bị bỏ qua nhất:
| Yêu cầu | Nếu thiếu thì sao |
|---|---|
| Cardinality cao (số giá trị khác nhau mà cột đó có thể nhận) |
Ít giá trị khác nhau ⇒ không chia được thành nhiều shard, dù thuật toán băm hoàn hảo. Ví dụ:
country chỉ có khoảng 200 giá trị và plan chỉ có 3 — băm kiểu gì cũng
không cho ra được 50 shard cân bằng; user_id thì có bao nhiêu người dùng là bấy nhiêu
giá trị
|
| Phân bố đều | Hotspot: một shard nhận gần hết tải trong khi các shard khác nằm không |
| Khớp với cách truy vấn | Mọi truy vấn không mang shard key phải hỏi tất cả shard rồi gộp kết quả (scatter-gather) — độ trễ bằng shard chậm nhất, và tải nhân với số shard |
created_at hoặc theo ID tự tăng nghe rất tự nhiên và rất dễ hiểu. Hậu quả:
toàn bộ lệnh ghi mới dồn vào một shard — shard của "hôm nay" — trong khi các shard cũ
chỉ nhận lưu lượng đọc thưa thớt. Bạn đã trả toàn bộ chi phí phức tạp của sharding mà không nhận được
thứ mình mua: chia tải ghi.Shard theo tenant có cùng vấn đề dưới hình thức khác: nếu một tenant lớn gấp trăm lần các tenant còn lại thì shard chứa nó là một hệ thống riêng đang quá tải. Với trường hợp đó, thường phải xử lý riêng tenant khổng lồ — cho nó shard riêng — chứ không có shard key nào giải quyết được bằng cách băm.
Đo thật: shard key tốt so với shard key lệch
Lab dựng hai shard PostgreSQL độc lập hoàn toàn — không replication, không biết gì về nhau — và
một router trong app quyết định key nào đi shard nào. Chế độ skew mô phỏng đúng tình huống
"một tenant khổng lồ": 95% request dùng cùng một shard key.
24 kết nối, 12 giây, 20.000 key khác nhau, mỗi shard giới hạn 1 CPU, hai lần lặp:
| Số liệu | Shard key tốt | Shard key lệch |
|---|---|---|
| Phân bố tải | 49,3% / 50,7% | 97,0% / 3,0% |
| Độ lệch | 2,7% / 3,0% | 188,0% |
| Throughput | 12.024 / 11.982 rps | 9.099 / 9.078 rps |
| p50 | 1,96 / 1,98 ms | 1,73 / 1,73 ms (tốt hơn!) |
| p95 | 3,03 / 3,01 ms | 2,84 / 2,82 ms (vẫn tốt hơn) |
| p99 | 3,59 / 3,59 ms | 37,08 / 36,59 ms |
| Độ trễ lớn nhất | 12,19 / 14,45 ms | 48,47 / 50,62 ms |
Shard key tốt cho +32% throughput. Nhưng hãy đọc kỹ ba dòng percentile: shard key lệch có p50 và p95 tốt hơn, rồi p99 đột ngột tệ hơn 10,3 lần. Hotspot không làm chậm phần lớn request — nó dồn toàn bộ thiệt hại vào phần đuôi.
Đây là lần thứ ba trong series cùng một hình mẫu xuất hiện: ở Bài 3 round robin cho biểu đồ cân bằng nhưng p95 tệ hơn 340 lần; ở Bài 5 single-flight làm p50 tệ hơn nhưng cắt đuôi 11 lần; ở đây shard key lệch thắng ở p50 và p95 rồi thua 10 lần ở p99. Nếu dashboard của bạn chỉ có trung bình và p95 thì cả ba lần bạn đều kết luận ngược.
Nguyên nhân: cả hai shard nằm trên cùng một máy và chia nhau CPU của máy đó. Cấu hình phân bố đều làm hai container PostgreSQL cùng hoạt động, tạo thêm tranh chấp CPU với ba app và bộ đo tải; cấu hình lệch chỉ làm một container bận. Trên một máy, "phân bố đều" là một điều bất lợi.
Nhưng đó chính là thứ mà sharding sinh ra để vượt qua: giới hạn của một máy. Một lab trên một laptop không thể đo được lợi ích đó, trừ khi ta áp giới hạn tài nguyên cho mỗi shard để mô phỏng "mỗi shard là một máy riêng". Thêm
cpus: '1.0' cho mỗi shard — đúng cùng lý do đã
giới hạn 1 CPU mỗi app từ Bài 2 — và kết quả đảo chiều đúng
như dự đoán.Bài học chung: khi một phép đo cho kết quả ngược với lý thuyết, khả năng cao nhất là môi trường đo đã triệt tiêu chính hiện tượng cần đo. Câu hỏi phải hỏi là "cấu hình này có cho hiện tượng đó cơ hội xuất hiện không", trước khi kết luận lý thuyết sai.
Router shard trong lab, đầy đủ như mã đang chạy:
// FNV-1a, then mixed with MurmurHash3's fmix32.
// Why the mixing step is needed: FNV-1a has POOR avalanche for short, similar strings
// ('user:1', 'user:2'...), so consecutive keys land on the same shard in clumps.
// The fmix32 step is cheap and removes that effect entirely.
function shardHash(key) {
let h = 2166136261;
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 16777619);
}
h ^= h >>> 16;
h = Math.imul(h, 2246822507);
h ^= h >>> 13;
h = Math.imul(h, 3266489909);
h ^= h >>> 16;
return h >>> 0;
}
// WARNING: this is modulo hashing. Simple, but read section 8.3 before using it for
// real — adding one shard means migrating nearly all of the data.
function pickShard(shardKey) {
return shardHash(String(shardKey)) % pgShards.length;
}
8.3 Modulo hashing và thảm hoạ resharding
Cách chia đơn giản nhất là hash(key) % N với $N$ là số shard. Nó ngắn, dễ hiểu, phân bố đều,
và chính là cách router trong lab đang làm. Vấn đề xuất hiện đúng vào ngày bạn thêm shard thứ $N+1$.
Một key chỉ giữ nguyên chỗ nếu $h \bmod N = h \bmod (N+1)$. Theo định lý số dư Trung Hoa, khi $N$ và $N+1$ nguyên tố cùng nhau — mà hai số liên tiếp thì luôn vậy — cặp $(h \bmod N,\ h \bmod (N+1))$ phân bố đều trên $N(N+1)$ khả năng. Số cặp thoả điều kiện giữ nguyên là $N$, nên:
$$P(\text{stay}) = \frac{N}{N(N+1)} = \frac{1}{N+1} \quad\Rightarrow\quad P(\text{move}) = \frac{N}{N+1}$$
Lưu ý con số này là $N/(N+1)$, không phải $(N-1)/N$ như thường bị trích dẫn. Chạy thật với 100.000 key, so cả hai thuật toán, consistent hashing dùng 150 virtual node:
| Thêm shard | Modulo — đo | Modulo — lý thuyết $\frac{N}{N+1}$ | Consistent — đo | Consistent — lý thuyết $\frac{1}{N+1}$ |
|---|---|---|---|---|
| 2 → 3 | 66,81% | 66,67% | 37,19% | 33,33% |
| 3 → 4 | 74,89% | 75,00% | 29,46% | 25,00% |
| 4 → 5 | 79,97% | 80,00% | 18,78% | 20,00% |
| 8 → 9 | 88,74% | 88,89% | 12,76% | 11,11% |
| 16 → 17 | 94,03% | 94,12% | 5,74% | 5,88% |
Modulo khớp lý thuyết tới hai chữ số thập phân ở mọi hàng. Và con số đáng nhớ nằm ở hàng 4 → 5: thêm một shard vào một hệ thống bốn shard buộc 80% dữ liệu phải chuyển chỗ. Với 16 shard thì con số là 94% — càng nhiều shard, thêm một shard càng tệ.
Consistent hashing sát với $1/(N+1)$ nhưng lệch rõ hơn ở $N$ nhỏ (37,19% so với 33,33% lý thuyết khi 2 → 3). Lý do: với ít node, vị trí các điểm băm trên vòng có phương sai lớn, nên tỉ lệ thực tế dao động quanh giá trị kỳ vọng. Càng nhiều node và càng nhiều virtual node thì càng khớp.
Một: mọi tầng cache dùng cùng cách băm đó cũng miss toàn bộ — nếu bạn đang có hit ratio 99% thì tải database tăng 100 lần đúng lúc nó đang phải di trú dữ liệu (nối Bài 5 mục 5.1).
Hai: trong lúc di trú, một key có thể ở shard cũ, shard mới, hoặc cả hai — nên tầng ứng dụng phải biết đọc cả hai chỗ và biết chỗ nào là chuẩn.
Ba: nếu phải dừng lại giữa đường, bạn ở một trạng thái không phải cũ cũng không phải mới. Đó là lý do consistent hashing không chỉ "tốt hơn về con số" mà là điều kiện để việc thêm shard trở thành một thao tác vận hành bình thường thay vì một dự án.
8.4 Consistent hashing và virtual node
Ý tưởng: thay vì băm key ra chỉ số shard, hãy băm cả key và cả tên shard ra cùng một không gian — một vòng tròn 32 bit. Mỗi key thuộc về shard đầu tiên gặp được khi đi theo chiều kim đồng hồ. Thêm một shard chỉ chiếm lấy phần cung nằm ngay trước nó, nên chỉ những key trong cung đó phải di trú.
node trên sysdesign-hashring.js, không cần Docker — bạn có
thể lặp lại trong vài giây.
Bảng đầy đủ, cùng 8 node và 100.000 key (lý tưởng là 12.500 key mỗi node):
| Virtual node / shard | Node ít nhất | Node nhiều nhất | Độ lệch |
|---|---|---|---|
| 1 | 66 | 25.465 | 203,2% |
| 2 | 1.002 | 28.766 | 222,1% |
| 5 | 5.621 | 18.705 | 104,7% |
| 10 | 8.428 | 20.368 | 95,5% |
| 50 | 10.599 | 14.722 | 33,0% |
| 150 | 11.514 | 14.688 | 25,4% |
| 500 | 10.887 | 13.483 | 20,8% |
Hàng đầu tiên là lý do virtual node tồn tại: với một điểm băm mỗi node, một shard nhận 66 key trong khi một shard khác nhận 25.465 — chênh 386 lần cho một thuật toán vẫn thường được giới thiệu là "phân bố đều".
Điều đó có một hệ quả thực hành: đừng chọn số vnode nhỏ rồi tăng dần cho tới khi thấy khá, vì đường đi lên không đơn điệu và bạn có thể dừng lại ở một điểm may mắn. Hãy chọn thẳng một giá trị đủ lớn (100–200 là khoảng thường dùng trong thực tế) và kiểm tra bằng đo thật với đúng tập key của bạn. Chi phí là bộ nhớ và thời gian tìm kiếm trên vòng — cả hai đều tăng theo số vnode, nên 500 không phải lúc nào cũng tốt hơn 150 (đo được 20,8% so với 25,4%, đổi lấy hơn ba lần số điểm phải giữ).
Toàn bộ hai bảng trên chạy lại được trong vài giây, không cần Docker. Lưu ý nhỏ để khỏi hoảng: Node sẽ in
trước một cảnh báo MODULE_TYPELESS_PACKAGE_JSON — đó chỉ là vì package.json của
repo không khai "type": "module", kết quả bên dưới vẫn đúng.
# No Docker needed — just node
cd blog/sysdesign
node --input-type=module -e "
import { compareAddNode, makeKeys, HashRing } from './sysdesign-hashring.js';
const keys = makeKeys(100000);
for (const n of [2,3,4,8,16]) {
const nodes = Array.from({length:n},(_,i)=>'s'+(i+1));
const r = compareAddNode(keys, nodes, 's'+(n+1), 150);
console.log(n+'->'+(n+1),
'modulo', (r.modulo.ratio*100).toFixed(2)+'%',
'(theory', ((n/(n+1))*100).toFixed(2)+'%)',
'| consistent', (r.consistent.ratio*100).toFixed(2)+'%',
'(theory', ((1/(n+1))*100).toFixed(2)+'%)');
}
for (const v of [1,2,10,150,500]) {
const ring = new HashRing({ vnodes: v });
for (const n of ['s1','s2','s3','s4','s5','s6','s7','s8']) ring.addNode(n);
const d = ring.loadDistribution(keys);
console.log(v+' vnode: min', d.min, 'max', d.max,
'spread', (d.spread*100).toFixed(1)+'%');
}
"
# And the self-test suite of 42 assertions, which covers the hash ring too
node sysdesign-engine-selftest.mjs
8.5 Hệ quả ở tầng ứng dụng — phần đắt thật sự
Chọn được shard key tốt và dùng consistent hashing chỉ giải quyết phần hạ tầng. Cái đắt nằm ở tầng ứng dụng, vì bốn thứ bạn vẫn coi là hiển nhiên nay không còn nữa.
| Thứ mất đi | Vì sao mất | Phải thay bằng gì |
|---|---|---|
JOIN giữa hai bảng khác shard |
Hai shard là hai database không biết gì về nhau | Nhân bản dữ liệu nhỏ sang mọi shard, hoặc đặt hai bảng cùng shard key để chúng luôn ở cùng shard |
| Transaction bao nhiều shard | Không có transaction coordinator giữa các database độc lập | Thiết kế lại để mỗi transaction nằm trong một shard; nếu không được thì saga (Bài 14) |
| Khoá tự tăng toàn cục | Mỗi shard có chuỗi riêng ⇒ hai shard sinh cùng ID | UUID, hoặc ID có nhúng shard, hoặc Snowflake-style (Bài 10) |
| Truy vấn theo cột không phải shard key | Không biết dữ liệu nằm shard nào | Global secondary index (một bảng tra cứu ánh xạ giá trị → shard), hoặc chấp nhận scatter-gather |
JOIN không còn, phản xạ tự nhiên là kéo dữ liệu từ cả hai shard về app rồi ghép trong
bộ nhớ. Với vài trăm dòng thì không sao. Với vài trăm nghìn dòng thì bạn vừa biến một database phân tán
thành một nút cổ chai mạng — và tệ hơn, chỗ nghẽn nằm ở app chứ không ở database, nên mọi dashboard
database vẫn xanh trong khi hệ thống chậm.Đây cũng chính là bài toán fan-out ở Bài 4 mục 4.4 với đúng cùng hai quy tắc: gọi các shard song song (độ trễ bằng shard chậm nhất, không phải tổng), và có timeout riêng cho từng shard để một shard chậm không kéo cả truy vấn xuống.
Nhưng câu trả lời tốt nhất thường không phải tối ưu việc ghép, mà là không cần ghép: chọn shard key sao cho dữ liệu hay được truy vấn cùng nhau thì nằm cùng shard. Đó là yêu cầu thứ ba ở mục 8.2 — "khớp với cách truy vấn" — và nó quan trọng hơn cả hai yêu cầu đầu.
Còn một việc nữa mà không có lời khuyên nào làm cho dễ: phân trang xuyên shard. Muốn lấy "20 dòng mới nhất" trên 8 shard, bạn phải lấy 20 dòng mới nhất từ mỗi shard (160 dòng), gộp, sắp xếp, rồi cắt 20. Trang thứ hai thì phải lấy 40 mỗi shard, và trang thứ 100 thì mỗi shard phải trả 2.000 dòng để cuối cùng dùng 20. Chi phí tăng theo số trang, không theo số dòng cần.
Cách thoát thông thường là bỏ phân trang theo số trang và chuyển sang phân trang theo con trỏ (cursor/keyset): "cho tôi 20 dòng sau mốc này". Nó cắt được chi phí tăng theo trang, nhưng đổi lại người dùng không nhảy tới trang 100 được nữa — một thay đổi sản phẩm, không chỉ là thay đổi kỹ thuật. Đây là dạng đánh đổi rất đặc trưng của sharding: giới hạn hạ tầng đi ngược lên và định hình cả tính năng.
Tự làm lại phép đo shard
cd blog/sysdesign/sysdesign-lab
docker compose --profile shard up -d # two INDEPENDENT PostgreSQL shards + the app router
# Check that the router really does distribute
for k in a b c d e f; do curl -s "http://localhost:3001/shard?key=$k&mode=good"; echo; done
# --- GOOD shard key ---
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js --url "http://lb:8080/shard?mode=good" \
-c 24 -d 12 -w 2 --json --key-space 20000
./tools/cache-stats.sh # look at the SHARD line + the spread
# --- SKEWED shard key (95% of requests share one shard key) ---
./tools/cache-stats.sh reset
docker compose run --rm loadgen loadgen.js --url "http://lb:8080/shard?mode=skew" \
-c 24 -d 12 -w 2 --json --key-space 20000
./tools/cache-stats.sh
# IMPORTANT: if you drop the `cpus: '1.0'` limit on the two shards in docker-compose.yml
# the result INVERTS — the skewed key will look faster. The reason is in section 8.2.
Tóm lại
Sharding là bước cuối vì nó là bước duy nhất có chi phí vĩnh viễn: mất JOIN, mất transaction xuyên shard, phân trang thành bài toán riêng. Trước nó còn bốn bước rẻ hơn, và câu hỏi kiểm tra luôn là "bạn đã đo và biết chính xác cái gì đang bão hoà chưa".
Modulo hashing đơn giản nhưng thêm một shard buộc $N/(N+1)$ dữ liệu di trú — đo được 79,97% khi đi từ 4 lên 5 shard, khớp lý thuyết tới hai chữ số. Consistent hashing hạ con số đó xuống 18,78%, nhưng chỉ khi có virtual node: không có vnode, một shard nhận 66 key trong khi shard khác nhận 25.465.
Và bài học về đo lường lặp lại lần thứ ba trong series: shard key lệch có p50 và p95 tốt hơn shard key tốt, rồi p99 tệ hơn 10,3 lần. Cộng thêm một bài học mới: lần đo đầu tiên của mục 8.2 cho kết quả ngược hoàn toàn, vì hai shard dùng chung CPU của một máy — môi trường đo đã triệt tiêu đúng hiện tượng cần đo.
Bài 9 hình thức hoá câu hỏi mà mục 7.4 để ngỏ: khi mạng bị chia, và không node nào biết bên kia đã chết hay chỉ mất liên lạc, thì phải chọn gì? Đó là CAP — và phần quan trọng hơn CAP mà ít người nói tới: lựa chọn bạn gặp hằng ngày, khi mạng hoàn toàn bình thường.
📖 Tài liệu tham khảo
- Karger và cộng sự (1997) — Consistent Hashing and Random Trees (bản PDF miễn phí ở MIT): bài báo gốc giới thiệu consistent hashing, nền của toàn bộ mục 8.4
- Amazon (2007) — Dynamo: Amazon's Highly Available Key-value Store: mục 4.2 là nơi virtual node được giới thiệu đúng vì lý do đo được ở bảng 8.4
- Wikipedia — hàm băm FNV-1a, thuật toán mà router shard trong lab dùng làm bước đầu
-
MurmurHash3 — nguồn của bước trộn
fmix32, và khái niệm avalanche mà bước đó khắc phục - Wikipedia — định lý số dư Trung Hoa, cơ sở của chứng minh $N/(N+1)$ ở mục 8.3
-
MDN —
Math.imul(): vì sao phải dùng nó thay cho*khi nhân số 32-bit trong hàm băm - PostgreSQL — Table Partitioning: chia bảng trong một database, bước thường bị nhầm với sharding và nên thử trước
Tải mã nguồn thực hành
Vòng hash và bộ so sánh modulo/consistent đã sinh ra mọi con số ở mục 8.3 và 8.4. Không phụ thuộc thư
viện nào và chạy được ngay bằng node, không cần Docker:
Bình luận