Migration trong CI/CD — lock, rollback, và chỗ để migration ở đâu

Tài liệu này trả lời 3 câu hỏi hay bị gộp làm một khi vận hành migration (đổi cấu trúc database) trên hệ thống đang chạy:

  1. Lockphp artisan migrate --isolated khoá cái gì, chống được gì, và không chống được gì.
  2. Rollback — khi migration đã nằm trong pipeline rồi thì quay lui thế nào? Có nên rollback migration không khi đã làm backward-compatible?
  3. Chỗ để migration — nên để trong repo app (Laravel, NestJS) hay tách ra service + git repo riêng?

Bổ sung cho migration-postgresql-zero-downtime.md — doc đó nói kỹ thuật viết migration (expand/contract, lock_timeout, CREATE INDEX CONCURRENTLY), doc này nói cách vận hành migration trong pipeline. Pipeline cụ thể xem laravel-gitlab-cicd.md.

Bối cảnh giả định (dùng cho ví dụ, đánh dấu rõ là minh hoạ): Laravel + PostgreSQL, deploy blue-green trên 1 VPS qua GitLab CI, đội 3-4 người, deploy vài lần/tuần. Phần nguyên lý áp dụng được cho NestJS/TypeORM, chỗ nào khác biệt sẽ ghi rõ.


Phần 1. Migration lock — migrate --isolated

1.1. Nó chống chuyện gì — chạy migrate hai lần cùng lúc

Đây là tình huống xảy ra khi migration được gọi từ entrypoint của container thay vì từ một job riêng. Scale lên 3 replica → 3 process cùng gọi migrate trong cùng một giây.

Không có lock, hai runner cùng chạy:

sequenceDiagram
  participant A as Runner A
  participant DB as PostgreSQL
  participant B as Runner B
  A->>DB: SELECT * FROM migrations
  B->>DB: SELECT * FROM migrations
  DB-->>A: chưa có "add_status"
  DB-->>B: chưa có "add_status"
  Note over A,B: cả hai đều kết luận: phải chạy migration này
  A->>DB: ALTER TABLE orders ADD status
  DB-->>A: OK
  B->>DB: ALTER TABLE orders ADD status
  DB-->>B: ERROR: column exists
  A->>DB: INSERT INTO migrations
  Note over B: pipeline B FAIL

Hậu quả tuỳ loại migration:

Loại migration Chạy trùng thì sao
ALTER TABLE ADD COLUMN Runner B lỗi "column already exists" → pipeline đỏ, deploy dừng giữa chừng
CREATE TABLE Tương tự — lỗi, deploy dở dang
Data migration / backfill Nguy hiểm nhất: chạy 2 lần → cộng tiền 2 lần, tạo bản ghi trùng. Không có lỗi nào báo, chỉ có data sai
Migration nhiều câu trên MySQL Mỗi DDL implicit commit → schema kẹt ở trạng thái dở dang, không transaction nào cứu

Trên PostgreSQL, DDL tự lấy lock ở tầng database nên hai lệnh ALTER sẽ nối đuôi nhau chứ không phá nhau — nhưng cái thứ hai vẫn lỗi, và pipeline vẫn đỏ. Lock của Laravel không phải để chống hỏng database, mà để chỉ một người được quyền chạy, tránh cả rừng lỗi và trạng thái nửa vời.

1.2. Laravel làm bằng cách nào

Theo tài liệu Laravel (mục Running Migrations > Isolating Migration Execution):

Để tránh migration bị chạy đồng thời từ nhiều server trong lúc deploy, dùng option --isolated. Cơ chế này lấy một atomic lock qua cache driver của application, bảo đảm chỉ một server thực hiện migration tại một thời điểm.

php artisan migrate --force --isolated

Bên dưới, migrate là một Isolatable command — cơ chế chung của Artisan, không riêng gì migration:

use Illuminate\Contracts\Console\Isolatable;

class SendEmails extends Command implements Isolatable
{
    // ...
}

Luồng chạy thật:

sequenceDiagram
  participant A as Runner A
  participant R as Cache (Redis)
  participant B as Runner B
  A->>R: lấy lock "artisan-migrate"
  R-->>A: OK, bạn giữ lock
  B->>R: lấy lock "artisan-migrate"
  R-->>B: TRƯỢT, đã có chủ
  Note over A: chạy migrate... (30 giây)
  Note over B: THOÁT NGAY LẬP TỨC<br/>exit code 0 ⚠️
  A->>R: xong, nhả lock

Ba chi tiết quan trọng của cơ chế này (theo docs Artisan):

use DateTimeInterface;
use DateInterval;

public function isolationLockExpiresAt(): DateTimeInterface|DateInterval
{
    return now()->plus(minutes: 5);
}

1.3. Ba cái bẫy — chỗ lock này KHÔNG bảo vệ

Đây là phần quan trọng nhất, và cũng là phần hay bị hiểu sai nhất.

Bẫy 1 — Runner trượt lock thoát với exit code 0 (thành công)

--isolated không phải hàng đợi. Runner B không đợi A chạy xong. Nó thấy lock bận thì thoát ngay và báo thành công.

Nghĩa là:

migrate job (B) → "✅ thành công" → deploy app version mới
                                        ↑
                    nhưng migration của A CHƯA chạy xong!
                    App mới cắm vào schema cũ → lỗi 500

Lock chống được "chạy trùng", nhưng lại đẻ ra một lỗi khác: deploy tiếp tục dù schema chưa sẵn sàng. Nếu chỉ có 1 job migrate trong pipeline (cách đúng — xem mục 1.5) thì không gặp; nếu để migration ở entrypoint của mỗi container thì đây là bug bạn sẽ gặp lúc 3h sáng.

Laravel cho phép đổi exit code khi không lấy được lock (--isolated=12) để script deploy phân biệt được "đã migrate" và "bị trượt lock". (Chi tiết flag này chưa verify được qua Context7 ở lần fetch này — chạy php artisan help migrate trên version đang dùng để xác nhận trước khi đưa vào pipeline.)

Bẫy 2 — Lock nằm ở cache, không nằm ở database

Tài liệu Cache của Laravel nói rõ: atomic lock cần driver hỗ trợ (memcached, redis, dynamodb, database, file, array) và mọi application server phải nói chuyện với cùng một cache server trung tâm.

CACHE_STORE Lock có tác dụng qua nhiều máy?
redis, memcached, dynamodb ✅ Có — đúng mục đích
database ✅ Có, nếu các máy dùng chung 1 DB
file Không — mỗi máy một file riêng, mỗi máy tự thấy mình lấy được lock
array Không — chỉ sống trong 1 process

Đây là cái bẫy im lặng: pipeline có --isolated, ai cũng yên tâm, nhưng CACHE_STORE=file → lock không tồn tại, mà không có cảnh báo nào.

Hệ quả kéo theo:

Đây là khác biệt lớn so với advisory lock (khoá do ứng dụng tự đặt, nằm ngay trong database) — cách Rails và Flyway làm:

Laravel --isolated Advisory lock (Rails, Flyway)
Lock nằm ở Cache (Redis/Memcached) Chính database đang migrate
Hành vi khi bận Thoát ngay, exit 0 Đợi đến lượt
DB chết / mất kết nối Lock vẫn treo tới khi hết hạn Lock tự tan (session đóng)
Phụ thuộc thêm hạ tầng Có — cần cache tập trung Không

Laravel chọn cách này vì Artisan Isolatable là cơ chế dùng chung cho mọi command, không riêng migration. Biết được điểm khác biệt này là câu trả lời tốt khi interviewer hỏi sâu.

Bẫy 3 — Lock không chống được người

Lock chỉ có tác dụng giữa các tiến trình cùng truyền --isolated. Một người SSH vào server gõ php artisan migrate trần thì đi thẳng qua, không thấy lock nào.

Chống bằng quy trình, không chống bằng code: production chỉ cho migrate qua pipeline, tài khoản deploy trên server không có quyền chạy artisan.

1.4. Lock chỉ là lưới an toàn — cách đúng là chỉ chạy migrate một chỗ

Đừng dựa vào lock để đảm bảo migration chạy đúng một lần. Hãy làm cho nó không thể chạy hai lần:

flowchart TB
  subgraph BAD["❌ SAI — migrate trong entrypoint container"]
    direction LR
    R1["replica 1"] --> MIG["cùng gọi migrate<br/>→ phải nhờ lock cứu"]
    R2["replica 2"] --> MIG
    R3["replica 3"] --> MIG
  end
  subgraph OK["✅ ĐÚNG — migrate là stage riêng, chạy trước, đúng một lần"]
    direction LR
    BU["build"] --> MG["migrate<br/>(1 job)"] --> DE["deploy replicas"] --> HC["healthcheck"] --> SW["switch"]
    MG -.-> NOTE["--isolated chỉ là lưới an toàn thứ hai<br/>(phòng khi ai đó bấm retry job)"]
  end

Giá trị thật của --isolated nằm ở chỗ này: chống retry job trong CI và chống hai pipeline chạy song song (merge 2 MR gần nhau) — chứ không phải để thay thế cho việc thiết kế pipeline cho đúng.

1.5. Cấu hình đúng — checklist

# .gitlab-ci.yml
migrate:
  stage: migrate
  script:
    - docker compose run --rm app php artisan migrate --force --isolated
  resource_group: production_db   # ← GitLab: chỉ 1 job của group này chạy tại 1 thời điểm

resource_group của GitLab tự nó đã chống hai pipeline chồng nhau, ở tầng CI, không cần cache. Dùng cả hai thì tốt nhất — chặn ở hai tầng khác nhau.

Checklist trước khi tin vào lock:

1.6. Bên NestJS/TypeORM thì sao

TypeORM không có cơ chế lock sẵn cho migration:run. Phải tự lo:

Cách thứ hai đơn giản hơn và đủ dùng cho phần lớn hệ thống.


Phần 2. Rollback — có nên rollback migration không?

Trả lời ngắn: không. Rollback code, giữ nguyên schema. Và chính backward-compatible migration là thứ làm cho rollback schema trở nên không cần thiết — đó là mục đích của nó, không phải tác dụng phụ.

2.1. Hai chữ "rollback", hai thứ khác hẳn nhau

Rollback code Rollback migration
Bản chất Deploy lại artifact cũ (image tag cũ) Chạy DDL mới (down()) lên DB đang có data
Trạng thái Stateless — về đúng như cũ Stateful — không có "như cũ"
Thời gian Vài giây (switch container) Vài giây đến vài chục phút, có thể khoá bảng
Đã test chưa Có — chính là bản đang chạy hôm qua Chưa bao giờdown() gần như không ai chạy trên prod
Đảo ngược được Không — data đã xoá là mất

2.2. Vì sao backward-compatible triệt tiêu nhu cầu rollback schema

flowchart LR
  V1["schema v1"] -->|"deploy hôm nay<br/>thêm cột nullable"| V2["schema v2<br/>(expand)"]
  V2 -->|"1-2 tuần sau, khi chắc chắn<br/>không rollback nữa"| V3["schema v3<br/>(contract)"]
  V2 -.-> W["CỬA SỔ ROLLBACK<br/>schema v2 chạy được với cả code v1 lẫn code v2<br/>→ rollback = đổi image tag<br/>→ schema đứng yên"]

Bảng tương thích code ↔ schema:

schema v1 schema v2 (expand) schema v3 (contract)
code v1 chạy được vẫn chạy được ✗ chết
code v2 chạy được chạy được
code v3 chạy được

Đây là hợp đồng N-1 compatibility (schema mới phải chạy được với bản code liền trước). Giữ được hợp đồng này thì rollback là thao tác network, không phải thao tác database.

2.3. Cơ chế rollback của từng framework — biết để không gõ nhầm

Cùng một chữ "rollback": Laravel gỡ cả cụm, TypeORM gỡ từng cái. Lúc 3h sáng gõ nhầm là hỏng thật.

