Cuối sprint, team chạy migration thêm cột vào bảng users — 12 triệu row. ALTER TABLE khoá bảng, API timeout hàng loạt. Revert xong, post-mortem ghi: “không có chiến lược migration an toàn”. Hầu hết team đều gặp ít nhất một lần — migration tưởng nhỏ mà gây downtime vì không lường trước hành vi lock của database engine.

Bài này đi qua các chiến lược thay đổi schema trên production mà không gây gián đoạn: expand-contract pattern, backward compatible migration, online DDL tool, và những pitfall phổ biến khi làm việc với bảng lớn.


Vì sao ALTER TABLE nguy hiểm trên production

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.

Trong 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ị khoá hoàn toàn hoặc chỉ cho phép đọc tuỳ loại thay đổi. Bảng 50 triệu row có thể mất vài phút đến hàng chục phút — toàn bộ thời gian đó, write bị block.

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ới bảng lớn, quá trình scan này giữ ACCESS EXCLUSIVE lock, block mọi thao tác khác.

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. Và trong khi DDL đang chờ lock, mọi query mới đến bảng đó cũng bị queue phía sau DDL — tạo hiệu ứng domino. 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.

Hiểu rõ hành vi lock của từng loại DDL trên engine cụ thể là bước đầu tiên. PostgreSQL document rõ lock level cho từng thao tác. MySQL document Online DDL operations. Đọc trước khi chạy migration trên production — không đoán.


Expand-contract pattern

Expand-contract là chiến lược cốt lõi cho mọi schema change không downtime. Ý tưởng đơn giản: thay vì thay đổi schema trong một bước (xoá cột cũ, thêm cột mới, đổi constraint), chia thành nhiều bước nhỏ, mỗi bước đều an toàn và có thể rollback.

Giai đoạn expand

Thêm cột mới, bảng mới, hoặc index mới mà không xoá 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.

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

-- Bước 2: 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 và hoàn toàn chấp nhận được.

Giai đoạn migrate

Deploy code mới viết vào cả hai cột — code ghi shipping_address cũ và đồng thời ghi shipping_address_id mới. 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ũ chưa có giá trị.

-- Backfill batch: cập nhật 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 tuỳ kích thước bảng và tốc độ backfill. Không vội — mọi thứ vẫn hoạt động bình thường với cả hai cột.

Giai đoạn contract

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 đó xoá cột cũ:

ALTER TABLE orders DROP COLUMN shipping_address;

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

flowchart LR A["Expand: thêm cột mới"] --> B["Migrate: dual-write + backfill"] B --> C["Contract: xoá cột cũ"] A -.- D["Schema: cũ + mới"] B -.- E["Code: ghi cả hai"] C -.- F["Schema: chỉ mới"]

Backward compatible migration

Nguyên tắc nền tảng: 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ế là 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.

Điều này dẫn đến một quy tắc đơn giản nhưng hay bị vi phạm: không bao giờ xoá 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 xoá 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ũ trước, đợi 100% pod đã chạy code mới, rồi mới chạy migration xoá cột. Trong thực tế, tách thành hai PR và hai deploy cycle riêng biệt — migration xoá cột nằm ở deploy sau, không gộp chung.

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. Vậy nên cột mới phải thêm dạng nullable trước, hoặc có DEFAULT — để code cũ INSERT mà không bị lỗi.


Online DDL tools

Khi engine không hỗ trợ DDL online cho thao tác cần thực hiện, hoặc khi 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, hoạt động bằng cách 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 là không khoá bảng trong suốt quá trình copy — chỉ khoá rất ngắn lúc swap tên. Nhược điểm là tốn disk gấp đôi (bảng shadow + bảng gốc), và trigger thêm overhead cho mỗi write trong lúc migration chạy.

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. gh-ost cũng cho phép pause/resume migration, throttle khi load cao, và thậm chí revert giữa chừng.

pg_repack (PostgreSQL)

PostgreSQL có ít nhu cầu 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 khoá — ví dụ VACUUM FULL mà không downtime, hoặc thay đổi physical layout (reorder theo index).

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 đã đủ mà không cần tool bên ngoài.


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

