Khi dọn nhà, cái tốn thời gian không phải quãng đường xe tải chạy hay kích thước cái xe — mà là lúc bạn mở từng thùng ra và xếp từng món vào đúng chỗ trong nhà mới. Một chiếc xe to gấp năm lần chở đúng ngần ấy đồ đạc thì việc xếp dọn vẫn mất chừng ấy thời gian. Khôi phục dữ liệu Redis từ đĩa hoá ra đúng như vậy: tôi cứ tưởng một tệp lớn gấp gần sáu lần sẽ nạp lâu hơn hẳn, và hoàn toàn sai. Bài này đặt RDB và AOF cạnh nhau trên cùng 2 triệu khoá và đo thứ quan trọng nhất — thời gian sống lại sau sự cố.

Hai phần trước đo RDB và AOF riêng lẻ. Giờ đặt chúng cạnh nhau.

Kích thước tệp chênh 5,8 lần

RDB                    46.888.988 byte
AOF có tiền tố RDB     46.893.128 byte    gần như y hệt
AOF văn bản thuần     270.893.097 byte    lớn hơn 5,8 lần

Hai dòng đầu gần bằng nhau, và đó không phải trùng hợp. Từ Redis 7, aof-use-rdb-preamble mặc định yes, và tệp cơ sở của AOF thật sự là một tệp RDB:

appendonly.aof.4.base.rdb
appendonly.aof.4.incr.aof
appendonly.aof.manifest

Sau mỗi lần viết lại, Redis chụp trạng thái hiện tại dưới dạng RDB rồi nối các lệnh mới vào tệp .incr.aof. Nghĩa là AOF hiện đại không phải một tệp văn bản khổng lồ; nó là RDB cộng thêm phần đuôi. Dòng thứ ba là AOF kiểu cũ, khi tôi tắt tiền tố RDB: 270 MB cho cùng dữ liệu.

Nhưng thời gian nạp lại thì bằng nhau

Đây là chỗ tôi trông đợi thấy khác biệt lớn và không thấy.

Thời gian nạp
RDB (46,9 MB) 1,416 / 1,331 / 1,343 giây
AOF văn bản (270,9 MB) 1,399 / 1,321 / 1,296 / 1,392 giây
AOF + tiền tố RDB 1,509 giây

Tệp lớn gấp 5,8 lần mà nạp nhanh bằng. Lý do: thời gian nạp bị chi phối bởi việc dựng 2 triệu mục trong bảng băm, không bởi việc đọc byte. Cấp phát bộ nhớ, băm khoá, chèn vào bảng, thỉnh thoảng mở rộng bảng — đó mới là công việc, đúng cái việc mở thùng xếp đồ. Phân tích 270 MB văn bản so với 47 MB nhị phân chỉ là phần nhỏ.

Con số đọc được từ nhật ký của chính Redis (DB loaded from disk: X seconds), không phải đo từ ngoài, nên không lẫn thời gian khởi động container.

Lời khuyên quen thuộc "AOF nạp chậm hơn RDB nhiều" không đúng ở quy mô này. Nó có thể đúng với một tệp AOF chưa từng được viết lại — một tệp chứa hàng chục triệu lệnh chồng lên nhau trên cùng vài khoá, vì lúc đó Redis phải chạy lại tất cả. Nhưng đó là vấn đề của việc không viết lại, không phải của định dạng.

Khác biệt thật nằm ở lượng mất

Tốc độ khôi phục gần như nhau, nên lựa chọn không nằm ở đó.

Mất tối đa
RDB mặc định (save 3600 1) một giờ thay đổi
RDB (save 60 10000) 60 giây, nếu đủ 10.000 thay đổi
AOF everysec 1 giây khi mất điện
AOF everysec hoặc no 0 khi tiến trình bị SIGKILL (đo ở phần 19)

Đây mới là bảng quyết định. RDB một mình là "mất một khoảng gần đây, khoảng đó có thể rất dài".

Bật cả hai

Redis cho phép, và đó là cấu hình đúng cho phần lớn trường hợp:

appendonly yes
appendfsync everysec
save 3600 1 300 100 60 10000

Khi khởi động, nếu appendonly yes thì Redis luôn nạp từ AOF và bỏ qua RDB — AOF mới hơn. Vậy giữ RDB làm gì? Vì nó là một tệp duy nhất, gọn, chép đi được:

  • dump.rdb mang sang máy khác, đưa vào kho sao lưu, nén lại.
  • AOF là một thư mục nhiều tệp kèm tệp kê khai; sao chép nửa vời là hỏng.

Nên: AOF để khôi phục tại chỗ, RDB để mang đi. Chi phí của việc bật cả hai là hai nguồn fork — và như đo ở phần 18, fork mang chi phí copy-on-write thật. Trên hệ thống ghi rất nhiều, cân nhắc tắt RDB tự động và chỉ gọi BGSAVE theo lịch vào lúc rảnh.

Ba tình huống, ba cấu hình

Bộ đệm thuần, mất hết cũng được: tắt cả hai.

save ""
appendonly no

