ALTER TABLE ADD COLUMN trên bảng 12 triệu row, khóa bảng, API timeout hàng loạt, phải revert. Migration tưởng nhỏ nhưng gây downtime vì không lường trước hành vi lock của database engine.

Bài này đi qua toàn bộ chiến lược đổi schema an toàn: hiểu lock, expand-contract, dual-write, backfill batch, và cách chọn thứ tự deploy cho từng loại thay đổi.


Trước hết: hiểu vì sao ALTER TABLE nguy hiểm

Trực giác của nhiều developer là ALTER TABLE ADD COLUMN chỉ thay đổi metadata, thêm một cột vào định nghĩa bảng, nhanh gọn. Điều này đúng với một số engine và một số loại thay đổi, nhưng sai với nhiều trường hợp khác.

MySQL (InnoDB)

Trước version 8.0, hầu hết ALTER TABLE đều tạo bản copy toàn bộ bảng: engine tạo bảng mới với schema mới, copy từng row sang, rồi swap. Trong suốt quá trình copy, bảng bị khóa — toàn bộ write bị block. Bảng 50 triệu row có thể mất vài phút đến hàng chục phút.

Từ 8.0, MySQL hỗ trợ ALGORITHM=INSTANT cho nhiều thao tác, nhưng không phải tất cả. TEXT, BLOB, và một số thay đổi khác vẫn cần table rebuild.

PostgreSQL

PostgreSQL xử lý tốt hơn ở một số thao tác: ADD COLUMN không có DEFAULT (trước Postgres 11) hoặc ADD COLUMNDEFAULT (từ Postgres 11+) chỉ thay đổi catalog metadata, gần như instant. Nhưng ALTER COLUMN SET NOT NULL cần scan toàn bộ bảng để verify không có giá trị NULL, và giữ ACCESS EXCLUSIVE lock trong suốt quá trình scan.

Vấn đề không chỉ là thời gian chạy DDL

Ngay cả khi DDL chỉ mất 2 giây, nếu nó yêu cầu ACCESS EXCLUSIVE lock thì nó phải chờ mọi transaction đang chạy trên bảng đó kết thúc trước khi bắt đầu. Một transaction quên commit giữ lock 30 giây có thể biến DDL 2 giây thành 30 giây downtime cho toàn bộ bảng.

Và trong khi DDL đang chờ lock, mọi query mới đến bảng đó cũng bị queue phía sau DDL — hiệu ứng domino. Toàn bộ traffic đến bảng đó ngừng hoạt động.

Trước khi chạy migration trên production: đọc doc về lock level cho từng thao tác trên engine của bạn. PostgreSQLMySQL đều document rõ. Đừng đoán.

Expand-contract: chiến lược cốt lõi

Thay vì thay đổi schema trong một bước (xóa cột cũ, thêm cột mới, đổi constraint), expand-contract chia thành nhiều bước nhỏ, mỗi bước đều an toàn và rollback được.


  flowchart LR
    A["Expand<br/>thêm cột/bảng mới"] --> B["Migrate<br/>dual-write + backfill"]
    B --> C["Contract<br/>xóa cột/bảng cũ"]

Bước 1: Expand — thêm, không xóa

Thêm cột mới, bảng mới, hoặc index mới mà không xóa hay thay đổi gì hiện có. Schema sau bước này phải tương thích ngược: code cũ vẫn chạy bình thường vì không có gì bị mất hay đổi tên.

-- Thêm cột mới, nullable, không default bắt buộc
ALTER TABLE orders ADD COLUMN shipping_address_id BIGINT;

-- Thêm index nếu cần (CONCURRENTLY trong Postgres)
CREATE INDEX CONCURRENTLY idx_orders_shipping_address
  ON orders (shipping_address_id);

Sau bước expand, database có cả cột cũ (shipping_address text) lẫn cột mới (shipping_address_id foreign key). Cả hai tồn tại song song — đây là trạng thái trung gian an toàn.

Bước 2: Migrate — ghi cả hai, backfill dần

Deploy code mới ghi vào cả hai cột. Code đọc ưu tiên cột mới, fallback cột cũ khi cột mới chưa có giá trị.

Đồng thời chạy backfill job để populate cột mới cho các row cũ:

-- Backfill theo batch 1000 row mỗi lần
UPDATE orders
SET shipping_address_id = (
  SELECT id FROM addresses WHERE addresses.raw = orders.shipping_address
)
WHERE shipping_address_id IS NULL
  AND id BETWEEN $start AND $end;

Giai đoạn này có thể kéo dài vài ngày tùy kích thước bảng. Mọi thứ vẫn hoạt động bình thường với cả hai cột.

Bước 3: Contract — xóa sau khi code đã chuyển hẳn

