Bỏ một lá thư vào thùng thư của bưu điện, rồi bạn ngất xỉu ngay tại chỗ. Lá thư có mất không? Không — nó đã rời khỏi tay bạn, nằm trong thùng, và bưu điện vẫn sẽ chuyển nó dù bạn nằm đó bất tỉnh. Nhưng nếu cả toà nhà bưu điện cháy trước khi xe thư kịp đến chở đi, thì lá thư trong thùng mới thật sự mất. "Tôi ngất thì thư có sao không" và "bưu điện cháy thì thư có sao không" là hai câu hỏi hoàn toàn khác nhau — và gần như mọi giải thích về AOF của Redis gộp chúng làm một. Bài này đo để tách bạch ra, và con số làm tôi bất ngờ nhất là số 0.

AOF ghi từng lệnh vào tệp thay vì chụp ảnh định kỳ. Ba chế độ appendfsync được mô tả khắp nơi là "nhanh / cân bằng / an toàn". Ta đo cả tốc độ lẫn lượng mất thật.

Ba chế độ, và cái giá phụ thuộc cách gửi lệnh

appendfsync Từng lệnh một Đường ống 100
no 20.009 ops/s 433.630 ops/s
everysec 21.472 ops/s 467.770 ops/s
always 1.717 ops/s 97.488 ops/s

no và everysec giống nhau ở cả hai cột — chênh lệch nằm trong nhiễu. Điều đó hợp lý: everysec gọi fsync mỗi giây trên một luồng nền, nên nó gần như không chạm vào đường đi của lệnh. always mới là chỗ trả giá, và cái giá phụ thuộc cách bạn gửi lệnh:

  • Gọi từng lệnh một: chậm 12,5 lần.
  • Có đường ống 100: chậm 4,8 lần.

Lý do: Redis gọi fsync một lần cho mỗi vòng lặp sự kiện, không phải một lần cho mỗi lệnh. Một đường ống 100 lệnh được xử lý trong cùng một vòng và tốn đúng một fsync. Nghĩa là con số "always chậm 10 lần" mà bạn đọc ở đâu đó chỉ đúng cho một kiểu ứng dụng. Đo trên chính cách ứng dụng của bạn gửi lệnh.

Giết tiến trình: mất bao nhiêu

Đây là phép đo tôi vào với một dự đoán rõ ràng, và dự đoán đó sai. Tôi cho một khách ghi liên tục, đếm số lệnh đã được xác nhận, rồi SIGKILL Redis giữa chừng và đếm lại sau khi nó khởi động lại.

Lần Chế độ Đã xác nhận Còn lại Mất
1 no 90.181 90.181 0
1 everysec 86.587 86.587 0
1 always 6.965 6.964 1
2 no 68.900 68.900 0
2 everysec 70.127 70.127 0
3 no 68.639 68.639 0
3 everysec 73.881 73.881 0

Kể cả appendfsync no cũng không mất gì. Lặp ba lần, đều vậy.

Vì sao — và điều này quan trọng

SIGKILL giết tiến trình, không giết hệ điều hành. Redis đã gọi write() để đưa dữ liệu vào hệ tệp. write() chỉ chép dữ liệu vào bộ đệm trang của kernel; fsync() mới là thứ ép kernel đẩy xuống đĩa. Nhưng bộ đệm trang thuộc về kernel, không thuộc về tiến trình — tiến trình chết thì nó vẫn còn nguyên, và tiến trình mới đọc lại thấy đủ. Đúng lá thư đã nằm trong thùng bưu điện: bạn ngất đi, nó vẫn ở đó.

Nên appendfsync không quyết định điều gì xảy ra khi Redis chết. Nó quyết định điều gì xảy ra khi máy mất điện hoặc kernel sụp — lúc toà bưu điện cháy. Đây là phân biệt bị làm mờ trong gần như mọi giải thích về AOF, và nó đổi hẳn cách chọn:

  • Sợ Redis bị OOM, bị SIGKILL, bị container khởi động lại — everysec là quá đủ, và ngay cả no cũng không mất gì.
  • Sợ mất điện, hỏng máy chủ vật lý, kernel panic — lúc đó no có thể mất tới 30 giây (chu kỳ đẩy mặc định của kernel Linux), everysec mất tối đa 1 giây, always gần như không mất.

Tôi không đo được trường hợp mất điện — không tắt được kernel trong container. Đó là giới hạn của phép đo này, và tôi nêu rõ thay vì suy diễn con số.

always mất 1 bản ghi

Dòng always lần 1 mất đúng một bản ghi. Không phải lỗi đo. Có một khe hở nhỏ: Redis ghi vào bộ đệm AOF, trả lời khách, rồi mới fsync ở cuối vòng lặp sự kiện. Lệnh cuối cùng được xác nhận ngay trước khi bị giết có thể chưa kịp qua fsync.

Nghĩa là always không phải bảo đảm tuyệt đối — nó là "mất tối đa một lệnh" chứ không phải "không bao giờ mất". Với hệ thống thật sự cần bảo đảm ghi bền, cần nhân bản đồng bộ, không chỉ cần fsync.

Chọn thế nào

Nếu Chọn
Bộ đệm, dựng lại được Tắt AOF, dùng RDB
Dữ liệu quan trọng, chạy trên máy ảo/đám mây everysec
Chấp nhận mất tối đa 1 giây everysec
Cần bền hơn nữa always + nhân bản, và đo lại thông lượng

everysec là mặc định và đúng cho gần như mọi trường hợp. Nó nhanh bằng no và mất tối đa một giây khi mất điện. Và nhớ điều đã đo ở phần trước: chỉ bật AOF thôi cũng đã tốt hơn hẳn RDB một mình, vì RDB mặc định có thể mất tới một giờ.

Ba điều dễ vấp

CONFIG SET appendonly yes không bền qua khởi động lại. Đây là chỗ tôi vấp ngay trong bài này: lần đo đầu tiên cả ba chế độ đều mất 100% dữ liệu, và tôi tưởng đã phát hiện điều gì to tát. Thực ra là Redis khởi động lại với appendonly no và nạp từ RDB. Phải ghi vào tệp cấu hình, hoặc gọi CONFIG REWRITE.

AOF cần viết lại định kỳ. Tệp chỉ nối thêm, nên SET k 1 một triệu lần tạo một triệu dòng. BGREWRITEAOF viết lại thành trạng thái hiện tại. Redis tự làm khi tệp lớn gấp đôi lần viết lại trước (auto-aof-rewrite-percentage 100).

Viết lại AOF cũng fork, nên nó mang đúng chi phí copy-on-write đã đo ở phần 18. Bật cả RDB lẫn AOF nghĩa là hai nguồn fork, và chúng có thể trùng nhau.

Mẫu số chung: dữ liệu "đã ghi" không có nghĩa "đã bền", và hai chữ đó ở hai tầng

Cái làm tôi bất ngờ — giết tiến trình mà mất 0 — là một phân biệt nền tảng mà mọi hệ lưu trữ đều có: giữa đã rời khỏi tiến trình và đã nằm bền trên đĩa. Một lệnh write() chỉ đẩy dữ liệu vào bộ đệm của hệ điều hành; nó sống sót qua tiến trình chết vì bộ đệm ấy thuộc về kernel. Chỉ fsync() mới ép dữ liệu xuống đĩa vật lý, và chỉ nó sống sót qua mất điện / kernel sụp. Hai loại lỗi này ở hai tầng khác nhau, và chọn nhầm mức bảo vệ cho loại lỗi bạn thật sự sợ là trả giá vô ích.

Trong thế giới Java, đây đúng là khác biệt giữa flush() và FileChannel.force(true) (fsync): một BufferedOutputStream.flush() đẩy byte tới hệ điều hành — đủ để sống qua một JVM crash — nhưng chỉ force() mới bảo đảm qua mất điện. Cùng phân biệt ấy nằm ở lõi WAL của mọi CSDL: đó là lý do một cơ sở dữ liệu chỉ được xác nhận "đã cam kết bền" sau khi fsync nhật ký, không phải sau khi write.

Trong Go, đây là bufio.Writer.Flush() so với os.File.Sync() — flush đẩy tới kernel, Sync mới xuống đĩa; nhầm hai cái là tưởng đã bền trong khi mới chỉ ra khỏi tiến trình.

Còn ở cơ sở dữ liệu, đây là toàn bộ ý nghĩa của fsync=off hay synchronous_commit=off trong PostgreSQL: tắt đi thì một backend crash vẫn an toàn (dữ liệu trong page cache), nhưng một mất điện sẽ mất các giao dịch chưa fsync — đúng ranh giới no/everysec/always của Redis.

Mẫu số chung: "đã ghi" và "đã bền" là hai trạng thái ở hai tầng — write tới bộ đệm kernel sống qua tiến trình chết, chỉ fsync tới đĩa sống qua mất điện — nên phải biết mình sợ loại lỗi nào trước khi chọn mức đồng bộ. Câu hỏi đúng không phải "dữ liệu đã lưu chưa" mà là "nếu tiến trình chết tôi mất gì, và nếu cả máy mất điện tôi mất gì" — vì câu trả lời thường khác nhau, và trả giá fsync-mỗi-lệnh để chống một rủi ro thật ra chỉ là process crash là lãng phí. Với phần lớn hệ chạy trên máy ảo, everysec là điểm cân đúng.

Muốn tự xem bạn đang hứa gì với dữ liệu của mình:

redis-cli config get appendonly appendfsync save | paste - -
redis-cli info persistence | grep -E \
  'aof_enabled|aof_last_write_status|aof_rewrite_in_progress|aof_last_bgrewrite_status|rdb_changes_since_last_save'

Ba dấu hiệu:

  • appendonly no và save để trống — không có gì được lưu cả. Redis khởi động lại là trắng.
  • aof_last_write_status khác ok — Redis đang không ghi được AOF, thường vì đĩa đầy. Nó vẫn phục vụ bình thường, nên bạn sẽ không biết cho tới lúc cần khôi phục.
  • appendonly yes mà chỉ đặt bằng CONFIG SET — kiểm bằng cách đọc tệp cấu hình. Nếu nó không có ở đó, bạn sẽ mất AOF ở lần khởi động lại tiếp theo.

Phần sau: RDB so với AOF — đo thời gian khôi phục thật của từng cách.