zero-downtime database migration
Tung Bui Thanh (TungDaDev)17 mins read

Treo bảng "Hệ thống bảo trì định kỳ từ 0h đến 4h sáng" để chạy migration database giờ đã lỗi thời. Khi dịch vụ cam kết High Availability 99.999% (Five Nines), mọi thay đổi cấu trúc bảng, từ đổi tên cột, tách bảng, đổi kiểu dữ liệu đến tạo index trên bảng trăm triệu bản ghi, đều phải chạy êm để người dùng không nhận ra gián đoạn nào.
Deploy backend stateless lên Kubernetes bằng Rolling Update hay Canary giờ đã thành chuyện thường ngày. Database thì khác, nó giữ trạng thái:
- Bạn không thể đơn giản "rollback commit cũ" sau khi đã chạy
ALTER TABLE DROP COLUMN. - Một câu DDL bất cẩn có thể xin khóa độc quyền (AccessExclusiveLock trong PostgreSQL hoặc Metadata Lock - MDL trong MySQL), khiến mọi câu
SELECT,INSERT,UPDATEcủa người dùng phải xếp hàng chờ. Connection Pool cạn, request timeout hàng loạt, và từ ngoài nhìn vào thì hệ thống coi như sập.
Dưới đây là cơ chế lock cần nắm, rồi tới cách làm Zero-Downtime Database Migration từng bước, kèm mấy cái bẫy hay gặp.
# cơ chế lock (locking matrix) & metadata lock queue
Trước khi chạy bất kỳ câu DDL nào trên Production, cần biết câu đó sẽ xin loại lock gì từ database engine.
Điểm đáng sợ nằm ở bước 3. Câu ALTER TABLE có khi chỉ cần giữ lock vài mili-giây, nhưng trong lúc chờ lock nó đã đứng trong hàng đợi. Ở PostgreSQL, yêu cầu lock mới mà xung đột với một yêu cầu đang chờ thì phải xếp sau yêu cầu đó, nên các câu SELECT vốn không hề xung đột với query chậm ban đầu cũng bị chặn theo. MySQL cũng tương tự: yêu cầu MDL exclusive đang chờ được ưu tiên hơn các yêu cầu shared mới. Vì vậy thủ phạm thật thường là transaction chạy lâu (kể cả một transaction mở rồi để đó, idle in transaction), còn câu DDL chỉ là giọt nước tràn ly.
# bảng lock thường gặp trong PostgreSQL
| Thao tác DDL | Khóa Yêu Cầu (PostgreSQL) | Cho phép Đọc (SELECT)? | Cho phép Ghi (INSERT/UPDATE/DELETE)? | Rủi ro Production |
|---|---|---|---|---|
ALTER TABLE ... ADD COLUMN (không default hoặc có default từ PG 11+) | AccessExclusiveLock | ❌ Chặn (nhưng thời gian khóa rất ngắn nếu không rewrite) | ❌ Chặn | Rất cao nếu có transaction chạy chậm trước đó. |
CREATE INDEX ... | ShareLock | ✅ Cho phép | ❌ Chặn đứng mọi thao tác Ghi! | Rất nguy hiểm trên bảng lớn. |
CREATE INDEX CONCURRENTLY ... | ShareUpdateExclusiveLock | ✅ Cho phép | ✅ Cho phép | An toàn hơn nhưng chậm hơn (quét bảng 2 lần, chờ các transaction cũ kết thúc); không chạy được trong transaction, lỗi giữa chừng để lại index INVALID. |
ALTER TABLE ... VALIDATE CONSTRAINT | ShareUpdateExclusiveLock | ✅ Cho phép | ✅ Cho phép | Bước 2 sau ADD CONSTRAINT ... NOT VALID (bước 1 chỉ lock rất ngắn): cách an toàn để thêm khóa ngoại/check. |
# luôn đặt lock_timeout
Trước khi chạy migration script nào cũng nên đặt thời gian chờ lock tối đa, để script tự hủy nếu không lấy được lock ngay, thay vì đứng chờ và làm nghẽn cả hệ thống:
-- Đặt timeout chờ khóa là 2 giây; timeout thực thi câu lệnh là 10 giây
SET lock_timeout = '2s';
SET statement_timeout = '10s';
-- Thực hiện thay đổi cấu trúc
ALTER TABLE customers ADD COLUMN phone_number VARCHAR(20);Hết 2 giây mà chưa lấy được lock thì câu lệnh báo lỗi và tự rút khỏi hàng đợi, traffic thường không bị ảnh hưởng. Việc của bạn là cho script retry vài lần (có nghỉ giữa các lần), và nếu vẫn hỏng thì đi tìm transaction đang giữ lock trong pg_stat_activity.
# quy trình chuẩn 5 pha: expand & contract (parallel change)
Nguyên tắc chính của Zero-Downtime: đừng bao giờ làm một thay đổi phá vỡ tương thích (Breaking Change) trong một lần deploy. Trong lúc rolling update, pod code cũ và pod code mới chạy cùng lúc trên cùng một schema, nên ở mọi thời điểm schema phải dùng được cho cả hai phiên bản.
Mỗi thay đổi cấu trúc bảng nên tách thành 5 giai đoạn độc lập:
# case study: đổi tên cột trên bảng 50 triệu records
Giả sử bảng users có 50 triệu bản ghi, đang nhận 3.000 writes/giây. Cần đổi tên cột phone thành phone_number và nới độ dài từ VARCHAR(15) lên VARCHAR(20).
Nói cho công bằng thì riêng về phía database, cả hai việc này đều nhẹ trong PostgreSQL: RENAME COLUMN chỉ sửa metadata, và tăng độ dài VARCHAR cũng không phải rewrite bảng. Cái khó nằm ở ứng dụng: đổi tên cột xong thì mọi pod code cũ đang chạy lập tức lỗi vì không còn cột phone. Expand-Contract ở đây là để code cũ và code mới cùng sống được trong lúc chuyển giao.
# expand (mở rộng schema)
Thêm cột mới vào bảng. Cột này phải cho phép NULL vì code hiện tại chưa biết đến nó:
-- Migration V1.1__add_phone_number_column.sql
SET lock_timeout = '2s';
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20) NULL;(Add column không có default thì xưa nay vẫn chỉ cập nhật metadata. Từ PostgreSQL 11, add column có default không volatile, ví dụ hằng số hay now(), cũng chỉ ghi metadata vào catalog, mất vài mili-giây khi đã lấy được lock và không rewrite cả bảng. Default volatile như random() thì vẫn rewrite.)
# dual-write & shadow read (ghi kép tại ứng dụng)
Cập nhật backend và triển khai lên Production:
- Ghi (Write/Update): Ghi đồng thời dữ liệu vào cả
phonevàphone_number. Lưu ý trong lúc rollout, pod cũ vẫn chỉ ghiphone. Nếu không muốn phụ thuộc vào app, có thể dùng trigger trong database để đồng bộ hai cột. - Đọc (Read): Vẫn đọc từ cột cũ
phone. - Shadow Read (tùy chọn, nên dùng cho nghiệp vụ tài chính): đọc song song cả hai cột, so sánh kết quả và bắn metric lên Prometheus nếu có sai lệch.
# throttled Keyset backfill (di chuyển dữ liệu cũ an toàn)
Lúc này 50 triệu user cũ vẫn có phone_number IS NULL, cần đồng bộ dữ liệu lịch sử.
Cảnh báo: đừng bao giờ chạy lệnh này:
UPDATE users SET phone_number = phone WHERE phone_number IS NULL;Nó chạy trong một transaction khổng lồ: khóa 50 triệu dòng tới khi xong (ghi vào các dòng đó sẽ phải chờ), sinh 50 triệu dead tuple mà VACUUM chưa dọn được, đẩy ra một lượng WAL rất lớn trong thời gian ngắn và kéo Replication Lag trên Read-Replica lên cao.
Thay vào đó, viết script Keyset Pagination (chia batch theo khóa chính), mỗi batch một transaction ngắn, và cho nghỉ giữa các batch:
import time
import psycopg2
def backfill_phone_numbers(batch_size=5000, sleep_seconds=0.1):
conn = psycopg2.connect("dbname=production_db user=migrator")
cursor = conn.cursor()
last_id = 0
total_migrated = 0
while True:
# Keyset pagination: mỗi batch đi tiếp từ id cuối, không chậm dần như OFFSET.
# IS DISTINCT FROM (thay vì phone_number IS NULL) để bắt cả dòng bị pod cũ sửa phone
# sau khi đã có phone_number, và bỏ qua dòng phone vốn NULL.
query = """
WITH batch AS (
SELECT id
FROM users
WHERE id > %s AND phone_number IS DISTINCT FROM phone
ORDER BY id ASC
LIMIT %s
)
UPDATE users u
SET phone_number = u.phone -- giá trị hiện tại của dòng, không dùng bản chụp trong CTE
FROM batch b
WHERE u.id = b.id
RETURNING u.id;
"""
cursor.execute(query, (last_id, batch_size))
updated_rows = cursor.fetchall()
conn.commit()
if not updated_rows:
print("Đã hoàn tất 100% dữ liệu backfill!")
break
# RETURNING không đảm bảo thứ tự, phải lấy max
last_id = max(row[0] for row in updated_rows)
total_migrated += len(updated_rows)
print(f"Đã sync: {total_migrated} bản ghi. ID mốc: {last_id}")
# Nghỉ nhẹ giữa các batch để Replica kịp sync WAL và CPU không bị spike
time.sleep(sleep_seconds)
cursor.close()
conn.close()# read shift (chuyển đổi nguồn đọc)
Sau khi kiểm tra SELECT COUNT(*) FROM users WHERE phone_number IS DISTINCT FROM phone trả về 0 (đừng đếm phone_number IS NULL: user không có số điện thoại thì cột mới vẫn NULL, con số đó không bao giờ về 0):
- Deploy code phiên bản mới (V2).
- Backend chuyển sang đọc trực tiếp từ
phone_number. - Giữ dual-write thêm vài ngày để quan sát (phòng khi phải rollback code về V1).
# contract (thu hẹp & xóa cột cũ)
Khi V2 đã chạy ổn và không còn pod nào đọc cột phone. Nếu dùng JPA/Hibernate, nhớ là entity còn map cột phone thì vẫn ghi vào nó, nên phải bỏ field trước rồi mới drop cột:
- Deploy code V3: xóa field
phonekhỏi Entity Java/Golang. - Chạy script DDL cuối để dọn dẹp:
-- Migration V1.2__contract_drop_phone_column.sql
SET lock_timeout = '2s';
ALTER TABLE users DROP COLUMN phone;DROP COLUMN trong PostgreSQL chỉ đánh dấu cột là đã xoá trong catalog nên chạy rất nhanh, nhưng vẫn cần AccessExclusiveLock (vì vậy vẫn có lock_timeout). Dung lượng của cột cũ chỉ được trả lại khi các dòng bị ghi lại về sau, hoặc khi bạn chạy công cụ như pg_repack.
# bài toán khó nhất: đổi kiểu khóa chính (int sang bigint)
Một sự cố khá hay gặp ở các startup tăng nhanh là Integer Overflow trên Primary Key: bảng orders dùng INT (tối đa 2.14 tỷ bản ghi) và sắp chạm trần.
Không thể chạy ALTER TABLE orders ALTER COLUMN id TYPE BIGINT; vì PostgreSQL sẽ giữ AccessExclusiveLock trong suốt thời gian viết lại toàn bộ bảng và index, với bảng tỷ dòng là nhiều giờ.
Phần chuẩn bị ở bước 4 chạy được trong lúc hệ thống vẫn nhận traffic:
CREATE UNIQUE INDEX CONCURRENTLY idx_orders_id_bigint ON orders (id_bigint);
-- PRIMARY KEY đòi cột NOT NULL. Có sẵn CHECK đã VALIDATE thì PostgreSQL 12+
-- bỏ qua bước quét cả bảng khi đặt NOT NULL ở bước 5
ALTER TABLE orders ADD CONSTRAINT id_bigint_not_null CHECK (id_bigint IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT id_bigint_not_null;
-- Bảng tạo bằng SERIAL thì sequence có kiểu integer, cũng sẽ chạm trần 2.14 tỷ
ALTER SEQUENCE orders_id_seq AS bigint;Bước 5 hoán đổi khóa chính. Mọi lệnh trong đây chỉ sửa metadata nên transaction rất ngắn, miễn là lấy được lock:
BEGIN;
SET LOCAL lock_timeout = '2s';
-- Trigger đồng bộ id -> id_bigint phải bỏ trước, không thì sau khi đổi tên
-- mọi câu INSERT sẽ lỗi trong trigger
DROP TRIGGER trg_sync_id_bigint ON orders;
-- Chuyển sequence/default sang cột mới
ALTER TABLE orders ALTER COLUMN id DROP DEFAULT;
ALTER TABLE orders RENAME COLUMN id TO id_old;
ALTER TABLE orders RENAME COLUMN id_bigint TO id;
ALTER TABLE orders ALTER COLUMN id SET DEFAULT nextval('orders_id_seq');
ALTER SEQUENCE orders_id_seq OWNED BY orders.id;
-- Khóa ngoại trỏ vào orders_pkey phải gỡ ra trước rồi dựng lại
ALTER TABLE order_items DROP CONSTRAINT order_items_order_id_fkey;
-- Tráo đổi Primary Key bằng Index đã dựng sẵn ở chế độ CONCURRENTLY
ALTER TABLE orders DROP CONSTRAINT orders_pkey;
ALTER TABLE orders ADD CONSTRAINT orders_pkey PRIMARY KEY USING INDEX idx_orders_id_bigint;
ALTER TABLE orders ALTER COLUMN id_old DROP NOT NULL;
ALTER TABLE orders DROP CONSTRAINT id_bigint_not_null;
ALTER TABLE order_items ADD CONSTRAINT order_items_order_id_fkey
FOREIGN KEY (order_id) REFERENCES orders (id) NOT VALID;
COMMIT;
-- Kiểm tra khóa ngoại sau, không chặn ghi
ALTER TABLE order_items VALIDATE CONSTRAINT order_items_order_id_fkey;Bản rút gọn chỉ gồm hai lệnh đổi tên và tráo PK nhìn thì gọn, nhưng thử trên PostgreSQL sẽ thấy ngay: câu INSERT đầu tiên sau khi swap lỗi vì trigger, cột id mới không có default, sequence vẫn là integer, và DROP CONSTRAINT orders_pkey bị từ chối nếu có bảng khác tham chiếu. Ngoài ra, cột order_items.order_id cũng là INT và cần đi qua quy trình tương tự. Bảng dùng GENERATED ... AS IDENTITY thay cho SERIAL thì cách chuyển sequence khác đi một chút. Cột id_old dọn sau, ở pha Contract.
# công cụ: gh-ost vs pt-online-schema-change vs pg_repack
Làm Expand-Contract thủ công qua ứng dụng tốn nhiều công, nhất là khi phải đổi nhiều bảng lớn. Bên MySQL có các công cụ đổi schema online làm sẵn việc "tạo bảng bóng, chép dữ liệu, đồng bộ thay đổi, tráo bảng":
# so sánh các công cụ
| Tiêu chuẩn | gh-ost (GitHub) | pt-online-schema-change (Percona) | pg_repack (PostgreSQL) |
|---|---|---|---|
| Hệ quản trị | MySQL 5.7, 8.0+ (cần binlog dạng ROW) | MySQL, MariaDB | PostgreSQL 9.5 tới 18 |
| Cơ chế hoạt động | Triggerless: Đọc trực tiếp từ Binlog stream | Dùng Triggers (AFTER INSERT, UPDATE, DELETE) | Dùng Log table và Triggers tối giản |
| Tác động Database | Thấp, tự throttle khi replica lag hoặc tải cao | Trigger cộng thêm việc vào mỗi câu ghi, dễ tranh chấp lock khi write lớn | Thấp, nhưng cần dung lượng trống khoảng gấp đôi bảng và index; giữ ACCESS EXCLUSIVE ngắn lúc bắt đầu và lúc swap |
| Khả năng kiểm soát | Tạm dừng bằng flag file hoặc lệnh qua unix socket | Có --max-load, --critical-load, --pause-file; nhưng trigger thì không tạm dừng được | Không đổi được schema: chỉ dùng để dọn bloat, sắp xếp lại bảng/index |
Lưu ý pg_repack không phải công cụ đổi schema: nó không thêm cột hay đổi kiểu dữ liệu được, chỉ viết lại bảng để lấy lại dung lượng (ví dụ sau khi drop cột hay sau đợt backfill sinh nhiều dead tuple). PostgreSQL không có công cụ chính chủ tương đương gh-ost; với Postgres, Expand-Contract làm tay như các phần trên vẫn là cách phổ biến, cộng với vài dự án mã nguồn mở tự động hoá quy trình này như pgroll. Còn với MySQL 8.0, nhiều thay đổi như thêm cột đã chạy được bằng ALGORITHM=INSTANT hoặc online DDL INPLACE, nên hãy kiểm tra trước xem có cần tới gh-ost không.
# thiết kế pipeline CI/CD: tách biệt migration khỏi app startup
Một sai lầm khá nguy hiểm trong microservices là để migration tự chạy khi container khởi động:
# CẤU HÌNH NGUY HIỂM TRÊN PRODUCTION
# (đây cũng là mặc định khi có Flyway trên classpath)
spring.flyway.enabled=trueKhi Kubernetes rollout 20 bản sao (Pods) mới đồng thời:
- 20 pods cùng muốn chạy migration. Flyway dùng lock (trên PostgreSQL là advisory lock) nên chỉ một pod chạy, 19 pod còn lại đứng chờ trong lúc khởi động.
- Nếu migration tốn vài phút mà probe không được cấu hình cho thời gian khởi động dài (startup probe), Kubernetes có thể coi pod bị treo và kill container, dẫn tới vòng lặp CrashLoopBackOff, thậm chí kill đúng pod đang chạy migration dở.
# luồng CI/CD đề xuất
Vì migration chạy trước khi pod mới lên, pod cũ vẫn chạy trên schema mới trong suốt quá trình rollout. Đây chính là lý do mọi migration phải tương thích ngược theo kiểu Expand-Contract ở trên.
# checklist trước khi chạy
Trước khi thực thi bất kỳ script thay đổi Database nào trên Production:
- 1. Kiểm tra
lock_timeout: Script migration đã được đặtSET lock_timeout = '2s'ngay dòng đầu tiên chưa? - 2. Thao tác Index Concurrently: Mọi câu lệnh tạo index trên PostgreSQL đã có từ khóa
CONCURRENTLYchưa? (Lệnh này không chạy trong transaction được; Flyway tự nhận ra và chạy riêng, nhưng không nên trộn nó với lệnh khác trong cùng một file migration.) - 3. Phân tách Rollout 2 Phiên Bản: Đã đảm bảo phiên bản code cũ (V1) và mới (V2) có thể chạy song song ít nhất 48 giờ mà không bị lỗi cột/bảng thiếu?
- 4. Giám sát Replication Lag: Script backfill dữ liệu có cơ chế ngắt quãng (sleep) và theo dõi độ trễ của các Read-Replicas không?
- 5. Tách biệt CI/CD Job: Migration có được chạy qua một Kubernetes Job độc lập trước khi scale pods mới không?
- 6. Kế hoạch Fallback: Nếu phải rollback mã nguồn khẩn cấp, cấu hình schema hiện tại có tiếp tục hỗ trợ phiên bản code cũ không?
# lời kết
Nếu chỉ nhớ ba việc thì là: luôn đặt lock_timeout, không bao giờ để một lần deploy vừa đổi schema vừa phá tương thích, và backfill theo batch nhỏ có nghỉ. Trước khi làm thật trên production, hãy chạy thử cả quy trình trên một bản sao dữ liệu có kích thước gần giống, kèm traffic giả lập, để đo xem mỗi bước mất bao lâu. Muốn đọc thêm thì trang Explicit Locking và ALTER TABLE của PostgreSQL là hai chỗ nên đọc kỹ.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.