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 APPEND nếu biết trước độ dài. SET một lần.
  • Có thể ép Redis cắt gọn: ghi đè bằng SET mộ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_memory mới là dữ liệu.
  • Thấy mem_fragmentation_ratio cao rồi hoảng. Tỷ lệ này chỉ có nghĩa khi used_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.