Xuyên suốt sê-ri, một kẻ thù trở đi trở lại: điểm ghi chung. Một bộ đếm, một map, một cấu trúc dùng chung bảo vệ bởi một khóa — mọi luồng tranh nhau đúng chỗ đó, và throughput sụp, convoy, dòng cache nảy. Sharding (chia phân vùng) là giải pháp tổng quát: đừng có một chỗ chung, hãy có nhiều phân vùng độc lập, để các luồng rải ra và hầu như không đụng nhau. Tôi đo nó, và thấy vừa sức mạnh vừa cái giá mà "cứ chia thật nhiều" bỏ qua.

Sharding: chia phân vùng, đọc chung đắt lên

Chia điểm nóng thành nhiều mảnh

Ý tưởng đơn giản: thay một bộ đếm chung (một khóa) bằng N shard, mỗi shard là một bộ đếm với khóa riêng. Khi một luồng cần cập nhật, nó băm khóa (key) của thao tác để chọn shard, rồi chỉ khóa shard đó. Vì các luồng băm vào các shard khác nhau, phần lớn thời gian chúng chạm các khóa khác nhau — không tranh chấp. Chỉ khi hai luồng tình cờ băm vào cùng shard mới có tranh chấp, và xác suất đó giảm khi số shard tăng.

Đây là nguyên lý nền của rất nhiều thứ: ConcurrentHashMap của Java (striped lock — chia map thành nhiều segment, mỗi segment một khóa), sharded counter (mỗi shard một biến, cộng lại khi cần tổng), phân vùng cơ sở dữ liệu (chia bảng theo khóa). Tôi đo trong container gcc:13 (10 lõi): các luồng tăng bộ đếm, băm mỗi thao tác vào một shard, mỗi shard nằm trên dòng cache riêng (_Alignas(64) để tránh false sharing).

Đo (a): một khóa sụp, sharding scale

So một bộ đếm chung (1 shard, 1 khóa) với sharded (64 shard), throughput theo số luồng:

NT | 1 khóa chung | 64 shard | sharded/chung
1  |  229 M/s     |  233 M/s |  1×
4  |   46 M/s     |  156 M/s |  3×
8  |   35,7 M/s   |  105 M/s |  3×

Ở 1 luồng, hai bên ngang nhau (không có ai để tranh). Nhưng thêm luồng, một khóa chung sụp: 229 → 35,7 triệu op/giây — càng nhiều luồng càng chậm (đúng scale âm, tất cả tranh một khóa). Sharded thì giữ throughput cao: 233 → 105 triệu (giảm chút vì vẫn có va chạm ngẫu nhiên, nhưng không sụp). Ở 8 luồng, sharding nhanh hơn 3 lần. Sharding biến một điểm nóng thành nhiều điểm nguội.

Đo (b): nhiều shard hơn, ít tranh chấp hơn

Số shard ảnh hưởng thế nào? Đo throughput ở 8 luồng, tăng số shard:

   1 shard :  34 M/s
   8 shard :  54 M/s
  64 shard : 106 M/s
 512 shard : 175 M/s

Càng nhiều shard, throughput càng cao (34 → 175 triệu). Vì với 8 luồng chọn ngẫu nhiên trong S shard, xác suất hai luồng cùng shard (đụng nhau) giảm khi S lớn — kiểu nghịch lý ngày sinh ngược lại. Với 8 shard và 8 luồng, va chạm còn thường; với 512 shard, gần như mỗi luồng một shard riêng, tranh chấp gần biến mất. Nên có vẻ "càng nhiều shard càng tốt". Nhưng đó là chỗ đo hớ — vì có một cái giá không hiện trong bảng này.

Đo (c): thao tác toàn cục đắt dần theo số shard

Cái giá của sharding: khi bạn cần một thao tác toàn cục — tính tổng tất cả bộ đếm, duyệt toàn bộ map — bạn phải chạm mọi shard. Đo chi phí tính tổng theo số shard:

     8 shard : ~0 µs
   512 shard :  0,1 µs
  4.096 shard :  2,0 µs
 65.536 shard : 32,8 µs

Chi phí tính tổng tăng tuyến tính theo số shard — O(số shard). Với 65.536 shard, mỗi lần lấy tổng mất 32,8 µs (phải đọc 65 nghìn ô). Đây là đánh đổi cốt lõi: nhiều shard làm ghi cục bộ rẻ (ít tranh chấp) nhưng làm đọc toàn cục đắt (chạm nhiều ô). Nếu bạn hiếm khi cần tổng, chia nhiều shard là tốt; nếu bạn thường xuyên cần tổng/duyệt, quá nhiều shard biến thao tác toàn cục thành nút thắt mới. Số shard tối ưu cân bằng hai chiều này — thường vài lần số luồng là đủ (đủ để tránh va chạm, không quá nhiều để đọc toàn cục còn rẻ), không phải "càng nhiều càng tốt".

