Data redundancy: một node lỗi là mất dữ liệu, và replica async không cứu được nếu không ai theo dõi lag
Data redundancy là việc giữ nhiều bản copy của cùng một dataset trên các node khác nhau để khi một node chết, dataset không đi theo nó. Cơ chế thực thi phổ biến nhất là replication: một node là source-of-truth (primary/leader), các node khác nhận và apply lại các thay đổi. Nghe đơn giản, nhưng phần lớn incident mất dữ liệu ở database không đến từ "chưa có replica" mà từ "có replica nhưng không sync kịp lúc primary chết", hoặc "replica đã broken từ lâu mà không ai biết vì không có alert trên replication lag". Postmortem của GitLab.com tháng 1/2017 là ví dụ điển hình: một engineer xoá nhầm data directory trên primary, mọi replica đã ngừng streaming từ trước, và các backup channel khác đều hỏng — dữ liệu vài giờ cuối mất hẳn dù về danh nghĩa hệ thống "có replica".
Cơ chế hoạt động
Ở database quan hệ (Postgres, MySQL), replication chạy trên write-ahead log (WAL / binlog): mọi thay đổi trước khi vào table file đều được append vào một file log tuần tự; replica kết nối tới primary, stream các WAL record đó, và apply lại y hệt vào bản data local. Vì WAL là physical/logical record của mọi thay đổi đã commit, replica hội tụ về cùng state với primary — miễn là nó theo kịp.
Điểm quyết định là thời điểm primary trả "commit ok" về client:
- Async replication — primary flush WAL local rồi trả ngay. WAL được đẩy sang replica ở background. Nhanh, nhưng nếu primary chết trong khoảng "đã commit local, chưa gửi sang replica", các transaction đó không tồn tại trên replica.
- Sync replication — primary phải chờ ít nhất một replica xác nhận đã nhận (hoặc đã flush, hoặc đã apply) WAL rồi mới trả về client. Không mất transaction đã ack, đổi lại latency mỗi commit tăng theo RTT tới replica chậm nhất, và nếu replica sync down thì primary có thể block write.
-
Quorum-based — thay vì phân biệt primary/replica cứng, N replica tham gia; một write chỉ coi là committed khi được W trong N replica ack, một read cần R replica trả để lấy giá trị mới. Với
W + R > N, mọi read đảm bảo thấy write đã committed. Dynamo/Cassandra dùng tunable quorum ở tầng client; etcd/Consul/ZooKeeper dùng Raft/Paxos ép quorum majority ở tầng consensus; Kafka dùng ISR (in-sync replicas) +acks=all+min.insync.replicas.
Postgres cho phép chọn strictness qua synchronous_commit và synchronous_standby_names:
# postgresql.conf trên primary
wal_level = replica
max_wal_senders = 10
synchronous_standby_names = 'ANY 1 (standby1, standby2)' # cần 1 trong 2 ack
synchronous_commit = on # 'on' = chờ remote flush WAL; 'remote_apply' = chờ apply xong
Với config trên, một COMMIT chỉ trả về client khi ít nhất một trong hai standby đã fsync WAL. Nếu đổi thành synchronous_commit = off, primary trả ngay cả trước khi fsync local — hiệu năng cao nhất, rủi ro mất vài trăm ms transaction gần nhất khi primary crash. MySQL có mô hình tương tự: async là mặc định, semi-sync qua plugin rpl_semi_sync_source chờ replica ack đã ghi relay log (chưa apply), Group Replication dùng consensus theo Paxos-variant.
Vấn đề gặp trong production
Failure mode 1: async replication + failover = mất transaction đã ack. Primary chạy async, replica trễ vài giây. Primary crash, orchestrator promote replica; các transaction commit trong khoảng "đã ack cho client, chưa stream sang replica" biến mất khỏi hệ thống mới. Client tưởng đơn hàng đã ghi (đã nhận 200 OK), sau failover query lại thì không thấy. Đây là RPO ≠ 0 — bug nghiệp vụ, không phải bug code. Kịch bản GitHub tháng 10/2018 (postmortem chính thức trên blog GitHub Engineering) đúng dạng này ở quy mô cross-region: một partition mạng ngắn kích hoạt failover ở region khác, và việc reconcile hai bản sau đó mất hàng giờ vì trạng thái đã diverge.
Fix ở tầng config, không phải code:
# Sai: async, chấp nhận mất write khi primary chết
synchronous_commit = local
synchronous_standby_names = ''
# Đúng cho data không được phép mất (đơn hàng, thanh toán):
synchronous_commit = on
synchronous_standby_names = 'ANY 1 (standby1, standby2)' # ít nhất 1 remote flush
Với ANY 1 (…), chỉ cần một trong nhiều standby còn sống là commit vẫn tiến; primary không bị block chỉ vì một standby chậm. Đừng dùng FIRST 1 (standby1) với chỉ một replica — replica đó down là toàn bộ write block.
Failure mode 2: replica đã ngừng streaming mà không ai biết. Đây là lỗi từ postmortem GitLab.com: replication đã broken (do WAL retention hết, replica bị bỏ xa), nhưng vì không có alert trên replication lag > threshold nên team tưởng replica đang healthy. Khi cần fallback thì replica hoặc quá cũ, hoặc đã tự stop hẳn. Nguyên nhân kỹ thuật thường gặp: primary purge WAL segment cũ trước khi replica kịp fetch (Postgres không có replication slot, hoặc slot bị drop; MySQL binlog expire_logs_days quá ngắn). Fix: bật replication slot (primary_slot_name trên standby + pg_create_physical_replication_slot trên primary) để primary giữ WAL đến khi replica đọc xong, KÈM alert khi slot lag vượt ngưỡng — vì slot cũng có tác dụng phụ: replica chết → WAL tích dồn → đầy disk primary.
Failure mode 3: semi-sync degrade thành async âm thầm. MySQL semi-sync có timeout (rpl_semi_sync_source_timeout): nếu không replica nào ack trong khoảng đó, primary fallback sang async để không block write. Đây là hành vi thiết kế — nhưng nếu network flap kéo dài, cluster chạy async suốt mà dashboard vẫn báo "semi-sync enabled". Client tưởng đang được bảo vệ. Postgres tương đương: synchronous_commit = on với synchronous_standby_names — nếu tất cả standby down, primary block write (an toàn hơn nhưng gây downtime); một số team hạ thành local để tránh block, và quay lại failure mode 1. Không có lựa chọn miễn phí; phải hiểu config đang trade cái gì.
Failure mode 4: quorum sai — acks=1 hoặc min.insync.replicas=1. Kafka topic replication factor 3 nhưng producer acks=1 chỉ cần leader ack; leader crash trước khi replicate sang follower là mất message đã ack. Fix chuẩn: acks=all + min.insync.replicas=2 (với RF=3) — producer nhận NotEnoughReplicas exception khi số ISR tụt xuống dưới 2, và có thể chọn fail-fast thay vì ghi vào cluster đã giảm redundancy. Đây là mặc định khuyến nghị trong Kafka documentation cho durability.
Failure mode 5: split-brain khi failover không dùng fencing. Primary bị coi là chết vì network partition, orchestrator promote standby thành primary mới, client mới ghi vào đó. Nhưng primary cũ chưa thực sự chết — chỉ mất kết nối tới orchestrator. Client cũ vẫn ghi vào primary cũ. Hai bản data diverge, không merge tự động được. Cách phòng: STONITH ("shoot the other node in the head") hoặc fencing bằng cách cắt VIP/DNS/network trước khi promote; hoặc dùng consensus-based system (etcd/Consul làm lease store) để chỉ một primary có lease tại một thời điểm. Async replication làm tình huống này tệ hơn hẳn sync, vì tại thời điểm split hai bản đã có khác biệt.
Cách debug và monitor
Triệu chứng replication lag: sau khi ghi vào primary, read từ replica trong vài giây tiếp theo không thấy record (read-your-writes fail); sau failover, một số record "biến mất"; trên dashboard, khoảng cách LSN giữa primary và replica tăng dần và không giảm.
Metric bắt buộc (Postgres, đọc từ pg_stat_replication trên primary):
-
write_lag— khoảng thời gian từ khi primary flush WAL local đến khi standby ack đã nhận. Đo lag mạng + xử lý của walreceiver. -
flush_lag— thêm thời gian standby fsync WAL xuống disk local. Đây là mốcsynchronous_commit = onchờ. -
replay_lag— thêm thời gian standby thực sự apply xong WAL vào data file. Đây là mốc quyết định "read từ replica có thấy write mới không". -
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)— bytes chưa replay, đơn vị byte thay vì thời gian. Đọc cùng với thời gian để phát hiện replica đã stall (lsn diff tăng mà replay không đuổi kịp). -
pg_replication_slots.active— slot có đang được stream không.falsekéo dài +restart_lsngiữ nguyên = replica đã disconnect, WAL đang tích trên primary.
MySQL tương đương: SHOW REPLICA STATUS (hoặc SHOW SLAVE STATUS bản cũ) cho Seconds_Behind_Source, Replica_IO_Running, Replica_SQL_Running, và Retrieved_Gtid_Set vs Executed_Gtid_Set. Chú ý Seconds_Behind_Source chỉ là ước lượng: nếu IO thread đứt, giá trị vẫn có thể là 0 vì SQL thread đang apply relay log đã có — check cả Replica_IO_Running=Yes.
Kafka: kafka-consumer-groups.sh --describe cho LAG per partition, JMX metric UnderReplicatedPartitions cho topic có ISR < RF, kafka-topics.sh --describe --under-min-isr-partitions để list topic đang không đủ quorum.
Alert cần có: (1) lag thời gian vượt ngưỡng SLA (vd vài giây với read-heavy service, hàng chục giây với reporting replica); (2) lag byte tăng đơn điệu không giảm trong X phút = replica đang thua tốc độ ghi; (3) replication slot active=false quá vài phút; (4) UnderReplicatedPartitions > 0 với Kafka; (5) min.insync.replicas không đạt.
Rule phòng: không bao giờ dùng replica để chống mất dữ liệu mà không có alert lag và không có backup riêng — replica là HA, không phải backup. Replica sao chép cả lệnh DROP TABLE nhanh chóng.
Tradeoff
Sync replication cho RPO = 0 (không mất transaction đã ack) đổi lại: mỗi write phải chờ RTT tới replica sync gần nhất, throughput commit giảm, và nếu tất cả standby sync đều down thì primary block write — HA của availability giảm để đổi lấy HA của data. Async cho latency thấp và write không phụ thuộc standby, đổi lại RPO > 0 và replica có thể trễ hàng chục giây dưới tải lớn, gây stale read và mất write khi failover. Quorum majority (Raft/Paxos, hoặc W+R>N) cho balance: chấp nhận latency của N/2+1 replica gần nhất chứ không phải toàn bộ, và tự động chịu được N/2 node chết mà không cần failover thủ công — đổi lại complexity vận hành cao hơn và write latency đều bị ràng buộc bởi replica chậm thứ N/2+1. Càng nhiều replica càng an toàn dữ liệu và scale read tốt hơn, nhưng chi phí storage + network + độ khó vận hành tăng tuyến tính.
Rule of thumb: data không được phép mất (payment, order, user identity) dùng sync (hoặc quorum majority) với ít nhất 2 replica remote-flush, alert khi lag > vài giây; data chấp nhận RPO vài giây (analytics, session, cache) dùng async với 1–2 replica, alert khi lag > 30 giây; luôn có backup off-cluster ngoài replica — replica sao chép cả lỗi người dùng.
Câu hỏi phỏng vấn
Sync và async replication khác gì nhau, và khi nào chọn cái nào?
Async: primary commit local xong trả về client ngay, WAL/binlog stream sang replica ở background. Nhanh, không phụ thuộc replica còn sống, throughput cao, nhưng RPO > 0 — nếu primary chết trong khoảng chưa kịp stream, các transaction đã ack cho client biến mất khỏi hệ thống sau failover. Sync: primary chờ ít nhất một replica ack đã flush (hoặc apply) WAL rồi mới trả về client. RPO = 0 cho transaction đã ack, nhưng latency mỗi commit tăng theo RTT tới replica sync chậm nhất, và nếu tất cả replica sync down thì primary block write (Postgres) hoặc âm thầm degrade về async (MySQL semi-sync với timeout). Chọn theo cost of losing data: payment/order dùng sync với synchronous_standby_names = 'ANY 1 (…)' và tối thiểu 2 standby (để một cái down không block write); analytics/session/cache dùng async vì mất vài giây record không sập nghiệp vụ. Điểm ăn điểm là nêu hai failure mode production: (1) async + failover = mất write đã ack (kịch bản dạng GitHub 2018 khi network partition kích hoạt promote replica đang trễ); (2) replica ngừng streaming âm thầm (GitLab.com 2017) vì không có alert trên replication lag và slot inactive, dẫn tới "có replica trên giấy nhưng không có replica lúc cần" — nên monitor pg_stat_replication (write/flush/replay lag), Kafka UnderReplicatedPartitions, và luôn có backup off-cluster riêng vì replica sao chép cả DROP TABLE.
Hands-on
Dựng một cluster Postgres primary + streaming standby trên Docker, tạo transaction, đo lag, kill primary và promote standby, so sánh khi chạy async vs sync.
mkdir -p pg-repl-lab && cd pg-repl-lab
docker network create pgnet
# --- primary ---
docker run -d --name pg-primary --network pgnet \
-e POSTGRES_PASSWORD=pw \
-e POSTGRES_DB=app \
-p 5432:5432 \
postgres:16 \
-c wal_level=replica \
-c max_wal_senders=10 \
-c hot_standby=on \
-c synchronous_commit=on
sleep 5
# Tạo replication user + slot trên primary
docker exec -u postgres pg-primary psql -c \
"CREATE ROLE repl WITH REPLICATION LOGIN PASSWORD 'rp';"
docker exec -u postgres pg-primary psql -c \
"SELECT pg_create_physical_replication_slot('standby1');"
docker exec -u postgres pg-primary bash -c \
"echo 'host replication repl 0.0.0.0/0 md5' >> /var/lib/postgresql/data/pg_hba.conf"
docker exec -u postgres pg-primary psql -c "SELECT pg_reload_conf();"
Bootstrap standby bằng pg_basebackup rồi start ở hot-standby mode (dùng volume riêng cho từng node ở lab thật):
docker volume create pgstandby
docker run --rm --network pgnet -v pgstandby:/var/lib/postgresql/data \
-e PGPASSWORD=rp postgres:16 \
pg_basebackup -h pg-primary -U repl -D /var/lib/postgresql/data \
-Fp -Xs -R -S standby1 -c fast
docker run -d --name pg-standby --network pgnet -p 5433:5432 \
-v pgstandby:/var/lib/postgresql/data \
postgres:16 -c hot_standby=on
Kiểm tra replication đang stream:
docker exec -u postgres pg-primary psql -c \
"SELECT application_name, state, write_lag, flush_lag, replay_lag,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes
FROM pg_stat_replication;"
# kỳ vọng: state='streaming', các cột lag rất nhỏ hoặc NULL
Bắn tải write để tạo lag:
docker exec -u postgres pg-primary psql -d app -c \
"CREATE TABLE t(id serial primary key, v text, ts timestamptz default now());"
docker exec -u postgres pg-primary psql -d app -c \
"INSERT INTO t(v) SELECT md5(g::text) FROM generate_series(1,100000) g;"
# đọc từ standby
docker exec -u postgres pg-standby psql -d app -c "SELECT count(*) FROM t;"
# so sánh với primary — khi lag còn tồn tại, hai giá trị khác nhau trong khoảnh khắc
Bật sync mode và quan sát khác biệt: sửa postgresql.conf trên primary thêm synchronous_standby_names = 'ANY 1 (walreceiver)', reload, rồi stop standby (docker stop pg-standby) và thử insert. Với synchronous_commit=on và không còn standby ack, INSERT sẽ hang cho tới khi standby quay lại — đúng behavior "bảo vệ dữ liệu, hy sinh availability write".
Test failover và đo RPO thực tế:
# quay lại async để dễ tái hiện data loss
docker exec -u postgres pg-primary psql -c \
"ALTER SYSTEM SET synchronous_commit = 'local'; SELECT pg_reload_conf();"
# ép lag lớn: pause standby, insert nhiều trên primary
docker pause pg-standby
docker exec -u postgres pg-primary psql -d app -c \
"INSERT INTO t(v) SELECT 'lost-' || g FROM generate_series(1,5000) g;"
# kill primary khi standby vẫn đang pause
docker stop pg-primary
docker unpause pg-standby
# promote standby thành primary mới
docker exec -u postgres pg-standby psql -c "SELECT pg_promote();"
docker exec -u postgres pg-standby psql -d app -c \
"SELECT count(*) FROM t WHERE v LIKE 'lost-%';"
# kỳ vọng: < 5000 — số row thiếu chính là RPO cụ thể tại thời điểm crash
Lặp lại với synchronous_commit = on + synchronous_standby_names set — thao tác INSERT khi standby paused sẽ block thay vì thành công, tức là không có write nào bị mất vì không có write nào được ack. Đây là bằng chứng trực tiếp cho tradeoff sync vs async.
Cuối cùng, mô phỏng "replica chết âm thầm giữ WAL": pause standby dài, chạy loop insert liên tục vào primary, và quan sát pg_wal_lsn_diff cùng dung lượng directory pg_wal/ trên primary tăng đều. Đây là failure mode "slot giữ WAL cho replica chết" — nếu để lâu primary sẽ hết disk và dừng nhận write. Alert cần bắt tình huống này ở giai đoạn lag_bytes vượt ngưỡng, không đợi tới lúc disk full.
Top comments (0)