ALTER TABLE chạy 4 tiếng, app treo 4 tiếng

Hầu hết dev từng gặp: thêm cột vào bảng 50M row trên production. ALTER TABLE ADD COLUMN chạy, application ngừng phản hồi vì bảng bị lock. Sau 30 phút, ai đó kill query. Migration fail, code deploy không khớp schema, incident leo thang.

Đây không phải lỗi của ALTER TABLE — nó làm đúng những gì được yêu cầu. Vấn đề nằm ở chỗ: không phải migration nào cũng an toàn với default của database engine, và cách bạn migration quyết định có downtime hay không.

Bài này cover ba hệ sinh thái chính:

  • MySQL/MariaDB: pt-online-schema-change, gh-ost
  • PostgreSQL: CONCURRENTLY, pgroll, expanding/contracting
  • Pattern chung: tư duy “không lock bảng” áp dụng cho mọi engine

Tại sao schema migration gây downtime?

Ba cơ chế lock gây ra vấn đề:

Lock typeEngineẢnh hưởng
Metadata lock (MDL)MySQLALTER TABLE cần MDL exclusive → chặn mọi SELECT/INSERT/UPDATE/DELETE trên bảng cho tới khi ALTER xong
AccessExclusiveLockPostgreSQLMột số DDL (thêm cột mặc định cũ, ALTER TYPE) cần lock này → chặn mọi access
Table rewriteCả haiThay đổi kiểu dữ liệu, thêm cột với DEFAULT, reorganize — database phải viết lại toàn bộ bảng

Quy tắc ngón tay cái: nếu migration cần copy toàn bộ dữ liệu sang cấu trúc mới, nó sẽ lock hoặc cần chiến lược đặc biệt.


MySQL/MariaDB: pt-online-schema-change

Percona Toolkitpt-online-schema-change (pt-osc), công cụ chuẩn cho zero-downtime migration trên MySQL:

# Thêm cột không downtime
pt-online-schema-change \
  --alter "ADD COLUMN email VARCHAR(255) DEFAULT NULL" \
  --execute \
  D=myapp,t=users,h=db.internal

Cách nó hoạt động (3 bước)

  1. Tạo bảng ghost copy cấu trúc bảng gốc + áp dụng ALTER lên ghost table
  2. Copy dữ liệu từ bảng gốc sang bảng ghost theo batch (chunk), giữa mỗi batch dùng trigger để capture delta (INSERT/UPDATE/DELETE trong lúc copy)
  3. Atomic swap: rename bảng gốc → _old, rename ghost → bảng gốc (cùng 1 transaction, lock cực ngắn ~vài ms)

Trigger overhead — trade-off

pt-osc dùng trigger để capture thay đổi trong lúc copy. Trigger có overhead write (~5-15% trên write-heavy workload). Nếu bảng đã có trigger, pt-osc có thể conflict.

Kiểm tra trước khi chạy

# Dry-run: kiểm tra mọi thứ ổn, không thực thi thật
pt-online-schema-change \
  --alter "ADD COLUMN email VARCHAR(255) DEFAULT NULL" \
  --dry-run \
  --critical-load Threads_running=100 \
  --max-load Threads_running=50 \
  --chunk-size 1000 \
  D=myapp,t=users,h=db.internal

Các flag an toàn:

  • --critical-load: tự động abort nếu server quá tải
  • --max-load: tạm dừng copy nếu load vượt ngưỡng
  • --chunk-size: kiểm soát batch size, nhỏ hơn = ít ảnh hưởng hơn nhưng chạy lâu hơn
  • --max-lag: tạm dừng nếu replication lag vượt ngưỡng
  • --check-slave-lag: kiểm tra lag trên replica

MySQL/MariaDB: gh-ost, triggerless alternative

GitHub phát triển gh-ost để giải quyết điểm yếu của pt-osc: không dùng trigger, thay bằng đọc binlog:

gh-ost \
  --alter="ADD COLUMN email VARCHAR(255) DEFAULT NULL" \
  --database=myapp \
  --table=users \
  --host=db.internal \
  --execute