Đổi tên cột nghe đơn giản — ALTER TABLE RENAME COLUMN old TO new — 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: Xoá cột cũ (sau khi code không còn dùng)
ALTER TABLE users DROP COLUMN name;

Nếu dùng ORM, view có thể giúp giai đoạn chuyển tiếp mượt hơn — tạo view với alias cột cũ trỏ sang cột mới, code cũ SELECT qua view vẫn hoạt động. Nhưng view thêm complexity, chỉ dùng khi cần thiết.


Thêm cột NOT NULL với default

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. Thêm NOT NULL DEFAULT 'value' thì hành vi khác nhau tuỳ engine.

PostgreSQL từ version 11 xử lý ADD COLUMN ... NOT NULL DEFAULT ... rất thông minh: engine lưu default value trong catalog metadata, không rewrite bảng. Mỗi row cũ khi được đọc sẽ tự động trả default — instant, không khoá. Đây là cải tiến rất lớn so với trước version 11.

MySQL InnoDB từ 8.0 cũng hỗ trợ instant ADD COLUMN cho nhiều trường hợp với ALGORITHM=INSTANT. Nhưng không phải mọi loại cột đều instant — TEXT, BLOB, và một số kiểu khác vẫn cần table rebuild.

Khi engine không hỗ trợ instant, dùng chiến lược multi-step:

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

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

-- Bước 3: Thêm constraint NOT NULL (sau khi mọi row đã có giá trị)
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;

Bước 3 trong Postgres yêu cầu scan toàn bộ bảng để verify. Từ Postgres 12+, nếu có CHECK (status IS NOT NULL) NOT VALID đã được validate trước, SET NOT NULL sẽ instant vì engine biết constraint đã được verify:

-- Thay vì SET NOT NULL trực tiếp, dùng CHECK constraint
ALTER TABLE orders
  ADD CONSTRAINT orders_status_not_null
  CHECK (status IS NOT NULL) NOT VALID;

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

-- Giờ SET NOT NULL instant vì Postgres biết constraint đã valid
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
ALTER TABLE orders DROP CONSTRAINT orders_status_not_null;

Pattern này dài hơn nhưng không giữ ACCESS EXCLUSIVE lock lâu — VALIDATE CONSTRAINT chỉ cần SHARE UPDATE EXCLUSIVE lock, cho phép read và write bình thường.


Tạo index không khoá bảng

Index mới trên bảng lớn có thể mất hàng phút đến hàng giờ. 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 trong Postgres scan bảng hai lần mà không giữ lock blocking write. Trade-off là chạy chậm hơn (khoảng 2-3 lần) và không thể chạy trong transaction block. Nếu bị interrupt giữa chừng, index sẽ ở trạng thái INVALID — cần DROP INDEX CONCURRENTLY rồi tạo lại.

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

Lưu ý quan trọng: 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 migration framework để chạy statement đó ngoài transaction — mỗi framework có cách riêng, đọc doc kỹ trước.


Foreign key trên bảng lớn

Thêm foreign key constraint cũng cần scan bảng để validate — hành vi tương tự SET NOT NULL. Trên bảng hàng chục triệu row, validation scan có thể mất nhiều phút và giữ lock.

Postgres cho phép thêm FK dạng NOT VALID rồi validate sau:

-- Thêm FK không validate (instant, không scan)
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 từ thời điểm thêm, không kiểm tra row cũ. VALIDATE chạy sau sẽ verify toàn bộ row cũ — nhưng với lock level nhẹ hơn, cho phép read và write bình thường.


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.

Chạy 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ộ. Với bảng hàng chục triệu row, cách này gần như luôn gây vấn đề.

Backfill theo batch nhỏ an toàn hơn nhiều:

-- Backfill 5000 row mỗi batch, sleep giữa các batch
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);  -- throttle
  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 bất đồng bộ là cách tiếp cận thực tế hơn — job chạy liên tục, throttle theo load hiện tại của database, có thể pause/resume, và report progress. Nhiều team dùng Sidekiq, Celery, hoặc script custom cho việc này. Thời gian backfill có thể tính bằng giờ hoặc ngày — đó là bình thường và chấp nhận được khi đổi lại không ảnh hưởng production traffic.


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

Đây là câu hỏi kinh điển và câu trả lời phụ thuộc vào loại thay đổi.

