Sharding — shard key, chunk, balancer, targeted và scatter-gather: chọn sai key là trả giá nhiều năm
Shard 2 triệu đơn (682 MB) đang nằm trên một shard theo hashed _id: balancer dời 2 range × 128 MB (18 s và 20 s) rồi dừng ở 427/256 MB — với nó thế là "cân". Sau đó estimatedDocumentCount báo 2 758 593 cho 2 006 418 đơn thật (+37 %) suốt hơn một giờ, vì bản cũ (orphan) chờ orphanCleanupDelaySecs = 3 600 s (8.0: 900 s). Shard key {createdAt: 1}: 100 % insert vào một shard; hashed: 50/50. Shard key {status: 1}: 4/5 range thành jumbo mà balancerCollectionStatus vẫn báo "compliant". Transaction ghi 2 shard: p50 36–51 ms (1 shard: 15–18 ms), và trong lúc commit, người đọc local thấy nửa transaction. Bẫy nổ trễ: Eloquent save() đổi customerId (shard key) luôn lỗi 72, vì laravel-mongodb dùng updateMany. Liên quan: replica set bài 7, transaction 1 shard bài 8, hashed index bài 3, explain bài 4, change stream trên sharded cluster bài 10.
Thuật ngữ dùng trong bài
shard (một replica set giữ một phần dữ liệu) · mongos (router: app nối vào đây, nó chuyển lệnh tới shard) · config server / CSRS (replica set giữ metadata của cluster) · shard key (field quyết định document nằm ở shard nào) · chunk / range (một đoạn liên tục[min, max)của giá trị shard key, thuộc đúng một shard) · balancer (tiến trình nền dời range cho đều) · migration (một lần dời range) · critical section (khoảng ngắn chặn ghi/đọc để chốt migration) · orphan (bản document còn sót ở shard cũ sau migration) · range deleter (tiến trình xoá orphan) · jumbo chunk (range quá to mà không chia được) · targeted (lệnh tới 1 shard) · scatter-gather / broadcast (hỏi mọi shard rồi gộp) · StaleConfig (lỗi "bảng range của bạn cũ rồi") · 2PC (two-phase commit: prepare rồi mới commit).
Kiểm chứng: Context7 (/websites/mongodb_manual); source v9.0: README migrations, range deleter, balancer, resharding, sessions and transactions, versioning protocols + vài file.cpp/.idl; release notes 9.0, 8.3; Jira SERVER-133518. Lab: cluster 2 shard (mỗi shard là replica set 1 node) + config server + mongos, MongoDB 9.0.2, cache 0,25 GB, output ởlabs/mongodb/out/09-*.txt. Máy dùng chung 4 vCPU: số ms chỉ để so tỉ lệ.
Trả lời 30 giây
Sharding cắt không gian giá trị của shard key thành các range (chunk), mỗi range thuộc đúng một shard (một replica set). Config server giữ bảng range → shard có version; mongos cache bảng đó để định tuyến: lệnh có shard key → một shard (targeted); không có → hỏi mọi shard rồi gộp (scatter-gather), nhanh bằng shard chậm nhất. Balancer dời range khi hai shard lệch quá 3 × chunk size (128 MB → 384 MB) bằng migration: chép, đuổi theo thay đổi, critical section vài trăm ms, chốt metadata; bản cũ thành orphan, range deleter xoá sau orphanCleanupDelaySecs (9.0: 1 giờ). Mọi thứ khác là hệ quả: key tăng dần → mọi insert vào range cuối (hot shard); key ít giá trị → range không chia được (jumbo); unique index phải chứa shard key; transaction nhiều shard phải 2PC; đổi key về sau tốn reshard (≥ 5 phút, gấp đôi đĩa, ~2 s chặn ghi). Chọn key theo truy vấn chính: cardinality cao, phân bố đều, không tăng dần. Và chỉ shard khi một replica set đã nâng cấu hình mà vẫn không đủ.
1. Bài toán gốc và bối cảnh ra đời
Replica set (bài 7) có một primary nhận mọi lệnh ghi; secondary chỉ gánh đọc. Trần của nó là trần của một máy: RAM cho working set (phần dữ liệu hay dùng), IOPS, dung lượng đĩa, và thời gian backup, restore, initial sync tăng theo số TB. Trước khi database tự làm, các công ty shard ngay trong code: một bảng "user_id → database", app tự định tuyến, tự dời dữ liệu khi thêm máy, tự gộp kết quả khi truy vấn chéo (kiểu sharding MySQL của Facebook, Instagram). 10gen thiết kế MongoDB để scale ngang từ đầu (bài 1): document là đơn vị phân tán, không có join nên dễ chia. Sharding chạy production từ 1.6 (8/2010), với mongos làm router để app vẫn thấy một database.
| Phiên bản | Thay đổi đáng nhớ |
|---|---|
| 1.6 (2010) | auto-sharding: mongos, config server, auto-split, balancer |
| 2.4 (2013) | hashed shard key |
| 3.2 / 3.4 | config server thành replica set (CSRS); balancer chuyển về config server primary |
| 4.2 (2019) | transaction xuyên shard (2PC); được đổi giá trị shard key của document |
| 4.4 / 5.0 | refineCollectionShardKey, compound hashed key / reshardCollection |
| 6.0 / 6.0.3 | chunk mặc định 128 MB; balancer cân theo data size thay vì số chunk, bỏ auto-split |
| 7.0 / 7.1 | AutoMerger gộp range liền nhau; ghi 1 document không cần shard key (manual: findAndModify; lab 9.0.2: cả updateOne/deleteOne) |
| 8.0 | moveCollection, unshardCollection, config shard (config server chứa luôn dữ liệu) |
| 8.3 | removeShard deprecated → startShardDraining / shardDrainingStatus / commitShardRemoval |
| 9.0.2 (lab) | orphanCleanupDelaySecs 3 600 s (8.0.32: 900 s); metadata "authoritative" ở shard (feature flag bật); known issue SERVER-133518 |
Tối ưu cho: dữ liệu hoặc lưu lượng ghi vượt một replica set, truy vấn chính có khoá phân vùng tự nhiên (tenant, khách hàng, người học), cần đặt dữ liệu theo vùng địa lý (zone). Không tối ưu cho: truy vấn không có key (hỏi mọi shard), ràng buộc toàn cục (unique, khoá ngoại), transaction nhiều shard, dữ liệu nhỏ (một replica set vài TB vẫn chạy được), team chưa có người vận hành.
2. Mô hình tư duy cốt lõi
Sharding = cắt trục giá trị của shard key thành các range; mỗi range thuộc đúng một shard; một bảng range → shard có version, ai cũng cache. Hashed key vẫn là mô hình đó, chỉ là trục giá trị băm (convertShardKeyToHashed, int64). Mọi hành vi khác suy ra từ đây:
- Lệnh có shard key → tra bảng → một shard. Không có → hỏi mọi shard, độ trễ = shard chậm nhất, tải nhân theo số shard.
- Giá trị mới luôn lớn nhất (ObjectId, timestamp) → luôn rơi vào range cuối → một shard nhận hết ghi (hot shard).
- Một giá trị key chiếm quá nhiều dữ liệu → không có điểm cắt bên trong → jumbo, balancer bó tay.
- Mỗi shard chỉ thấy phần của mình → unique toàn cục chỉ được khi shard key nằm trong index;
_idcó thể trùng giữa hai shard. - Dời dữ liệu = chép range rồi đổi một dòng trong bảng → bản cũ thành orphan tới khi được xoá → số đếm từ metadata sai.
- Ghi chạm 2 range ở 2 shard trong một transaction → cần two-phase commit.
- Bảng được cache → cache có thể cũ → StaleConfig, làm mới, thử lại.
flowchart LR A["App (Laravel / Node)"] --> M["mongos
cache bảng range"] M -- "có shard key: 1 shard" --> S1["sh1 · replica set
range A, C"] M -. "không có key: mọi shard" .-> S1 M -. "không có key: mọi shard" .-> S2["sh2 · replica set
range B, D"] C["config server (CSRS)
config.chunks: range → shard, version
balancer chạy ở primary"] -- "làm mới khi StaleConfig" --> M C -- "moveRange" --> S1 S1 -- "migration: chép range" --> S2
3. Internals và quyết định thiết kế
3.1 Bảng range, version và StaleConfig
Config server giữ config.databases (primary shard của mỗi database — nơi chứa collection chưa shard), config.collections (shard key, uuid) và config.chunks (min, max, shard, version). Version của một shard gồm generation (epoch + timestamp: đổi khi drop/tạo lại, rename, refine, reshard) và placement major|minor (migration tăng major; split/merge tăng minor). Theo README versioning protocols, định tuyến là lạc quan: mongos dùng bảng đang cache, gắn shardVersion vào lệnh; shard so với version nó biết, lệch thì trả StaleConfig; mongos làm mới (chỉ phần đổi) rồi gửi lại. Config server không nằm trên đường đi của mỗi lệnh — nhờ vậy nó không thành nút thắt. Ở 9.0.2, featureFlagAuthoritativeShardsCRUD/DDL đang bật (lab 09-params): mỗi shard tự giữ bản chuẩn metadata của phần nó sở hữu (config.shard.catalog.*), config server là bản tổng hợp.
sequenceDiagram
participant A as App
participant M2 as mongos2 (bảng cũ)
participant S1 as sh1 (chủ cũ)
participant C as config server
participant S2 as sh2 (chủ mới)
A->>M2: findOne({_id})
M2->>S1: find + shardVersion cũ
S1-->>M2: StaleConfig (range đã sang sh2)
M2->>C: lấy phần bảng đã đổi (incremental)
M2->>S2: find + shardVersion mới
S2-->>M2: document
M2-->>A: kết quả (chỉ chậm hơn)
Lab 09-stale: mongos1 dời 3 range của hot_hash sh1 → sh2; mongos2 (không ai báo) đọc ngay sau mỗi lần:
-- lượt 1: mongos1 dời hash [0, 3000000000000000000) sh1→sh2: ok=1 (353 ms)
mongos2 đọc 5 _id trong range đó: 17 / 3 / 6 / 3 / 4 ms · catalogCache mongos2: stale +1, incr +1, full +0, waitMs +8 · countStaleConfigErrors ở shard: sh1 +1, sh2 +0
-- lượt 3: mongos1 dời hash [6000000000000000000, MaxKey) sh1→sh2: ok=1 (490 ms)
mongos2 đọc 5 _id trong range đó: 18 / 4 / 3 / 3 / 3 ms · catalogCache mongos2: stale +1, incr +1, full +0, waitMs +7 · …
App không thấy lỗi, chỉ lệnh đầu chậm hơn (13–18 ms so với 3–6 ms). Sau reshard (đổi generation), lệnh đầu qua mongos2 mất 58 ms.
3.2 Shard một collection đang có dữ liệu
Lab 09-shard: shop.orders 2 triệu đơn nạp qua mongos nên nằm hết ở primary shard sh1; tải nền 2 luồng ghi + 2 luồng đọc chạy suốt (09-shard-probe).
createIndex({"_id":"hashed"}): 5375 ms
sh.shardCollection: 148 ms → ok=1 · chunk ngay sau đó: sh1:1ch
27 s sh1:1ch sh2:1ch | sh1 2003278 doc 682 MB · sh2 376075 doc 128 MB | … (chunksImbalance)
49 s sh1:1ch sh2:2ch | sh1 2003908 doc 682 MB · sh2 752336 doc 256 MB | …
22:49:55.373 moveChunk.start … 22:50:13.716 moveChunk.commit (18,3 s)
22:50:15.309 moveChunk.start … 22:50:35.644 moveChunk.commit (20,3 s)
getShardDistribution: sh1 427,24 MiB · 1 chunk | sh2 256,29 MiB · 2 chunk → 62,5 % / 37,5 % dữ liệu
Ba điều đáng nhớ. (1) shardCollection trên collection có dữ liệu tạo 1 chunk — từ 6.0 không còn auto-split; balancer tự cắt lúc dời (moveRange chỉ có min, shard nguồn tìm điểm cắt sao cho range ≈ 128 MB). (2) Balancer chỉ dời khi hai shard lệch ≥ 3 × chunk size: 682/0 → 554/128 → 427/256 thì lệch 171 MB < 384 MB → dừng, coi là cân. "Cân" theo balancer ≠ đều. (3) Cột size ở trên ($collStats) vẫn ghi sh1 682 MB vì gồm cả orphan (mục 3.4). Trong lúc chép, p99 ghi nhảy lên 100–627 ms (shard nguồn vừa phục vụ app vừa đọc 128 MB); không lệnh nào lỗi.
3.3 Một migration chạy thế nào
README migrations: config server gửi moveRange tới shard nguồn (donor). Donor đăng ký migration (ghi majority, nên failover vẫn khôi phục được), bảo shard đích (recipient) bắt đầu chép; recipient kéo từng batch document trong range, rồi kéo tiếp các thay đổi xảy ra trong lúc chép ("transfer mods"); khi gần xong, donor vào critical section chặn ghi, recipient lấy nốt, donor chặn cả đọc, ghi bảng range mới lên config server (commit), rồi thả. Một shard chỉ tham gia một migration tại một thời điểm (làm donor hoặc recipient, không cả hai); cluster nhiều shard thì các cặp khác nhau chạy song song. Session của retryable write và transaction cũng được chép theo, để lệnh thử lại sau migration không chạy hai lần.
09-shard (số từ config.changelog). Donor: bước 3 (bắt đầu chép) 746 ms, bước 4 (chờ recipient chép + đuổi theo) 17 471 ms, bước 5 (vào critical section, chốt ở recipient) 42 ms, bước 6 (chặn cả đọc, commit lên config server) 241 ms. Ghi bị chặn cỡ 0,3 s; đọc chỉ bị chặn lúc commit. Sau commit, range ở sh1 thành orphan: lab đếm 752 175 orphan (2 migration) nằm nguyên tới khi hết orphanCleanupDelaySecs, rồi range deleter xoá ~1 040 doc/s. Trục thời gian bị nén ở đoạn "//".Đánh đổi: chép trực tuyến thay vì khoá range → app gần như không thấy, nhưng shard nguồn đọc lại 128 MB (ngẫu nhiên nếu key hashed) trong lúc vẫn phục vụ app; xoá bản cũ để sau → migration nhanh hơn, đổi lại orphan và số đếm sai. Bẫy: (1) đĩa yếu thì đặt balancing window (khung giờ cho phép dời) ngoài giờ cao điểm; (2) _secondaryThrottle: true bắt mỗi batch chờ secondary theo kịp — chậm hơn nhưng secondary không bị bỏ xa; _waitForDelete: true xoá orphan ngay, bỏ qua delay, nhưng truy vấn dài ở secondary có thể bị cắt; (3) shard đang bận một migration thì migration khác phải chờ — thêm shard, rút shard, balancer đều xếp hàng sau nhau.
3.4 Orphan và range deleter: số đếm sai suốt một giờ
Range deleter chạy trên primary của shard, theo config.rangeDeletions. Với mỗi range vừa dời đi, nó (1) chờ các truy vấn đang đọc range đó xong, (2) chờ thêm orphanCleanupDelaySecs để truy vấn ở secondary kịp xong, (3) xoá theo lô, nghỉ rangeDeleterBatchDelayMS (20 ms) giữa các lô. Mặc định: 3 600 s ở 9.0.2, 900 s ở 8.0.32 (lab 09-params, 09-params-v80; README trong source vẫn ghi 15 phút). Lab 09-orphan, ngay sau 2 migration:
4 s | estimatedDocumentCount 2758593 (5 ms) · count{} 2758593 · countDocuments 2006418 (3724 ms) · chênh 752175
sh1 (nối thẳng, không qua mongos): estimated 2005365 · countDocuments 2005365 · config.rangeDeletions 2 task … whenToClean=delayed
$shardedDataDistribution sh1: owned 1253190 (425 MB) · orphan 752175 (255 MB)
5077 s | estimatedDocumentCount 2758593 … chênh 752175
5220 s | estimatedDocumentCount 2519166 (34 ms) · count{} 2509520 · countDocuments 2006418 (22015 ms) · chênh 512748
5496 s | estimatedDocumentCount 2231518 (5 ms) · count{} 2220251 · countDocuments 2006418 (7470 ms) · chênh 225100
estimatedDocumentCountvàcountkhông filter đọc metadata của từng shard rồi cộng → gồm orphan: thừa 752 175 (37,5 %).countDocumentsvà$countchạy truy vấn thật, shard lọc orphan bằng stageSHARDING_FILTER→ đúng, nhưng chậm (2,5–9 s cho 2 triệu).- Nối thẳng vào shard (không qua mongos, không có shardVersion) thì
countDocumentscũng đếm cả orphan: 2 005 365 ở sh1. Job báo cáo, ETL đọc thẳng shard sẽ ra số trùng. - Orphan nằm 85 phút chứ không phải 60: lab đã restart sh1 lúc gỡ sự cố (mục 5.6). Source
range_deleter_service.cpp: bộ đếm giờ tính từ lúc task được xếp lịch, và step-up xếp lịch lại → mỗi lần failover hay restart, orphan chờ thêm trọn 1 giờ. - Khi xoá chạy (~1 040 doc/s),
countDocumentschậm từ 2,5 s lên 22–29 s: range deleter tranh I/O với app. Truy vấn còn tốn thêm vì phải lọc orphan: trang 51 theocustomerIdở sh1 phải xem 1 617 document để trả 1 020 (09-query-orphan), sau khi dọn còn 1 020.
3.5 Targeted và scatter-gather
09-query, orders shard theo hashed _id, 200 lần mỗi kiểu. Đếm lệnh find tới từng shard (opcounters): theo _id 125 + 75 = 200 (mỗi lệnh 1 shard); theo customerId +200 ở cả hai. Lượt 3 dùng failpoint failCommand (blockTimeMS: 50) làm sh2 chậm: truy vấn 1 shard không đổi nhiều (p50 14 ms, chỉ nửa số _id nằm ở sh2), scatter-gather lên p50 65 ms.| Truy vấn (orders 2 triệu) | hashed _id | sau reshard {customerId: 1, _id: 1} |
|---|---|---|
find({_id}) | SINGLE_SHARD · p50 6 ms | SHARD_MERGE (2 shard) · p50 7 ms |
find({customerId}).sort({createdAt: -1}).limit(20) | SHARD_MERGE_SORT · p50 8–17 ms | SINGLE_SHARD · p50 5–13 ms |
như trên + skip(1000) (trang 51) | mỗi shard trả 1 020 doc, mongos bỏ 1 000 · p50 27 ms | 1 shard, xem 1 020 · p50 10 ms |
find({_id: {$gte: X}}).sort({_id: 1}).limit(20) | 2 shard (range trên hashed = hỏi tất cả) | 2 shard |
Cơ chế: mongos chỉ "cắt" được truy vấn có equality (hoặc range với key thường) trên tiền tố của shard key. Hashed key chỉ hỗ trợ equality — truy vấn range trên _id luôn hỏi mọi shard. Sort + limit: mỗi shard trả về limit + skip document đã sort, mongos merge — vì vậy phân trang bằng skip trên sharded cluster tốn gấp số shard (keyset pagination: bài 4). Ở 2 shard, scatter-gather chỉ đắt hơn vài ms; ở 50 shard, mỗi truy vấn là 50 lệnh, và p99 của cả cụm là p99 của shard tệ nhất.
Ghi không có shard key. Từ 7.1, ghi một document không có shard key vẫn chạy (lab 9.0.2: updateOne, deleteOne, findOneAndUpdate) (manual trang query router vẫn ghi "phải có shard key hoặc _id" — đã cũ), bằng giao thức ghi 2 pha: pha 1 hỏi mọi shard document nào khớp, pha 2 ghi vào đúng shard đó. Lab 09-nokey, 100 lần:
-- updateOne({_id}) có shard key: 100 lần p50 7 ms p95 14 ms | sh1: update+50 command+2 | sh2: update+50 command+2
-- updateOne({orderNo}) KHÔNG shard key: 100 lần p50 20 ms p95 39 ms | sh1: update+50 command+152 | sh2: update+50 command+152
-- deleteOne({orderNo}) KHÔNG shard key: 100 lần p50 25 ms p95 131 ms | sh1: delete+54 command+153 | sh2: delete+46 command+163
Chậm ~3 lần, mỗi lệnh thêm ~1,5 lệnh nội bộ tới mỗi shard. Chạy được không có nghĩa là rẻ: endpoint "sửa đơn theo mã đơn" gọi 10 000 lần/phút trên 20 shard là ~300 000 lệnh nội bộ mỗi phút (ví dụ minh hoạ, nhân theo số đo trên).
3.6 Chọn shard key: cardinality, frequency, tăng dần
Bốn câu hỏi, theo thứ tự quan trọng. (1) Truy vấn chính có chứa key không? Không thì mọi truy vấn chính là scatter-gather. (2) Cardinality (số giá trị khác nhau): ít giá trị → ít điểm cắt → range không nhỏ hơn được một giá trị (mục 3.7). (3) Frequency (mỗi giá trị chiếm bao nhiêu): một khách hàng lớn bằng nửa hệ thống thì range của khách đó là jumbo. (4) Monotonic (tăng dần): ObjectId, timestamp, số tự tăng → giá trị mới luôn lớn nhất → luôn rơi vào range cuối. Lab 09-hot: 4 collection rỗng, chunk đã chia sẵn 2 shard như một cluster đang cân, 16 client ghi đơn mới trong 30 s, đếm opcounters.insert của từng shard mỗi giây:
09-hot (16 client × 30 s). {createdAt: 1}: sh1 0,0 % · sh2 100 % (13 771 insert) — thêm bao nhiêu shard thì vẫn một shard ghi. Hashed _id: 50,1 / 49,9 %. {customerId: 1, createdAt: 1} cắt ở trung vị số đơn (khách 43 527): 50,8 / 49,2 %; cùng key nhưng cắt ở giữa dải id (100 000): 73,0 / 27,0 % — khách id nhỏ mua nhiều, chia đều theo giá trị không phải chia đều theo tải. Throughput tuyệt đối (459–798 insert/s) không so được vì mọi shard chung 4 vCPU.| Kiểu key | Ghi | Đọc theo key | Range query trên key | Hợp với |
|---|---|---|---|---|
ranged tăng dần (createdAt, ObjectId) | 1 shard nhận hết (hot shard) | targeted | targeted (range liền) | gần như không bao giờ một mình |
hashed ({_id: 'hashed'}) | đều | targeted (equality) | hỏi mọi shard | log, event, ghi nhiều mà đọc theo id |
compound {customerId: 1, createdAt: 1} | đều nếu có nhiều khách vừa phải | targeted theo khách, theo khách + thời gian | targeted trong một khách | đơn hàng, lịch sử học — truy vấn "của tôi" |
compound hashed {tenantId: 1, _id: 'hashed'} (4.4+) | đều trong tenant | targeted theo tenant | trong tenant: hỏi các shard của tenant | SaaS nhiều tenant cỡ khác nhau |
Zones (gắn range với nhóm shard, vd khách VN ở shard đặt tại Singapore): cùng cơ chế range, balancer coi range nằm sai zone là việc ưu tiên số 2 (sau rút shard) — lab không dựng (chưa verify). Bẫy: (1) chọn key khi dữ liệu còn nhỏ, phân bố "trông đều", vài năm sau một tenant lớn gấp trăm lần; (2) hashed rồi mới nhận ra truy vấn chính là theo khách — mọi trang "đơn của tôi" thành scatter-gather (mục 3.5); (3) shardCollection collection rỗng với key ranged chỉ tạo 1 chunk: mọi ghi vào một shard cho tới khi balancer kịp chia — pre-split bằng split + moveRange trước khi mở tải (hashed key thì tự chia sẵn: hot_hash có ngay 2 chunk ở 2 shard); (4) analyzeShardKey (7.0+) đo cardinality, frequency, tỉ lệ targeted trên tải thật trước khi chọn (lab chưa verify).
3.7 Jumbo chunk: range không chia được, balancer bỏ qua
Theo README balancer: migration bị từ chối nếu range quá lớn (số document > 2 × chunk size / kích thước TB của document); balancer thử chia; chia không được vì cả range chỉ một giá trị key → gắn cờ jumbo và từ đó bỏ qua. Lab 09-jumbo, thu nhỏ: 200 000 đơn (59 MB), shard key {status: 1} (5 giá trị, delivered 65 %), chunk size của collection = 1 MB (ngưỡng lệch 3 MB) — cùng cơ chế với 128 MB/384 MB của production:
0 s sh1[MinKey..MaxKey) | sh1 59.0 MB | compliant=false (chunksImbalance)
19 s sh1[MinKey.."delivered") JUMBO sh1["delivered".."paid") JUMBO sh1["paid".."pending") JUMBO sh2["pending".."shipped")
sh1["shipped"..MaxKey) JUMBO | sh1 59.0 MB · sh2 1.2 MB | compliant=true
moveChunk.error Cannot move chunk: the maximum number of documents for a chunk is 6786, the maximum chunk size is 1048576,
average document size is 309. Found 130000 documents in chunk ns: shop.tmp_jumbo { status: "delivered" } -> { status: "paid" }
moveRange tay: LỖI ChunkTooBig (153): … Cannot move chunk: … Found 130000 documents in chunk …
refineCollectionShardKey → {status: 1, _id: 1}: ok, 311 ms — cờ jumbo mất ngay
180 s sau refine: 31 chunk · sh2 28.2 MB (balancer chia và dời tiếp)
Bẫy nổ trễ nhất: balancerCollectionStatus báo compliant: true trong khi 98 % dữ liệu nằm ở một shard — balancer không còn việc gì làm được, nên với nó là "ổn". Dashboard chỉ theo dõi cờ này sẽ xanh mãi. Theo dõi $shardedDataDistribution (dung lượng thật mỗi shard) và đếm chunk jumbo: true. Cách chữa theo thứ tự rẻ → đắt: refineCollectionShardKey thêm field đuôi có cardinality cao (chỉ thêm, không đổi tiền tố; cần index trước; lab 311 ms vì chỉ đổi metadata) → clearJumboFlag → moveRange với forceJumbo (khoá range lâu) → reshard.
3.8 Unique index, _id trùng và upsert trùng
Mỗi shard chỉ kiểm tra unique trong phần dữ liệu của nó, nên MongoDB chỉ cho unique index khi shard key là tiền tố. Lỗi nguyên văn (lab 09-unique):
sh.shardCollection('shop.customers', {_id: 'hashed'}): InvalidOptions (72): can't shard collection 'shop.customers' with unique index
on { email: 1 }, proposed shard key { _id: "hashed" } and collation {}. Uniqueness can't be maintained unless shard key is a prefix …
createIndex({email: 1}, {unique: true}) trên collection shard theo hashed _id: CannotCreateIndex (67): … cannot create index with
'unique' or 'prepareUnique' option over { email: 1 } with shard key pattern { _id: "hashed" } and collation {}
shardCollection({email: 'hashed'}, unique: true): InvalidOptions (72): Hashed shard keys cannot be declared unique. …
_id KHÔNG phải shard key: insertOne({_id: 'ORD-1', k: -5}) → sh1 OK · insertOne({_id: 'ORD-1', k: 5}) → sh2 OK
find({_id: 'ORD-1'}) qua mongos: [{"_id":"ORD-1","k":5},{"_id":"ORD-1","k":-5}]
updateOne({_id: 'ORD-1'}, …): OK (matched 2, modified 2) · deleteOne({_id: 'ORD-1'}): deleted 2
_id trùng giữa shard là có thật khi app tự sinh _id (mã đơn) và shard key không phải _id; tệ hơn, updateOne/deleteOne theo _id được gửi tới mọi shard và sửa/xoá cả 2 bản. Hệ quả thứ hai: upsert theo mã nghiệp vụ không có unique index tạo bản trùng (bài 1 đã thấy trên 1 node). Lab 09-upsert, 20 client cùng upsert một orderNo, 20 vòng:
A hashed _id + index thường orderNo: 20/20 vòng có bản trùng, nhiều nhất 20 document/1 orderNo, thừa tổng 303
B shard key {orderNo: 1} unique : 0/20 vòng có bản trùng
Cách làm: (1) shard key chứa field cần unique (B); (2) cần unique trên field khác thì dùng collection phụ shard theo field đó (vd user_emails shard theo email, unique) và ghi cả hai trong transaction; (3) để _id là ObjectId do driver sinh, hoặc đưa _id vào shard key.
3.9 Transaction xuyên shard: two-phase commit
README sessions and transactions: mongos chọn shard đầu tiên transaction chạm tới làm coordinator. Chỉ một shard bị ghi → commit thẳng như replica set (bài 8). Từ hai shard bị ghi trở lên → mongos gửi coordinateCommitTransaction: coordinator ghi danh sách participant (chờ majority), gửi prepareTransaction tới mọi participant, ghi quyết định vào config.transaction_coordinators (chờ majority), rồi gửi commitTransaction. Ở 9.0.2, coordinateCommitReturnImmediatelyAfterPersistingDecision = false (lab 09-params; README ghi "true (default)" — đã cũ): client chỉ nhận kết quả khi mọi participant đã commit.
09-txn torn: T cộng 1000 vào _id −1 (sh1) và _id 1 (sh2); failpoint failCommand chặn commitTransaction ở sh2 4 s. Client B đọc qua mongos với read concern local: 2000/1000 — sh1 đã commit, sh2 còn prepared nên trả bản trước transaction. Đọc snapshot cùng lúc: chờ 3 778–3 886 ms rồi thấy 2000/2000. Client của T chỉ nhận commit sau 4 062–4 148 ms, nên chính nó không thấy nửa vời — người khác thì thấy.Lab 09-txn prepare giữ transaction ở trạng thái đã prepare, chưa có quyết định (failpoint hangBeforeWritingDecision ở coordinator):
+2.58 s sh2 $currentOp: inactive transaction … prepared=true
+3.02 s sh1 config.transaction_coordinators: [{"participants":["sh1","sh2"],"decision":null}]
+3.08 s đọc local qua mongos: [{"_id":-1,"bal":1000},{"_id":1,"bal":1000}] sau 59 ms
+6.19 s đọc snapshot: LỖI MaxTimeMSExpired (50) sau 3107 ms: operation exceeded time limit
+9.27 s ghi thường _id 1: LỖI (50) sau 3054 ms: operation exceeded time limit
+9.30 s đọc _id 2 (không thuộc T): 28 ms
+13.40 s T commit xong sau 13286 ms · ghi thường (không maxTimeMS) _id 1: xong sau 4138 ms
Document đã prepare: đọc local không chờ mà trả bản trước transaction (manual: mọi read concern trừ snapshot, linearizable và đọc causal có afterClusterTime), đọc snapshot phải chờ, và mọi lệnh ghi đụng tới nó phải chờ tới khi có quyết định. Coordinator chậm hoặc chết giữa chừng = các document đó bị khoá ghi tới khi coordinator mới (sau failover) khôi phục quyết định. Chi phí đo được (09-txn cost, 300 lần × 2 lượt):
không transaction (2 update) p50 14.9 / 21.5 ms
transaction 1 shard (sh1) p50 15.3 / 17.8 ms
transaction 2 shard (2PC) p50 36.5 / 50.8 ms · p95 238 / 177 ms
2PC thêm 2 lần ghi majority ở coordinator và một vòng prepare: p50 ~2,4–2,9 lần. Cách tránh: chọn shard key để dữ liệu cùng transaction nằm cùng shard (đơn + dòng đơn cùng customerId), hoặc thiết kế để invariant nằm trong một document (bài 8). Jepsen 4.2.6 (2020) từng thấy anomaly trên chính cấu hình sharded transaction — xem bài 8.
3.10 Đổi key: đổi giá trị, refine, reshard, moveCollection
Đổi giá trị shard key của một document (4.2+) = xoá ở shard cũ + chèn ở shard mới, nên phải là retryable write hoặc nằm trong transaction (lab 09-skey, hot_cust shard theo {customerId: 1, createdAt: 1}):
-- retryWrites=false, ngoài transaction: LỖI (undefined): This MongoDB deployment does not support retryable writes.
Please add retryWrites=false to your connection string.
runCommand thô: writeErrors code 20: … Must run update to shard key field in a multi-statement transaction or with retryWrites: true.
-- updateMany đổi shard key: LỖI (72): … Multi-update operations are not allowed when updating the shard key field.
-- filter CHỈ có _id (thiếu shard key), retryable write: OK 144 ms (modified 1) → document sang sh2
-- 50 document: update field thường p50 19 ms · update đổi customerId sang shard kia p50 116 ms
Hai bẫy: (1) mongosh (driver Node bên trong) viết lại lỗi code 20 thành "Please add retryWrites=false" — đúng điều ngược lại với cách chữa; lỗi thật chỉ thấy khi gửi lệnh thô. (2) Manual còn ghi "phải có đủ shard key trong filter"; 9.0.2 chạy được với chỉ _id. Đổi giá trị chậm ~6 lần update thường — đừng thiết kế key là field hay đổi (trạng thái, cấp hạng).
Laravel (lab 09-laravel, orders sau reshard theo {customerId: 1, _id: 1}, đếm lệnh tới từng shard):
-- Order::where('customerId', X)->orderBy('createdAt')->limit(20) 50 lần p50 5.1 ms | sh1: query+31 | sh2: query+19
-- Order::find($id) (chỉ _id) 50 lần p50 5.1 ms | sh1: query+50 | sh2: query+50
-- save() đổi customerId: MongoDB\Driver\Exception\BulkWriteException code 72: Plan executor error during update :: caused by ::
Multi-update operations are not allowed when updating the shard key field.
-- Order::raw(updateOne) đổi customerId 404 → 100404: modified 1, 11 ms
Order::find($id) trông vô hại nhưng hỏi mọi shard. Và Eloquent save() không bao giờ đổi được shard key: Query\Builder::performUpdate của laravel-mongodb 5.11 mặc định 'multiple' => true rồi gọi updateMany — bật/tắt retryWrites đều lỗi 72. Lỗi chỉ nổ khi có màn "gộp tài khoản", "chuyển đơn cho khách khác" — thường sau khi đã shard lâu. Dùng Model::raw(fn ($c) => $c->updateOne(...)) với filter đủ shard key.
Đổi chính shard key. refineCollectionShardKey (4.4+): chỉ thêm field vào đuôi, đổi metadata, không chép dữ liệu (lab 311 ms) — chữa jumbo, không chữa key tăng dần. reshardCollection (5.0+): chép lại cả collection theo key mới. Lab 09-reshard: orders 2 triệu, hashed _id → {customerId: 1, _id: 1}, tải nền 2 ghi + 2 đọc:
ok=1 · 354313 ms (đo trong mongosh)
63 s sh1 recv cloning docs 1072942/1003406 365MB/341MB | sh2 recv cloning docs 933870/1003406 318MB/341MB
304 s … recv building-index … 319 s … recv applying oplog 5638/5638 … 350 s sh2 donor preparing-to-block-writes
du -sm /data/db trước: s1 1367 · s2 558 MB → ngay sau commit: s1 1771 · s2 920 MB → khi bản cũ đã xoá: s1 1006 · s2 720 MB
probe giây 360–364: ghi 10 → 11 → 4 → 1 → 25 lệnh/s, chậm nhất 692 / 470 / 868 / 194 / 1388 ms
Chép xong sau ~60 s nhưng lệnh mất 5 phút 54 giây: reshardingMinimumOperationDurationMillis = 300 000 giữ recipient ở trạng thái chép tối thiểu 5 phút (để transaction và retryable write cũ kịp xong). Sau đó dựng index, áp oplog, và khi ước tính còn < 500 ms thì vào critical section — lab thấy ~2 s ghi gần như dừng (manual: "chịu được 2 giây chặn ghi"); quá reshardingCriticalSectionTimeoutMillis (5 s) thì huỷ. Đĩa: manual yêu cầu mỗi shard còn trống ≥ 2 × (data + index) / số shard; lab đỉnh +404 MB và +362 MB. Bản mới gọn hơn (fragmentation mất): storage 507 + 93 MB → 130 + 112 MB. Sau reshard, collection có 90 chunk xen kẽ hai shard.
8.0: moveCollection / unshardCollection (lab 09-v8x): dời products (100 000 doc, collection chưa shard) sang sh2: 331 945 ms; unshard hot_mid (19 325 doc): 332 058 ms. Cả hai chạy bằng máy resharding nên cũng tối thiểu 5 phút, dù collection chỉ vài MB. Config shard (8.0: config server chứa luôn dữ liệu, bớt một replica set): lab không dựng, chưa verify.
3.11 Thêm và rút shard (8.3+)
Thêm shard chỉ là đăng ký; dữ liệu tới shard mới nhờ balancer, theo đúng ngưỡng 3 × chunk size — với chunk 128 MB và orders ~340 MB mỗi shard, shard thứ 3 sẽ không nhận gì. Lab 09-drain nên hạ chunk size của orders xuống 32 MB (ngưỡng 96 MB) và orphanCleanupDelaySecs xuống 60 s (để range vừa rời sh1 quay về sh1 được, mục 5.3):
-- addShard sh3: 1331 ms
== chia lại sau khi thêm sh3: 22 migration, 105 s → orders sh1 244 MB · sh2 256 MB · sh3 168 MB
-- commitShardRemoval khi CHƯA drain: LỖI ConflictingOperationInProgress (117): Shard sh3 is not currently draining
-- startShardDraining sh3: {"ok":1}
-- removeShard sh3 (deprecated, gọi khi đang drain): {"msg":"draining ongoing","state":"ongoing","remaining":{"chunks":22, …}}
346 s shardDrainingStatus: drainingComplete draining completed successfully
== drain xong sau 239 s, 22 migration · orders sh1 345 MB · sh2 339 MB · sh3 0 MB
-- commitShardRemoval sh3: {"ok":1} · 653 ms · listShards: sh1, sh2
Bộ lệnh mới tách "rút" thành ba bước rõ ràng: startShardDraining (balancer ưu tiên số 1 là dời hết range khỏi shard đang rút), shardDrainingStatus (còn bao nhiêu chunk, jumbo, collection chưa shard và database có primary ở đó — hai thứ sau phải tự moveCollection/movePrimary), commitShardRemoval (chỉ chạy khi đã rút xong). removeShard cũ vẫn chạy ở 9.0.2 và trả về trạng thái như trước. Rút nhanh hay chậm phụ thuộc số migration: mỗi shard một migration một lúc, nên 168 MB mất 4 phút; 2 TB thì tính bằng ngày. Bẫy lab tự gặp: xoá container shard trước khi commitShardRemoval → mongod mới cùng tên không có shardIdentity, commit báo "Cannot accept sharding commands if sharding state has not been initialized with a shardIdentity document".
4. Đảm bảo và giới hạn
| Khía cạnh | Đảm bảo / giới hạn | Nguồn |
|---|---|---|
| Atomic | 1 document: như replica set. Nhiều shard: transaction 2PC; ngoài transaction, updateMany chạy riêng ở từng shard | bài 8, lab txn |
Đọc qua mongos, local | orphan bị lọc (có shardVersion); có thể thấy nửa transaction đang commit; không có snapshot chung giữa các shard | lab 09-txn torn |
Đọc snapshot / causal | một thời điểm chung cho mọi shard; chờ document đang prepared | lab 09-txn |
| Đếm | estimatedDocumentCount, count không filter: gồm orphan; countDocuments: đúng, quét thật | lab 09-orphan |
| Unique | chỉ khi shard key là tiền tố của index; hashed key không unique được; _id chỉ unique trong 1 shard | lab 09-unique |
| Định tuyến | equality / range trên tiền tố shard key → targeted; hashed: chỉ equality | lab 09-query |
| Chunk | mặc định 128 MB; dời khi lệch ≥ 3 × chunk size; range > 2 × chunk size (theo số document) không dời được | manual, lab jumbo |
| Migration | mỗi shard 1 migration một lúc; critical section ~0,3 s; orphan 1 giờ (9.0) / 15 phút (8.0) | lab 09-shard, params |
| Reshard / moveCollection | ≥ 5 phút kể cả collection nhỏ; ~2 s chặn ghi; cần ~2 × dung lượng trên đĩa | lab reshard, v8x |
| Config server | mất toàn bộ config server: lab không đọc/ghi được qua mongos (mục 5.5) | lab 09-cfgdown |
Theo khung CAP/PACELC: mỗi shard là một replica set CP (primary + majority, bài 7); cluster sẵn sàng cho một range khi shard giữ range đó có primary, nên mất primary một shard chỉ làm "một phần dữ liệu" không ghi được — các range khác vẫn chạy. Khi không có lỗi (ELC), scatter-gather trả giá latency theo shard chậm nhất; transaction 2PC trả thêm 2 lần ghi majority.
5. Failure modes
5.1 Hot shard vì key tăng dần
Mục 3.6: 100 % insert vào một shard, thêm shard không giúp. Phát hiện: opcounters.insert từng shard lệch, $shardedDataDistribution lệch mà balancer liên tục dời (range cuối lớn lên mãi). Chữa: reshard sang hashed hoặc compound có field phân tán đứng trước; tạm thời không có cách rẻ.
5.2 Jumbo và cờ "compliant" nói dối
Mục 3.7. Hệ quả chậm: shard giữ jumbo đầy đĩa trước, backup/initial sync shard đó lâu hơn, rút shard đó không xong (remaining.jumboChunks).
5.3 Dời ngược trong vòng 1 giờ: migration hỏng lặp lại
Range vừa rời shard A thì A còn giữ orphan cho tới khi xoá. Migration đưa range đó (hoặc một phần) về lại A sẽ hỏng (lab 09-moveback, hot_ts sau khi đổi chunk size xuống 1 MB):
23:06:59.045 shop.hot_ts moveChunk.error sh2→sh1 | Data transfer error: migrate failed: ExceededTimeLimit: Migration failed because
the orphans cleanup routine didn't clear yet a portion of the range being migrated that was previously owned by the recipient shard.
balancer.round 1012 ms, candidateChunks 1, chunksMoved 0 (lặp mỗi ~11 s, 42 lần hỏng trong 4 phút)
Balancer thử lại mãi; collection khác vẫn dời được. Gặp thật khi: thêm shard rồi rút ngay (thử nghiệm, sự cố), đổi chunk size hai lần, zone đổi qua đổi lại. Ở 9.0 cửa sổ này là 1 giờ (8.0: 15 phút), và mỗi lần restart/failover shard tính lại từ đầu (mục 3.4).
5.4 Một shard chậm kéo cả cụm
Mục 3.5: sh2 chậm thêm 50 ms → mọi truy vấn scatter-gather chậm theo (p50 17 → 65 ms; trang 51: max 2 001 ms), truy vấn targeted vào sh1 không bị gì. Nguyên nhân thật hay gặp: một shard đang chạy migration/range deleter, đang build index, secondary lag, đĩa ồn. Theo dõi latency theo shard, không chỉ ở mongos.
5.5 Config server chết
Lab 09-cfgdown: config server của lab là replica set 1 node, nên docker kill = mất toàn bộ config server:
== docker kill tmp-mdb-cfg — config server replica set chỉ 1 node: mất TOÀN BỘ config server
đọc orders theo customerId (targeted): LỖI MaxTimeMSExpired 8054 ms: operation exceeded time limit
đọc orders không có shard key (scatter): LỖI FailedToSatisfyReadPreference 366 ms: could not get updated shard list from
config server :: caused by :: Could not find host matching read preference { mode: "nearest" } for set cfg
ghi orders: LỖI 15040 ms: could not get updated shard list from config server :: …
moveRange (migration): LỖI MaxTimeMSExpired 8043 ms · tạo collection mới (DDL): LỖI FailedToSatisfyReadPreference 5837 ms
== mongos MỚI khởi động khi config server chết: sau 40 s vẫn không phục vụ được
== docker start tmp-mdb-cfg: config server primary lại sau 2897 ms → mọi kiểm tra OK
Trang monitoring của manual viết "clusters remain accessible from already-running mongos instances"; lab 9.0.2 mất toàn bộ config server thì không: đọc lẫn ghi đều hỏng (mongos không lấy được danh sách shard mới) — khớp câu khác của manual: "If all config servers become unavailable, the cluster can become inoperable". CSRS 3 node chỉ mất primary: manual nói metadata thành chỉ đọc, đọc/ghi dữ liệu vẫn chạy, không migration — chưa verify. Bài học: config server là thành phần sống còn, luôn 3 node, khác vùng lỗi, theo dõi như primary của shard.
5.6 Một lệnh admin sai kiểu làm kẹt collection
Lab gặp ngoài ý muốn (09-wedge): gọi moveRange từ driver Node với max: {_id: {$maxKey: 1}} — object JS có field $maxKey, không phải kiểu MaxKey (phải dùng new MaxKey()). Server 9.0.2 nhận lệnh, vào critical section, rồi kẹt ở bước commit:
Re-executing sharding coordinator | reason: BadValue: Shard key element _id: { $maxKey: 1 } is not valid for storage
tổng (riêng shop.tmp_acc): 430 lần trong ~7 phút
s1 shop.tmp_acc blockReads=true {"command":"moveChunk", …} → đọc collection qua mongos: MaxTimeMSExpired
balancer: shop.hot_ts ConflictingOperationInProgress: … Unable to start new balancer operation because this shard is currently
receiving chunk [{ _id: 0 }, { _id: { $maxKey: 1 } }) for namespace shop.tmp_acc from sh1
== thử 1: docker restart tmp-mdb-s1 → sau restart: coordinator ["globalCatalogCommit"], critical section còn nguyên
Một collection không đọc/ghi được, mọi migration của cặp shard đó dừng, restart không gỡ. Lab chỉ gỡ được bằng cách xoá tay trạng thái nội bộ (config.system.sharding_ddl_coordinators, migrationCoordinators, migrationRecipients, collection_critical_sections, rangeDeletions pending trên 2 shard) rồi restart — production đừng làm thế, gọi support. Bài học: lệnh admin chạy qua script phải được review như code, và kiểu BSON đặc biệt (MinKey/MaxKey, Long cho hashed) dễ sai trong driver.
5.7 Known issue 9.0.0–9.0.2: SERVER-133518
Release notes 9.0 và Jira: shard có thể treo khi step-up nếu một chunk operation (moveRange, split, merge) bị ngắt lúc step-down: slot trong ActiveMigrationsRegistry không được trả, recovery của recipient chờ slot đó mãi. Hệ quả: shard không có primary, mất ghi và đọc primary (secondary vẫn đọc được). Chữa tạm: restart/kill node treo để secondary khác lên. Sửa ở 9.0.4. Lab chỉ có shard 1 node nên không tái hiện được (chưa verify). Cùng trang còn SERVER-136226 (9.0+, chưa sửa): mỗi node mở ít nhất ShardingTaskExecutorPoolMinSize kết nối tới mỗi config server, có thể làm cạn kết nối của config server.
6. Vận hành trong production
| Theo dõi | Lấy ở đâu | Báo động khi |
|---|---|---|
| Dung lượng thật mỗi shard, orphan | $shardedDataDistribution | lệch > 20–30 %, orphan tồn > 2 giờ |
| Balancer | balancerStatus, config.actionlog (balancer.round), config.changelog (moveChunk.error) | round lặp chunksMoved 0 với cùng lỗi; migration > vài phút |
| Jumbo | config.chunks.find({jumbo: true}) | > 0 (đừng tin balancerCompliant) |
| Định tuyến | serverStatus().shardingStatistics (countStaleConfigErrors, catalogCache.*); slow log của mongos có nShards (lab: 2 cho lọc theo status, 1 cho theo customerId) | stale tăng vọt; nhiều truy vấn nóng có nShards = tổng số shard |
| Latency từng shard | slow log / FTDC của từng shard | một shard p99 cao hơn hẳn |
| Nhất quán metadata | checkMetadataConsistency (thêm checkIndexes: true) | có bản ghi; lab: index tạo thẳng trên 1 shard → InconsistentIndex |
Tham số đáng biết (mặc định 9.0.2, lab 09-params): chunkSize 128 MB (theo collection: configureCollectionBalancing); orphanCleanupDelaySecs 3 600; rangeDeleterBatchDelayMS 20; balancerMigrationsThrottlingMs 1 000; autoMergerIntervalSecs 3 600; reshardingMinimumOperationDurationMillis 300 000; reshardingCriticalSectionTimeoutMillis 5 000; minSnapshotHistoryWindowInSeconds 300. Lab còn thấy: tạo index nối thẳng vào một shard vẫn được (release notes 8.3 nói DDL chỉ qua mongos — lab không có auth, chưa thấy chặn) → index lệch giữa các shard.
Capacity: thêm shard trước khi shard cũ đầy (rebalance cần chỗ trống và thời gian: lab 168 MB ~2 phút; nhiều TB là nhiều ngày); kế hoạch theo dung lượng owned chứ không theo storageSize (gồm orphan, file chưa thu hồi). Chi phí tối thiểu cho production: 3 node config server + 3 node mỗi shard + ≥ 2 mongos → 2 shard đã là 9 mongod; config shard (8.0) bớt được 3 node. Gánh nặng team: người trực phải đọc được changelog, balancer, orphan, StaleConfig; nâng cấp nhiều bước hơn (config server, từng shard, mongos, rồi mới đổi FCV); backup nhất quán xuyên shard cần công cụ hiểu cluster (Atlas, Ops Manager, Percona Backup — bài 11).
7. So sánh và ma trận quyết định
| Trục | 1 replica set (scale dọc) | MongoDB sharding | PostgreSQL + Citus | Vitess (MySQL) | DynamoDB / Cassandra |
|---|---|---|---|---|---|
| Chia dữ liệu | không chia | range/hashed theo shard key, balancer tự dời | hash theo distribution column | keyspace + vindex | hash partition key (Cassandra: token ring) |
| Truy vấn không có key | bình thường | hỏi mọi shard | hỏi mọi worker | scatter | scan rất đắt / không cho |
| Transaction nhiều phần | có (bài 8) | 2PC, đắt ~2,5× | 2PC | có giới hạn (2PC tuỳ chọn) | hạn chế |
| Unique toàn cục | có | chỉ khi chứa shard key | chỉ khi chứa distribution column | qua lookup vindex | không (tự làm) |
| Đổi key về sau | — | reshard trực tuyến (≥ 5 phút, 2× đĩa) | chép lại bảng | resharding workflow | tạo bảng mới, chép |
| Vận hành | dễ nhất | cao (config server, balancer, orphan) | cao | cao | thấp (DynamoDB managed) / cao (Cassandra) |
| Tuyển người | dễ | trung bình | dễ (PostgreSQL) | khó | trung bình |
Dùng khi: một replica set đã nâng cấu hình hợp lý (RAM đủ cho working set, NVMe) mà vẫn không đủ ghi/dung lượng; hoặc cần dữ liệu theo vùng (zone); và có shard key tự nhiên mà truy vấn chính luôn mang theo. Tránh khi: dữ liệu dưới vài TB, tải đọc là chính (thêm secondary, cache, index trước); truy vấn chính không có key chung; cần unique/transaction xuyên nhiều thực thể; team chưa vận hành nổi replica set. Lựa chọn rẻ hơn theo thứ tự: tối ưu index/schema → scale dọc → tách collection lớn sang cluster riêng (theo chức năng) → archive dữ liệu cũ → mới tới sharding.
8. Góc nhìn tech lead: tác động tổ chức
- Shard key là quyết định schema, không phải cấu hình. Đổi được (reshard), nhưng mỗi lần là một dự án: gấp đôi đĩa, chạy giờ thấp điểm, sửa app để truy vấn mang key mới. Viết ADR: truy vấn chính, phân bố key đo trên dữ liệu thật (
analyzeShardKey), kịch bản tenant lớn gấp 100, kế hoạch reshard nếu sai. - Code phải "biết" shard key. Repository/service bắt buộc truyền key (vd
customerId) cho mọi truy vấn và ghi; code review chặnfind($id)trơn trên collection đã shard; không dùng Eloquentsave()để đổi field nằm trong key. - Onboarding: người mới phải hiểu range + orphan + StaleConfig, nếu không sẽ đếm bằng
estimatedDocumentCount, viết báo cáo đọc thẳng shard. - Lock-in: sharding của MongoDB (Atlas hay self-host) không mang sang DocumentDB/Cosmos DB nguyên vẹn; logic định tuyến nằm ở mongos, app không có — đó là điểm mạnh (app đơn giản) và điểm khoá.
- Exit strategy: giảm về ít shard bằng
startShardDraining; về không shard bằngunshardCollection(8.0+; lab 5,5 phút cho collection nhỏ, cần chỗ trên shard đích); rời MongoDB thì export theo từng range song song (mỗi worker một range shard key) để không quét một chỗ. - Rủi ro dài hạn: bản vá sharding hay có bug ở đường hiếm (SERVER-133518, mục 5.6) — chạy bản patch mới, giữ quy trình upgrade đều đặn, có môi trường staging sharded thật (không phải replica set).
9. Anti-patterns và bài học thực tế
Đã gặp trong bài: key tăng dần (3.6), key ít giá trị (3.7), hashed _id trong khi truy vấn chính theo khách (3.5), tin estimatedDocumentCount ngay sau migration (3.4), đọc/ghi/tạo index thẳng vào shard (3.4, mục 6), upsert theo mã nghiệp vụ không có unique (3.8), Eloquent save() đổi field trong key (3.10), dời đi dời lại một range (5.3). Thêm một cái không cần lab: shard quá sớm — vài chục GB mà trả toàn bộ chi phí vận hành của sharding.
Postmortem Foursquare, 10/2010. Theo bài của Eliot Horowitz (CTO 10gen) trên mongodb-user và TechCrunch: dữ liệu check-in chạy trên 2 shard, mỗi shard một máy EC2 66 GB RAM, shard theo user id, chia sẵn 200 chunk đều. Check-in tăng không đều, một shard lên ~67 GB (shard kia ~50 GB) — vượt RAM, phải đọc đĩa, hàng đợi dồn, site sập 11 giờ ngày 4/10. Thêm shard thứ 3 và dời 5 % dữ liệu không đỡ: check-in ~300 byte nằm rải trên trang 4 KB, dời đi để lại file phân mảnh vẫn chiếm chừng ấy RAM; phải tắt để repairDatabase (~4 giờ) và sập thêm ~6 giờ hôm sau (TechCrunch). Bài học còn nguyên: (1) "chia đều lúc đầu" khác "đều về sau" — đo phân bố liên tục; (2) thêm shard khi còn dư sức, vì dời dữ liệu tốn tài nguyên của chính shard đang quá tải; (3) dời dữ liệu đi không trả lại chỗ ngay (ngày nay: orphan 1 giờ, file WiredTiger không tự co — bài 6).
10. Câu hỏi phụ interviewer hay hỏi tiếp
- Chọn shard key cho orders thế nào? Sao không dùng ObjectId? Theo truy vấn chính "đơn của khách":
{customerId: 1, _id: 1}. Kiểm cardinality, frequency (khách lớn nhất chiếm bao nhiêu). ObjectId tăng dần → mọi insert vào range cuối (lab 100 %); hashed_idđều nhưng truy vấn theo khách thành scatter-gather. - Query không có shard key thì sao? mongos gửi mọi shard, merge; latency = shard chậm nhất, tải × số shard; sort + skip tốn mỗi shard
skip + limitdocument. - Jumbo là gì, chữa thế nào? Range một giá trị key quá lớn, không chia được, balancer bỏ qua;
refineCollectionShardKeythêm field đuôi → chia được; cờ compliant không báo. - Orphan là gì, có nguy hiểm? Bản cũ sau migration, bị lọc khi đọc qua mongos; nguy hiểm khi đếm bằng metadata, đọc thẳng shard, và khi dời ngược range trong cửa sổ dọn (1 giờ ở 9.0).
- Unique email trên collection shard theo
_id? Không được — collection phụ shard theo email có unique, ghi cả hai trong transaction. - Transaction xuyên shard chạy thế nào, tốn gì? Coordinator = shard đầu tiên; ghi participant list, prepare, ghi quyết định, commit; p50 ~2,5× transaction 1 shard; document prepared chặn ghi; đọc local có thể thấy nửa transaction.
- mongos biết dữ liệu ở đâu bằng cách nào? Nếu bảng cũ? Cache routing table có version; shard trả StaleConfig, mongos làm mới rồi thử lại — app chỉ thấy chậm hơn một lần.
- Đổi shard key khi đã có 2 TB?
reshardCollection: ~2 × dung lượng, ≥ 5 phút, ~2 s chặn ghi cuối; sửa app để truy vấn mang key mới. Chỉ jumbo thì refine. - Khi nào KHÔNG shard? Dữ liệu dưới vài TB, tải đọc là chính, chưa scale dọc, truy vấn không có key chung, team chưa vận hành nổi replica set.
Phụ lục: lab đã chạy
Môi trường: mongo:9.0.2 (so tham số với 8.0.32), config server tmp-mdb-cfg + 2 shard tmp-mdb-s1/s2 (mỗi shard replica set 1 node, --setParameter enableTestCommands=1 cho failpoint) + tmp-mdb-mongos; thêm tạm tmp-mdb-mongos2 (StaleConfig), tmp-mdb-s3 (thêm/rút shard), tmp-mdb-mongos3 (config server chết). Mỗi mongod cache 0,25 GB, --memory 1g; máy 4 vCPU dùng chung. Dữ liệu: bộ chung (js/seed.js, SCALE=1: products, customers, orders 2 triệu) nạp qua mongos. Node 22 + driver mongodb 7.7.0; PHP 8.3 + ext-mongodb 2.5.3 + laravel-mongodb 5.11.0.
cd labs/mongodb
./09-sharding.sh # tất cả (~3 giờ: phần orphan chờ range deleter)
./09-sharding.sh shard # 1 phần: params | seed | shard | orphan | query | nokey | unique | upsert | hot | moveback | jumbo
# | txn | skey | stale | reshard | laravel | drain | cfgdown (wedge: chạy tay, xem chú thích)
(source lib.sh && mdb_down tmp-mdb-cfg tmp-mdb-s1 tmp-mdb-s2 tmp-mdb-mongos) # dọn
File: 09-sharding.sh, js/09-*.js, node/09-*.mjs, laravel/09-shard.php; output out/09-*.txt.
Thứ tự có ý nghĩa: skey chạy sau hot (dùng hot_cust); laravel sau reshard; dời một range về shard từng giữ nó trong 1 giờ sẽ hỏng (mục 5.3) — script đã chọn range tránh việc đó. Chưa verify: config shard (8.0); zones; analyzeShardKey; CSRS 3 node mất primary; SERVER-133518 (cần shard nhiều node); chặn DDL nối thẳng shard khi bật auth (8.3); hành vi ở quy mô nhiều shard / nhiều TB (số migration, thời gian refresh routing table); removeShard, prepare conflict, reshard trên 8.0 (lab chỉ so tham số mặc định).