Hình dung bạn nhồi một cái vali nhỏ tới mức bung cả khoá kéo, đành đổi sang một cái vali lớn có khung cứng và ngăn chỉ mục. Sau chuyến đi bạn lấy bớt đồ ra, giờ chỉ còn vài món — nhưng bạn vẫn đang vác cái vali to nặng, vì chẳng ai tự động chuyển đồ ngược về cái nhỏ. Redis đối xử với cấu trúc dữ liệu y hệt: khi một tập hợp vượt ngưỡng, nó nâng cấp sang mã hoá lớn có chỉ mục, và một khi đã đổi thì không bao giờ quay lại — dù khoá đó co nhỏ lại từ lâu. Bài này đo cái giá của việc đó, và một ngưỡng thứ hai mà ít ai để ý.
Phần trước tìm ra ba vách đá bộ nhớ khi tập hợp vượt ngưỡng. Ta đo chuyện gì xảy ra khi nó nhỏ lại.
Chuyển mã hoá là một chiều
Tôi tạo một Hash 513 trường để ép nó thành hashtable, rồi xoá bớt còn 199 trường:
tạo 513 rồi xoá còn 199 hashtable 16.256 byte
tạo mới đúng 199 trường listpack 1.840 byte
Cùng 199 trường. Cùng dữ liệu. Chênh 8,8 lần. Redis không bao giờ chuyển ngược về mã hoá gọn. Một lần vượt ngưỡng là khoá đó mang mã hoá đắt đến hết đời — cái vali to đến hết chuyến.
Đây là chỗ rò rỉ bộ nhớ âm thầm nhất tôi biết trong Redis. Một Hash phình lên trong một đợt cao điểm rồi co lại, và nó giữ chi phí của lúc lớn nhất, vĩnh viễn. Không có cảnh báo nào, không có chỉ số nào cho biết. Cách chữa duy nhất là ghi lại khoá:
redis-cli --scan --pattern 'user:*' | while read k; do
redis-cli --raw dump "$k" > /tmp/d && \
redis-cli del "$k" >/dev/null && \
redis-cli -x restore "$k" 0 < /tmp/d >/dev/null
done
Hoặc đơn giản hơn nếu dữ liệu dựng lại được: xoá và ghi lại từ nguồn.
Được gì để đổi
Cùng 199 trường, hai mã hoá, đo thời gian thật:
hashtable |
listpack |
|
|---|---|---|
HGET f1 |
0,192 µs | 0,216 µs |
HGET f100 |
0,208 µs | 0,595 µs |
HGET f199 |
0,206 µs | 1,148 µs |
HGETALL |
17,791 µs | 15,653 µs |
hashtable phẳng ở mọi vị trí — 0,19 đến 0,21 µs, đúng như O(1). listpack tăng tuyến tính theo vị trí: trường cuối tốn gấp 5,6 lần trường cuối trong hashtable. Nó phải quét từ đầu, không có chỉ mục. Nhưng dòng cuối lật ngược: đọc cả tập thì listpack nhanh hơn 12%. Dữ liệu nằm liền mạch trong bộ nhớ, không con trỏ nào phải đuổi theo, bộ nhớ đệm CPU thích điều đó.
Đó là toàn bộ đánh đổi trong một bảng: gọn và đọc-hết-nhanh, đổi lấy tra-từng-phần-chậm.
Cách chọn ngưỡng theo cách truy cập
Từ bảng trên, quy tắc thực dụng:
- Nếu bạn chủ yếu
HGETALL/SMEMBERS— đọc cả tập — thì nâng ngưỡng lên rất đáng. Bạn được bộ nhớ, và còn nhanh hơn một chút. - Nếu bạn tra từng trường trong tập lớn —
HGETmột trường trong 500 — thì để nguyên. 1,1 µs mỗi lần tra nhân với vài chục nghìn lệnh mỗi giây là một phần đáng kể của luồng duy nhất.
Cụ thể:
redis-cli config set hash-max-listpack-entries 1024
Nhớ hai điều. Thứ nhất, cấu hình không áp cho khoá đã tồn tại — chúng giữ mã hoá cũ tới khi được ghi lại. Thứ hai, config set không bền qua khởi động lại; ghi vào tệp cấu hình hoặc config rewrite.
Ngưỡng thứ hai ít người biết: độ dài giá trị
Mọi bài viết đều nhắc hash-max-listpack-entries. Ít bài nhắc cái này:
giá trị 64 byte -> listpack 128 byte
giá trị 65 byte -> hashtable 248 byte
Một Hash một trường, chênh một byte, gần gấp đôi bộ nhớ. hash-max-listpack-value mặc định 64. Một giá trị vượt 64 byte là cả Hash chuyển sang hashtable — không phải trường đó, mà toàn bộ khoá.
Điều này giải thích một hiện tượng hay gặp: bạn thêm một trường description dài vào đối tượng người dùng, và bộ nhớ Redis tăng gấp đôi dù chỉ thêm vài chục byte mỗi bản ghi. Không phải trường mới nặng — mà là nó đẩy mọi Hash sang mã hoá đắt. Cách chữa: tách trường dài ra khoá riêng, hoặc nâng hash-max-listpack-value. Với dữ liệu chủ yếu đọc cả tập, nâng lên 128 hoặc 256 thường an toàn.
Bảng mã hoá đầy đủ
| Kiểu | Gọn | Đầy đủ | Ngưỡng |
|---|---|---|---|
| String | int, embstr |
raw |
44 byte |
| Hash | listpack |
hashtable |
512 mục / 64 byte |
| List | listpack |
quicklist |
128 mục / 64 byte |
| Set số nguyên | intset |
listpack → hashtable |
512 số |
| Set | listpack |
hashtable |
128 mục / 64 byte |
| Sorted Set | listpack |
skiplist |
128 mục / 64 byte |
ziplist là tên cũ; Redis 7 thay bằng listpack — cùng ý tưởng, sửa vài lỗi tràn số và bỏ trường độ dài ngược. Nếu tài liệu bạn đọc nói ziplist thì nó viết cho Redis 6 trở về trước, và các ngưỡng có tên khác (*-max-ziplist-*). Redis 7 vẫn nhận tên cũ làm bí danh.
Mẫu số chung: nâng cấp một chiều dưới tải, không tự hạ xuống
Cái rò rỉ âm thầm này là một dạng chuyển trạng thái một chiều (hysteresis) mà rất nhiều cấu trúc có: chúng nâng cấp sang biểu diễn đắt hơn khi vượt một ngưỡng để phục vụ trường hợp lớn, nhưng không tự hạ xuống khi tải rút đi — nên chi phí bị neo ở đỉnh cao nhất từng chạm, vĩnh viễn, và không chỉ số kích thước hiện tại nào phản ánh nó.
Trong thế giới Java, đây đúng là ArrayList/HashMap/StringBuilder: dung lượng nội bộ tăng khi bạn thêm phần tử, nhưng remove/clear không thu nhỏ mảng nền — một list từng có một triệu phần tử rồi xoá gần hết vẫn giữ mảng một triệu ô cho tới khi bạn gọi trimToSize() hoặc tạo lại. Trong Go, cắt một slice (s = s[:0]) giữ nguyên cap cũ; muốn trả bộ nhớ phải cấp lại slice mới. Còn ở cơ sở dữ liệu, đây chính là bloat đã gặp: bảng/chỉ mục phình theo đỉnh và DELETE không co tệp — phải VACUUM FULL/REINDEX/ghi lại, đúng cái DUMP+RESTORE ở đây.
Mẫu số chung: cấu trúc nâng cấp một chiều dưới tải giữ chi phí của đỉnh cao nhất chứ không của kích thước hiện tại, nên "đã nhỏ lại rồi" không có nghĩa "đã rẻ lại" — muốn đòi chi phí về phải chủ động dựng lại. Câu hỏi đúng khi thấy bộ nhớ cao mà dữ liệu ít không phải "có rò rỉ logic không" mà là "cấu trúc này có từng lớn hơn nhiều không, và nó có tự co lại không hay tôi phải ghi lại". Với Redis, câu trả lời là ghi lại; với ArrayList là trimToSize; với CSDL là VACUUM FULL.
Muốn tự tìm khoá đang mang mã hoá đắt mà không cần tới — tức là khoá từng lớn rồi nhỏ lại:
redis-cli --scan --count 500 | head -3000 | while read k; do
e=$(redis-cli object encoding "$k")
case "$e" in
hashtable|skiplist)
t=$(redis-cli type "$k")
n=$(case "$t" in
hash) redis-cli hlen "$k";; set) redis-cli scard "$k";;
zset) redis-cli zcard "$k";; *) echo 99999;;
esac)
if [ "${n:-99999}" -lt 100 ]; then
echo "$(redis-cli memory usage "$k") byte / $n phan tu / $e / $k"
fi
;;
esac
done | sort -rn | head -20
Mỗi dòng in ra là một khoá dùng cấu trúc của tập lớn để chứa vài chục phần tử. Ghi lại chúng — DUMP rồi RESTORE, hoặc dựng lại từ nguồn — và bộ nhớ trả về thường nhiều hơn bạn nghĩ.
Phần sau: Bitmap và HyperLogLog — đo bộ nhớ đổi lấy độ chính xác.