Hình dung một văn phòng có ba tủ hồ sơ đặt ở ba phòng khác nhau, mua đúng để chia đều tài liệu ra cho khỏi chật. Rồi vì một quy ước dán nhãn vô tình, mọi hồ sơ mới đều bị xếp vào đúng một tủ. Điều thú vị là: người thư ký rút hồ sơ vẫn nhanh như thường — lục một tủ có nhanh hay chậm gì đâu. Nhưng cái tủ ấy đầy dần rồi tràn, trong khi hai tủ kia trống trơn, và cả văn phòng "hết chỗ" dù thực ra còn thừa hai phần ba. Đây gần như chính xác chuyện xảy ra khi một thẻ băm trong Redis Cluster gom mọi khoá về một nút — và điều làm tôi bất ngờ là hậu quả không nằm ở chỗ tôi tưởng.

Phần 23 đo Cluster và thấy CRC16 rải khoá rất đều. Ta đo chuyện gì xảy ra khi nó không đều.

Một thẻ băm biến ba nút thành một

20.000 khoá, cụm 3 nút:

tên khoá  k:1 ... k:20000          ->  6.663 | 6.669 | 6.668
tên khoá  {khach}:1 ... {khach}:N  ->      0 |     0 | 20.000

Cùng số khoá, cùng cụm. Chỉ khác một cặp ngoặc nhọn. Như đo ở phần 23, chỉ phần trong {} được băm. Mọi khoá mang cùng thẻ nằm cùng một slot, và một slot nằm trên một nút. Hai nút giữ đúng số không.

Nhưng lệch không đồng nghĩa với chậm

Đây là chỗ tôi trông đợi thấy hậu quả rõ ràng và không thấy. 3 kết nối, không đường ống:

tất cả vào MỘT nút     37.137 | 34.353 ops/s
trải đều ba nút        34.368 | 34.120 ops/s

Lệch hoàn toàn mà không chậm hơn. Đo hai lần, cùng kết quả. Lý do đã được đo ở phần 2: với lệnh gọi từng cái một, nút thắt là số lượt đi về, không phải sức của nút. Ba kết nối tạo ra khoảng 34.000 lệnh mỗi giây bất kể chúng đi tới một nút hay ba nút — và một nút Redis xử lý con số đó mà gần như không phải cố gắng.

Trần thật của một nút

Ba kết nối có đường ống 200 vào cùng một nút:

1.229.164 ops/s

Một nút đơn chịu được trên một triệu lệnh mỗi giây. Nên phân mảnh lệch chỉ bắt đầu là vấn đề về tốc độ khi lưu lượng của bạn tiến gần con số đó. Dưới ngưỡng, nó không tốn của bạn một mili giây nào.

Điều này đảo ngược một trực giác phổ biến: người ta lo khoá nóng vì nghĩ nó làm chậm hệ thống. Với phần lớn hệ thống, nó không. Điều đáng lo nằm ở chỗ khác.

Cái lệch luôn tốn ngay lập tức: bộ nhớ

Hai nút trống rỗng trong khi một nút giữ hết. Cụm ba nút có RAM của ba máy, nhưng sức chứa thật bằng một máy. Bạn trả tiền cho ba, dùng được một — đúng cái tủ hồ sơ đầy tràn giữa hai tủ trống.

Và khi nút đó chạm maxmemory, nó bắt đầu đuổi khoá hoặc từ chối ghi — như đo ở phần 9 — trong khi hai nút kia còn trống nguyên. Cụm không tự cân bằng lại được, vì slot đã gắn với nút. Đây mới là lý do chính để tránh thẻ băm quá rộng, không phải tốc độ.

Thẻ băm nên rộng đến đâu

Quy tắc: thẻ nên gom đúng những khoá cần dùng chung trong một lệnh nhiều khoá, không hơn.

{u:12345}:ho-so       tốt — vài khoá mỗi người dùng
{u:12345}:gio-hang
{u:12345}:phien

{khach}:12345         xấu — mọi người dùng vào một slot
{san-pham}:98765      xấu — mọi sản phẩm vào một slot

Với {u:12345}, số slot khác nhau bằng số người dùng — rải đều tự nhiên. Với {khach}, có đúng một slot.

Cách kiểm nhanh: thẻ có bao nhiêu giá trị khác nhau? Dưới vài nghìn là dấu hiệu xấu; cụm 16.384 slot cần ít nhất vài chục nghìn giá trị khác nhau để rải đều.

Khoá nóng theo lưu lượng, không theo dữ liệu

Có một dạng lệch thứ hai mà số khoá không thấy được: một khoá duy nhất nhận phần lớn lượt đọc. Ví dụ cấu hình toàn cục, danh sách bài nổi bật, tỉ giá.

