Hình dung bạn ghi chép vào một cuốn sổ, và một người bạn ngồi cạnh chép lại từng dòng sang cuốn thứ hai, luôn chậm hơn bạn một nhịp. Chừng nào người ấy theo kịp, cuốn sổ của bạn có cháy thì cũng chẳng mất gì — bản thứ hai đã có đủ. Nhưng nếu đúng lúc người ấy dừng lại gọt bút chì, và cuốn của bạn biến mất ngay khoảnh khắc đó, bạn mất đúng những dòng viết trong lúc họ ngừng chép — có thể một dòng, có thể cả một chương, tuỳ họ tụt lại bao xa. Sao chép bất đồng bộ của Redis hành xử y hệt, và điều làm tôi bất ngờ khi đo là: lượng mất không phải một hằng số nhỏ, nó bằng đúng khoảng cách bản sao đang chậm ngay lúc sự cố.

Sao chép của Redis là bất đồng bộ, và mọi tài liệu đều cảnh báo rằng điều đó có nghĩa mất dữ liệu. Ta đo xem mất bao nhiêu.

Ba lần giết máy chính

Tôi cho một khách ghi liên tục vào máy chính, đếm số lệnh đã được xác nhận, rồi SIGKILL máy chính và đếm lại trên bản sao.

Tình huống Xác nhận Bản sao có Mất
Liên kết khoẻ, ghi từng lệnh 87.972 87.972 0
Liên kết khoẻ, đường ống 500 2.782.500 2.782.500 0
Bản sao bị dừng trước khi giết 100.719 21.127 79.592 (79%)

Hai dòng đầu không mất gì — kể cả với 2,78 triệu lệnh ghi trong tám giây. Dòng thứ ba mất gần bốn phần năm.

Bất đồng bộ không có nghĩa "mất một ít"

Đây là chỗ trực giác hay sai. Người ta hình dung sao chép bất đồng bộ như một khoảng trễ nhỏ cố định, mất "vài lệnh cuối". Thực tế: nó mất đúng bằng phần bản sao đang chậm. Bình thường phần đó bằng không; khi có sự cố mạng, nó có thể là gần hết — đúng cái chương mất trắng khi người chép ngừng tay.

Lý do hai dòng đầu không mất gì: Redis đẩy dữ liệu vào bộ đệm sao chép trong cùng vòng lặp sự kiện với lệnh, và gửi đi trước khi khách nhận được trả lời. Với liên kết nhanh và bản sao theo kịp, dữ liệu đã ở bên kia trước khi khách biết lệnh thành công. Đo độ trễ sao chép khi khoẻ mạnh, 300 lần:

p50 0,120 ms | p99 0,290 ms | max 0,428 ms

Con số này còn gồm cả thời gian khách hỏi bản sao, nên độ trễ thật còn thấp hơn. Nhưng bảo đảm đó hoàn toàn phụ thuộc vào liên kết. Bản sao chậm — vì mạng nghẽn, vì nó đang bận lưu RDB, vì nó ở vùng khác — thì khoảng cách nới ra ngay, và mọi thứ trong khoảng đó là thứ bạn sẽ mất.

Theo dõi khoảng cách đó

Vì lượng mất bằng khoảng cách, hãy đo khoảng cách:

redis-cli info replication | grep -E 'master_repl_offset|slave0'
slave0:ip=...,port=6379,state=online,offset=123456,lag=0
master_repl_offset:123456

Hiệu giữa master_repl_offset và offset của bản sao là số byte đang chậm — chính là lượng sẽ mất nếu máy chính chết ngay lúc đó. lag là số giây kể từ lần bản sao báo cáo gần nhất, không phải khoảng cách dữ liệu. Đừng nhầm hai cái. Cảnh báo nên đặt trên hiệu số byte, không trên lag.

WAIT: đổi tốc độ lấy bảo đảm

WAIT numreplicas timeout chặn cho tới khi đủ số bản sao xác nhận đã nhận.

không WAIT              20.905 ops/s
có WAIT 1 100 sau mỗi SET   835 ops/s

Chậm 25 lần. Mỗi WAIT là một lượt đi về nữa, cộng thời gian chờ bản sao xác nhận. Với hệ thống ghi nhiều, cái giá đó thường không chấp nhận được. Và WAIT bảo đảm ít hơn người ta tưởng: nó chỉ cho biết bản sao đã nhận được dữ liệu vào bộ nhớ, không cho biết đã ghi xuống đĩa. Bản sao chết ngay sau đó vẫn có thể mất.

Cách dùng thực tế: đừng gọi WAIT sau mỗi lệnh. Gọi nó ở những điểm thật sự cần — sau khi ghi xong một giao dịch quan trọng, trước khi trả lời người dùng rằng "đã lưu".

Bản sao chỉ đọc

SET trên bản sao  ->  READONLY You can't write against a read only replica.

Mặc định replica-read-only yes, và nên giữ. Ghi vào bản sao tạo ra dữ liệu chỉ tồn tại ở đó, và nó biến mất im lặng ở lần đồng bộ toàn phần tiếp theo. Điều này cũng nghĩa là bản sao không dùng để san tải ghi. Nó dùng để:

  • San tải đọc — nhưng nhớ dữ liệu có thể cũ hơn máy chính một chút.
  • Chạy các lệnh nặng như BGSAVE hoặc quét toàn bộ mà không ảnh hưởng máy chính.
  • Sẵn sàng thay thế khi máy chính chết.

Ba mục đích đó khác nhau và có thể xung đột: bản sao đang chạy BGSAVE là bản sao đang chậm lại, và đó chính là lúc nó mất nhiều nhất nếu máy chính chết.

Đồng bộ toàn phần rất đắt

Khi bản sao mất kết nối quá lâu, nó không thể đồng bộ từng phần và phải làm đồng bộ toàn phần: máy chính BGSAVE rồi gửi cả tệp RDB. Với dữ liệu 338 MB đo ở phần 18, đó là một lần fork cộng chi phí copy-on-write, cộng 47 MB qua mạng — trên máy chính, đúng lúc nó đang phục vụ.

Cách giảm: nới bộ đệm quay lui.

repl-backlog-size 64mb
repl-backlog-ttl 3600

Bộ đệm này giữ các thay đổi gần đây để bản sao nối lại có thể lấy phần thiếu thay vì đồng bộ toàn phần. Mặc định 1 MB — quá nhỏ cho hệ thống ghi nhiều; một giây gián đoạn mạng cũng đủ để tràn nó.

Sao chép không phải sao lưu

Nhắc lại điều đã nói ở phần trước, vì nó là hiểu nhầm phổ biến nhất: FLUSHALL gõ nhầm trên máy chính được nhân bản sang bản sao trong vài mili giây. Bản sao không cứu bạn khỏi lỗi con người, chỉ cứu khỏi hỏng phần cứng. Cho lỗi con người, cần bản sao lưu ở nơi khác, tại một thời điểm trong quá khứ.

Mẫu số chung: độ bền là một phổ, và mất mát bằng độ trễ lúc sự cố

Cái ta vừa đo là bản chất của mọi sao chép bất đồng bộ, và nó có tên trong thiết kế hệ thống: RPO — recovery point objective, lượng dữ liệu tối đa có thể mất. Với sao chép async, RPO không phải một hằng số bạn chọn, nó bằng đúng độ trễ của bản sao tại khoảnh khắc máy chính chết — thường bằng không, thỉnh thoảng bằng gần hết. Và "độ bền" không phải bật/tắt mà là một phổ liên tục: một đầu là async (nhanh, mất = độ trễ), đầu kia là đồng bộ (chậm, mất ≈ 0), và bạn trả tốc độ để mua một cửa sổ mất nhỏ hơn.

Trong thế giới Java và hệ luồng, đây chính xác là acks của Kafka producer: acks=0/1 nhanh nhưng mất đúng phần chưa nhân bản khi broker chết, còn acks=all chờ tập bản sao đồng bộ (ISR) xác nhận — đúng vai trò của WAIT, và trả giá thông lượng đúng như 25 lần ở trên. Một consumer đọc từ read-replica cũng thấy dữ liệu cũ hơn primary, đúng cảnh "san tải đọc nhưng dữ liệu có thể cũ".

Trong Go hay bất kỳ client nào, ghi rồi đọc lại từ một bản sao có thể không thấy chính cái mình vừa ghi (không "read-your-writes") — cùng khoảng trễ ấy, chỉ khác chỗ nó cắn.

Còn ở cơ sở dữ liệu, đây là synchronous_commit của PostgreSQL: off/local nhanh nhưng RPO = độ trễ luồng sao chép, on/remote_apply chờ bản sao và chậm hơn hẳn. Và bài học giám sát giống hệt: theo dõi byte tụt hậu (pg_stat_replication write/flush/replay lag tính theo LSN), không theo dõi độ trễ thời gian — vì thời gian có thể nhỏ trong khi lượng byte chưa kịp gửi lại lớn.

Mẫu số chung: dưới sao chép bất đồng bộ, mất mát tệ nhất bằng độ trễ của bản sao ngay lúc sự cố — thường bằng không, đôi khi bằng gần hết — nên độ bền là một phổ đánh đổi với tốc độ, và bạn phải cảnh báo trên khoảng cách tính bằng byte chứ không bằng giây. Câu hỏi đúng không phải "sao chép có an toàn không" mà là "nếu máy chính chết ngay bây giờ, bản sao đang tụt lại bao nhiêu byte, và tôi chấp nhận mất đúng ngần ấy không" — rồi mới quyết định có trả giá WAIT/acks=all/synchronous_commit ở những điểm thật sự cần.

Muốn tự đo lượng dữ liệu bạn sẽ mất nếu máy chính chết ngay bây giờ:

redis-cli info replication | python3 -c '
import sys, re
d = sys.stdin.read()
m = int(re.search(r"master_repl_offset:(\d+)", d).group(1))
for s in re.finditer(r"slave\d+:.*?offset=(\d+).*?lag=(\d+)", d):
    off, lag = int(s.group(1)), int(s.group(2))
    print("cham %d byte, lag %d giay" % (m - off, lag))
'

Con số byte đó là câu trả lời trực tiếp. Bình thường nó gần 0 — như đo được ở trên. Thấy nó lên tới hàng megabyte và ở đó là bạn đang chạy với một bản sao không còn giá trị bảo vệ nhiều.

Phần sau: Sentinel — đo thời gian phát hiện máy chính chết và chuyển đổi.