Skip to content
DevRecall
← Roadmap
34

Scenarios: Zero-Downtime Migration & Zero Data Loss

drafted

Mock interview tình huống production: đổi schema Postgres không downtime (lock queue, CONCURRENTLY, NOT VALID), backfill bảng lớn, đổi DB engine bằng snapshot + CDC, strangler fig cho legacy, outbox, drain Kafka backlog 10M, durability knobs và cutover/rollback.

0/57 rated · mastery 0%

Bài học

6 bài · ~94 phút đọc. Học theo thứ tự, rồi làm câu hỏi.
  1. 01~18 phút· 9 câuverify

    Playbook: đổi schema Postgres khi traffic vẫn chạy

    Chẩn đoán lock queue, lock_timeout + retry, CONCURRENTLY, NOT VALID, fast default, migration tool, MySQL/SQL Server online DDL và guardrail cho cả team.

  2. 02~18 phút· 10 câuverify

    Playbook: expand–contract, backfill và rollback qua nhiều deploy

    Thứ tự migration so với rollout, rename/split cột với writer không kiểm soát, int → bigint, backfill tự điều tiết, rollback forward-only và rollout theo tenant.

  3. 03~13 phút· 9 câuverify

    Playbook: chuyển database bằng snapshot + CDC, verify và cutover

    Chọn dump/restore, logical replication, DMS hay Debezium; chứng minh dữ liệu giống nhau; runbook cutover dưới 5 phút; sequence, collation, time zone và đường lui.

  4. 04~12 phút· 11 câuverify

    Playbook: legacy, API cũ và các kho dữ liệu khác

    Strangler fig cho monolith PHP, adapter XML/JSON, shadow traffic an toàn, deprecation, canary đổi format dữ liệu, reindex Elasticsearch, chuyển S3 và nâng cấp hash password.

  5. 05~17 phút· 11 câuverify

    Playbook: chuỗi durability và chứng minh zero data loss

    RPO/RTO, no ack until durable, synchronous_commit, Multi-AZ vs replica, PITR và restore drill, outbox đúng cách, Kafka acks/ISR, reconciliation.

  6. 06~15 phút· 7 câuverify

    Playbook: drain Kafka backlog 10M mà không mất message

    Tính ETA, chẩn đoán lag theo partition, giới hạn consumer ≤ partition, song song theo key, poison message, không tăng partition giữa backlog, ưu tiên traffic mới.

Tóm tắt & cheat sheet của track

TL;DR

  • "Zero-downtime" = mọi bước đều tương thích với cả code version N và N-1 đang chạy song song (rolling deploy, blue/green, canary). Công cụ chính: expand → migrate → contract, mỗi bước deploy riêng và rollback được.
  • Postgres hiếm khi chết vì DDL chậm; nó chết vì lock queue: một ALTER TABLE cần ACCESS EXCLUSIVE xếp hàng sau một query dài và chặn mọi query đến sau nó. Luôn đặt lock_timeout (vài giây) + retry, dùng CONCURRENTLY / NOT VALID + VALIDATE.
  • Đổi DB engine / cluster không downtime = snapshot + CDC (Debezium, AWS DMS, logical replication) → verify (count, checksum theo chunk, shadow read) → cutover ngắn (write freeze vài giây–phút) → reverse replication để có đường lui. Dual-write từ app là phương án cuối vì không atomic.
  • "Zero data loss" không phải một config mà là một chuỗi durability: client retry + idempotency key → API chỉ ack khi đã durable → outbox / Kafka acks=all + min.insync.replicas=2 → consumer commit sau xử lý → DB sync replication / PITR → reconciliation để chứng minh.
  • Kafka backlog 10M: tính ETA trước (backlog / (consume − produce)), scale consumer tối đa bằng số partition, song song theo key bên trong partition nếu cần ordering, bảo vệ downstream bằng rate limit, đừng tăng partition vội vì nó đổi mapping key → partition.

Khái niệm cốt lõi

Khái niệmMột câuVí dụ ngắn
Expand–contract (parallel change)Thêm cái mới song song cái cũ, chuyển dần, rồi mới xoá cái cũzip → thêm postal_code, dual-write, backfill, đổi read, drop zip
Lock queueLock request chờ sẽ chặn các request đến sau xung đột với nóALTER chờ sau SELECT 10 phút → mọi SELECT mới cũng chờ
lock_timeoutHủy statement nếu chờ lock quá N msSET lock_timeout = '3s' rồi retry có backoff
NOT VALID + VALIDATETạo constraint chỉ áp cho row mới, validate row cũ sau với lock nhẹFK / CHECK trên bảng 500M rows
CREATE INDEX CONCURRENTLYBuild index không chặn write, đổi lại chậm hơn và có thể để lại index INVALIDKhông chạy được trong transaction block
BackfillĐiền dữ liệu cho cột/bảng mới theo batch, idempotent, resume đượcUPDATE ... WHERE id BETWEEN $1 AND $2 AND new_col IS NULL
CDCĐọc change log (WAL / binlog / SQL Server CDC) để stream thay đổiDebezium → Kafka → sink Postgres
Shadow readĐọc cả nguồn cũ và mới, so sánh, vẫn trả kết quả cũLog diff 0.02% → tìm bug mapping
Strangler figProxy route từng endpoint từ hệ cũ sang hệ mới/v1/orders/* → Node, còn lại → PHP
RPO / RTOMất tối đa bao nhiêu dữ liệu / khôi phục trong bao lâuRPO = 0, RTO = 5 phút cho payments
Transactional outboxGhi business row + event trong cùng transaction, relay publish sauorders + outbox trong 1 COMMIT
ReconciliationJob so sánh định kỳ nguồn và đích để phát hiện lệchCount + sum amount mỗi giờ theo tenant
Rehash-on-loginNâng cấp hash password dần khi user đăng nhậpMD5 → argon2id

Cơ chế hoạt động

Expand–contract qua nhiều lần deploy

Lock queue: vì sao một ALTER "0.1 giây" làm sập API

Đổi DB engine: snapshot + CDC + cutover + đường lui

Chuỗi durability (không ack cho tới khi durable)

Khung trả lời

Dùng lại cho mọi câu "migrate X không downtime / không mất dữ liệu":

  1. Làm rõ ràng buộc: kích thước dữ liệu (rows, GB), QPS đọc/ghi, RPO/RTO, có được freeze write vài phút không, các app/consumer nào đang dùng, yêu cầu ordering.
  2. Liệt kê điều phải tương thích song song: code N và N-1, schema cũ và mới, consumer cũ và mới, client cũ (mobile không update được).
  3. Chia bước reversible: expand → dual (write / replicate) → backfill → verify → switch read → switch write → contract. Mỗi bước: cách rollback, tiêu chí go/no-go.
  4. Bảo vệ production khi chạy: lock_timeout, batch + throttle, theo dõi replica lag / WAL / consumer lag / error rate, kill switch (feature flag).
  5. Chứng minh đúng: count, checksum theo chunk, shadow read diff, reconciliation job, audit log.
  6. Cutover + rollback: runbook, người quyết định, thời gian freeze, reverse replication, khi nào xoá đường lui.
  7. Nói trade-off bằng số: "freeze 2 phút đổi lấy không phải dual-write 6 tuần", "sync commit thêm ~1–3 ms mỗi write trong cùng region".

Trade-offs

Cách đồng bộ khi migrateConsistencyĐộ phức tạpRủi ro chínhHợp khi
Downtime window (dump/restore)Tuyệt đốiThấpDowntime tỉ lệ với data sizeDB nhỏ, business chấp nhận maintenance
Dual-write từ appYếu (không atomic)Trung bìnhLệch khi 1 bên fail, race orderingChỉ khi không có CDC, có reconciliation mạnh
CDC (Debezium / DMS / logical replication)Tốt, theo commit orderCao (vận hành connector)Type mapping, DDL, lag, replication slot giữ WALĐa số migrate engine / cluster
Snapshot restore + native replicationTốtThấp–TBChỉ cùng engine / version tương thíchSelf-hosted PG → RDS/Aurora
Transactional outboxTốt, at-least-onceTBRelay lag, bảng outbox phìnhSync SQL → NoSQL / event cho service khác
Knob "zero data loss"Được gìTrả giá gì
PG synchronous_commit=on + sync standbyRPO ≈ 0 khi mất primaryLatency mỗi commit + write dừng nếu standby chết (trừ khi có nhiều candidate)
Kafka acks=all + min.insync.replicas=2 + RF=3Không mất message đã ack khi mất 1 brokerLatency cao hơn, mất 2 broker → producer bị từ chối
unclean.leader.election.enable=falseKhông bầu replica lỗi thời làm leaderPartition offline cho tới khi replica ISR quay lại
Consumer commit sau xử lýKhông mất khi crashDuplicate → cần idempotent sink
PITR (WAL archive)Khôi phục tới thời điểm trước lỗi người dùngChi phí lưu trữ, RTO phụ thuộc lượng WAL replay

Pitfalls

  • Gộp migration và code dùng schema mới trong một deploy → pod cũ crash / pod mới crash tùy thứ tự.
  • Migration không có lock_timeout → một ALTER "instant" chặn toàn bộ bảng phía sau một report query.
  • CREATE INDEX CONCURRENTLY trong migration tool mặc định bọc transaction → lỗi ngay; hoặc chạy fail để lại index INVALID vẫn làm chậm write.
  • Backfill bằng một UPDATE khổng lồ hoặc vòng lặp OFFSET → lock lâu, WAL bùng, replica lag, bloat.
  • Tin rằng "sync xong row count bằng nhau" = đúng. Count bằng nhau vẫn có thể sai collation, time zone, precision, trailing space.
  • Quên reset sequence / identity sau khi copy → duplicate key ngay khi app ghi vào DB mới.
  • Outbox relay "publish rồi mark done" coi là exactly-once; polling id > last_id bỏ sót event commit muộn.
  • Tăng partition Kafka giữa lúc backlog để "chạy nhanh hơn" → phá ordering theo key, không giảm lại được.
  • Có backup nhưng chưa từng restore thử → không biết RTO thật, không biết backup hỏng.
  • Traffic mirroring cho endpoint ghi → hệ mới gửi email/charge thẻ thật lần hai.

Cheat sheet

  • Mọi DDL Postgres trong migration: SET lock_timeout = '3s'; SET statement_timeout = '15min'; + retry có jitter.
  • Thêm cột: nullable hoặc DEFAULT hằng/stable (PG11+ không rewrite). Default volatile (gen_random_uuid(), clock_timestamp()) → rewrite bảng.
  • NOT NULL cột có sẵn: ADD CONSTRAINT ... CHECK (c IS NOT NULL) NOT VALID → VALIDATE CONSTRAINT → SET NOT NULL (PG12+ bỏ qua scan) → drop CHECK.
  • FK: ADD CONSTRAINT ... FOREIGN KEY ... NOT VALID → VALIDATE CONSTRAINT (lock nhẹ SHARE UPDATE EXCLUSIVE).
  • Index: CREATE INDEX CONCURRENTLY ngoài transaction; fail → DROP INDEX CONCURRENTLY rồi làm lại; kiểm pg_index.indisvalid.
  • MySQL: ALGORITHM=INSTANT/INPLACE, LOCK=NONE, bảng lớn dùng gh-ost / pt-online-schema-change. SQL Server: WITH (ONLINE = ON, RESUMABLE = ON) (edition-dependent).
  • Backfill: keyset theo PK, batch 1k–10k, commit mỗi batch, sleep / throttle theo replay_lag, checkpoint để resume, WHERE new IS NULL để idempotent.
  • DB migration: snapshot + CDC → count + checksum theo chunk + shadow read → freeze → lag 0 → setval → flip → reverse replication.
  • Kafka zero loss: acks=all, idempotent producer, RF=3, min.insync.replicas=2, unclean.leader.election.enable=false, commit sau xử lý, sink idempotent.
  • Backlog: ETA = backlog / (consume − produce); consumers ≤ partitions; per-key concurrency; rate limit downstream; park poison message; ưu tiên traffic mới.
  • Legacy: strangler proxy, contract test, shadow traffic chỉ cho read, adapter XML ↔ JSON, Deprecation / Sunset header, feature flag theo tenant, canary.

Personal notes (Notion)

Ghi chú cá nhân — có thể sai hoặc lỗi thời, dùng để tham khảo.

Interview Questions

Showing 57 / 57

SET lock_timeout = '3s';
SET statement_timeout = '15min';
ALTER TABLE orders ADD COLUMN channel text;
async function verify(user: User, pw: string): Promise<boolean> {
  const ok =
    user.scheme === "argon2" ? await argon2.verify(user.hash, pw)
    : await argon2.verify(user.hash, md5hex(pw)); // wrapped legacy
  if (ok && user.scheme !== "argon2") {
    await users.update(user.id, { hash: await argon2.hash(pw), scheme: "argon2" });
  }
  return ok;
}
POST _aliases
{ "actions": [
  { "remove": { "index": "products_v1", "alias": "products" } },
  { "add":    { "index": "products_v2", "alias": "products" } }
] }
-- migration 20260901_add_fk.sql
ALTER TABLE order_items
  ADD CONSTRAINT order_items_order_fk
  FOREIGN KEY (order_id) REFERENCES orders(id);
export const dataSource = new DataSource({
  type: "postgres",
  url: process.env.DATABASE_URL,
  entities: [__dirname + "/entities/*.js"],
  synchronize: true, // "so dev and prod stay in sync"
  migrationsRun: false,
});
// Entity change in this release:
// @Column() referralCode: string;  (renamed from `refCode`)
// migrations/20260910_orders_status_idx.ts
import type { Knex } from "knex";
export async function up(knex: Knex) {
  await knex.schema.alterTable("orders", (t) => {
    t.index(["tenant_id", "status", "created_at"], "orders_tenant_status_created_idx");
  });
}
export async function down(knex: Knex) {
  await knex.schema.alterTable("orders", (t) => t.dropIndex([], "orders_tenant_status_created_idx"));
}
const BATCH = 50_000;
await db.transaction(async (tx) => {
  for (let offset = 0; ; offset += BATCH) {
    const rows = await tx.query(
      `SELECT id, total FROM orders ORDER BY id LIMIT $1 OFFSET $2`, [BATCH, offset]);
    if (rows.length === 0) break;
    for (const r of rows) {
      await tx.query(`UPDATE orders SET total_cents = $1 WHERE id = $2`,
        [Math.round(r.total * 100), r.id]);
    }
  }
});
-- Postgres side, per 100k-id chunk
SELECT id / 100000 AS chunk, count(*),
       md5(string_agg(concat_ws('|', id, status, amount_cents,
           to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US')), ',' ORDER BY id))
FROM orders GROUP BY 1 ORDER BY 1;
await consumer.run({
  autoCommit: true,          // commits periodically in the background
  eachBatchAutoResolve: true,
  eachBatch: async ({ batch }) => {
    for (const m of batch.messages) {
      buffer.push(JSON.parse(m.value!.toString())); // in-memory
    }
    if (buffer.length >= 5000) {
      await db.insertOrders(buffer.splice(0));       // flushed later
    }
  },
});
app.get("/api/orders/:id", async (req, res) => {
  const order = await orders.get(req.params.id);       // one core model
  if (req.accepts(["json", "xml"]) === "xml") {
    res.type("application/xml").send(toLegacyXml(order)); // matches PHP output
  } else {
    res.json(toJsonV2(order));
  }
});
SELECT pid, pg_blocking_pids(pid) AS blocked_by, state,
       now() - xact_start AS xact_age, left(query, 60) AS query
FROM pg_stat_activity
WHERE datname = current_database() AND wait_event_type = 'Lock'
ORDER BY xact_start;
async function waitForReplicas(maxLagSec = 5) {
  for (;;) {
    const { rows } = await pg.query(
      `SELECT coalesce(max(extract(epoch FROM replay_lag)), 0) AS lag FROM pg_stat_replication`);
    if (Number(rows[0].lag) < maxLagSec) return;
    await sleep(2_000);
  }
}
ERROR: duplicate key value violates unique constraint "orders_pkey"
DETAIL: Key (id)=(1) already exists.
-- fix: move the sequence past the copied data (plus headroom)
SELECT setval(pg_get_serial_sequence('orders', 'id'),
              (SELECT coalesce(max(id), 0) + 1000 FROM orders), false);
// runs every 200 ms in each of 3 replicas
async function relayOnce() {
  const last = await state.get("last_id");               // shared checkpoint
  const { rows } = await pg.query(
    `SELECT * FROM outbox WHERE id > $1 ORDER BY id LIMIT 500
     FOR UPDATE SKIP LOCKED`, [last]);
  for (const e of rows) await kafka.send({ topic: e.topic, messages: [{ value: e.payload }] });
  await pg.query(`UPDATE outbox SET published_at = now() WHERE id = ANY($1)`, [rows.map(r => r.id)]);
  await state.set("last_id", rows.at(-1)?.id ?? last);
}
eachBatch: async ({ batch, resolveOffset, heartbeat }) => {
  const byKey = new Map<string, KafkaMessage[]>();
  for (const m of batch.messages) {
    const k = m.key?.toString() ?? "";
    byKey.set(k, [...(byKey.get(k) ?? []), m]);
  }
  await pMap([...byKey.values()], async (msgs) => {
    for (const m of msgs) await handleIdempotent(m);   // in order per key
  }, { concurrency: 32 });
  resolveOffset(batch.lastOffset());                    // whole batch done
  await heartbeat();
}
const queue: Order[] = [];
setInterval(async () => {
  const batch = queue.splice(0, 500);
  if (batch.length) await producer.send({ topic: "orders", messages: batch.map(toMsg) });
}, 1000);

app.post("/orders", (req, res) => {
  queue.push(validate(req.body));
  res.status(202).json({ status: "accepted" }); // acked before durable
});