0

[Backend Masterclass] Đừng Mù Quáng Thêm RAM: Tối Ưu Hóa Bộ Nhớ Redis Ở Cấp Độ "Byte"

Chào anh em, tiếp nối những bài trước về thảm họa OOM (Out Of Memory). Có một thực tế khá buồn cười: Rất nhiều anh em khi thấy Redis báo đầy RAM, phản xạ đầu tiên là nhắn tin cho DevOps: "Anh ơi, nâng cho em con Redis lên 32GB nhé, AWS dạo này đang rẻ!".

Thêm RAM là cách giải quyết của kẻ có tiền, nhưng là cách làm lười biếng của một Kỹ sư phần mềm. Hôm nay, chúng ta sẽ lặn xuống đáy của tầng lưu trữ, mổ xẻ mã nguồn C của Redis để xem: Tại sao lưu một chữ "A" lại tốn đến 50 Bytes RAM, và làm sao để nén dữ liệu tiết kiệm đến 80% bộ nhớ?

1. Cú lừa ngoạn mục mang tên "Overhead" (Chi phí chìm)

Anh em thường nghĩ: SET key "A", Value là "B". Chữ "A" tốn 1 byte, chữ "B" tốn 1 byte. Tổng cộng tốn 2 bytes đúng không? Sai bét! Thực tế, Redis sẽ ngốn của anh em khoảng 50 đến 60 Bytes cho cặp Key-Value bé tí teo này.

Tại sao lại có sự phi lý này? Vì Redis là một In-Memory Database, để đảm bảo tốc độ O(1), nó phải bọc dữ liệu của anh em qua 3 lớp vỏ (Structs trong C):

  1. dictEntry (Cổng gác): Mỗi cặp Key-Value trong Redis đều được quản lý bởi một dictEntry. Thằng này chứa 3 con trỏ: 1 trỏ tới Key, 1 trỏ tới Value, và 1 trỏ tới Entry tiếp theo (để giải quyết đụng độ Hash). Trên hệ điều hành 64-bit, 3 con trỏ này ngốn 24 Bytes.
  2. redisObject (Giấy khai sinh): Value của anh em không được lưu trần trụi. Nó bị bọc trong một struct tên là redisObject để Redis biết đây là String, List hay Hash, thuật toán dọn rác LRU là gì. Cái vỏ bọc này tốn thêm 16 Bytes nữa.
  3. SDS (Simple Dynamic String): Kể cả Key hay Value, Redis không dùng String mặc định của C (kết thúc bằng \0). Nó tự chế ra cấu trúc SDS chứa độ dài chuỗi, dung lượng còn trống... Tốn thêm ít nhất 3-5 Bytes overhead cho mỗi chuỗi.

Tiểu kết: Chỉ cần anh em tạo ra 1 triệu cái Key nhỏ xíu, anh em đã vứt qua cửa sổ khoảng 50MB - 60MB RAM chỉ để nuôi cái đống "vỏ bọc" (Overhead) kia.

2. Ma thuật "Ziplist" - Nén dữ liệu đỉnh cao của Redis

Nếu tạo hàng triệu Key độc lập (SET user:1, SET user:2) làm tốn quá nhiều dictEntryredisObject, thì giải pháp là gom chúng nó lại vào một cấu trúc Hash (HSET users 1 "data"). Lúc này, hàng triệu user chỉ chung 1 Key, chung 1 dictEntry, giảm được vô số Overhead.

Nhưng chờ đã... nếu Hash bên trong cũng dùng cấu trúc con trỏ thì lại tốn RAM tiếp à? Không! Kĩ sư của Redis cực kì quái dị. Họ phát minh ra một cấu trúc gọi là Ziplist (hoặc Listpack ở các bản mới).

Sự khác biệt rúng động:

  • Hashtable thông thường: Các phần tử nằm rải rác trên RAM, nối với nhau bằng con trỏ (Tốn RAM, phân mảnh).
  • Ziplist: Nó ép toàn bộ dữ liệu của Hash thành một Mảng byte liên tục (Continuous Memory block) duy nhất. Không có con trỏ next, không có con trỏ prev. Mọi thứ nằm sát rạt vào nhau.

