Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn đã dày công xây dựng một pipeline CI/CD hoàn hảo cho phần code ứng dụng: mỗi lần deploy đều zero-downtime, rollout từ từ, có health check, rollback trong vài giây nếu có sự cố. Ứng dụng của bạn chạy mượt mà, không một giây gián đoạn.
Nhưng rồi một ngày, bạn cần thêm một cột mới vào bảng orders — bảng có 1,2 tỷ dòng. Bạn viết một câu lệnh tưởng chừng vô hại: ALTER TABLE orders ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending'. Pipeline chạy, và... database khóa bảng lại. Toàn bộ giao dịch đọc/ghi vào orders bị treo. Trang thanh toán trả về lỗi 500. Sau 45 phút, câu lệnh mới chạy xong. Trong 45 phút đó, công ty mất hàng trăm triệu đồng doanh thu và một lượng khách hàng không quay lại.
Đây chính là nghịch lý mà nhiều DevOps engineer bỏ quên: chúng ta đầu tư rất nhiều để deploy code an toàn, nhưng lại coi migration database như một chuyện phụ. Trong khi đó, code có thể rollback dễ dàng, còn database thì mang trạng thái (stateful) — một khi dữ liệu đã thay đổi, việc quay lui vô cùng phức tạp, và một câu lệnh DDL sai có thể làm sập cả hệ thống.
Bài học này tập trung vào một câu hỏi cốt lõi: làm thế nào để đưa việc thay đổi schema database vào pipeline CI/CD một cách an toàn, tự động, và zero-downtime? Đây là kỹ năng phân biệt một DevOps engineer "biết chạy deploy" với một người thực sự hiểu cách vận hành hệ thống production ở quy mô lớn.
Khái niệm cốt lõi
Migration là gì và vì sao cần đưa vào CI/CD
Database migration là quá trình thay đổi cấu trúc (schema) hoặc dữ liệu của database một cách có kiểm soát, có phiên bản. Thay vì một DBA gõ lệnh SQL thủ công lên production lúc nửa đêm, chúng ta viết migration dưới dạng các file được version control cùng với code, và pipeline sẽ tự động áp dụng chúng.
Mỗi migration thường gồm hai phần: up (áp dụng thay đổi) và down (hoàn tác). Các công cụ phổ biến như Flyway, Liquibase, Alembic (Python), golang-migrate, Prisma Migrate, hay Rails ActiveRecord đều theo mô hình này. Chúng lưu một bảng metadata (ví dụ schema_migrations) để biết migration nào đã chạy, đảm bảo mỗi migration chỉ chạy đúng một lần.
Lợi ích khi đưa migration vào CI/CD:
- Schema database được version control, ai cũng xem được lịch sử thay đổi.
- Môi trường dev, staging, production luôn đồng bộ schema.
- Không còn cảnh "trên máy tôi chạy được mà trên production thì lỗi" vì thiếu cột.
Vấn đề then chốt: coupling giữa code version và schema version
Đây là điểm mấu chốt mà nhiều người bỏ qua. Trong một lần deploy, code cũ (version N) và code mới (version N+1) sẽ cùng tồn tại trong một khoảng thời gian — vì rolling deployment không thay tất cả instance cùng lúc. Nếu migration của bạn xóa một cột mà code cũ vẫn đang đọc, code cũ sẽ crash trong lúc rollout.
Nguyên tắc vàng: mọi thay đổi schema phải tương thích ngược (backward-compatible) với version code đang chạy. Migration và code deploy phải được thiết kế để hai version có thể sống chung.
Chiến lược Expand–Contract (Parallel Change)
Đây là pattern quan trọng nhất của bài học. Thay vì thay đổi schema một phát, ta chia thành nhiều bước nhỏ, mỗi bước đều an toàn:
- Expand: Thêm cấu trúc mới mà không xóa cái cũ. Ví dụ: thêm cột mới, thêm bảng mới. Ở bước này, schema hỗ trợ cả code cũ lẫn code mới.
- Migrate + Deploy code: Deploy code mới biết dùng cả cột cũ lẫn cột mới; backfill dữ liệu từ cấu trúc cũ sang mới.
- Contract: Sau khi chắc chắn không còn code nào dùng cấu trúc cũ, mới xóa nó đi.
username thành user_name:
- Bước 1 (Expand): thêm cột
user_name, viết trigger hoặc code ghi vào cả hai cột. - Bước 2: backfill
user_name = usernamecho dữ liệu cũ; deploy code đọcuser_name. - Bước 3 (Contract): xóa cột
usernamesau vài ngày, khi chắc chắn ổn định.
Vì sao "ADD NOT NULL column" lại nguy hiểm
Quay lại tình huống mở đầu. Trên PostgreSQL cũ (trước 11) và MySQL, việc thêm cột NOT NULL với giá trị mặc định buộc database phải ghi lại (rewrite) toàn bộ bảng và giữ khóa (lock) trong suốt quá trình. Với 1 tỷ dòng, điều này có thể mất hàng chục phút, và trong thời gian đó bảng bị khóa hoàn toàn.
Cách an toàn:
ADD COLUMN status VARCHAR(20)— nullable, thao tác này nhanh vì chỉ đổi metadata.- Backfill dữ liệu theo từng batch nhỏ (ví dụ 10.000 dòng mỗi lần), có nghỉ giữa các batch để không gây áp lực lên database.
- Sau khi backfill xong, thêm ràng buộc NOT NULL (trên PostgreSQL 12+ có thể dùng
SET NOT NULLvớiNOT VALIDrồiVALIDATE CONSTRAINTđể giảm khóa).
Migration có nên tách khỏi deploy code không?
Có. Best practice là migration chạy như một bước riêng trong pipeline, không nhồi chung vào lệnh khởi động ứng dụng. Nếu bạn để mỗi container tự chạy migration lúc start, bạn sẽ gặp race condition khi 20 pod cùng cố chạy một migration. Hãy dùng một Kubernetes Job hoặc một stage CI riêng chạy migration đúng một lần, trước hoặc sau khi deploy code tùy chiến lược.
Tình huống thực tế
Tình huống 1: Sàn TMĐT tại Việt Nam khóa bảng orders giờ cao điểm
Một sàn thương mại điện tử lớn ở TP.HCM (giả định gọi là ShopViet) chạy trên PostgreSQL 10, bảng orders khoảng 800 triệu dòng. Team backend cần thêm cột delivery_zone để phục vụ tính năng giao hàng nhanh. Bạn dev viết migration ADD COLUMN delivery_zone INT NOT NULL DEFAULT 0 và merge vào main. Pipeline deploy lúc 20h — đúng giờ cao điểm mua sắm.
Kết quả: PostgreSQL 10 phải rewrite toàn bộ bảng, giữ ACCESS EXCLUSIVE lock. Trong 38 phút, mọi đơn hàng mới đều thất bại, giỏ hàng không cập nhật được. Team phải kill câu lệnh, nhưng lúc đó bảng đã trong trạng thái nửa vời và cần thêm thời gian dọn dẹp.
Bài học rút ra: Không bao giờ chạy migration nặng vào giờ cao điểm, và không bao giờ thêm cột NOT NULL kèm DEFAULT trên bảng lớn với PostgreSQL cũ. Cách đúng là tách thành: thêm cột nullable (nhanh), backfill theo batch ngoài giờ cao điểm, rồi mới siết NOT NULL. Sau sự cố này, ShopViet đưa thêm một cổng kiểm tra (linter) vào CI để tự động chặn các câu lệnh DDL nguy hiểm.
Tình huống 2: Startup fintech Singapore và bài học Expand–Contract
Một startup fintech ở Singapore (gọi là PayFlow) cần đổi kiểu dữ liệu cột amount từ INTEGER (lưu số cent) sang BIGINT vì giao dịch của khách doanh nghiệp vượt ngưỡng 2,1 tỷ. Dev ban đầu định ALTER COLUMN amount TYPE BIGINT trực tiếp — nhưng đây là thao tác rewrite bảng, và bảng transactions có 400 triệu dòng.
Team quyết định áp dụng Expand–Contract:
- Expand: thêm cột
amount_big BIGINT, sửa code ghi vào cảamountvàamount_big. - Migrate: chạy backfill job copy
amountsangamount_bigtheo batch 50.000 dòng, tổng cộng mất 6 giờ chạy nền, không ảnh hưởng traffic. - Deploy code mới đọc từ
amount_big. - Contract: sau 5 ngày theo dõi, xóa cột
amountcũ.
Bài học rút ra: Với hệ thống tài chính nơi mỗi giây downtime là mất tiền và mất niềm tin, việc chia nhỏ thay đổi thành các bước tương thích ngược không phải là "làm quá" mà là yêu cầu bắt buộc. Sự rườm rà của quy trình đổi lấy sự an toàn tuyệt đối.
Tình huống 3: Migration thất bại nửa chừng và bài toán rollback
Một công ty SaaS làm phần mềm quản lý nhà hàng (gọi là MenuOne) có pipeline chạy migration ngay trước khi deploy code. Một migration thêm 3 index vào bảng lớn chạy được 2 index thì timeout ở index thứ 3 do hết dung lượng tạm. Vấn đề: migration này không nằm trong một transaction (vì MySQL không hỗ trợ DDL transactional), nên database rơi vào trạng thái "2 index đã tạo, 1 chưa". Khi retry, migration lỗi vì 2 index đầu đã tồn tại.
Team phải vào sửa tay: dùng CREATE INDEX IF NOT EXISTS và chia mỗi index thành một migration riêng để mỗi bước độc lập, idempotent (chạy lại nhiều lần vẫn ra kết quả như nhau).
Bài học rút ra: Mỗi migration nên nhỏ, độc lập và idempotent. Đừng gộp nhiều thao tác DDL nặng vào một file. Và luôn nhớ rằng nhiều DDL trên MySQL không thể rollback tự động — bạn cần thiết kế migration sao cho có thể retry an toàn.
Hướng dẫn từng bước
Dưới đây là quy trình thực hành để tích hợp migration an toàn vào pipeline CI/CD:
Bước 1 — Chọn công cụ và cấu trúc migration. Chọn một công cụ phù hợp ngôn ngữ (Flyway/Liquibase cho JVM, Alembic cho Python, golang-migrate cho Go). Đặt các file migration vào repo, đánh số thứ tự rõ ràng (ví dụ V001__add_delivery_zone.sql). Mỗi file có phần up và down.
Bước 2 — Viết migration tương thích ngược. Trước khi viết, tự hỏi: "Nếu code version cũ đang chạy gặp schema mới này, nó có crash không?". Nếu câu trả lời là có, hãy tách theo Expand–Contract. Ưu tiên: thêm mới trước, xóa sau; cột mới luôn nullable ban đầu.
Bước 3 — Thêm cổng kiểm tra vào CI. Dùng công cụ như squawk (PostgreSQL) hoặc một linter tự viết để tự động phát hiện các thao tác nguy hiểm: thêm NOT NULL column, drop column, rename, thao tác giữ lock lâu. Nếu phát hiện, pipeline fail và bắt dev xem lại.
Bước 4 — Chạy migration ở stage riêng. Trong pipeline, tạo một stage migrate tách biệt, chạy trước stage deploy. Trên Kubernetes, dùng một Job có restartPolicy: Never và lệnh migrate. Đảm bảo chỉ một instance chạy migration (dùng advisory lock của database để tránh race condition).
Bước 5 — Test migration trên bản sao production. Trước khi lên production, chạy migration trên một database staging có kích thước gần bằng production để đo thời gian và phát hiện lock. Đừng bao giờ để production là nơi đầu tiên migration được chạy trên dữ liệu thật quy mô lớn.
Bước 6 — Backfill tách khỏi migration schema. Với thay đổi dữ liệu lớn, đừng nhồi vào migration schema. Viết một job backfill riêng, chạy theo batch, có thể theo dõi tiến độ và tạm dừng nếu database quá tải.
Bước 7 — Giám sát và có kế hoạch rollback. Sau khi migration chạy, theo dõi độ trễ query, lock, replication lag. Chuẩn bị sẵn migration down hoặc kế hoạch khôi phục. Nhớ: với thay đổi phá hủy dữ liệu (drop column), rollback không lấy lại được dữ liệu — nên contract chỉ làm khi đã hoàn toàn chắc chắn.
Lỗi thường gặp & mẹo
Lỗi 1: Deploy code và migration cùng lúc theo kiểu "big bang". Nhiều team nghĩ deploy code mới và schema mới đồng thời là gọn. Nhưng trong lúc rollout, code cũ và mới sống chung, gây crash. Mẹo: luôn tách thành ít nhất hai lần release — release Expand trước, release Contract sau.
Lỗi 2: Để ứng dụng tự chạy migration lúc khởi động. Gây race condition khi nhiều pod cùng start. Mẹo: dùng init container, Kubernetes Job, hoặc CI stage riêng, kết hợp advisory lock để đảm bảo chạy đúng một lần.
Lỗi 3: Migration không idempotent. Khi retry sau lỗi, migration chạy lại từ đầu và fail vì object đã tồn tại. Mẹo: dùng IF NOT EXISTS, IF EXISTS, chia nhỏ mỗi migration một thao tác.
Lỗi 4: Backfill trong cùng transaction với migration schema. Một UPDATE toàn bộ bảng 500 triệu dòng sẽ giữ lock và làm phình transaction log. Mẹo: tách backfill ra, chạy theo batch nhỏ với LIMIT và vòng lặp có nghỉ.
Lỗi 5: Không test trên dữ liệu quy mô thật. Migration chạy 0,2 giây trên dev với 100 dòng có thể mất 40 phút trên production với 1 tỷ dòng. Mẹo: luôn có một môi trường staging kích thước gần production.
Mẹo bổ sung: Với PostgreSQL, dùng CREATE INDEX CONCURRENTLY để tạo index không khóa bảng (nhưng nhớ nó không chạy được trong transaction). Với MySQL, cân nhắc pt-online-schema-change hoặc gh-ost (của GitHub) để thay đổi schema online mà không khóa bảng lâu.
Bài tập thực hành
- Thiết kế Expand–Contract: Bạn cần đổi tên cột
phonethànhphone_numbertrên bảnguserscó 50 triệu dòng, hệ thống đang chạy 30 pod. Hãy viết ra chi tiết các bước migration và các lần deploy code tương ứng, chỉ rõ ở mỗi bước code version cũ và mới cùng chạy được như thế nào.
- Sửa migration nguy hiểm: Cho câu lệnh
ALTER TABLE payments ADD COLUMN currency CHAR(3) NOT NULL DEFAULT 'VND'trên bảng 300 triệu dòng (PostgreSQL 11). Hãy viết lại thành chuỗi các bước an toàn, zero-downtime.
- Viết CI gate: Phác thảo một script (giả mã cũng được) kiểm tra file migration
.sqlvà fail pipeline nếu phát hiệnDROP COLUMN,ADD COLUMN ... NOT NULLkhông kèm giá trị mặc định an toàn, hoặcALTER COLUMN ... TYPE.
- Kịch bản rollback: Migration của bạn đã chạy trên production và deploy code mới xong, nhưng sau 2 giờ phát hiện bug logic. Code có thể rollback, nhưng migration đã thêm một cột NOT NULL mà code cũ không biết. Hãy mô tả bạn sẽ xử lý thế nào để rollback code an toàn — và giải thích vì sao thiết kế Expand–Contract từ đầu sẽ giúp bạn tránh tình huống này.
Tóm tắt
Database migration là mắt xích dễ bị xem nhẹ nhưng nguy hiểm nhất trong pipeline CI/CD, vì database mang trạng thái và rất khó rollback. Những điểm cốt lõi cần nhớ:
- Code có thể rollback dễ, database thì không — mọi thay đổi schema phải được thiết kế cẩn thận để tương thích ngược với version code đang chạy.
- Expand–Contract (Parallel Change) là pattern chủ đạo để đạt zero-downtime: thêm cấu trúc mới, migrate và deploy code, rồi mới xóa cấu trúc cũ ở release sau.
- Các thao tác như thêm cột NOT NULL, đổi kiểu cột, thêm index trên bảng lớn có thể khóa bảng hàng chục phút — hãy tách nhỏ, backfill theo batch, và dùng công cụ online như
CREATE INDEX CONCURRENTLY,gh-ost,pt-online-schema-change. - Chạy migration ở một stage riêng với cơ chế lock để tránh race condition, không để ứng dụng tự migrate lúc khởi động.
- Migration phải nhỏ, độc lập, idempotent, và luôn được test trên môi trường có quy mô dữ liệu gần production.
- Thêm CI gate để tự động chặn các câu lệnh DDL nguy hiểm trước khi chúng chạm production.