Phần trước kết luận hit ratio là thước đo mù trước nhiều chuyện. Đây là chuyện mù nhất: một key duy nhất hút phần lớn lưu lượng. Hit ratio vẫn 100% — key đó luôn có trong cache. Miss trên giây vẫn bằng 0. Tải database vẫn bằng 0. Mọi biểu đồ đều xanh. Mà một node Redis nóng rực, độ trễ p99 leo thang, còn các node khác trong cụm ngồi chơi.

Vì sao một key nóng không chia tải được

Ảnh chụp đoạn mã nền tối hot key là chỗ hit ratio 100 phần trăm vẫn giấu một sự cố với phân bố Zipf slot bằng CRC16 key mod 16384 và tách key nóng, vì sao một key nóng không thể chia tải được là do Redis Cluster mỗi key thuộc đúng một slot và mỗi slot thuộc đúng một shard với hàm slot key string trả về int trong đó nếu có dấu ngoặc nhọn thì lấy hash tag rồi return int crc16 key chia dư 16384, nên thêm shard làm tăng tổng dung lượng nhưng không chia nhỏ được một key vì key sp 4242 luôn rơi vào đúng một shard dù cụm có 3 hay 300 node, cách tách key nóng là đổi một key thành N key con với phần ghi điền cả N bản sao bằng vòng lặp for i từ 0 tới N chạy rdb Set với tên sp id thăng i giá trị v và ttl, phần đọc bốc ngẫu nhiên một bản bằng rdb Get với tên sp id thăng rand Intn N, vì N bản sao có N slot khác nhau nên rơi vào tối đa N shard khác nhau còn giá phải trả là N lần bộ nhớ và mỗi lần invalidate phải xoá cả N key, phép đo dùng 1 triệu request theo Zipf trên 10.000 key với 16 shard và CRC16 thật để đo tỉ lệ lưu lượng của key nóng nhất độ lệch tải giữa các shard và trần vật lý của một key khi giá trị lớn dần

Hình 1: Cơ chế gán key vào shard, và cách tách key nóng. slot ở đây là đúng thuật toán Redis Cluster dùng — CRC16-CCITT rồi chia dư 16384, có xử lý hash tag {...}.

Cơ chế này là nguồn gốc của vấn đề. Redis Cluster băm key thành một trong 16.384 slot, mỗi slot thuộc về đúng một shard. Hệ quả: thêm shard làm tăng tổng dung lượng nhưng không chia nhỏ được một key. Cụm 3 node hay 300 node thì sp:4242 vẫn nằm trên đúng một node, và node đó là trần của cả hệ thống cho key ấy.

Đây là lý do hot key khác hẳn mọi vấn đề dung lượng khác trong loạt bài này: nó không giải được bằng cách thêm máy.

Đo thật: 20,81% lưu lượng vào một key, 4,44× tải lên một shard

Lưu lượng thật không bao giờ rải đều. Mình sinh 1 triệu request theo phân bố Zipf — mô hình chuẩn cho lượt truy cập web, và cũng là thứ đứng sau mọi "bài viết bỗng lên top" — rồi tính slot thật của từng key:

Ảnh chụp output thật nền tối một key hút 20,8 phần trăm lưu lượng và một shard gánh 4,44 lần với 1 triệu request Zipf trên 10.000 key qua 16 shard dùng CRC16 thật trên Redis 7, phần lưu lượng thật lệch tới mức nào cho thấy key nóng nhất nhận 208139 request tức 20.81 phần trăm toàn bộ lưu lượng trong khi top 10 key chiếm 51.46 phần trăm top 100 key chiếm 75.11 phần trăm top 1000 key chiếm 90.45 phần trăm và 9000 key còn lại tức 90 phần trăm số key chỉ nhận 9.55 phần trăm lưu lượng, phần 16 shard với slot bằng CRC16 key mod 16384 cho thấy nếu lưu lượng rải đều thì shard nặng nhất gánh 1.01 lần mức trung bình còn với lưu lượng Zipf thì shard nặng nhất là số 7 gánh 4.44 lần mức trung bình tức 27.7 phần trăm toàn bộ, và thêm shard thứ 17 18 hay 100 cũng không giúp vì key sp 4242 vẫn rơi vào đúng một slot và slot đó vẫn thuộc đúng một shard nên shard số 7 là trần của cả cụm, phần tách key nóng thành 16 bản sao cho thấy tách 0 key thì shard nặng nhất gánh 4.44 lần tách 1 key nóng nhất còn 2.44 lần tách 10 key nóng nhất còn 1.80 lần và tách 100 key nóng nhất còn 1.63 lần, phần trần của một key là băng thông chứ không phải CPU cho thấy giá trị 1KB đạt 783367 GET mỗi giây tức 771.7 MB mỗi giây ra khỏi Redis giá trị 16KB đạt 242784 GET mỗi giây tức 3795.8 MB mỗi giây và giá trị 128KB chỉ còn 46713 GET mỗi giây ở 5839.6 MB mỗi giây, nói thẳng con số 5,8 GB mỗi giây là vì hai container nằm trên cùng một máy nên qua NIC 10 Gb thật khoảng 1,25 GB mỗi giây thì giá trị 128KB chặn một shard ở khoảng 10.000 GET mỗi giây trong khi key nóng kia cần 208.139

Hình 2: Kết quả thật. Ba tầng của cùng một vấn đề: lưu lượng lệch, lệch đó dồn vào một shard, và shard đó có trần vật lý.

Lưu lượng lệch nhiều hơn người ta tưởng. Key nóng nhất nhận 208.139/1.000.000 request — 20,81% toàn bộ lưu lượng vào đúng một key. Top 10 key chiếm 51,46%, tức mười key gánh hơn nửa hệ thống. Còn 9.000 key ở đuôi — 90% số key — chia nhau vỏn vẹn 9,55%. Đây không phải trường hợp bệnh lý; đây là hình dạng bình thường của lưu lượng thật.

Độ lệch đó không bị san phẳng khi chia shard. Với lưu lượng rải đều, 16 shard cân nhau gần như hoàn hảo: shard nặng nhất gánh 1,01× mức trung bình — CRC16 băm rất tốt. Với lưu lượng Zipf, shard nặng nhất gánh 4,44× mức trung bình và ôm 27,7% toàn bộ request. Cùng một hàm băm, cùng một cách chia slot; khác biệt nằm hoàn toàn ở phân bố lưu lượng, thứ mà hàm băm không biết gì về nó.

Chi tiết đáng nhớ: băm key rất đều và tải rất lệch là hai chuyện độc lập. Hàm băm chia đều số lượng key, không chia đều số lượng request.

Trần của một shard là băng thông, không phải CPU. Bảng cuối đo thông lượng GET trên một key duy nhất với ba cỡ giá trị: 1KB cho 783.367 GET/s, 16KB cho 242.784, 128KB chỉ còn 46.713. Thông lượng lệnh giảm 17 lần trong khi lượng byte ra khỏi Redis vẫn tăng — tức là cái chạm trần là đường truyền, không phải sức tính.

Và phải nói thẳng con số 5,8 GB/s kia: nó cao như vậy vì hai container nằm trên cùng một máy, đi qua loopback. Qua một NIC 10 Gb thật (~1,25 GB/s), giá trị 128KB chặn một shard ở khoảng 10.000 GET/s. Key nóng trong phép đo cần 208.139. Khoảng cách đó không đóng được bằng phần cứng nhanh hơn — chỉ bằng cách không gửi từng ấy byte.

Tách key nóng: hiệu quả, nhưng giảm dần

Cách chữa trực tiếp nhất là biến một key nóng thành N key con sp:4242#0 … sp:4242#15, ghi cả N bản, đọc một bản ngẫu nhiên. N bản có N slot khác nhau nên trải ra tối đa N shard.

tách shard nặng nhất gánh
không tách 4,44×
1 key nóng nhất × 16 bản 2,44×
10 key nóng nhất × 16 bản 1,80×
100 key nóng nhất × 16 bản 1,63×

Tách đúng một key đã cắt gần một nửa độ lệch (4,44× → 2,44×) — hợp lý, vì key đó chiếm 20,81% lưu lượng. Nhưng dừng ở đó thì key nóng thứ hai trở thành vấn đề mới, nên phải tách 10 key mới xuống 1,80×.

Và tách tới 100 key chỉ còn cải thiện được tới 1,63% — hiệu quả giảm dần rõ rệt. Lý do: phân bố Zipf không có điểm cắt tự nhiên; sau 100 key nóng vẫn còn 900 key tương đối nóng. Đuổi theo đuôi đó là công việc không có điểm dừng, và mỗi key tách ra lại nhân chi phí bộ nhớ lẫn chi phí invalidation lên 16 lần. Tách vài key nóng nhất lấy phần lớn lợi ích; tách tiếp là trả giá nhiều hơn nhận.

Đánh đổi cần cân nhắc

Tách key nhân chi phí invalidation lên N lần, và mở lại khe hở của phần 7. Xoá một key nóng giờ là xoá 16 key, và 16 lệnh Del đó không nguyên tử: giữa lệnh thứ 3 và thứ 4, một số người đọc thấy giá mới, số khác thấy giá cũ. Với dữ liệu chịu được hơi cũ thì không sao; với dữ liệu cần nhất quán thì tách key là một đánh đổi về tính đúng đắn, không chỉ về bộ nhớ.

Cách chữa tốt hơn cho hầu hết trường hợp là đừng gửi request tới Redis. Một key chiếm 20,81% lưu lượng cũng có nghĩa là cùng một giá trị được gửi qua mạng 208.139 lần. Giữ nó trong bộ nhớ tiến trình với TTL vài giây thì lưu lượng tới Redis cho key đó giảm xuống gần bằng số instance — rẻ hơn tách key rất nhiều, và không đụng tới chuyện invalidation nhiều bản. Đó chính là chủ đề phần sau.

Hash tag là con dao hai lưỡi. Redis Cluster cho phép ép nhiều key vào cùng một slot bằng {...} — tiện khi cần MGET nhiều key trong một lệnh, vì lệnh đa key chỉ chạy được trong cùng một slot. Nhưng hash tag cũng là cách tự tạo ra hot shard: gom user:{123}:* vào một slot nghĩa là mọi lưu lượng của người dùng đó dồn vào một node. Dùng hash tag khi cần gom lệnh, và biết rằng mình vừa từ bỏ việc chia tải cho nhóm key đó.

Số đo ở đây là mô phỏng phân bố và tính slot, không phải một cụm Redis Cluster thật. Hàm slot dùng đúng CRC16-CCITT và phép chia dư 16384 của Redis, và cách chia dải slot cho 16 shard là cách redis-cli --cluster create chia mặc định — nên bản đồ key → shard là thật. Nhưng độ lệch thực tế trên một cụm thật còn phụ thuộc replica đọc, client routing và việc di chuyển slot. Riêng bảng thông lượng ở mục cuối là đo trên Redis 7 thật, chỉ là qua loopback.

Ba ý mang về

  1. Lưu lượng thật lệch tới mức một key chiếm 20,81% và top 10 key chiếm 51,46% — trong khi 90% số key chia nhau 9,55%. Hàm băm chia đều số key, không chia đều số request, nên 16 shard vẫn có shard gánh 4,44× mức trung bình.
  2. Hot key không giải được bằng cách thêm máy: một key thuộc đúng một slot, slot thuộc đúng một shard, nên shard đó là trần của cả cụm. Và trần của nó là băng thông: đo thật 783.367 GET/s với giá trị 1KB nhưng chỉ 46.713 với giá trị 128KB.
  3. Tách key nóng hiệu quả nhưng giảm dần: 4,44× → 2,44× khi tách một key, → 1,80× khi tách 10, → 1,63× khi tách 100. Lấy vài key nóng nhất là đủ; mỗi key tách ra nhân bộ nhớ và chi phí invalidation lên N lần, và 16 lệnh Del không nguyên tử.

Nguồn

Phần sau ta làm đúng cái gợi ý ở trên: thêm một tầng cache trong bộ nhớ tiến trình trước Redis. Đo thật độ trễ tầng local so với Redis, lưu lượng Redis giảm bao nhiêu, và cái giá của việc mỗi instance có một bản dữ liệu riêng có thể lệch nhau.