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:
- Lock —
php artisan migrate --isolatedkhoá cái gì, chống được gì, và không chống được gì. - 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?
- 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):
- Lock ID mặc định lấy từ tên command. Đổi được bằng method
isolatableId()— dùng khi muốn gộp argument/option vào ID (ví dụ mỗi tenant một lock riêng). - Lock được nhả khi command chạy xong. Nếu command bị cắt ngang (kill, runner chết, container bị OOM), lock tự hết hạn sau 1 tiếng.
- Đổi thời gian hết hạn bằng
isolationLockExpiresAt():
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:
- Redis restart / bị
FLUSHALLgiữa lúc migrate → lock biến mất → runner khác chen vào được. - Cache và database là hai hệ thống khác nhau. Lock nói "không ai đang chạy migrate", nhưng nó không biết gì về trạng thái thật của database.
Đâ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:
-
CACHE_STORElàredis/memcached/database— không phảifile - Mọi runner/máy dùng chung một cache server
-
migratechạy ở stage riêng, không nằm trong entrypoint container - Migration viết idempotent (chạy lại được) khi có thể:
if (!Schema::hasColumn(...)) -
isolationLockExpiresAt()đặt ngắn lại (5-10 phút) nếu migration của bạn luôn nhanh — mặc định 1 tiếng là quá dài khi runner chết giữa chừng - Có
resource_group(GitLab) hoặc concurrency group (GitHub Actions)
1.6. Bên NestJS/TypeORM thì sao
TypeORM không có cơ chế lock sẵn cho migration:run. Phải tự lo:
- Dùng advisory lock của PostgreSQL trong migration đầu tiên:
SELECT pg_advisory_lock(12345)— nó đợi, đúng hành vi mong muốn. - Hoặc dựa hoàn toàn vào
resource_groupcủa CI + chạy migrate ở 1 job duy nhất.
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 | 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
- Laravel: bảng
migrationslưu(migration, batch).php artisan migrate:rollbackgỡ cả batch cuối — deploy vừa chạy 4 file thì cả 4 cùng batch, một lệnh gỡ hết 4. Muốn chọn lọc:--step=5(gỡ 5 migration gần nhất) hoặc--batch=3(gỡ đúng batch 3). Luôn xem trước bằng--pretend(in ra SQL, không chạy). - TypeORM: bảng
migrationslưu(timestamp, name).migration:revertgỡ đúng 1 cái cuối, muốn gỡ 3 thì chạy 3 lần.
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
- Mất data không hồi được.
down()củaadd columnlàdrop 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. 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.- Rollback quá tay (Laravel): chỉ muốn gỡ 1, gõ
migrate:rollback→ mất cả batch. - MySQL không cứu được: mỗi DDL implicit commit, fail giữa chừng để lại
schema dở dang mà
migrationstable chưa ghi nhận. - Đ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.
- Khoá bảng:
down()chạyALTER TABLEtrê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:
- Production có traffic thật → C, không bàn thêm.
- Lỗi không nằm ở schema (bug logic, config sai) → B. Đừng đụng DB.
- Local/CI dùng DB tạm → A thoải mái,
migrate:freshcòn nhanh hơn. - Chỉ dùng A trên production khi: migration vừa chạy, chưa có app version
mới nào nhận traffic, và bạn đã đọc
down()xác nhận nó không xoá data.
Chi phí thật của C là kỷ 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()
Là 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ả:
- 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. - 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.
- 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ủ".
- 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.
- Switch traffic về container blue (code cũ). ~5 giây.
- Không đụng schema. Cột
discount_codeở lại, code cũ không biết nó tồn tại → không sao. - 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.
- PostgreSQL: Laravel bọc mỗi migration trong transaction (Postgres cho phép
DDL trong transaction) → DB tự rollback sạch,
migrationskhông ghi. Pipeline fail ở stage migrate, chưa deploy app. Sửa rồi chạy lại. Không thành sự cố. Lưu ý: phải đặt$withinTransaction = falsetrên class migration khi dùngCREATE INDEX CONCURRENTLY— lệnh đó không chạy trong transaction được. - MySQL: câu 1 và 2 đã commit, câu 3 fail. Schema có cột mới, có data, thiếu
constraint,
migrationschưa ghi nhận. Chạy lại y nguyên → fail ở câu 1 ("column already exists").- Xử lý: sửa migration cho idempotent (
if (!Schema::hasColumn(...))) rồi chạy lại. Hoặc chèn tay dòng vàomigrationsrồi viết migration mới làm nốt phần thiếu. - Bài học: trên MySQL, 1 migration = 1 thay đổi. Đừng gộp 3 việc vào 1 file.
- Xử lý: sửa migration cho idempotent (
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:
- Rollback code → app cũ crash vì thiếu cột. Không dùng được.
- Roll forward: fix bug ở code mới, deploy đè. Đường duy nhất còn lại.
- 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.
- Luôn đặt
lock_timeouttrướ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. - Tách:
ADD COLUMNnullable (nhanh) → backfill theo lô (10k dòng/lô, có nghỉ) →ADD CONSTRAINT ... NOT VALIDrồiVALIDATE CONSTRAINT(không khoá ghi). - 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.
- Kiểm tra ngay:
php artisan tinker→config('cache.default'). - Đổi sang
redis, và thêmresource_group: production_dbvào job migrate. - Thêm một test trong CI: fail nếu
CACHE_STOREcủ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:
- Kỹ thuật viết migration an toàn: migration-postgresql-zero-downtime.md
- Pipeline và
deploy.sh: laravel-gitlab-cicd.md - Chọn chiến lược deploy: chien-luoc-deployment.md
- Branch/tag đưa code qua các môi trường: git-workflow.md