Database migration không downtime: expand-contract pattern và bài học từ production — hoatq.dev

cat blog/.md

Database migration không downtime: expand-contract pattern và bài học từ production

date: tags: database, postgresql, devops, backend, best-practices

Bạn vừa viết xong một migration thêm cột mới, rename bảng, đổi kiểu dữ liệu. Chạy local ngon lành. Lên staging cũng ổn. Rồi đến lúc deploy production — bạn phải announce maintenance window, tắt app, chạy migration, bật lại, cầu nguyện không có gì sai.

Nếu quy trình deploy của bạn vẫn đang như vậy, bài này dành cho bạn.

Mình sẽ chia sẻ cách mình chạy database migration trên production mà không cần downtime — pattern nào an toàn, pattern nào cần tránh, và những sai lầm mình đã trả giá.

Vì sao migration gây downtime?

Không phải mọi migration đều nguy hiểm. Vấn đề nằm ở những thao tác block bảng hoặc break code đang chạy.

Một số ví dụ:

Thao tácRủi ro
ALTER TABLE ADD COLUMN ... DEFAULTPostgreSQL < 11: rewrite toàn bộ bảng. PG 11+: an toàn
ALTER TABLE DROP COLUMNApp cũ đang SELECT * sẽ crash
ALTER TABLE RENAME COLUMNApp cũ reference tên cũ sẽ crash
CREATE INDEXLock bảng (nếu không dùng CONCURRENTLY)
ALTER TABLE ALTER COLUMN TYPECó thể rewrite toàn bộ bảng
NOT NULL constraint trên cột có sẵnFull table scan để validate

Khi bảng có vài triệu rows, một ALTER TABLE sai cách có thể lock bảng hàng phút — và hàng phút đó, mọi query INSERT/UPDATE đều bị block. User thấy loading vô tận, request timeout, và bạn nhận alert lúc 2 giờ sáng.

Nguyên tắc cốt lõi: tách migration ra khỏi deployment

Rule số 1 mình luôn áp dụng: migration và code deployment phải có thể chạy độc lập.

Nghĩa là:

  • Migration chạy trước, code cũ vẫn hoạt động bình thường
  • Code mới deploy sau, database đã sẵn sàng
  • Nếu cần rollback code, database không bị ảnh hưởng

Đây là nền tảng của expand-contract pattern.

Expand-Contract Pattern

Pattern này chia mỗi thay đổi database thành 3 phase:

Phase 1: Expand (mở rộng)

Thêm cái mới mà không xoá hoặc sửa cái cũ. Code cũ vẫn chạy bình thường.

Phase 2: Migrate (chuyển đổi)

Deploy code mới sử dụng cấu trúc mới. Đồng thời migrate data từ cấu trúc cũ sang mới nếu cần.

Phase 3: Contract (thu gọn)

Sau khi code mới đã stable, xoá cấu trúc cũ không còn dùng.

Nghe trừu tượng, nên mình lấy ví dụ cụ thể.

Ví dụ: Rename cột name thành full_name

Cách sai (gây downtime):

-- Một migration duy nhất
ALTER TABLE users RENAME COLUMN name TO full_name;

Deploy code mới cùng lúc. Trong khoảnh khắc giữa migration xong và code mới lên, mọi request đều crash vì code cũ vẫn đang đọc cột name.

Cách đúng (expand-contract):

Phase 1 — Expand:

-- Migration 1: thêm cột mới, giữ cột cũ
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);

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

-- Trigger để sync hai cột trong thời gian chuyển tiếp
CREATE OR REPLACE FUNCTION sync_user_name()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' OR NEW.name IS DISTINCT FROM OLD.name THEN
        NEW.full_name := NEW.name;
    END IF;
    IF TG_OP = 'INSERT' OR NEW.full_name IS DISTINCT FROM OLD.full_name THEN
        NEW.name := NEW.full_name;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sync_user_name
    BEFORE INSERT OR UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION sync_user_name();

Lúc này code cũ vẫn đọc/ghi name bình thường. Cột full_name được sync tự động.

Phase 2 — Migrate:

Deploy code mới đọc/ghi full_name. Code cũ vẫn hoạt động nhờ trigger sync.

# Code mới
class UserService:
    async def get_user(self, user_id: str):
        query = select(User).where(User.id == user_id)
        result = await self.db.execute(query)
        user = result.scalar_one()
        return {"full_name": user.full_name}  # dùng cột mới

Phase 3 — Contract:

Sau khi confirm code mới ổn định (thường mình đợi 1-2 tuần):

-- Migration 3: dọn dẹp
DROP TRIGGER trg_sync_user_name ON users;
DROP FUNCTION sync_user_name();
ALTER TABLE users DROP COLUMN name;

Ba lần deploy, không lần nào có downtime.

Những migration an toàn (không cần expand-contract)

Không phải lúc nào cũng cần phức tạp. Một số thao tác an toàn chạy trực tiếp:

-- Thêm cột nullable (không default) — luôn an toàn
ALTER TABLE orders ADD COLUMN note TEXT;

-- Thêm cột với DEFAULT (PostgreSQL 11+) — an toàn
ALTER TABLE orders ADD COLUMN status VARCHAR(20) DEFAULT 'pending';

-- Tạo index không lock bảng
CREATE INDEX CONCURRENTLY idx_orders_status ON orders(status);

-- Thêm bảng mới — không ảnh hưởng gì
CREATE TABLE order_items (...);

Mình có một checklist nhanh: nếu migration chỉ thêm (thêm cột, thêm bảng, thêm index concurrently) và không sửa/xoá gì đang có, thường an toàn để chạy trực tiếp.

Những migration nguy hiểm cần cẩn thận

1. Thêm NOT NULL constraint

-- Nguy hiểm: full table scan trên bảng lớn
ALTER TABLE orders ALTER COLUMN customer_id SET NOT NULL;

Cách an toàn hơn — dùng constraint riêng với NOT VALID:

-- Bước 1: thêm constraint nhưng chưa validate data cũ (nhanh, không lock)
ALTER TABLE orders
    ADD CONSTRAINT orders_customer_id_not_null
    CHECK (customer_id IS NOT NULL) NOT VALID;

-- Bước 2: validate trong background (không block writes)
ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_id_not_null;

NOT VALID nói với PostgreSQL: “áp dụng constraint cho data mới, nhưng chưa cần check data cũ.” Bước VALIDATE sau đó check data cũ mà không lock bảng.

2. Đổi kiểu dữ liệu

-- Nguy hiểm: có thể rewrite toàn bộ bảng
ALTER TABLE products ALTER COLUMN price TYPE NUMERIC(12,2);

Thay vào đó, dùng expand-contract: thêm cột mới với type mới, migrate data, rồi xoá cột cũ.

3. Backfill data lớn

-- Nguy hiểm: một transaction khổng lồ, lock nhiều rows
UPDATE orders SET region = 'VN' WHERE region IS NULL;

Mình luôn backfill theo batch:

async def backfill_region(db: AsyncSession, batch_size: int = 1000):
    while True:
        result = await db.execute(
            text("""
                UPDATE orders
                SET region = 'VN'
                WHERE id IN (
                    SELECT id FROM orders
                    WHERE region IS NULL
                    LIMIT :batch_size
                    FOR UPDATE SKIP LOCKED
                )
                RETURNING id
            """),
            {"batch_size": batch_size},
        )
        updated = result.rowcount
        await db.commit()

        if updated == 0:
            break

        logger.info(f"Backfilled {updated} rows")
        await asyncio.sleep(0.1)  # nhường DB xử lý traffic thường

FOR UPDATE SKIP LOCKED đảm bảo batch này không block transaction khác đang sửa cùng row. Sleep giữa các batch để database có thời gian phục vụ traffic bình thường.

Alembic workflow cho expand-contract

Nếu bạn dùng Alembic (migration tool phổ biến cho Python/SQLAlchemy), mình organize migration theo convention:

migrations/versions/
├── 2026_04_13_001_expand_add_full_name.py
├── 2026_04_13_002_backfill_full_name.py
└── 2026_04_27_003_contract_drop_name.py     # chạy 2 tuần sau

Mỗi file rõ ràng phase nào. Phase contract luôn là PR riêng, deploy riêng, sau khi đã verify code mới ổn định.

# 2026_04_13_001_expand_add_full_name.py
"""expand: add full_name column to users"""

revision = "abc123"
down_revision = "xyz789"

def upgrade():
    op.add_column("users", sa.Column("full_name", sa.String(255)))

    # Backfill
    op.execute("UPDATE users SET full_name = name WHERE full_name IS NULL")

    # Sync trigger
    op.execute("""
        CREATE OR REPLACE FUNCTION sync_user_name() ...
        CREATE TRIGGER trg_sync_user_name ...
    """)

def downgrade():
    op.execute("DROP TRIGGER IF EXISTS trg_sync_user_name ON users")
    op.execute("DROP FUNCTION IF EXISTS sync_user_name()")
    op.drop_column("users", "full_name")

CI/CD: chạy migration tự động nhưng an toàn

Trong pipeline CI/CD, mình chạy migration trước khi deploy code mới:

# GitHub Actions
deploy:
  steps:
    - name: Run migrations
      run: |
        docker exec -e PYTHONPATH=/app app-container \
          alembic upgrade head

    - name: Health check after migration
      run: |
        curl -f http://localhost:8000/health || exit 1

    - name: Deploy new code
      run: |
        # Rolling update - pods mới dần thay pods cũ
        kubectl set image deployment/api api=myapp:${{ github.sha }}
        kubectl rollout status deployment/api --timeout=300s

Thứ tự quan trọng: migration → verify → deploy. Nếu migration fail, code mới không được deploy. Nếu code mới fail, rollback code nhưng database vẫn ổn (vì expand phase chỉ thêm, không xoá).

5 bài học từ production

1. Luôn test migration trên bản copy của production data

Migration chạy ngon trên database rỗng hoặc seed data nhỏ không có nghĩa sẽ ổn với 5 triệu rows. Mình có thói quen dump production schema (không data) + một phần data để test migration timing.

2. Đặt statement_timeout cho migration

SET statement_timeout = '30s';
ALTER TABLE ...;

Nếu migration chạy lâu hơn dự kiến, tốt hơn là fail sớm thay vì lock bảng 10 phút.

3. Đừng chạy migration lúc peak traffic

Dù migration an toàn, backfill vẫn tạo thêm load. Mình thường chạy migration lúc traffic thấp nhất — kiểm tra monitoring dashboard để chọn thời điểm.

4. Contract phase cần ticket riêng

Nhiều team expand xong rồi quên contract. Cột cũ, trigger, function… nằm đó mãi tạo thành tech debt. Mình luôn tạo ticket cho contract phase ngay khi tạo expand migration.

5. Backup trước khi chạy migration phức tạp

Nghe hiển nhiên nhưng nhiều người skip. Ít nhất hãy pg_dump bảng sắp bị sửa. Nếu dùng RDS/Cloud SQL, tạo snapshot trước khi chạy.

Tổng kết

Database migration không downtime không phải phép thuật. Nó là kỷ luật:

  1. Tách migration khỏi deployment — chạy độc lập, rollback độc lập
  2. Expand-contract pattern — thêm trước, migrate, rồi mới xoá
  3. Migration an toàn: thêm cột nullable, thêm bảng, CREATE INDEX CONCURRENTLY
  4. Migration nguy hiểm: rename, drop, change type, add NOT NULL — cần chia phase
  5. Backfill theo batchFOR UPDATE SKIP LOCKED, sleep giữa các batch
  6. CI/CD: migration chạy trước deploy, health check ở giữa

Mình biết expand-contract tốn effort hơn “chạy một migration rồi deploy.” Nhưng cái giá của 5 phút downtime lúc 2 giờ sáng, kèm theo data inconsistency và khách hàng phàn nàn, luôn đắt hơn nhiều so với effort viết thêm 2-3 migration files.

Bắt đầu từ việc nhỏ: lần tới cần rename một cột, thử expand-contract. Khi đã quen, nó sẽ trở thành thói quen tự nhiên — giống như viết test vậy.


Bạn đang quản lý database migration thế nào trong team? Có dùng expand-contract hay pattern nào khác? Chia sẻ với mình qua email nhé!

// reactions


cat comments.log


hoatq@dev : ~/blog $