Khi thêm cột, bảng, hoặc index: chạy migration trước, deploy code sau. Code mới expect cột mới tồn tại — nếu deploy code trước khi cột tồn tại, code sẽ lỗi. Migration thêm cột là backward compatible (code cũ không biết cột mới, không ảnh hưởng).

Khi xoá cột hoặc bảng: deploy code mới (không còn dùng cột cũ) trước, chạy migration xoá sau. Nếu xoá cột trước khi code ngừng SELECT nó, code cũ crash.

Khi đổi kiểu dữ liệu hoặc constraint: dùng expand-contract — thêm cột mới (migration), dual-write (deploy code), backfill, chuyển sang cột mới (deploy code), xoá cột cũ (migration). 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 xoá). Thao tác destructive (xoá, rename) nằm ở migration riêng, chạy sau khi code đã chuyển hoàn toàn.


Rollback migration

Mỗi migration cần có reverse migration — nhưng không phải mọi migration đều dễ rollback. Thêm cột thì reverse là xoá cột — đơn giản. Nhưng xoá cột thì reverse là thêm lại cột — data đã mất, không khôi phục được.

Đây là lý do thêm nữa để không xoá 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. Chỉ xoá khi chắc chắn không cần rollback nữa.

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 trên production.

Migration tool hầu hết hỗ trợ up/down migration. Nhưng trên thực tế, down migration ít khi được test kỹ — team viết up, bỏ qua down hoặc viết sơ sài. Nếu đã quyết định dùng down migration như 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 migration trên staging, đo thời gian, monitor lock wait, kiểm tra replica lag nếu có replica.

Một trick hữu ích: 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 đề.

Ngoài ra, 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 — migration phải an toàn ở mọi thời điểm — nhưng off-peak giảm blast radius nếu có unexpected behavior.


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 khi DDL cuối cùng chạy xong hoặc bị cancel. Mitigation: 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.

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

Statement timeout cho migration

Tương tự, set statement_timeout hợp lý để migration không chạy vô hạn. Migration backfill chạy 30 phút mà không có 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 migration nhẹ (DDL đơn lẻ), transaction là 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) là cách tiếp cận đúng.

Quên index cho cột mới

Thêm cột dùng trong WHERE clause hoặc JOIN mà quên tạo index — query chạy sequential scan trên bảng lớn, latency tăng vọt. Checklist migration nên bao gồm: cột mới có cần index không? Foreign key tự động tạo index ở một số engine (MySQL InnoDB — có, Postgres — không). Với Postgres, FK column không có index sẽ gây sequential scan khi DELETE row ở bảng parent — vì engine phải kiểm tra bảng child có row nào reference không.

Migration trên replica

Nếu dùng logical replication hoặc streaming replication, migration trên primary sẽ replicate sang replica. DDL nặng trên primary gây replica lag — replica phải apply cùng DDL đó. Với online DDL tool (gh-ost, pt-online-schema-change), hành vi trên replica khác nhau tuỳ tool — đọc doc về replication compatibility trước khi chạy.


Tóm tắt

Database migration trên production không phải là viết SQL và chạy — đó là bài toán phối hợp giữa schema change, code deployment, và data migration. Expand-contract pattern chia thay đổi lớn thành nhiều bước nhỏ an toàn: thêm cột mới, dual-write, backfill, chuyển code, xoá cột cũ. Mỗi bước đều backward compatible và rollback được.

Migration chỉ nên additive — thêm cột, thêm bảng, thêm index. Thao tác destructive (xoá, rename) tách riêng deploy cycle, chạy sau khi code đã chuyển hoàn toàn. Dùng CREATE INDEX CONCURRENTLY trong Postgres, NOT VALID constraint rồi validate riêng, và online DDL tool khi engine không hỗ trợ non-blocking DDL.

Backfill theo batch nhỏ với throttle, không chạy UPDATE toàn bộ bảng trong một transaction. Set lock_timeoutstatement_timeout cho DDL session. Test migration trên staging với data volume tương đương production — migration pass trên 1000 row không nói gì về hành vi trên 50 triệu row.

Quy tắc đơn giản nhất: nếu migration có thể gây lock lâu hơn vài giây trên bảng có traffic, nó cần được thiết kế lại thành multi-step. 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.