Hình dung một bãi đỗ xe nhiều tầng, mỗi dãy 100 chỗ. Quy tắc dọn dẹp: chỉ đóng và tân trang được cả một dãy khi mọi xe trong dãy đã rời đi. Bây giờ 90% khách ra về, nhưng họ đỗ rải rác — gần như dãy nào cũng còn sót một chiếc. Kết quả: chỗ trống thì mênh mông, mà không đóng nổi một dãy nào, và bãi xe vẫn báo "gần đầy". Bộ cấp phát bộ nhớ của Redis hành xử y hệt cái bãi xe ấy, và bài này đo cái nghịch lý "xoá cả đống mà không đòi lại được gì". mem_fragmentation_ratio là chỉ số hay bị hiểu sai nhất trong INFO memory; ta đo nó ở ba trạng thái để thấy nó nói gì và không nói gì.

Ba trạng thái, ba con số

Trạng thái used_memory used_memory_rss Tỷ lệ
Gần như rỗng 2,6 MB 42,6 MB 16,59
Nạp 300.000 khoá 200 byte 90,7 MB 107,4 MB 1,18
Xoá 90% số khoá 11,5 MB 107,6 MB 9,39

Dòng cuối là chỗ đáng dừng lại: xoá 270.000 khoá và RSS không giảm một byte nào. Redis trả 79 MB về cho bộ cấp phát, và bộ cấp phát giữ lại toàn bộ — đúng cái bãi xe không đóng nổi dãy nào.

Tỷ lệ một mình không nói được gì

Dòng đầu giải thích vì sao không nên cảnh báo trên mem_fragmentation_ratio. Một instance gần rỗng có tỷ lệ 16,59 và hoàn toàn khoẻ mạnh. 42,6 MB RSS đó là mã Redis, ngăn xếp, bộ đệm — những thứ không tỷ lệ với dữ liệu. Chia một số cố định cho một số nhỏ thì ra một số lớn, và nó chẳng nói lên điều gì.

Con số đáng đọc là allocator_frag_bytes:

allocator_frag_bytes: 82.439.560

82 MB đã cấp phát mà không chứa gì. Đó là byte thật — so được với maxmemory, quyết định được có đáng làm gì không. Quy tắc thực dụng: cảnh báo trên allocator_frag_bytes, không trên tỷ lệ. Và chỉ xem tỷ lệ khi used_memory đã đủ lớn để nó có nghĩa.

Cũng nên phân biệt hai cặp chỉ số:

  • mem_fragmentation_ratio = rss / used_memory — gồm cả phần không do bộ cấp phát gây ra.
  • allocator_frag_ratio = phần thật sự thuộc về jemalloc.

Ở phép đo trên, hai con số là 9,39 và 8,10 — gần nhau, nên phân mảnh đúng là do bộ cấp phát. Nếu chúng lệch nhiều thì phần chênh nằm ở chỗ khác: bộ đệm sao chép, fork để lưu RDB, hoặc bộ đệm đầu ra của khách.

Vì sao bộ cấp phát không trả lại

jemalloc chia bộ nhớ thành các trang lớn và cắt nhỏ theo lớp kích thước. Một trang chỉ trả về hệ điều hành khi mọi đối tượng trong đó đã được giải phóng. Sau khi xoá 90% khoá ngẫu nhiên, gần như mọi trang còn ít nhất một đối tượng sống — mỗi dãy còn sót một chiếc xe. Nghĩa là gần như không trang nào trả về được, dù 90% chỗ trong đó đã trống.

Đây là lý do phân mảnh nặng nhất xảy ra sau xoá rải rác, chứ không phải sau xoá theo cụm. Xoá theo tiền tố khoá — nếu chúng được ghi liên tiếp — thường trả lại nhiều hơn.

Bật activedefrag xong vẫn không có gì xảy ra

config set activedefrag yes
   t=10s  rss 97,4 MB  frag 8,91
   t=20s  rss 97,4 MB  frag 8,91
   t=30s  rss 97,4 MB  frag 8,91

Ba mươi giây, không thay đổi gì. Tôi tưởng cấu hình không được nhận. Nó được nhận — config get activedefrag trả về yes. Vấn đề ở tham số khác:

active-defrag-ignore-bytes = 104857600   (100 MB)
active-defrag-threshold-lower = 10       (%)

Redis chỉ bắt đầu chống phân mảnh khi cả hai điều kiện đúng: chỗ phí vượt 100 MB và tỷ lệ phí vượt 10%. Chỗ phí của tôi là 82 MB — chưa đủ. Hạ ngưỡng xuống:

config set active-defrag-ignore-bytes 10mb

   t=10s  rss 97,4 MB  frag 8,91
   t=20s  rss 96,2 MB  frag 8,80
   t=40s  rss 48,0 MB  frag 4,39     đã trả về 49 MB
   t=60s  rss 48,0 MB  frag 4,39

RSS giảm một nửa trong khoảng 40 giây, sau 67.497 lần di chuyển đối tượng. Bài học: activedefrag yes một mình thường là lệnh không có tác dụng. Với instance nhỏ hơn vài GB, ngưỡng mặc định 100 MB có nghĩa là nó gần như không bao giờ chạy. Đây là kiểu cấu hình tệ nhất — bật rồi mà tưởng đã xong.

Chống phân mảnh không miễn phí

Chống phân mảnh là di chuyển từng đối tượng sang vùng nhớ mới rồi cập nhật con trỏ, và nó chạy trên chính luồng xử lý lệnh. 67.497 lần di chuyển ở đây là 67.497 khoảng thời gian nhỏ mà không lệnh nào của khách được phục vụ. Hai tham số kiểm soát mức độ:

active-defrag-cycle-min 1     % CPU tối thiểu dành cho việc này
active-defrag-cycle-max 25    % CPU tối đa

Mặc định tối đa 25% — tức một phần tư luồng duy nhất. Với hệ thống nhạy độ trễ, hạ xuống 5–10% và chấp nhận dọn lâu hơn.

Khi nào đáng làm gì

Không làm gì, nếu allocator_frag_bytes nhỏ so với maxmemory. Vài chục MB phí trên một instance 16 GB là bình thường.

Bật activedefrag kèm hạ ignore-bytes, nếu chỗ phí đáng kể và bạn chịu được thêm một chút độ trễ. Đây là cách duy nhất trả bộ nhớ về mà không gián đoạn.

Khởi động lại, nếu bạn có bản sao và chuyển đổi được. Đó là cách sạch nhất và nhanh nhất, nhưng nó là gián đoạn thật.

Và cách phòng hiệu quả hơn cả ba: giữ kích thước giá trị đồng đều. Phân mảnh tệ nhất khi trộn lẫn nhiều lớp kích thước rất khác nhau, vì mỗi lớp có vùng riêng và không dùng lại chỗ của nhau.

Mẫu số chung: bộ nhớ trả về theo khối lớn, không theo từng đối tượng

Cái nghịch lý "xoá 270.000 khoá mà RSS không giảm" là quy luật của mọi hệ quản lý bộ nhớ: bộ nhớ được cấp và thu hồi theo khối lớn (trang, arena, extent), không theo từng đối tượng — nên giải phóng ở tầng logic không tự động trả về hệ điều hành, và xoá rải rác để lại những lỗ nằm rải khắp các khối khiến gần như không khối nào trống hẳn để trả lại. Muốn thu hồi thật phải nén/dọn (di chuyển các đối tượng sống lại cho sát nhau), một thao tác tốn kém.

Trong thế giới Java, đây đúng là chuyện heap của JVM: free (GC thu gom) một triệu đối tượng không có nghĩa RSS trả về hệ điều hành — heap giữ vùng đã cấp, và chỉ vài bộ thu gom (G1, ZGC, Shenandoah) mới uncommit trả bớt về, và cũng chỉ khi rảnh. "Bộ nhớ ứng dụng đã nhả nhưng RSS vẫn cao" là câu hỏi kinh điển, cùng gốc với used_memory tụt mà used_memory_rss đứng im ở đây.

Trong Go, runtime trả bộ nhớ về hệ điều hành một cách lười (chỉ sau một khoảng, hoặc khi gọi debug.FreeOSMemory) — cùng lý do: nó giữ arena để tái dùng thay vì trả ngay.

Còn ở cơ sở dữ liệu, đây chính là table bloat: DELETE hàng loạt để lại các dead tuple rải rác trong các trang, VACUUM thường dọn để tái dùng bên trong nhưng không co tệp trả về đĩa — phải VACUUM FULL (viết lại cả bảng, khoá độc quyền) mới trả về, đúng như activedefrag phải di chuyển từng đối tượng.

Mẫu số chung: giải phóng ở tầng logic khác với trả tài nguyên về hệ điều hành — bộ nhớ/đĩa được thu hồi theo khối lớn, nên xoá rải rác để lại lỗ giữ nguyên dung lượng vật lý, và đòi lại thật đòi một lần nén tốn kém. Câu hỏi đúng khi thấy "đã xoá mà chỗ không giảm" không phải "có rò rỉ không" mà là "bộ cấp phát/engine của tôi thu hồi theo đơn vị nào, và những đối tượng sống còn lại có đang giữ nguyên cả khối không" — rồi mới quyết định có đáng trả giá một lần dọn hay chỉ nên để yên. Và đo byte tuyệt đối (allocator_frag_bytes), đừng đo tỷ lệ.

Muốn tự đọc đúng ba con số thay vì nhìn tỷ lệ:

redis-cli info memory | grep -E \
  'used_memory:|used_memory_rss:|used_memory_peak:|allocator_frag_bytes|allocator_frag_ratio|maxmemory:'

Cách đọc:

  • allocator_frag_bytes so với maxmemory — dưới 5% thì bỏ qua.
  • used_memory_peak cao hơn used_memory nhiều — bạn từng có một đợt phình, và RSS đang giữ dấu vết của nó. Đây là chỉ số hay bị bỏ qua nhất, và nó giải thích phần lớn các ca "RSS cao mà dữ liệu ít".
  • Tỷ lệ dưới 1 nghĩa là hệ điều hành đã đẩy một phần Redis ra bộ nhớ tráo đổi. Đó mới là cảnh báo thật sự khẩn cấp — nó làm mọi lệnh chậm đi hàng nghìn lần.

Phần sau: RDB và AOF — đo chính xác bao nhiêu dữ liệu mất khi Redis tắt đột ngột.