Một lần tôi đo hớ: cục bộ rẻ đi kèm toàn cục đắt

Tôi vào đo với hai niềm tin. Thứ nhất: "một cấu trúc chung với một khóa là đủ, đơn giản mà". Sai — một khóa chung là điểm nóng, sụp khi đông luồng (35,7 so với 105 triệu); sharding cần thiết để scale. Thứ hai, sau khi thấy sharding tốt: "vậy càng nhiều shard càng tốt". Cũng sai — throughput ghi tăng theo số shard, nhưng thao tác toàn cục (tổng, duyệt) đắt lên O(số shard), và băm lệch có thể gây shard nóng (mất cân bằng như cân bằng tải).

Bài học đo lường: sharding không xóa chi phí, nó dời chi phí từ đường ghi cục bộ (giờ rẻ) sang đường đọc toàn cục (giờ đắt) — và bạn phải biết đường nào nóng hơn để chọn số shard. Đây là cùng một khuôn mẫu với RCU (đọc rẻ, thu hồi trễ) và mọi tối ưu đồng thời: không có bữa trưa miễn phí, chỉ có chuyển chi phí. Nếu workload của bạn 99% là cập nhật cục bộ và 1% là lấy tổng, sharding nhiều là tuyệt; nếu bạn lấy tổng liên tục, sharding nhiều tự bắn vào chân. Đúng tinh thần đo lường: đo cả hai đường (ghi cục bộ đọc toàn cục) trước khi chọn số shard, đừng chỉ tối ưu throughput ghi rồi ngạc nhiên khi lấy tổng chậm.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: khi một cấu trúc dùng chung là điểm nóng, hãy sharding nó. Bộ đếm nhiều luồng cùng tăng, map nhiều luồng cùng ghi, một hàng đợi công việc bị tranh — chia thành N phân vùng độc lập (mỗi cái khóa/counter riêng), băm khóa để chọn phân vùng. Đây là cách các cấu trúc đồng thời tốc độ cao (ConcurrentHashMap, LongAdder của Java, sharded rate limiter) đạt được khả năng mở rộng. Nhớ đặt mỗi shard trên dòng cache riêng để tránh false sharing.

Hệ quả thứ hai: chọn số shard theo tỉ lệ ghi-cục-bộ / đọc-toàn-cục, không phải max. Nếu thao tác chủ yếu là cập nhật cục bộ và hiếm khi cần toàn cục, nhiều shard tốt (giảm tranh chấp). Nếu thường xuyên cần tổng/duyệt toàn bộ, giữ số shard vừa phải (thao tác toàn cục là O(số shard)). Điểm khởi đầu hợp lý: số shard cỡ vài lần số lõi/luồng. Và coi chừng băm lệch — nếu một khóa "nóng" luôn rơi vào một shard, shard đó thành điểm nóng dù có nhiều shard khác.

Hệ quả thứ ba là tinh thần đo lường: sharding dời chi phí, không xóa — đo cả hai đường. Con số mang theo: một khóa chung là điểm nóng SỤP khi đông luồng (229 -> 35,7 M/s ở 1->8 luồng); sharding chia N phân vùng độc lập (băm key -> shard) giảm tranh chấp, throughput scale (64 shard: 105 M/s, 3x ở 8 luồng), và tăng theo số shard (34 -> 175 từ 1 -> 512 shard). NHƯNG thao tác TOÀN CỤC (tổng/duyệt mọi shard) đắt O(số shard) — 65.536 shard tính tổng mất 32,8µs so 8 shard ~0. Sharding đổi ghi cục bộ rẻ lấy đọc toàn cục đắt; chọn số shard ~vài lần số luồng cân bằng hai chiều, không phải càng nhiều càng tốt.

Thử ba mươi giây

Tìm trong hệ của bạn một cấu trúc dùng chung mà nhiều luồng ghi vào — một bộ đếm thống kê, một cache, một map trạng thái. Hỏi: nó có phải một khóa/một điểm duy nhất không, và có bị tranh dưới tải không? Nếu có (nhiều luồng cùng cập nhật, throughput không scale), thử chia nó thành vài chục phân vùng, mỗi phân vùng khóa riêng, băm khóa để chọn — bạn rất có thể thấy throughput nhảy vọt vì tranh chấp tan ra. Nhưng rồi hỏi ngay câu thứ hai: bao lâu tôi cần một cái nhìn toàn cục (tổng, danh sách tất cả)? Nếu thường xuyên, đừng chia quá nhiều — mỗi lần toàn cục phải gộp mọi phân vùng. Ba mươi giây cân "ghi cục bộ bao nhiêu" với "đọc toàn cục bao nhiêu" đó cho bạn số shard đúng — và nhắc rằng sharding, như mọi tối ưu đồng thời, không cho không thứ gì: nó đổi sự rẻ ở chỗ này lấy sự đắt ở chỗ khác, và việc của bạn là biết chỗ nào quan trọng hơn.