Khoá đó nằm trên một nút, và nút đó nhận toàn bộ lưu lượng. Với ngưỡng một triệu ops/s đo được ở trên, phần lớn hệ thống vẫn không sao. Nhưng khi có sao, có ba cách:

  • Đệm ở phía ứng dụng. Giá trị đọc nhiều mà đổi ít nên nằm trong bộ nhớ tiến trình với TTL vài giây. Đây là cách rẻ nhất và hiệu quả nhất.
  • Nhân bản khoá. cauhinh:0 đến cauhinh:9, khách chọn ngẫu nhiên. Mười khoá rơi vào mười slot khác nhau. Đổi lại: cập nhật phải ghi cả mười.
  • Đọc từ bản sao. Cluster cho phép đọc từ bản sao bằng READONLY, chia lưu lượng đọc ra hai máy.

Chuyển slot khi đã lệch

Nếu đã lỡ lệch, redis-cli --cluster rebalance không cứu được — nó cân bằng số slot, không cân bằng số khoá. Một slot chứa 20.000 khoá vẫn là một slot.

Cách duy nhất là đổi tên khoá, tức là ghi lại dữ liệu với thẻ băm mới. Đây là dự án di trú, và đó là lý do việc chọn thẻ băm đúng ngay từ đầu quan trọng đến vậy.

Mẫu số chung: sức chứa của hệ chia mảnh bị chặn bởi mảnh đầy nhất

Cái bẫy thẻ băm này là một trường hợp của thứ mọi hệ thống chia mảnh đều gặp: khoá phân mảnh (partition/shard key). Khi bạn rải dữ liệu ra N máy bằng một hàm băm trên một khoá, tổng dung lượng và tổng tải bạn thật sự dùng được không phải là tổng của N máy — nó bị chặn bởi mảnh nặng nhất. Và hàm băm chỉ rải đều khi khoá có đủ nhiều giá trị khác nhau; chọn một khoá ít giá trị (cardinality thấp) là dồn mọi thứ vào một mảnh, y như {khach} dồn vào một slot.

Trong thế giới Java, đây chính xác là bài học về khoá phân vùng của Kafka: producer.send với cùng một key sẽ đẩy mọi message vào cùng một partition, nên một partition đầy và tụt hậu trong khi các partition khác rảnh — thông lượng của cả topic bị chặn bởi partition nóng nhất và số consumer hữu ích không vượt được số partition. Cùng nguyên lý với sharded cache hay ConcurrentHashMap chia sọc (striping): một hàm băm tồi làm mọi khoá rơi vào một sọc, và cái sọc đó thành điểm tranh chấp.

Trong Go, khi bạn tự cắt một map lớn thành các shard theo hash(key) % N để giảm tranh khoá, một phân bố khoá lệch khiến một shard giữ phần lớn dữ liệu và lãnh phần lớn lượt truy cập — vòng băm nhất quán (consistent hashing) tồn tại một phần để làm nhẹ đúng chuyện này.

Còn ở cơ sở dữ liệu, đây là "hot partition" kinh điển của DynamoDB/Cassandra: chọn partition key cardinality thấp (như country hay một status) là dồn mọi hàng vào một phân vùng, và phân vùng đó chạm trần dung lượng/throughput trong khi phần còn lại của bảng nhàn rỗi — đúng "trả tiền cho cả bảng, dùng được một phân vùng".

Mẫu số chung: trong một hệ chia mảnh, năng lực khả dụng bằng năng lực của mảnh nặng nhất chứ không phải tổng các mảnh, và hàm phân mảnh chỉ san đều khi khoá có đủ giá trị khác nhau — nên phân bố lệch không làm mỗi thao tác chậm đi, nó âm thầm biến một cụm N máy thành một máy. Câu hỏi đúng khi thiết kế không phải "tôi có bao nhiêu mảnh" mà là "khoá phân mảnh của tôi có bao nhiêu giá trị khác nhau, và có một giá trị nào chiếm phần bất cân xứng không" — vì cái mảnh nóng nhất, không phải con số trung bình, mới quyết định trần của cả hệ.

Muốn tự xem cụm có lệch không, và lệch ở đâu:

# so khoa tren tung nut chinh
for n in $(redis-cli --cluster call <mot-nut>:6379 dbsize 2>/dev/null | grep -B1 '^[0-9]'); do
  echo "$n"
done

# hoac don gian hon
redis-cli --cluster info <mot-nut>:6379

--cluster info in số khoá của từng nút. Lệch quá hai lần giữa nút nhiều nhất và ít nhất là dấu hiệu có thẻ băm quá rộng. Tìm thủ phạm:

redis-cli --scan --count 500 | head -5000 | \
  grep -o '{[^}]*}' | sort | uniq -c | sort -rn | head -10

Thẻ nào xuất hiện hàng nghìn lần chính là thẻ đang gom mọi thứ vào một slot. Nếu dòng đầu bảng chiếm phần lớn tổng số, bạn đã tìm ra.

Phần sau: kích thước khoá và giá trị lớn — đo lúc nào chúng bắt đầu gây nghẽn.