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ảnThay đổi đáng nhớ
1.6 (2010)auto-sharding: mongos, config server, auto-split, balancer
2.4 (2013)hashed shard key
3.2 / 3.4config 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.0refineCollectionShardKey, compound hashed key / reshardCollection
6.0 / 6.0.3chunk mặc định 128 MB; balancer cân theo data size thay vì số chunk, bỏ auto-split
7.0 / 7.1AutoMerger 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.0moveCollection, unshardCollection, config shard (config server chứa luôn dữ liệu)
8.3removeShard 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:

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.

Một lần migration: chép, đuổi theo, critical section, commit, rồi orphan chờ dọn Hàng donor sh1 và recipient sh2: 0,75 s khởi động, 17,4 s chép 376 060 document, catch-up, critical section khoảng 0,3 s, commit metadata lúc 18,3 s; bảng range đổi chủ từ sh1 sang sh2. Sau đó 752 175 orphan nằm ở sh1 chờ 1 giờ rồi range deleter xoá khoảng 12 phút. sh1 · donor sh2 · recipient bảng range config server đọc range, gửi từng batch chép 376 060 document · 17,4 s catch-up critical section ~0,3 s commit metadata range [MinKey, x) → sh1 → sh2 · major +1 752 175 orphan · chờ 1 giờ xoá~12 phút sh2 sở hữu range, nhận đọc/ghi 017,5 s 18,3 s// +1 giờ
Hình 1 — Migration đầu tiên của lab 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

3.5 Targeted và scatter-gather

Targeted đi một shard, scatter-gather chờ shard chậm nhất Lượt 1: find theo _id với shard key hashed _id, mongos gửi tới một shard, p50 6 ms. Lượt 2: find theo customerId không có trong shard key, mongos hỏi cả hai shard rồi merge, p50 17 ms. Lượt 3: sh2 chậm thêm 50 ms, mongos phải chờ sh2, p50 65 ms. find({_id}) · hashed _id có trong filter → 1 shard (SINGLE_SHARD) · p50 6 ms find({customerId}) · không có shard key → mọi shard + merge (SHARD_MERGE_SORT) · p50 17 ms cùng truy vấn, sh2 chậm thêm 50 ms → p50 65 ms: nhanh bằng shard chậm nhất App mongosbảng range (cache) sh11,25 triệu đơn sh20,75 triệu đơn +50 ms mỗi lệnh (failCommand) chờ shard chậm nhất
Hình 2 — Lab 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 _idsau reshard {customerId: 1, _id: 1}
find({_id})SINGLE_SHARD · p50 6 msSHARD_MERGE (2 shard) · p50 7 ms
find({customerId}).sort({createdAt: -1}).limit(20)SHARD_MERGE_SORT · p50 8–17 msSINGLE_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 ms1 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:

Shard key tăng dần dồn mọi insert vào một shard, hashed chia đều Trên: shard key createdAt, đơn mới luôn có createdAt lớn nhất nên rơi vào range cuối ở sh2; sh2 nhận 100 % insert, sh1 0 %. Dưới: shard key hashed _id, insert chia đều sh1 50,1 %, sh2 49,9 %. {createdAt: 1} — đơn mới luôn có giá trị lớn nhất App16 luồng ghi sh1[MinKey, hôm qua) 0 % sh2[hôm qua, MaxKey) 100 % {_id: 'hashed'} — băm trước rồi mới chia range App16 luồng ghi sh1hash [0, MaxKey) 50,1 % sh2hash [MinKey, 0) 49,9 %
Hình 3 — Lab 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 keyGhiĐọc theo keyRange query trên keyHợp với
ranged tăng dần (createdAt, ObjectId)1 shard nhận hết (hot shard)targetedtargeted (range liền)gần như không bao giờ một mình
hashed ({_id: 'hashed'})đềutargeted (equality)hỏi mọi shardlog, event, ghi nhiều mà đọc theo id
compound {customerId: 1, createdAt: 1}đều nếu có nhiều khách vừa phảitargeted theo khách, theo khách + thời giantargeted 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 tenanttargeted theo tenanttrong tenant: hỏi các shard của tenantSaaS 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.

Two-phase commit và khoảng người khác đọc thấy nửa transaction Transaction T sửa _id âm 1 ở sh1 và _id 1 ở sh2. Coordinator sh1 lưu participant, prepare ở cả hai shard, ghi quyết định commit, sh1 commit. Lệnh commit tới sh2 bị chặn 4 giây nên sh2 vẫn prepared. Client B đọc local thấy 2000 và 1000, tức nửa transaction; đọc snapshot phải chờ 3,8 giây rồi thấy 2000 và 2000. Client của T nhận commit sau 4 062 ms. mongos · T sh1 · coordinatordocument _id −1 sh2 · participantdocument _id 1 client Bđọc −1 và 1 commitTransaction lưu participant PREPARED PREPARED · 1 = 1000 (bản cũ còn đọc được) quyết định: commit đã commit · −1 = 2000 commitTransaction tới sh2 bị chặn 4 s local: −1/1 = 2000/1000 · nửa transaction snapshot: chờ (3,8 s) commit commit trả về sau 4 062 ms2000/2000
Hình 4 — Lab 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ạnNguồn
Atomic1 document: như replica set. Nhiều shard: transaction 2PC; ngoài transaction, updateMany chạy riêng ở từng shardbài 8, lab txn
Đọc qua mongos, localorphan bị lọc (có shardVersion); có thể thấy nửa transaction đang commit; không có snapshot chung giữa các shardlab 09-txn torn
Đọc snapshot / causalmột thời điểm chung cho mọi shard; chờ document đang preparedlab 09-txn
ĐếmestimatedDocumentCount, count không filter: gồm orphan; countDocuments: đúng, quét thậtlab 09-orphan
Uniquechỉ khi shard key là tiền tố của index; hashed key không unique được; _id chỉ unique trong 1 shardlab 09-unique
Định tuyếnequality / range trên tiền tố shard key → targeted; hashed: chỉ equalitylab 09-query
Chunkmặc định 128 MB; dời khi lệch ≥ 3 × chunk size; range > 2 × chunk size (theo số document) không dời đượcmanual, lab jumbo
Migrationmỗ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 đĩalab reshard, v8x
Config servermấ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õiLấy ở đâuBáo động khi
Dung lượng thật mỗi shard, orphan$shardedDataDistributionlệch > 20–30 %, orphan tồn > 2 giờ
BalancerbalancerStatus, config.actionlog (balancer.round), config.changelog (moveChunk.error)round lặp chunksMoved 0 với cùng lỗi; migration > vài phút
Jumboconfig.chunks.find({jumbo: true})> 0 (đừng tin balancerCompliant)
Định tuyếnserverStatus().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 shardslow log / FTDC của từng shardmột shard p99 cao hơn hẳn
Nhất quán metadatacheckMetadataConsistency (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ục1 replica set (scale dọc)MongoDB shardingPostgreSQL + CitusVitess (MySQL)DynamoDB / Cassandra
Chia dữ liệukhông chiarange/hashed theo shard key, balancer tự dờihash theo distribution columnkeyspace + vindexhash partition key (Cassandra: token ring)
Truy vấn không có keybình thườnghỏi mọi shardhỏi mọi workerscatterscan rất đắt / không cho
Transaction nhiều phầncó (bài 8)2PC, đắt ~2,5×2PCcó giới hạn (2PC tuỳ chọn)hạn chế
Unique toàn cụccóchỉ khi chứa shard keychỉ khi chứa distribution columnqua lookup vindexkhông (tự làm)
Đổi key về sau—reshard trực tuyến (≥ 5 phút, 2× đĩa)chép lại bảngresharding workflowtạo bảng mới, chép
Vận hànhdễ nhấtcao (config server, balancer, orphan)caocaothấp (DynamoDB managed) / cao (Cassandra)
Tuyển ngườidễtrung bìnhdễ (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

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

  1. 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.
  2. 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 + limit document.
  3. Jumbo là gì, chữa thế nào? Range một giá trị key quá lớn, không chia được, balancer bỏ qua; refineCollectionShardKey thêm field đuôi → chia được; cờ compliant không báo.
  4. 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).
  5. Unique email trên collection shard theo _id? Không được — collection phụ shard theo email có unique, ghi cả hai trong transaction.
  6. 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.
  7. 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.
  8. Đổ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.
  9. 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).