Khi 100% row đã có giá trị ở cột mới và code mới đã chạy ổn định, deploy code chỉ đọc/ghi cột mới. Sau đó xóa cột cũ:

ALTER TABLE orders DROP COLUMN shipping_address;

Toàn bộ quá trình trải qua 3-5 lần deploy riêng biệt. Mỗi lần deploy đều rollback được vì schema luôn tương thích với code version trước đó.


Nguyên tắc backward compatible

Tại bất kỳ thời điểm nào trong quá trình deploy, code version Ncode version N+1 phải cùng hoạt động được với schema hiện tại.

Lý do thực tế: trong rolling deployment, có khoảng thời gian cả hai version code chạy đồng thời — pod cũ chưa terminate, pod mới đã nhận traffic.

Không bao giờ xóa hoặc đổi tên cột trong cùng deploy với code ngừng sử dụng cột đó. Nếu code N vẫn SELECT cột email_verified, migration xóa cột đó sẽ gây lỗi cho pod cũ đang chạy.

Thứ tự an toàn luôn là: deploy code mới (không phụ thuộc cột cũ) → đợi 100% pod chạy code mới → mới chạy migration xóa cột. Tách thành hai PR và hai deploy cycle riêng biệt.

Tương tự khi thêm cột NOT NULL không có default: code cũ INSERT vào bảng không truyền giá trị cho cột mới sẽ bị reject. Luôn thêm cột dạng nullable trước, hoặc có DEFAULT.


Các tình huống migration phổ biến

Đổi tên cột an toàn

ALTER TABLE RENAME COLUMN old TO new nghe đơn giản, nhưng trên production thao tác này break ngay mọi code đang SELECT hoặc INSERT theo tên cột cũ. Với rolling deployment, chắc chắn có pod cũ đang chạy.

Chiến lược an toàn là biến rename thành expand-contract:

-- Deploy 1: Thêm cột mới
ALTER TABLE users ADD COLUMN full_name TEXT;

-- Backfill
UPDATE users SET full_name = name WHERE full_name IS NULL;

Deploy code mới đọc full_name, fallback name. Code mới ghi cả hai. Khi 100% pod chạy code mới và backfill xong:

-- Deploy 2: Xóa cột cũ
ALTER TABLE users DROP COLUMN name;

Thêm cột NOT NULL

Thêm cột NOT NULL không có DEFAULT vào bảng đang có data sẽ fail ngay vì row hiện tại không có giá trị cho cột mới.

Cách an toàn là multi-step:

-- Bước 1: Thêm cột nullable
ALTER TABLE orders ADD COLUMN status TEXT;

-- Bước 2: Backfill giá trị default (batch nếu bảng lớn)
UPDATE orders SET status = 'pending' WHERE status IS NULL;

-- Bước 3: Thêm constraint NOT NULL
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;

Bước 3 trong Postgres cần scan toàn bộ bảng để verify. Từ Postgres 12+, bạn có thể dùng pattern CHECK ... NOT VALID rồi validate sau để tránh giữ exclusive lock lâu:

-- Thêm CHECK constraint không validate (instant)
ALTER TABLE orders
  ADD CONSTRAINT orders_status_not_null
  CHECK (status IS NOT NULL) NOT VALID;

-- Validate riêng (scan bảng nhưng không giữ exclusive lock)
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_not_null;

-- Giờ SET NOT NULL sẽ instant
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
ALTER TABLE orders DROP CONSTRAINT orders_status_not_null;

VALIDATE CONSTRAINT chỉ cần SHARE UPDATE EXCLUSIVE lock — cho phép read và write bình thường trong lúc validate.

Tạo index không khóa bảng

CREATE INDEX mặc định trong Postgres giữ SHARE lock, cho phép đọc nhưng block write. Trên bảng có write traffic liên tục, đây là downtime thực tế.

-- ĐỪNG chạy trên production:
CREATE INDEX idx_orders_customer ON orders (customer_id);

-- Thay vào đó:
CREATE INDEX CONCURRENTLY idx_orders_customer ON orders (customer_id);

CREATE INDEX CONCURRENTLY scan bảng hai lần mà không giữ lock blocking write. Trade-off: chạy chậm hơn (2-3 lần) và không chạy được trong transaction block. Nếu bị interrupt giữa chừng, index ở trạng thái INVALID, cần DROP INDEX CONCURRENTLY rồi tạo lại.

Migration framework (Rails, Django, Alembic) mặc định wrap migration trong transaction. CREATE INDEX CONCURRENTLY không chạy được trong transaction — phải configure để statement này chạy ngoài transaction.

MySQL InnoDB từ 5.6+ hỗ trợ online index creation mặc định, cho phép read và write trong lúc build index. Với bảng rất lớn, vẫn nên monitor lock wait và replication lag.