2.4. Tác hại khi rollback migration trên production

  1. Mất data không hồi được. down() của add columndrop column — cùng với mọi giá trị người dùng đã ghi vào trong 20 phút bản lỗi chạy.
  2. down() là code chết. Không ai chạy nó trên dữ liệu thật. Nó có thể sai cú pháp, thiếu index, sai kiểu — và bạn phát hiện đúng lúc đang cháy.
  3. Rollback quá tay (Laravel): chỉ muốn gỡ 1, gõ migrate:rollback → mất cả batch.
  4. MySQL không cứu được: mỗi DDL implicit commit, fail giữa chừng để lại schema dở dang mà migrations table chưa ghi nhận.
  5. Đua với replica đang chạy: rollback schema trong khi vẫn còn replica code mới đang ghi → lỗi 500 hàng loạt, hoặc tệ hơn là ghi sai âm thầm.
  6. Khoá bảng: down() chạy ALTER TABLE trên bảng 50 triệu dòng có thể khoá vài phút. Đang cứu sự cố thì tự tạo sự cố thứ hai.

2.5. Trade-off — 3 chiến lược

A. Rollback schema B. Roll forward (sửa tới) C. Expand/contract + chỉ rollback code
Cách làm Chạy down() Viết migration mới sửa lỗi, deploy tiếp Schema luôn tương thích N-1, rollback = đổi image tag
Thời gian khắc phục Trung bình–chậm Chậm (phải viết + review + build) Nhanh nhất (giây)
Rủi ro mất data Cao Thấp Không
Áp lực lên người trực Cao — quyết định không undo được Cao — phải code lúc đang cháy Thấp — bấm nút
Chi phí trả trước 0 0 Có: chia 2-3 lần deploy, code phải chịu được cả 2 schema
Hợp với Local dev, staging Lỗi logic không liên quan schema Production

Tiêu chí chọn:

Chi phí thật của Ckỷ luật con người, không phải kỹ thuật: phải nhớ chia expand/contract, và phải nhớ quay lại làm phần contract vài tuần sau. Đội hay quên phần contract → schema đầy cột _old, _v2, _deprecated không ai dám xoá. Cách chống: tạo issue "contract: drop users.full_name" ngay lúc merge phần expand, hẹn ngày.

2.6. Viết down() hay bỏ luôn?

Vẫn viết, cho local dev và migrate:fresh. Nhưng migration nào mất data thì để down() ném lỗi rõ ràng, đừng để nó xoá âm thầm:

public function down(): void
{
    throw new RuntimeException(
        'Irreversible. Khôi phục bằng PITR, xem docs/migration-ci-cd.md'
    );
}

Thà fail sớm còn hơn xoá xong mới biết.

2.7. Lưới an toàn thật sự không phải down()

PITR (point-in-time recovery — khôi phục database về đúng một mốc thời gian). Chi phí: dung lượng WAL/binlog + phải diễn tập restore ít nhất mỗi quý. Backup chưa từng restore thử = không có backup.

Với DB nhỏ (<50GB), snapshot trước mỗi deploy có migration là rẻ và đáng làm. DB lớn thì snapshot mất 10-30 phút → dựa vào PITR thay vì snapshot theo deploy.

2.8. Chặn bằng máy, đừng chặn bằng niềm tin

Thêm một job CI quét diff migration, fail nếu thấy thao tác phá tương thích ngược mà MR không gắn label duyệt:

check-destructive-migration:
  stage: test
  script:
    - |
      if git diff --name-only origin/main...HEAD -- database/migrations \
         | xargs -r grep -lE 'dropColumn|dropIfExists|renameColumn'; then
        echo "❌ Migration có thao tác phá backward-compatibility."
        echo "   Tách thành release contract riêng, hoặc gắn label contract-approved."
        exit 1
      fi
  rules:
    - if: '$CI_MERGE_REQUEST_LABELS !~ /contract-approved/'

~10 dòng, chặn được cả một lớp sự cố.


Phần 3. Có nên tách migration ra repo/service riêng?

Trả lời ngắn: không tách repo. Nhưng nên tách bước chạy ra khỏi app runtime (đã nói ở mục 1.4). Hai chuyện này hay bị gộp làm một.

3.1. Vì sao giữ migration chung repo với app