Cách hoạt động

  1. Tạo ghost table (giống pt-osc)
  2. Copy dữ liệu theo batch
  3. Đọc binlog để lấy delta (thay vì trigger) → apply lên ghost table
  4. Atomic swap (cut-over) với lock cực ngắn

Ưu điểm so với pt-osc

  • Không trigger → không ảnh hưởng write performance, không conflict trigger có sẵn
  • Kiểm soát cut-over tốt hơn: có thể chờ tới thời điểm ít traffic để swap
  • Testable trên replica trước: chạy --test-on-replica để xác nhận migration OK mà không ảnh hưởng primary
  • Pausable: có thể pause/resume migration giữa chừng, hữu ích khi thấy load tăng

Kiểm tra trước

# Test trên replica trước
gh-ost \
  --alter="ADD COLUMN email VARCHAR(255)" \
  --database=myapp --table=users \
  --host=replica.internal \
  --test-on-replica \
  --verbose

# Production với throttle control
gh-ost \
  --alter="ADD COLUMN email VARCHAR(255)" \
  --database=myapp --table=users \
  --host=db.internal \
  --max-load=Threads_running=30 \
  --critical-load=Threads_running=80 \
  --chunk-size=500 \
  --execute

PostgreSQL: CONCURRENTLY và chiến lược không lock

PostgreSQL có lợi thế: MVCC cho phép nhiều DDL chạy mà không cần tool bên ngoài, nếu dùng đúng syntax.

Index: CREATE INDEX CONCURRENTLY

-- Không CONCURRENTLY: lock bảng trong suốt quá trình build index
CREATE INDEX idx_orders_date ON orders(order_date);

-- CONCURRENTLY: build index không lock, cho phép write song song
CREATE INDEX CONCURRENTLY idx_orders_date ON orders(order_date);

CONCURRENTLY quét bảng 2 lần (thay vì 1), chậm hơn nhưng không block write. Không chạy được trong transaction, nếu fail giữa chừng → index INVALID, cần DROP INDEX và chạy lại.

Thêm cột không DEFAULT (instant)

Từ PG 11, thêm cột nullable không có DEFAULT là O(1), không rewrite bảng:

-- An toàn, instant, không lock (PG 11+)
ALTER TABLE orders ADD COLUMN internal_note TEXT;

Thêm cột có DEFAULT (PG 11+)

Từ PG 11, DEFAULT không NULL được lưu ở catalog thay vì rewrite toàn bộ bảng:

-- PG 11+: DEFAULT lưu trong pg_attribute, không rewrite bảng
-- Nhưng: vẫn cần AccessExclusiveLock (dù rất ngắn)
ALTER TABLE orders ADD COLUMN status VARCHAR(20) DEFAULT 'pending';

Nguy hiểm: thay đổi kiểu dữ liệu

-- CẢNH BÁO: rewrite toàn bộ bảng + AccessExclusiveLock suốt thời gian chạy
ALTER TABLE orders ALTER COLUMN total TYPE NUMERIC(12,2);

Đây là migration cần chiến lược expanding/contracting.


Expanding/Contracting pattern (áp dụng mọi engine)

Pattern “mở rộng rồi thu hẹp” giúp deploy schema change qua nhiều bước nhỏ, mỗi bước tương thích ngược:

Ví dụ: đổi kiểu cột total_amount từ INT sang BIGINT

Bước 1: Expand — thêm cột mới

-- Thêm cột mới (nullable, không DEFAULT — instant trên PG, pt-osc/gh-ost trên MySQL)
ALTER TABLE orders ADD COLUMN total_amount_new BIGINT;

Bước 2: Backfill dữ liệu (off-peak, batch)

-- Backfill từng batch, không lock (có thể chạy background job)
UPDATE orders SET total_amount_new = total_amount
WHERE id BETWEEN ? AND ? AND total_amount_new IS NULL;