Không fork, không ghi đĩa, không copy-on-write. Đây là cấu hình nhanh nhất và nhiều người quên rằng nó tồn tại.

Dữ liệu quan trọng: AOF everysec + RDB theo lịch. Cấu hình ở trên.

Cần khởi động lại thật nhanh: chỉ RDB, và chấp nhận mất. Nạp RDB không nhanh hơn AOF ở phép đo này, nhưng nó tránh được chi phí ghi AOF liên tục lúc chạy bình thường.

Điều cả hai đều không làm

Cả RDB lẫn AOF đều không bảo vệ bạn khỏi:

  • Lệnh FLUSHALL gõ nhầm — nó được ghi vào AOF và nhân bản sang bản sao ngay lập tức.
  • Đĩa hỏng — cả hai tệp nằm trên cùng ổ đó.
  • Máy chủ mất.

Cho ba thứ đó cần sao lưu ra ngoài máy: chép dump.rdb sang nơi khác theo lịch. Đây là chỗ RDB thắng rõ, vì nó là một tệp. Và mẹo nhỏ với FLUSHALL: đổi tên nó đi.

rename-command FLUSHALL ""
rename-command FLUSHDB ""

Mẫu số chung: chọn theo trục thật sự khác, không theo trục dễ thấy

Cái làm tôi hụt hẫng — tệp gấp 5,8 lần mà nạp nhanh bằng — là một quy luật đáng nhớ: thời gian phục hồi bị chi phối bởi việc dựng lại cấu trúc trong bộ nhớ, không bởi kích thước của dạng tuần tự trên đĩa. Một định dạng gọn hơn không mua cho bạn khởi động nhanh hơn, vì bước tốn kém — cấp phát, băm, chèn vào bảng, xây chỉ mục — là bước mà cả hai định dạng đều phải làm y hệt. Nên khi hai lựa chọn khác nhau rõ trên một trục (kích thước) nhưng giống nhau trên trục bạn thật sự cần (tốc độ khôi phục), thì phải quyết định theo trục còn lại thật sự khác — ở đây là cửa sổ mất dữ liệu.

Trong thế giới Java, đây đúng là chuyện chọn định dạng tuần tự: một payload protobuf/Kryo nhỏ hơn JSON nhiều trên đĩa và trên dây, nhưng thời gian giải tuần tự thành đối tượng thường bị chi phối bởi cấp phát đối tượng và dựng đồ thị, không bởi việc đọc byte — nên tệp nhỏ hơn không đồng nghĩa nạp nhanh hơn tương ứng. JVM khởi động cũng vậy: thời gian bị class-loading và JIT chi phối, không bởi kích thước file jar.

Trong Go, giải mã gob hay json vào struct cũng bị cấp phát chi phối; một biểu diễn nén hơn tiết kiệm I/O nhưng không tỉ lệ thuận với thời gian dựng lại cấu trúc.

Còn ở cơ sở dữ liệu, đây là điều ai từng chạy pg_restore đều gặp: thời gian phục hồi bị chi phối bởi việc dựng lại chỉ mục, không bởi kích thước tệp dump — một bản dump nén nhỏ hơn không restore nhanh hơn tương ứng, và cách tăng tốc thật là song song hoá việc build index (--jobs), không phải nén tệp nhỏ hơn.

Mẫu số chung: thời gian nạp/khôi phục thường bị chi phối bởi bước dựng lại cấu trúc trong bộ nhớ chứ không bởi kích thước biểu diễn trên đĩa, nên một khác biệt lớn về kích thước có thể là con số vô nghĩa với trục bạn quan tâm — hãy quyết định theo trục thật sự khác nhau. Câu hỏi đúng khi so hai định dạng lưu trữ không phải "cái nào nhỏ/gọn hơn" mà là "trên trục tôi thật sự cần — thời gian sống lại, hay lượng dữ liệu mất — chúng có khác nhau không, và nếu trục dễ thấy nhất lại giống nhau thì tôi đang định tối ưu nhầm chỗ nào". Với RDB và AOF, trục đó là cửa sổ mất dữ liệu, không phải kích thước tệp.

Muốn tự xem bạn đang mất bao nhiêu nếu Redis chết ngay bây giờ:

redis-cli info persistence | grep -E \
  'aof_enabled|rdb_changes_since_last_save|rdb_last_save_time|aof_last_write_status'
date +%s

Lấy date +%s trừ rdb_last_save_time ra số giây kể từ lần lưu RDB gần nhất. Nếu aof_enabled:0 thì đó chính là lượng thời gian bạn sẽ mất. Và kiểm thời gian khôi phục bằng chính nhật ký của Redis:

docker logs <container> 2>&1 | grep -i "DB loaded from disk"

Dòng đó có sẵn sau mỗi lần khởi động. Nó là con số thật cho cụm của bạn, chính xác hơn mọi ước lượng — và nếu bạn chưa từng nhìn nó, bây giờ là lúc tốt hơn nhiều so với lúc đang có sự cố.

Phần sau: nhân bản — đo độ trễ giữa bản chính và bản sao.