Lý do Nếu tách repo thì sao
Code và schema đổi cùng nhau add column ở repo A, code đọc cột đó ở repo B → phải canh thứ tự deploy 2 pipeline bằng tay
Atomic revert (quay lui nguyên khối) Revert 1 commit → phải revert 2 repo, đúng thứ tự
Code review Reviewer thấy migration + code dùng nó trong cùng 1 MR
Local dev git clone một lần là chạy được, không phải khớp version 2 repo
Tooling có sẵn php artisan migrate, TypeORM migrate gắn chặt với app. Tách ra là mất, phải mang Flyway/Liquibase vào

Chi phí tách repo là thật và trả hàng ngày. Lợi ích chỉ có trong vài trường hợp cụ thể bên dưới.

3.2. Khi nào tách repo riêng là đúng

Chỉ khi schema không thuộc về app nào cả:

  1. Nhiều service dùng chung 1 database — service A và B cùng ghi vào orders. Ai sở hữu bảng đó? Lúc này schema cần owner riêng, repo riêng, một team chịu trách nhiệm.
  2. Có team DBA duyệt riêng — ngân hàng, fintech, compliance. Schema change đi qua quy trình approve khác với code change, khác cả người duyệt.
  3. Legacy DB nhiều app cắm vào — 1 DB, 5 app (PHP cũ, Java, cron script). Không app nào là "chủ".
  4. Data warehouse / analytics schema — bảng phục vụ báo cáo, không app nào deploy nó.

Nếu bạn đang ở trường hợp 1, câu hỏi thật không phải "tách repo hay không" mà là "tại sao 2 service share database?". Tách repo migration chỉ là băng dán lên vết thương kiến trúc — đỡ đau, không lành.


Phần 4. Case thực tế + cách xử lý

Case A — Deploy xong app lỗi, migration chỉ là expand

Ví dụ minh hoạ: deploy 14h, migration thêm cột orders.discount_code nullable. Code mới có bug tính tiền sai. 14h20 phát hiện, đã có 800 đơn ghi vào cột mới.

  1. Switch traffic về container blue (code cũ). ~5 giây.
  2. Không đụng schema. Cột discount_code ở lại, code cũ không biết nó tồn tại → không sao.
  3. 800 dòng data còn nguyên. Fix bug, deploy lại, dùng tiếp.

Nếu chạy migrate:rollback: mất sạch 800 giá trị đó, và phải giải thích với business.

Case B — Migration fail giữa chừng

Tình huống: migration có 3 câu — thêm cột, backfill data, thêm NOT NULL. Câu 3 fail vì backfill sót vài dòng NULL.

Case C — Lỡ chạy contract rồi mới cần rollback

Tình huống: migration dropColumn('users.full_name') (đã tách thành first_name/last_name từ release trước). Deploy xong app lỗi nặng, cần về code cũ — nhưng code cũ vẫn đọc full_name.

Đây là trường hợp rollback code không cứu được:

  1. Rollback code → app cũ crash vì thiếu cột. Không dùng được.
  2. Roll forward: fix bug ở code mới, deploy đè. Đường duy nhất còn lại.
  3. Nếu bug quá nặng: restore cột từ PITR sang bảng tạm → ADD COLUMN + backfill từ bảng tạm → rồi mới rollback code. Mất 30-60 phút và có downtime.

Cách phòng: migration contract đi một mình trong một release riêng, không kèm feature nào. Release đó không có gì để rollback nên rủi ro bằng 0. Deploy nó sau khi bản dùng cột mới đã chạy ổn định hết cửa sổ rollback (2 tuần là con số hợp lý cho đội 3-4 người, deploy vài lần/tuần).

Case D — Migration khoá bảng, cả site treo

Tình huống: ALTER TABLE orders ADD COLUMN status VARCHAR(20) DEFAULT 'pending' NOT NULL trên bảng 40 triệu dòng. Postgres ≥ 11 thì nhanh; Postgres 10 thì rewrite toàn bộ bảng → khoá ACCESS EXCLUSIVE vài phút → mọi query vào orders xếp hàng → app timeout hàng loạt dù chưa deploy code mới.

  1. Luôn đặt lock_timeout trước mọi migration: SET lock_timeout = '5s';. Thà migration fail nhanh còn hơn khoá bảng kéo cả hệ thống xuống.
  2. Tách: ADD COLUMN nullable (nhanh) → backfill theo lô (10k dòng/lô, có nghỉ) → ADD CONSTRAINT ... NOT VALID rồi VALIDATE CONSTRAINT (không khoá ghi).
  3. Index dùng CREATE INDEX CONCURRENTLY + $withinTransaction = false.