Bước 3: Dual-write — app ghi cả hai cột

Code ứng dụng cập nhật cả total_amounttotal_amount_new trong transaction. Deploy code này trước.

Bước 4: Migrate read — app đọc từ cột mới

Sau khi backfill hoàn tất, chuyển app đọc từ total_amount_new. Deploy code này. Quan sát vài ngày.

Bước 5: Contract — xóa cột cũ

-- Sau khi xác nhận không còn code nào đọc cột cũ
ALTER TABLE orders DROP COLUMN total_amount;

-- Đổi tên cột mới thành tên gốc nếu muốn
ALTER TABLE orders RENAME COLUMN total_amount_new TO total_amount;

Checklist expanding/contracting

  • Mỗi bước deploy độc lập, không phá phiên bản app đang chạy
  • Có monitoring cho cả cột cũ và mới trong giai đoạn dual-write
  • Backfill chạy batch nhỏ, có retry, không chạy giờ cao điểm
  • Rollback plan cho từng bước
  • Test restore từ backup với schema mới

pgroll: migration dạng khai báo cho PostgreSQL

Xata pgroll là tool 2024+ cho phép khai báo schema migration kiểu declarative, tự động sinh expanding/contracting steps:

pgroll start add-column \
  --table orders \
  --column status VARCHAR(20) DEFAULT 'pending'

pgroll tự quản lý: thêm cột mới, backfill, tạo view che cột cũ, và migrate read/write dần. Phù hợp team muốn GitHub-style migration review nhưng không muốn viết expanding/contracting thủ công.


Playbook: kiểm tra migration trước production

# 1. Chạy trên staging/test với dataset tương đương production
# 2. Đo thời gian thực tế
# 3. Kiểm tra lock — query này khi migration đang chạy

# PostgreSQL: kiểm tra lock đang chờ
SELECT pid, usename, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';

# MySQL: kiểm tra metadata lock
SELECT * FROM performance_schema.metadata_locks
WHERE object_schema = 'myapp' AND lock_status = 'PENDING';

# 4. Kiểm tra replication lag (nếu có replica)
# 5. Xác nhận rollback plan trước khi bắt đầu

Migration nên là non-event, không phải scheduled maintenance window

Zero-downtime migration không phải tính năng của database — nó là pattern và tooling bạn chọn. pt-osc và gh-ost cho MySQL, CONCURRENTLY cho PostgreSQL index, expanding/contracting cho mọi thứ khác. Điểm chung: chia việc lớn thành việc nhỏ, không lock, tương thích ngược từng bước.


Liên hệ các bài trong loạt


Câu hỏi hay gặp

1. “Migration mất bao lâu với bảng N triệu row?”
pt-osc/gh-ost thường chạy 1-4h cho bảng 50M row trên SSD, tùy chunk size và load. PG CREATE INDEX CONCURRENTLY trên bảng tương tự ~30 phút đến 2 giờ. Luôn test trên snapshot production data.

2. “Làm sao biết migration đang lock bảng?”
PostgreSQL: query pg_stat_activity với wait_event_type = 'Lock'. MySQL: SHOW PROCESSLIST tìm state Waiting for table metadata lock. Cả hai: application latency đột ngột tăng + connection pool đầy.

3. “gh-ost hay pt-osc, chọn cái nào?”
gh-ost nếu: write-heavy table, đã có trigger trên bảng, hoặc muốn test trên replica trước. pt-osc nếu: workload đơn giản, team đã quen Percona Toolkit, hoặc cần chạy trên MySQL 5.6 cũ (gh-ost cần row-based replication).

4. “Có cần maintenance window cho migration không?”
Với pt-osc/gh-ost/CONCURRENTLY: không cần. Nhưng luôn có window dự phòng phòng trường hợp migration fail và cần manual recovery. “Zero-downtime” không có nghĩa “zero risk”.


Đọc thêm


Bài tiếp theo: Phần 16: Connection pooling chuyên sâu, PgBouncer, ProxySQL, pool modes và transaction pooling.