Gửi một món quà, cái quyết định bạn tốn bao nhiêu giấy gói không phải kích thước món hàng, mà là cách bạn gói: hộp vừa khít hay hộp dư, gói một lần hay mở ra thêm đồ rồi gói lại, có kèm thẻ tên và ruy băng cố định hay không. Cùng một món đồ có thể ngốn gấp đôi vật liệu tuỳ quy trình đóng gói. Chuỗi trong Redis đúng như vậy: cùng 1024 byte dữ liệu tốn 1.328 hay 2.608 byte tuỳ cách bạn ghi nó vào, và bài này đo cái phần "giấy gói" mà ít ai nhìn tới.
Phần trước đo được 185 byte cho mỗi khoá chứa 100 byte dữ liệu. Ta đào vào con số đó.
Bao nhiêu byte cho một chuỗi
Khoá dài cố định 8 ký tự, đo bằng MEMORY USAGE:
| Giá trị | Tổng | Phụ trội | Mã hoá |
|---|---|---|---|
| 1 byte | 64 | 63 | embstr |
| 8 byte | 72 | 64 | embstr |
| 32 byte | 96 | 64 | embstr |
| 44 byte | 104 | 60 | embstr |
| 45 byte | 112 | 67 | raw |
| 100 byte | 168 | 68 | raw |
| 256 byte | 376 | 120 | raw |
| 1024 byte | 1.336 | 312 | raw |
Phụ trội cố định khoảng 64 byte cho chuỗi ngắn. Đó là khoá, dictEntry, robj, và tiêu đề chuỗi — thẻ tên và ruy băng đi kèm mỗi món. Nghĩa là lưu một khoá 8 byte dữ liệu tốn 72 byte — 9 lần kích thước dữ liệu. Với hàng triệu khoá nhỏ, phần phụ trội chiếm phần lớn RAM.
Số nguyên là ngoại lệ đẹp: SET num 12345 tốn 48 byte và mã hoá là int — Redis lưu giá trị số thẳng trong con trỏ, không cấp phát chuỗi nào.
Ranh giới 44 byte
embstr cấp phát một khối chứa cả robj lẫn chuỗi. raw cấp phát hai khối riêng. Ranh giới là 44 byte, và nó không tuỳ chỉnh được. Chuỗi 44 byte tốn 104, chuỗi 45 byte tốn 112 — thêm một ký tự, thêm 8 byte, vì phải thêm một lần cấp phát.
Với chuỗi ngắn thì embstr nhanh hơn: một lần cấp phát, một lần giải phóng, và dữ liệu nằm liền kề nên tốt cho bộ nhớ đệm CPU. Điều này gợi ra một chỗ tối ưu thật: nếu bạn lưu JSON nhỏ và đang ở quanh 45–60 byte, cắt vài trường thừa để xuống dưới 44 sẽ đổi hẳn cách lưu.
Cùng một chuỗi, ba con số
Đây là chỗ tôi không lường trước. Cùng 1024 byte dữ liệu:
| Cách ghi | Bộ nhớ |
|---|---|
redis-cli SET bình thường |
1.336 byte |
--pipe với RESP đúng chuẩn |
1.328 byte |
--pipe với lệnh dạng văn bản |
2.096 byte (+58%) |
SET 1 byte rồi APPEND 1023 byte |
2.608 byte (+96%) |
Tôi phát hiện ra vì hai phép đo của chính mình không khớp: MEMORY USAGE báo 1.336 trong một phép thử và 2.096 trong phép thử khác, cùng một độ dài. Tưởng đo nhầm, đo lại ba lần, vẫn vậy.
Nguyên nhân là giao thức. redis-cli --pipe với lệnh dạng văn bản thuần đi qua đường phân tích inline: Redis tách chuỗi từ dòng lệnh và không cắt gọn phần dư. RESP multibulk khai trước độ dài, nên cấp phát vừa khít. Kiểm chứng: tôi sinh RESP đúng chuẩn rồi đưa qua chính --pipe, và được 1.328 byte. Vậy thủ phạm là dạng lệnh, không phải công cụ.
APPEND thì tệ hơn nữa: chiến lược tăng trưởng của Redis cấp phát gấp đôi để lần sau khỏi cấp phát lại. Chuỗi 1024 byte dựng bằng APPEND chiếm 2.608 byte — gần gấp đôi, đúng cái hộp mua dư phòng khi thêm đồ.
Nó có quan trọng không
Với vài nghìn khoá thì không. Với vài triệu thì có: 58% của 10 GB là 5,8 GB, và RAM là thứ đắt nhất trong hoá đơn Redis. Ba việc rút ra:
- Nhập dữ liệu hàng loạt bằng RESP, đừng bằng lệnh văn bản. Mọi thư viện đều gửi RESP; chỉ có tệp lệnh viết tay là dùng inline.
- Tránh dựng chuỗi bằng
APPENDnếu biết trước độ dài.SETmột lần. - Có thể ép Redis cắt gọn: ghi đè bằng
SETmột lần sẽ cấp phát lại vừa khít.
Ba chỗ đo cho ba con số
Với 200.000 khoá × 100 byte:
MEMORY USAGE mỗi khoá 160 byte
used_memory / số khoá 185 byte
used_memory_rss 47,3 MB (used_memory 37,1 MB)
Chênh 25 byte giữa hai dòng đầu là bảng băm — MEMORY USAGE tính khoá và giá trị nhưng không tính hết phần cấu trúc chỉ mục của cả không gian khoá. Nên MEMORY USAGE luôn báo thấp hơn thực tế. Ước lượng dung lượng bằng nó là sẽ thiếu, và thiếu đúng 15% ở phép đo này.
Dòng thứ ba là phân mảnh: hệ điều hành cấp cho tiến trình 47,3 MB trong khi Redis đang dùng 37,1 MB. Tỷ lệ 1,28 là bình thường.
FLUSHALL không trả bộ nhớ về hệ điều hành
sau FLUSHALL:
used_memory 1,4 MB
used_memory_rss 13,1 MB
mem_fragmentation_ratio 9,87
Xoá sạch dữ liệu, nhưng RSS vẫn 13,1 MB. Tỷ lệ phân mảnh nhảy lên 9,87. Đây là hành vi bình thường của bộ cấp phát: nó giữ lại vùng nhớ để dùng cho lần sau thay vì trả về hệ điều hành. Nhưng nó tạo ra hai hiểu nhầm hay gặp:
- Nhìn RSS rồi tưởng Redis vẫn giữ dữ liệu. Không —
used_memorymới là dữ liệu. - Thấy
mem_fragmentation_ratiocao rồi hoảng. Tỷ lệ này chỉ có nghĩa khiused_memoryđủ lớn; với một instance gần rỗng nó luôn cao và không nói lên gì.
Muốn trả bộ nhớ về thật thì cần activedefrag yes, và nó tốn CPU. Thường không đáng, trừ khi tỷ lệ vượt 1,5 trong lúc đang chứa nhiều dữ liệu.
Mẫu số chung: chi phí thật của một giá trị là dữ liệu cộng phụ trội cộng cách cấp phát
Bài này gói một điều đúng với mọi hệ lưu trữ trong bộ nhớ: kích thước dữ liệu không phải chi phí bộ nhớ. Chi phí thật là dữ liệu, cộng phụ trội cố định mỗi đối tượng (tiêu đề, con trỏ, mục chỉ mục), cộng cách cấp phát (một khối hay hai, cắt khít hay tăng gấp bội). Với ít đối tượng lớn, phần dữ liệu áp đảo và mấy khoản kia không đáng kể; với nhiều đối tượng nhỏ, phụ trội cố định mới là hoá đơn — đúng cái 72 byte cho 8 byte dữ liệu.
Trong thế giới Java, đây chính xác là object header 12–16 byte mỗi đối tượng cộng padding 8 byte: một triệu Integer boxed tốn gấp mấy lần một int[], và một HashMap mang Node cho mỗi entry — lý do người ta gom dữ liệu nhỏ vào mảng nguyên thuỷ thay vì hàng triệu đối tượng con. ArrayList/StringBuilder cũng tăng gấp đôi dung lượng khi đầy, đúng cái APPEND làm. Trong Go, một slice tăng trưởng theo hệ số (~2× rồi giảm dần), và struct có padding căn lề — cùng hai nguồn phụ trội. Còn ở cơ sở dữ liệu, mỗi hàng mang phụ trội cố định (header, con trỏ, null bitmap): một bảng hàng triệu dòng hẹp tốn phần lớn dung lượng cho phụ trội hàng, không cho dữ liệu — lý do gộp cột hoặc dùng lưu trữ cột.
Mẫu số chung: chi phí bộ nhớ của một giá trị là dữ liệu + phụ trội cố định mỗi đối tượng + lãng phí do cách cấp phát/tăng trưởng — nên với nhiều đối tượng nhỏ, phụ trội thống trị, và cùng một dữ liệu tốn khác nhau tuỳ đường ghi. Câu hỏi đúng khi ước lượng RAM không phải "dữ liệu của tôi bao nhiêu byte" mà là "tôi có bao nhiêu đối tượng, mỗi cái gánh bao nhiêu phụ trội cố định, và tôi có đang cấp phát dư vì tăng-gấp-bội không" — vì ở quy mô triệu, hai khoản sau mới là tiền thật.
Muốn tự ước lượng đúng dung lượng cần cho một tập dữ liệu, thay vì nhân số khoá với kích thước giá trị:
# lấy mẫu 1000 khoá và tính trung bình thật
redis-cli --no-raw eval "
local t = 0
for i = 1, 1000 do
local k = redis.call('randomkey')
if k then t = t + redis.call('memory', 'usage', k) end
end
return t
" 0
# rồi so với con số toàn cục
redis-cli info memory | grep -E 'used_memory:|used_memory_dataset:'
redis-cli dbsize
Chia used_memory cho dbsize sẽ ra con số cao hơn trung bình MEMORY USAGE — phần chênh là bảng băm, và nó không biến mất khi bạn làm giá trị nhỏ đi. Với dữ liệu gồm hàng triệu khoá nhỏ, đó mới là khoản chi lớn nhất.
Phần sau: Hash, List, Set và Sorted Set — đo bộ nhớ và độ phức tạp của từng thao tác.