Chi tiết ở migration-postgresql-zero-downtime.md.

Case E — Lock có mà như không

Tình huống: pipeline có --isolated, nhưng .env production để CACHE_STORE=file. Hai MR merge cách nhau 10 giây, 2 pipeline chạy song song, cả hai đều "lấy được lock" (mỗi runner một file riêng) → migration chạy trùng.

  1. Kiểm tra ngay: php artisan tinkerconfig('cache.default').
  2. Đổi sang redis, và thêm resource_group: production_db vào job migrate.
  3. Thêm một test trong CI: fail nếu CACHE_STORE của môi trường production không nằm trong danh sách driver hỗ trợ atomic lock.

Bài học: --isolated không báo lỗi khi cache driver không phù hợp. Nó im lặng không làm gì cả.


Phần 5. Câu hỏi phụ interviewer hay hỏi tiếp

"migrate --isolated khoá ở đâu?" Ở cache (Redis/Memcached/DynamoDB/database), không phải ở database đang migrate. Nó là cơ chế chung của Artisan Isolatable command. Điểm cần nhớ: runner trượt lock thoát ngay chứ không đợi, và nếu CACHE_STORE=file thì lock vô nghĩa qua nhiều máy.

"Khác gì advisory lock của Rails/Flyway?" Advisory lock nằm trong chính database, và nó đợi đến lượt. Laravel thoát ngay. Advisory lock cũng tự tan khi session DB đóng; lock cache thì treo tới khi hết hạn (mặc định 1 tiếng, đổi bằng isolationLockExpiresAt()).

"Vậy còn viết down() không?" Có, cho local dev. Nhưng migration mất data thì để down() ném exception. Production không rollback schema.

"Làm sao biết một migration là backward-compatible?" Quy tắc: chỉ thêm, không sửa/xoá. Cột thêm phải nullable hoặc có default. Đổi tên = thêm cột mới + ghi cả hai + xoá cột cũ ở release sau. Bất cứ thao tác nào làm code N-1 chết đều là contract, và contract chỉ chạy sau cửa sổ rollback.

"Data migration (backfill) thì sao?" Tách khỏi schema migration. Chạy như một command riêng (php artisan backfill:orders --chunk=10000), dừng và chạy lại được, có ghi tiến độ. Nhét backfill 40 triệu dòng vào file migration là cách chắc chắn nhất để treo pipeline — và là loại migration nguy hiểm nhất khi chạy trùng.

"Nếu migration đã chạy nhưng deploy fail ở healthcheck?" Không sao — đó chính là lý do migration phải backward-compatible. Schema mới, code cũ vẫn chạy. Pipeline dừng, blue vẫn phục vụ traffic, không ai biết có sự cố. Migration "thừa" nằm đó chờ lần deploy sau.

"Đội bạn có bao giờ rollback migration trên production chưa?" Câu trả lời tốt: "Có một lần, và đó là lý do chúng tôi chuyển sang expand/contract." Kể case thật (Case A hoặc C) — interviewer quan tâm bạn học được gì hơn là bạn chưa bao giờ sai.


Tóm tắt một trang

CHỖ ĐỂ MIGRATION — Cùng repo với app. Tách repo chỉ khi nhiều service share DB — mà lúc đó vấn đề thật là shared database, không phải repo.

CHẠY MIGRATION — Một job riêng trong pipeline, trước khi deploy replica. KHÔNG nhét vào entrypoint container. php artisan migrate --force --isolated · CACHE_STORE = redis (không phải file!) · resource_group ở tầng CI.

ROLLBACK — Rollback CODE (đổi image tag). KHÔNG rollback schema. Expand hôm nay → contract 2 tuần sau, release riêng. Lưới an toàn là PITR, không phải down().

Đọc tiếp: