Migration lớn, zero-downtimeLarge migrations, zero downtime
Migration chia thành các bước nhỏ, mỗi bước quay lui được: thêm cột (chỉ đổi metadata), app ghi cả hai cột, worker backfill từng lô có checkpoint, verifier so từng khoảng id, rồi mới bật cờ đọc cột mới. Chọn “Backfill bị crash” hoặc “Kiểm chứng phát hiện lệch” để xem các điểm an toàn.The migration is split into small, reversible steps: add the column (a metadata-only change), have the app write both columns, backfill in checkpointed batches, verify each id range, and only then flip the flag that reads the new column. Pick “Backfill crashes” or “Verification finds a mismatch” to see the safety points.
1Migration V41 thêm cột nullable không default. Từ Postgres 11 đây chỉ là thay đổi metadata, giữ khoá ACCESS EXCLUSIVE vài mili giây; lock_timeout 3s để không xếp hàng chặn các truy vấn khác.Migration V41 adds a nullable column with no default. Since Postgres 11 this is metadata only and holds ACCESS EXCLUSIVE for milliseconds; lock_timeout 3s keeps it from queuing up and blocking other queries.
Thêm cột có default biến động, đổi kiểu cột hay thêm NOT NULL sẽ rewrite hoặc quét toàn bảng dưới ACCESS EXCLUSIVE. Đặt lock_timeout ngắn, thêm cột nullable, dùng CHECK … NOT VALID rồi VALIDATE CONSTRAINT riêng; với MySQL dùng gh-ost hoặc pt-online-schema-change.Adding a column with a volatile default, changing a type or adding NOT NULL rewrites or scans the whole table under ACCESS EXCLUSIVE. Set a short lock_timeout, add the column as nullable, use CHECK … NOT VALID and VALIDATE CONSTRAINT separately; on MySQL use gh-ost or pt-online-schema-change.
CREATE INDEX thường chặn ghi suốt thời gian build. Dùng CREATE INDEX CONCURRENTLY, chạy ngoài transaction của migration tool; nếu thất bại sẽ để lại index INVALID, phải DROP rồi tạo lại.A plain CREATE INDEX blocks writes for the whole build. Use CREATE INDEX CONCURRENTLY outside the migration tool's transaction; a failure leaves an INVALID index that must be dropped and recreated.
Một UPDATE cho 50M dòng tạo transaction khổng lồ, bloat và replica lag hàng giờ. Chia lô theo khoảng khoá chính, mỗi lô commit riêng, nghỉ khi replica lag hoặc CPU vượt ngưỡng, và chạy nhiều worker song song theo các khoảng không chồng nhau.One UPDATE over 50M rows creates a huge transaction, bloat and hours of replica lag. Batch by primary-key range, commit each batch, pause when replica lag or CPU passes a threshold, and run several workers on non-overlapping ranges.
Dual write trong app đơn giản nhưng bỏ sót mọi đường ghi khác (job, script, service khác). Trigger trong DB hoặc CDC (Debezium) bắt mọi thay đổi bất kể ai ghi, và là cách chuẩn khi chuyển dữ liệu sang database khác.Dual writes in the app are simple but miss every other write path (jobs, scripts, other services). A DB trigger or CDC (Debezium) catches every change regardless of the writer, and is the standard approach when moving data to another database.
Count bằng nhau chưa nói lên giá trị đúng. So checksum theo từng khoảng id, và chạy shadow read: đọc cả hai, trả kết quả cũ, ghi log khi lệch. Chỉ bật cờ khi tỉ lệ lệch bằng 0 trong vài ngày.Equal counts say nothing about correct values. Compare checksums per id range and run shadow reads: read both, return the old result, log any difference. Flip the flag only when the mismatch rate has stayed at zero for days.
Mỗi bước trước contract đều quay lui được: tắt cờ là đọc lại cột cũ, cột cũ vẫn được ghi. Chỉ DROP COLUMN khi đã qua một release ổn định, và giữ bản dump hoặc snapshot của cột cũ trước khi xoá.Every step before contract is reversible: turning the flag off reads the old column again, and the old column is still written. Drop the column only after a stable release, and keep a dump or snapshot of it before deleting.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
orders | id, customer_id, amount NUMERIC(12,2), amount_minor BIGINT NULL, currency, updated_at | Expand: thêm amount_minor nullable. Contract (release sau): SET NOT NULL qua CHECK NOT VALID, rồi DROP amountExpand: add a nullable amount_minor. Contract (a later release): SET NOT NULL via CHECK NOT VALID, then DROP amount |
migration_checkpoint | job, last_id, max_id, rows_done, status, updated_at | Ghi cùng transaction với mỗi lô; status RUNNING · PAUSED · DONEWritten in the same transaction as each batch; status RUNNING · PAUSED · DONE |
verify_report | run_id, range_lo, range_hi, src_count, dst_count, src_sum, dst_sum, mismatched_ids | Cổng chuyển đọc chỉ mở khi mọi khoảng đều khớpThe read-switch gate opens only when every range matches |
feature_flag | key, enabled, rollout_percent, updated_by | orders.read_amount_minor: 0 → 1 → 10 → 100%, tắt ngay được khi lỗiorders.read_amount_minor: 0 → 1 → 10 → 100%, switched off instantly on errors |
Migration expand chỉ đổi metadata. Worker Java backfill từng lô, ghi checkpoint cùng transaction và giảm tốc theo replica lag. Verifier Go so count và checksum theo từng khoảng.The expand migration only changes metadata. The Java worker backfills in batches, writes the checkpoint in the same transaction and throttles on replica lag. The Go verifier compares counts and checksums per range.
-- Expand: metadata-only on Postgres 11+, never waits long for its lock
SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN amount_minor bigint;
-- Separate migration, outside a transaction (Flyway: executeInTransaction=false)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_amount_minor_null
ON orders (id) WHERE amount_minor IS NULL;const rangeQ = `
SELECT count(*),
count(*) FILTER (WHERE amount_minor IS DISTINCT FROM round(amount * 100)),
coalesce(sum(amount_minor), 0)::bigint, coalesce(sum(round(amount * 100)), 0)::bigint
FROM orders WHERE id > $1 AND id <= $2`
type RangeResult struct {
Lo, Hi, Rows, Mismatched int64
DstSum, SrcSum int64
}
func (r RangeResult) OK() bool { return r.Mismatched == 0 && r.DstSum == r.SrcSum }
type Verifier struct {
db *pgxpool.Pool // a read replica
report Reporter
step int64 // 100_000 ids per range
}
// Verify returns true only when every range matches; it is the gate for the read flag.
func (v *Verifier) Verify(ctx context.Context, maxID int64) (bool, error) {
ok := true
for lo := int64(0); lo < maxID; lo += v.step {
if err := ctx.Err(); err != nil {
return false, err
}
res := RangeResult{Lo: lo, Hi: lo + v.step}
err := v.db.QueryRow(ctx, rangeQ, res.Lo, res.Hi).
Scan(&res.Rows, &res.Mismatched, &res.DstSum, &res.SrcSum)
if err != nil {
return false, fmt.Errorf("range %d: %w", lo, err)
}
if err := v.report.Save(ctx, res); err != nil {
return false, err
}
if !res.OK() {
ok = false // keep scanning: report every bad range at once
}
}
return ok, nil
}| Dual write trong appApp dual writes | CDC (Debezium) / triggerCDC (Debezium) / triggers | |
|---|---|---|
| Độ phủCoverage | Chỉ các đường ghi đi qua code đã sửaOnly write paths that go through the changed code | Mọi thay đổi, kể cả job và script ghi thẳng SQLEvery change, including jobs and scripts writing raw SQL |
| Nhất quánConsistency | Cùng database thì cùng transaction; khác database thì có thể lệchOne transaction in the same database; across databases it can drift | Theo thứ tự commit, có độ trễ nhỏIn commit order, with a small lag |
| Chi phíCost | Thấp, nhưng phải gỡ code sau migrationLow, but the code must be removed afterwards | Cần Kafka Connect hoặc trigger, thêm vận hànhNeeds Kafka Connect or triggers, more to operate |
| Chọn khiPick it when | Đổi cột trong cùng một bảng, một service sở hữu mọi đường ghiChanging a column in one table that a single service fully owns | Chuyển bảng sang database hoặc service khácMoving a table to another database or service |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Một transaction 50M dòng giữ khoá dòng hàng giờ, tạo 50M phiên bản dòng chết làm bloat, replica tụt lại theo, và nếu thất bại ở 90% thì rollback toàn bộ. Chia lô nhỏ có checkpoint thì mỗi lô commit trong vài trăm mili giây và dừng được bất cứ lúc nào.A 50M-row transaction holds row locks for hours, leaves 50M dead tuples as bloat, drags the replica behind, and if it fails at 90% everything rolls back. Small checkpointed batches commit in a few hundred milliseconds each and can stop at any time.
Bật CDC từ database cũ sang mới để theo kịp thay đổi, snapshot dữ liệu cũ, kiểm chứng bằng count và checksum, chuyển đọc sau cờ, rồi chuyển ghi: dừng ghi vài giây hoặc ghi qua một proxy, đợi CDC lag về 0, đổi hướng. Giữ CDC chiều ngược lại một thời gian để quay lui được.Run CDC from the old database to the new one to keep up with changes, snapshot the existing data, verify with counts and checksums, switch reads behind a flag, then switch writes: pause writes for seconds or route them through a proxy, wait for CDC lag to hit zero, flip. Keep reverse CDC for a while so you can roll back.
SET NOT NULL trực tiếp sẽ quét cả bảng dưới ACCESS EXCLUSIVE. Thêm CHECK (amount_minor IS NOT NULL) NOT VALID (tức thì), VALIDATE CONSTRAINT (chỉ khoá SHARE UPDATE EXCLUSIVE, không chặn ghi), rồi từ Postgres 12 SET NOT NULL dùng constraint đó mà không quét lại.A direct SET NOT NULL scans the whole table under ACCESS EXCLUSIVE. Add CHECK (amount_minor IS NOT NULL) NOT VALID (instant), VALIDATE CONSTRAINT (SHARE UPDATE EXCLUSIVE only, writes continue), and from Postgres 12 SET NOT NULL uses that constraint without another scan.
Khi cờ đọc đã 100% qua ít nhất một chu kỳ release, không còn code nào đọc hoặc ghi cột cũ (kể cả job, báo cáo, ETL), và đã sao lưu cột đó. Drop là bước duy nhất không quay lui được, nên để nó ở một release riêng.When the read flag has been at 100% for at least one release cycle, no code reads or writes the old column (jobs, reports and ETL included), and the column is backed up. Dropping is the only irreversible step, so give it its own release.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.