Khi một Hash có số lượng phần tử nhỏ và dữ liệu mỗi phần tử ngắn, Redis sẽ TỰ ĐỘNG dùng Ziplist để lưu thay vì Hashtable chuẩn. Nó đánh đổi một chút xíu xiu CPU (vì mảng liên tục thêm/xóa sẽ chậm hơn) để lấy lại một lượng RAM khổng lồ!

3. Thực Chiến: Case Study tiết kiệm 80% RAM (Kỹ thuật Bucketing)

Hãy xem cách Instagram từng tiết kiệm hàng trăm GB RAM trên AWS. Giả sử anh em cần lưu cache Mapping ID cho 10 triệu User: User ID (1000000) -> Photo ID (999999)

Cách 1 (Ngây thơ): Dùng String Key bình thường

SET user:1000000 999999
SET user:1000001 999998

10 triệu keys. 10 triệu cái dictEntry. 10 triệu cái redisObject. Tổng cộng ngốn khoảng ~700MB RAM.

Cách 2 (Đẳng cấp Senior): Chia lô (Bucketing) ép dùng Ziplist

Chúng ta không tạo 10 triệu Keys nữa. Chúng ta sẽ gom 1000 User vào 1 cái Bucket (Hash Key). Làm sao để biết User nào vào Bucket nào? Chia lấy phần nguyên!

  • Bucket ID = User ID / 1000
  • Field = User ID % 1000

Ví dụ User 1234567:

  • Bucket: 1234567 / 1000 = 1234
  • Field: 1234567 % 1000 = 567

Câu lệnh lưu trữ biến thành:

HSET bucket:1234 567 999999

Lúc này, chúng ta chỉ còn 10,000 Keys (thay vì 10 triệu). Mỗi Key là một Hash chứa 1000 phần tử. Anh em cấu hình thêm trong redis.conf:

hash-max-ziplist-entries 1000 
hash-max-ziplist-value 64

Chỉ thị này ép Redis: "Ê, nếu Hash nào có từ 1000 phần tử trở xuống, mày PHẢI DÙNG Ziplist nén lại cho tao!".

👉 Kết quả: Sau khi chạy cách 2, dung lượng RAM tụt từ 700MB xuống chỉ còn khoảng 120MB. Tiết kiệm 82% RAM! Một cú tối ưu kinh điển có thể đem thẳng vào CV để "khè" nhà tuyển dụng.

4. Những nguyên tắc "Vàng" bảo vệ từng Byte

Khi anh em hiểu đến tầng này, hãy rèn cho mình tính kỷ luật:

  1. Rút gọn số tiền (Key Naming): Đừng viết tiểu thuyết vào Key.
  • Ngu ngốc: user:transaction:history:100000 (Quá dài, tốn RAM ở tầng SDS header).
  • Thông minh: u:tx:h:100000. Nhân với 100 triệu keys, anh em tiết kiệm được cả GB RAM chỉ nhờ việc viết tắt.
  1. Hạn chế dùng cấu trúc Set/Sorted Set cho data nhỏ: Set và ZSet bản chất ngốn rất nhiều Overhead (ZSet dùng SkipList tốn gấp đôi con trỏ). Nếu chỉ cần lưu một list ID ngắn (khoảng vài chục phần tử), hãy Serialize nó thành JSON string rồi nhét vào Value của một Key thường. Việc CPU giải mã JSON tốn ít hơn nhiều so với việc duy trì một cái Set trong Redis.
  2. Tránh lưu Object quá lớn: Giới hạn Value dưới 10KB. Nếu anh em có một cục JSON to 1MB, hãy lưu nó xuống S3 hoặc Database, chỉ lưu cái URL hoặc ID vào Redis thôi. Redis sinh ra để truy xuất nhanh data nhỏ, không phải thùng chứa rác vạn năng.

Tổng kết

Việc bỏ tiền mua thêm RAM thì quá dễ, nhưng khả năng thiết kế Data Structure (Cấu trúc dữ liệu) sao cho hệ thống chạy mượt mà trên lượng tài nguyên hữu hạn mới là thứ định giá mức lương của anh em.

Hy vọng qua bài viết này, anh em sẽ không bao giờ nhìn cấu trúc SET, HSET trong Redis bằng một ánh mắt ngây thơ như trước nữa. Nếu thấy "đã ngứa", tiếc gì một Upvote và Bookmark đúng không nào?


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí