Hình dung bạn cần photocopy một cuốn sổ cái dày trong khi cả phòng kế toán vẫn đang chỉnh sửa nó. Có một mẹo hay: để giữ được đúng bản gốc tại thời điểm bắt đầu, mỗi khi ai đó định sửa một trang, bạn chỉ cần sao trang đó ra trước rồi để họ sửa trên bản sao. Nếu suốt buổi photocopy không ai đụng vào sổ, bạn gần như chẳng phải sao thêm gì. Nhưng nếu cả phòng hí hoáy sửa liên tục, bạn có thể phải nhân bản gần cả cuốn. Đây chính xác là cách Redis chụp ảnh bộ nhớ, và bài này đo cái chi phí "sao trang trước khi sửa" ấy — một khoản mà chỉ số của Redis giấu kỹ đến mức tôi mất một lúc mới tìm ra.

RDB là cách lưu dữ liệu mặc định của Redis: chụp toàn bộ bộ nhớ ra một tệp. Ta đo nó tốn gì.

BGSAVE trên 2 triệu khoá

thời gian BGSAVE       1,21 giây
tệp RDB trên đĩa       46.889.287 byte  = 46,9 MB
dữ liệu trong bộ nhớ   337,94 MB

Tệp nhỏ hơn bộ nhớ 7,2 lần. RDB là định dạng nhị phân nén chặt: không con trỏ, không bảng băm, không phần phụ trội 64 byte mỗi khoá đo được ở phần 3. Độ trễ của khách trong lúc chụp:

PING p99 1,695 ms, max 5,3 ms

Gần như không ảnh hưởng gì. Đó là nhờ fork: Redis tạo một tiến trình con và tiến trình con ghi từ bản sao đông cứng của bộ nhớ, còn tiến trình cha tiếp tục phục vụ.

Chi phí thật nằm ở copy-on-write

fork không sao chép bộ nhớ ngay — cả hai tiến trình dùng chung các trang cho tới khi một bên ghi. Lúc đó kernel sao chép trang đó ra. Đúng cái mẹo "sao trang trước khi sửa". Tôi đo rdb_last_cow_size ở hai điều kiện:

không ghi gì trong lúc chụp      1.429.504 byte  = 1,4 MB
ghi đè 7.016.600 khoá            91.254.784 byte  = 87 MB

Gấp 64 lần. Đây là khoản chi phí quyết định việc bạn cần bao nhiêu RAM dư. Với hệ thống ghi nhiều và bộ dữ liệu lớn, phần sao chép có thể tiến gần tới kích thước toàn bộ dữ liệu — đó là nguồn gốc của lời khuyên "chỉ dùng 50% RAM cho Redis".

Và chỉ số của Redis không thấy khoản đó

used_memory_rss: 348,5 MB trước và sau, không đổi

Đây là chỗ tôi mất một lúc mới hiểu. rdb_last_cow_size báo 87 MB, nhưng RSS không nhúc nhích. Lý do: những trang bị sao chép thuộc về tiến trình con. INFO memory chỉ đo tiến trình cha. Redis biết con số — nó báo trong rdb_last_cow_size — nhưng nó không cộng vào bất kỳ chỉ số bộ nhớ nào.

Nghĩa là: bảng điều khiển dựa trên used_memory_rss sẽ không bao giờ thấy đỉnh bộ nhớ lúc lưu. Muốn thấy phải nhìn ở tầng hệ điều hành, hoặc theo dõi chính rdb_last_cow_size. Tôi cũng thử bắt đỉnh bộ nhớ ở mức container và không bắt được — BGSAVE xong trong 1,59 giây, ngắn hơn chu kỳ lấy mẫu. Trên bộ dữ liệu hàng chục GB, cửa sổ đó dài hơn nhiều và đỉnh sẽ thấy rõ.

Nạp lại khi khởi động

rdb_last_load_keys_loaded   2.000.000
thời gian nạp               khoảng 2,5 – 3 giây

Trong suốt thời gian đó, mọi lệnh nhận:

LOADING Redis is loading the dataset in memory

Redis không phục vụ gì cho tới khi nạp xong. Với bộ dữ liệu 50 GB, đó là vài phút không có dịch vụ sau mỗi lần khởi động lại. Con số đáng ước lượng trước: khoảng 700.000 khoá mỗi giây ở phép đo này. Chia số khoá của bạn cho con số đó để biết thời gian khởi động lại — và so nó với thời gian chịu đựng được.

Quy tắc save mặc định là một lời hứa mỏng

save 3600 1   300 100   60 10000

Đọc là: lưu nếu 3600 giây qua có ít nhất 1 thay đổi, hoặc 300 giây qua có 100 thay đổi, hoặc 60 giây qua có 10.000 thay đổi. Dòng đầu là chỗ đáng chú ý: hệ thống ghi ít có thể mất tới một giờ thay đổi. Và ngay cả dòng cuối cũng chỉ hứa 60 giây. RDB về bản chất là "mất một khoảng gần đây" — nó không phải cơ chế cho dữ liệu không được mất. Phần sau đo AOF, thứ dựng cho đúng nhu cầu đó.

Bốn điều nên biết

SAVE chặn, BGSAVE thì không. SAVE chụp trên luồng chính — với 338 MB là hơn một giây không phục vụ ai. Đừng gõ nó trên production; nó có mặt cho những trường hợp không fork được.

fork thất bại nếu không đủ RAM. Kernel cần cấp phát bảng trang cho tiến trình con. Với vm.overcommit_memory=0 (mặc định trên nhiều bản Linux), fork có thể bị từ chối và BGSAVE thất bại im lặng — chỉ hiện trong rdb_last_bgsave_status. Redis khuyến nghị đặt vm.overcommit_memory=1 chính vì lý do này.

Trang lớn làm copy-on-write tệ hơn nhiều. Với transparent huge pages, mỗi lần ghi sao chép 2 MB thay vì 4 KB — gấp 512 lần. Redis in cảnh báo lúc khởi động nếu phát hiện; nên tắt.

Kiểm rdb_last_bgsave_status chứ đừng chỉ tin có tệp. Tệp cũ vẫn nằm đó sau một lần lưu thất bại, và dấu thời gian của nó không nói cho bạn biết lần gần nhất thành công hay không.

Mẫu số chung: chụp ảnh nhất quán rẻ, trừ khi có người ghi trong lúc chụp

Cái ta vừa đo — 1,4 MB thành 87 MB tuỳ lượng ghi — là bản chất của copy-on-write và snapshot isolation, một kỹ thuật xuất hiện ở khắp nơi: cho hai bên dùng chung dữ liệu cho tới khi một bên ghi, rồi mới sao đúng phần bị đổi. Nhờ vậy chụp một ảnh nhất quán gần như miễn phí — nếu không ai ghi trong lúc chụp; còn chi phí thật tỉ lệ với lượng ghi trong cửa sổ chụp, không với kích thước dữ liệu. Và nó thường ẩn ở một chỗ mà chỉ số chính của bạn không đếm.

Trong thế giới Java, đây đúng là CopyOnWriteArrayList: mỗi lần ghi nhân cả mảng nền, nên đọc thì rẻ và an toàn với đa luồng, nhưng ghi nhiều thì tốn kém — dùng đúng khi đọc áp đảo ghi, và trở thành bẫy khi ghi nhiều, y như BGSAVE giữa lúc ghi ồ ạt.

Còn ở cơ sở dữ liệu, đây chính xác là MVCC và snapshot isolation: một giao dịch đọc dài thấy một bản chụp nhất quán, nhưng mọi ghi diễn ra trong lúc nó còn mở đều tạo phiên bản mới, và PostgreSQL không thu hồi được các bản cũ cho tới khi giao dịch đóng — một transaction dài chạy song song với ghi nhiều làm bảng phình (dead tuples/bloat) đúng tỉ lệ với lượng ghi, không với kích thước bảng; pg_dump trên một CSDL bận là cùng câu chuyện.

Ở tầng hệ điều hành và hạ tầng, tầng ghi của một container Docker hay một snapshot ZFS/btrfs cũng chỉ lưu những block đã thay đổi kể từ ảnh gốc — chụp thì tức thì và gần như không tốn, chi phí đến dần theo lượng ghi sau đó.

Mẫu số chung: copy-on-write biến việc chụp một ảnh nhất quán thành gần như miễn phí, nhưng dời chi phí sang lượng ghi diễn ra trong lúc bản chụp còn sống — và khoản chi phí đó thường nằm ở nơi chỉ số chính không nhìn tới (tiến trình con, dead tuple, tầng ghi container). Câu hỏi đúng khi chụp một bản trạng thái không phải "chụp mất bao lâu" mà là "trong cửa sổ đó hệ ghi nhiều tới đâu, và cái RAM/đĩa dôi ra để giữ bản gốc đang được đếm ở đâu" — vì đỉnh tài nguyên tỉ lệ với ghi-trong-lúc-chụp, và nó hay vô hình đến đúng lúc bạn hết chỗ.

Muốn tự kiểm ba thứ quyết định bạn mất bao nhiêu khi Redis tắt đột ngột:

redis-cli config get save
redis-cli info persistence | grep -E \
  'rdb_changes_since_last_save|rdb_last_bgsave_status|rdb_last_cow_size|rdb_last_bgsave_time_sec'

Cách đọc:

  • rdb_changes_since_last_save — chính xác số thay đổi sẽ mất nếu Redis chết ngay bây giờ. Con số này là câu trả lời trực tiếp nhất cho câu hỏi "chúng ta mất gì".
  • rdb_last_bgsave_status khác ok — bản lưu gần nhất hỏng, và bạn đang không có bản sao lưu nào mới.
  • rdb_last_cow_size gần bằng used_memory — mỗi lần lưu đang cần gần gấp đôi RAM, và bạn đang tiến gần tới lúc fork thất bại.

Phần sau: AOF — đo chính xác bao nhiêu dữ liệu mất ở từng mức appendfsync.