Foreign key trên bảng lớn

Thêm foreign key constraint cũng cần scan bảng để validate, tương tự SET NOT NULL. Postgres cho phép thêm FK dạng NOT VALID rồi validate sau:

-- Thêm FK không validate (instant)
ALTER TABLE orders
  ADD CONSTRAINT fk_orders_customer
  FOREIGN KEY (customer_id) REFERENCES customers (id)
  NOT VALID;

-- Validate riêng (scan bảng nhưng lock nhẹ hơn)
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_customer;

NOT VALID nghĩa là constraint chỉ enforce cho row mới INSERT/UPDATE, không kiểm tra row cũ. VALIDATE chạy sau sẽ verify toàn bộ row cũ với lock level nhẹ hơn.


Chiến lược backfill data

Khi expand-contract yêu cầu populate cột mới cho hàng triệu row cũ, cách backfill ảnh hưởng trực tiếp đến performance production.

Đừng UPDATE toàn bộ bảng một lần

Một UPDATE lớn trên toàn bộ bảng là cách nhanh nhất nhưng nguy hiểm nhất: transaction lock hàng triệu row, WAL phình to, replica lag tăng, và nếu fail giữa chừng phải rollback toàn bộ.

Backfill theo batch nhỏ

DO $$
DECLARE
  batch_size INT := 5000;
  updated INT;
BEGIN
  LOOP
    UPDATE orders
    SET shipping_address_id = compute_address_id(shipping_address)
    WHERE shipping_address_id IS NULL
      AND id IN (
        SELECT id FROM orders
        WHERE shipping_address_id IS NULL
        LIMIT batch_size
        FOR UPDATE SKIP LOCKED
      );

    GET DIAGNOSTICS updated = ROW_COUNT;
    EXIT WHEN updated = 0;

    COMMIT;
    PERFORM pg_sleep(0.1);
  END LOOP;
END $$;

FOR UPDATE SKIP LOCKED tránh block transaction khác đang thao tác trên cùng row. Sleep giữa các batch cho replica kịp catch up và giảm áp lực IO.

Với bảng rất lớn (trăm triệu row trở lên), background job chạy liên tục là cách tiếp cận thực tế hơn: job throttle theo load hiện tại của database, có thể pause/resume, và report progress. Thời gian backfill tính bằng giờ hoặc ngày là bình thường.


Deploy code trước hay migrate database trước?

Đây là câu hỏi gặp trong mọi migration, và câu trả lời phụ thuộc vào loại thay đổi.

Loại thay đổiThứ tựLý do
Thêm cột, bảng, indexMigration trước, deploy code sauCode mới expect cột tồn tại. Migration thêm cột là backward compatible
Xóa cột, bảngDeploy code trước, migration sauCode cũ còn SELECT cột đó sẽ crash nếu xóa trước
Đổi kiểu dữ liệu hoặc constraintExpand-contract: thêm mới → dual-write → backfill → chuyển code → xóa cũKhông có “một bước” an toàn

Quy tắc tổng quát: migration chỉ nên chứa thao tác additive (thêm, không xóa). Thao tác destructive (xóa, rename) nằm ở migration riêng, chạy sau khi code đã chuyển hoàn toàn.


Online DDL tools

Khi engine không hỗ trợ DDL online cho thao tác cần làm, hoặc bảng quá lớn khiến ngay cả “fast DDL” cũng giữ lock lâu, online DDL tool giải quyết bằng cách tạo bảng shadow, copy data dần, rồi swap atomic.

pt-online-schema-change (MySQL)

Tool từ Percona Toolkit: tạo bảng mới với schema mong muốn, copy data theo batch từ bảng cũ sang bảng mới qua trigger, rồi RENAME TABLE atomic khi copy xong. Trigger đảm bảo mọi write trong lúc copy cũng được áp dụng lên bảng mới.

pt-online-schema-change \
  --alter "ADD COLUMN phone VARCHAR(20)" \
  --execute \
  D=mydb,t=users

Ưu điểm: không khóa bảng trong suốt quá trình copy. Nhược điểm: tốn disk gấp đôi và trigger thêm overhead write.

gh-ost (MySQL)

GitHub phát triển gh-ost để tránh trigger. Thay vào đó, gh-ost đọc binlog stream để capture changes, apply lên bảng shadow. Không trigger nghĩa là không overhead write trên bảng gốc. Hỗ trợ pause/resume, throttle khi load cao, và revert giữa chừng.

pg_repack (PostgreSQL)

PostgreSQL ít cần online DDL tool hơn MySQL vì nhiều DDL đã non-blocking. Nhưng pg_repack hữu ích khi cần rebuild bảng hoặc index mà không khóa — ví dụ VACUUM FULL không downtime, hoặc thay đổi physical layout.

Với Postgres, trong hầu hết trường hợp, kết hợp ADD COLUMN (instant) + CREATE INDEX CONCURRENTLY + application-level migration đã đủ.


Rollback: không phải migration nào cũng rollback được

Mỗi migration cần có reverse migration, nhưng không phải migration nào cũng dễ rollback.

Thêm cột → reverse là xóa cột, đơn giản. Nhưng xóa cột → reverse là thêm lại cột, data đã mất, không khôi phục được. Đây là lý do không xóa cột trong cùng deploy với code change: giữ cột cũ thêm một deploy cycle cho phép rollback code về version cũ mà không mất data.

Với migration phức tạp (backfill, transform data), rollback có thể yêu cầu reverse backfill, tốn thời gian tương đương. Cân nhắc snapshot hoặc logical backup trước khi chạy migration destructive.

Down migration ít khi được test kỹ. Team viết up cẩn thận, bỏ qua down hoặc viết sơ sài. Nếu định dùng down migration làm chiến lược rollback, phải test nó trên staging với data thực tế. Down migration chưa test = không có rollback plan.

Test migration trên staging với data thực tế

Migration pass trên staging 1000 row không đảm bảo pass trên production 50 triệu row. Hành vi lock, thời gian chạy, disk IO, WAL size — tất cả thay đổi phi tuyến theo kích thước bảng.

Staging nên có bản data gần production. Không nhất thiết clone toàn bộ, nhưng bảng lớn nhất phải có volume tương đương. Dùng pg_dump với sampling hoặc tool generate data fake cùng distribution.

Chạy EXPLAIN cho mọi query trong migration trên staging trước — đặc biệt là UPDATE backfill. Nếu query plan show sequential scan trên bảng 50 triệu row, bạn biết trước sẽ có vấn đề.

Chạy migration lúc off-peak nếu có thể. Đây không phải thay thế cho thiết kế migration an toàn, nhưng giảm blast radius nếu có unexpected behavior.


Các pitfall phổ biến

Lock queue cascade

Trong Postgres, khi DDL chờ ACCESS EXCLUSIVE lock, mọi query mới cũng queue phía sau — kể cả SELECT. Một DDL chờ lock 10 giây có thể tạo queue hàng trăm query, tất cả timeout cùng lúc.

SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN notes TEXT;

Set lock_timeout ngắn cho DDL session. Nếu không lấy được lock trong 3-5 giây, cancel và retry sau thay vì chờ vô hạn.

Statement timeout cho migration

Set statement_timeout hợp lý để migration không chạy vô hạn. Migration backfill chạy 30 phút không timeout, nếu có vấn đề (dead tuple bloat, lock contention), nó giữ resource mà không ai biết.

Transaction quá lớn

Migration framework mặc định wrap mọi thứ trong một transaction. Với DDL đơn lẻ thì tốt — atomic, rollback được. Nhưng backfill triệu row trong một transaction thì WAL phình to, lock giữ lâu, vacuum không dọn được dead tuple.

Tách migration DDL (trong transaction) và backfill (ngoài transaction, chạy batch).

Quên index cho cột mới

Thêm cột dùng trong WHERE hoặc JOIN mà quên tạo index → sequential scan trên bảng lớn. Checklist migration nên có: cột mới có cần index không? FK column trong Postgres không tự động tạo index — khi DELETE row ở bảng parent, engine phải scan bảng child để check, không có index là sequential scan toàn bộ.

Migration trên replica

DDL nặng trên primary gây replica lag. Với online DDL tool (gh-ost, pt-online-schema-change), hành vi trên replica khác nhau tùy tool. Đọc doc về replication compatibility trước khi chạy.


Tổng kết

Database migration trên production không phải là viết SQL rồi bấm chạy. Nó là bài toán phối hợp giữa schema change, code deployment, và data migration.

Mấy nguyên tắc chính:

  • Expand-contract: chia thay đổi lớn thành nhiều bước nhỏ — thêm cột mới → dual-write → backfill → chuyển code → xóa cột cũ
  • Backward compatible: mỗi bước phải tương thích với cả code version N và N+1
  • Additive first: migration chỉ nên thêm, thao tác xóa/rename tách riêng deploy cycle
  • Hiểu lock: đọc doc engine, set lock_timeout, dùng CONCURRENTLYNOT VALID khi cần
  • Backfill batch: không UPDATE toàn bộ bảng một lần, throttle và commit thường xuyên
  • Test với data thật: migration pass trên 1000 row không nói gì về hành vi trên 50 triệu row

Chi phí viết thêm vài migration nhỏ luôn rẻ hơn một incident downtime lúc giờ cao điểm.


Tham khảo