Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn đang làm việc tại một công ty thương mại điện tử ở TP.HCM. Đội ngũ đã đầu tư rất nhiều để có một pipeline CD (Continuous Delivery) mượt mà: code merge vào nhánh chính, pipeline tự động chạy, và chỉ sau vài phút phiên bản mới đã lên production. Ứng dụng deploy nhanh như chớp. Nhưng có một "hành khách" thường bị bỏ quên trên chuyến tàu tốc hành này: cơ sở dữ liệu.
Vấn đề nằm ở chỗ: code thì stateless (không lưu trạng thái), bạn có thể triển khai rồi rollback (quay lui) trong tích tắc mà không để lại dấu vết. Nhưng database thì stateful — nó chứa dữ liệu thật của khách hàng, đơn hàng, giao dịch. Khi bạn thay đổi schema (cấu trúc bảng, cột, index) sai cách trong một lần deploy, hậu quả không chỉ là "lỗi hiển thị" mà có thể là downtime toàn hệ thống hoặc tệ hơn — mất dữ liệu vĩnh viễn.
Đây chính là nút thắt cổ chai của rất nhiều đội DevOps. Bạn đã tự động hóa việc deploy code, nhưng nếu migration database vẫn phải chạy tay lúc nửa đêm, phải "khấn" cho nó không lỗi, thì pipeline của bạn vẫn chưa thực sự an toàn và liên tục. Bài học này sẽ giúp bạn hiểu cách đưa database migration vào trong CD pipeline một cách bài bản, để mỗi lần deploy đều dự đoán được, an toàn, và không gây gián đoạn dịch vụ.
Khái niệm cốt lõi
Database migration là gì?
Migration là một tập hợp các thay đổi có phiên bản (versioned) đối với schema database: thêm bảng, thêm cột, đổi tên cột, tạo index, thêm ràng buộc (constraint), hoặc thậm chí biến đổi dữ liệu (data transformation). Thay vì sửa database thủ công qua giao diện, bạn viết migration dưới dạng file code — mỗi file mô tả một bước thay đổi và cách hoàn tác nó.
Các công cụ phổ biến bạn sẽ gặp: Flyway và Liquibase (thế giới Java/JVM), Alembic (Python), Prisma Migrate và TypeORM (Node.js), golang-migrate (Go), hay các migration tích hợp sẵn trong framework như Laravel, Rails, Django. Điểm chung của tất cả: mỗi migration có một số thứ tự/timestamp, và công cụ ghi lại migration nào đã chạy trong một bảng theo dõi (thường tên là schema_migrations hoặc flyway_schema_history).
Ba nguyên tắc vàng khi migration trong CD
Điều làm cho migration trong CD khác với migration thủ công là bạn phải đảm bảo ba nguyên tắc sau:
1. Backward compatibility (tương thích ngược). Trong một lần deploy, sẽ có khoảnh khắc code cũ và code mới cùng chạy song song (đặc biệt với rolling update). Schema mới phải hoạt động được với cả code cũ lẫn code mới. Nếu bạn xóa một cột mà code cũ vẫn đang đọc, code cũ sẽ crash ngay lập tức.
2. Idempotency và versioning (tính bất biến khi lặp lại và phiên bản hóa). Chạy lại migration đã chạy rồi thì không được gây lỗi hay thay đổi thêm. Công cụ migration phải biết rõ trạng thái hiện tại của database để chỉ áp dụng đúng những gì còn thiếu.
3. Reversibility hoặc forward-fix (khả năng hoàn tác hoặc sửa tiến). Với schema, "rollback" không đơn giản như code. Xóa một cột rồi muốn khôi phục dữ liệu đã mất là bất khả thi. Vì vậy triết lý hiện đại thiên về forward-only migration — luôn tiến về phía trước bằng migration mới để sửa, thay vì cố quay lui.
Vấn đề cốt lõi: thay đổi không tương thích (breaking change)
Đây là trái tim của bài học. Một số thay đổi schema về bản chất là phá vỡ tương thích:
- Xóa cột / xóa bảng.
- Đổi tên cột (thực chất = xóa cột cũ + thêm cột mới).
- Thu hẹp kiểu dữ liệu (ví dụ
VARCHAR(255)xuốngVARCHAR(50)). - Thêm ràng buộc
NOT NULLvào cột đang có giá trị null. - Thêm khóa ngoại (foreign key) hoặc
UNIQUEconstraint lên dữ liệu chưa "sạch".
Nguyên tắc tách rời: deploy schema và deploy code
Bài học quan trọng nhất về mặt tư duy: migration schema và deploy code phải được tách thành các bước độc lập trong pipeline, không gộp làm một. Migration nên chạy trước khi code mới nhận traffic, và schema đó phải tương thích với cả code cũ đang chạy lẫn code mới sắp lên. Đây là điều kiện tiên quyết để có zero-downtime deployment.
Tình huống thực tế
Tình huống 1: Tiki và sự cố cột NOT NULL
Giả định một đội backend tại một sàn TMĐT lớn của Việt Nam (gọi là "sàn T") cần thêm trường phone_verified (BOOLEAN NOT NULL) vào bảng users có 12 triệu bản ghi. Một kỹ sư viết migration:
ALTER TABLE users ADD COLUMN phone_verified BOOLEAN NOT NULL;
Pipeline chạy migration này lúc 2 giờ chiều, giờ cao điểm mua sắm. Hai vấn đề xảy ra cùng lúc. Thứ nhất, với 12 triệu dòng, MySQL (phiên bản cũ) khóa bảng users để viết lại toàn bộ — mọi request đăng nhập, đăng ký bị treo suốt 4 phút. Thứ hai, câu lệnh thất bại vì NOT NULL không có DEFAULT, database không biết điền gì cho 12 triệu dòng hiện có. Migration rollback nửa chừng, để lại schema ở trạng thái không nhất quán.
Diễn giải: Lỗi ở đây là gộp quá nhiều thay đổi vào một bước và không tính đến dữ liệu đang tồn tại. Cách đúng: thêm cột nullable (hoặc có DEFAULT false) trước, backfill dữ liệu theo lô (batch) ngoài giờ cao điểm, rồi mới thêm ràng buộc NOT NULL ở migration sau.
Bài học rút ra: Thay đổi schema trên bảng lớn không bao giờ là thao tác "một lệnh". Luôn tách thành nhiều migration nhỏ, chạy đúng thời điểm, và ưu tiên online DDL (như pt-online-schema-change hoặc gh-ost cho MySQL).
Tình huống 2: Startup fintech Singapore và bài toán đổi tên cột
Một startup fintech ở Singapore phục vụ thị trường Đông Nam Á cần đổi tên cột cust_name thành customer_full_name cho rõ nghĩa. Kỹ sư mới, quen tư duy "code deploy được thì database cũng vậy", viết một migration:
ALTER TABLE customers RENAME COLUMN cust_name TO customer_full_name;
Rồi deploy cùng lúc với code mới đọc customer_full_name. Pipeline dùng rolling update trên Kubernetes với 6 pod. Trong 90 giây rolling, có 3 pod chạy code cũ (vẫn đọc cust_name) và 3 pod chạy code mới. Ngay khi migration chạy xong, 3 pod code cũ đồng loạt báo lỗi column "cust_name" does not exist, khách hàng thấy màn hình lỗi khi tra cứu tài khoản. Tỷ lệ lỗi (error rate) vọt lên 40% trong gần 2 phút.
Diễn giải: Đổi tên trực tiếp là breaking change điển hình. Giải pháp Expand-Contract: (1) thêm cột mới customer_full_name, copy dữ liệu sang; (2) deploy code ghi vào cả hai cột, đọc từ cột mới; (3) sau khi ổn định vài ngày, deploy code chỉ dùng cột mới; (4) migration cuối cùng xóa cột cũ. Quá trình trải qua 3–4 lần deploy nhưng không có một giây downtime nào.
Bài học rút ra: Trong CD, "nhanh" không có nghĩa là "gộp". Một thay đổi phá vỡ tương thích cần được chia thành chuỗi các bước tương thích ngược, mỗi bước đi kèm một lần deploy.
Tình huống 3: Migration tự động và chuyện "ai chạy migration"
Một công ty SaaS B2B ở Hà Nội cấu hình pipeline sao cho mỗi pod ứng dụng tự chạy migration khi khởi động (một anti-pattern khá phổ biến với Prisma/TypeORM). Khi họ scale lên 8 pod và deploy phiên bản mới, cả 8 pod cùng khởi động và cùng cố chạy migration một lúc. Kết quả: race condition (đua tranh) — hai pod cùng thử CREATE INDEX, một pod thành công, pod kia báo lỗi và crash, kích hoạt vòng lặp restart. Deploy "kẹt" ở trạng thái nửa vời suốt 15 phút.
Diễn giải: Migration phải được chạy đúng một lần, ở một nơi duy nhất, trước khi các pod ứng dụng khởi động. Cách đúng là dùng một bước riêng trong pipeline (một Kubernetes Job, hoặc một init-container có cơ chế lock), chạy tuần tự trước khi rollout ứng dụng. Đa số công cụ migration hiện đại đã có locking tích hợp, nhưng bạn vẫn nên tập trung migration vào một bước duy nhất thay vì phó mặc cho từng pod.
Bài học rút ra: Câu hỏi "ai chạy migration và chạy khi nào" phải được trả lời rõ ràng trong thiết kế pipeline, không để mặc định.
Hướng dẫn từng bước
Dưới đây là quy trình chuẩn để tích hợp database migration an toàn vào một CD pipeline, kèm ví dụ với GitHub Actions.
Bước 1: Đưa migration vào version control cùng code
Đặt các file migration trong cùng repository với code ứng dụng (thư mục như db/migrations/). Mỗi thay đổi schema là một file mới, được review trong cùng pull request với code sử dụng nó. Điều này đảm bảo schema và code luôn đồng bộ về mặt phiên bản.
Bước 2: Áp dụng Expand-Contract cho breaking change
Với bất kỳ thay đổi phá vỡ tương thích nào, chia thành ba giai đoạn:
- Expand (mở rộng): Thêm cấu trúc mới (cột/bảng mới) mà không đụng đến cái cũ. Deploy migration này trước. Schema lúc này tương thích với cả code cũ và code mới.
- Migrate + dual-write: Deploy code ghi vào cả cấu trúc cũ lẫn mới, đọc từ mới. Backfill dữ liệu cũ sang cấu trúc mới theo lô.
- Contract (thu hẹp): Sau khi chắc chắn không còn code nào dùng cấu trúc cũ, deploy migration cuối để xóa nó.
Bước 3: Tách bước migration ra khỏi bước deploy code trong pipeline
Trong workflow, migration là một job chạy trước, deploy ứng dụng là job phụ thuộc chạy sau:
jobs:
migrate:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Run DB migrations
run: flyway -url=${{ secrets.DB_URL }} -user=${{ secrets.DB_USER }} \
-password=${{ secrets.DB_PASS }} migrate
deploy:
needs: migrate # chỉ deploy sau khi migrate thành công
runs-on: ubuntu-latest
steps:
- name: Rollout new version
run: kubectl rollout restart deployment/app
Lưu ý needs: migrate — nếu migration thất bại, deploy code sẽ không chạy, tránh việc code mới lên nhưng schema chưa sẵn sàng.
Bước 4: Backup trước migration nguy hiểm
Trước các migration có rủi ро (đặc biệt các migration có DROP hoặc biến đổi dữ liệu), thêm một bước snapshot/backup tự động. Với cloud như AWS RDS, một lệnh tạo snapshot trước khi migrate là mạng lưới an toàn cuối cùng.
Bước 5: Chạy migration ở môi trường thấp trước
Migration phải chạy qua staging (và các môi trường thấp khác) trong pipeline trước khi tới production. Điều này bắt được lỗi cú pháp, lỗi khóa bảng, và thời gian chạy quá lâu trước khi chúng chạm dữ liệu thật.
Bước 6: Kiểm thử migration trên dữ liệu giống production
Với bảng lớn, hãy test migration trên một bản sao dữ liệu có kích thước tương đương production để đo thời gian chạy và mức độ khóa. Một migration chạy 2 giây trên staging (1.000 dòng) có thể chạy 8 phút trên production (10 triệu dòng).
Lỗi thường gặp & mẹo
Lỗi 1: Rollback code mà quên schema. Bạn rollback code từ v2 về v1, nhưng schema đã ở trạng thái v2. Nếu v2 đã xóa một cột mà v1 cần, rollback code sẽ crash. Mẹo: luôn giữ schema tương thích ngược ít nhất một phiên bản, để rollback code không kéo theo thảm họa.
Lỗi 2: Migration khóa bảng lâu. Trên PostgreSQL, thêm cột có DEFAULT trên bản cũ, hay tạo index không dùng CONCURRENTLY, sẽ khóa bảng. Mẹo: dùng CREATE INDEX CONCURRENTLY, thêm cột nullable trước, và với MySQL cân nhắc gh-ost / pt-online-schema-change.
Lỗi 3: Data migration nặng chạy đồng bộ trong pipeline. Backfill 50 triệu dòng trong một transaction sẽ làm pipeline treo và có thể timeout. Mẹo: tách data backfill thành job nền chạy theo lô (batch), không chặn quá trình deploy. Migration schema thì nhanh, còn "dời" dữ liệu thì làm bất đồng bộ.
Lỗi 4: Không có bước xác minh sau migration. Migration "thành công" về mặt kỹ thuật nhưng dữ liệu sai. Mẹo: thêm smoke test kiểm tra schema và một vài truy vấn chủ chốt ngay sau migration.
Lỗi 5: Secret database lộ trong log pipeline. Chuỗi kết nối chứa mật khẩu bị in ra log. Mẹo: luôn dùng secret manager của CI (như GitHub Secrets) và không bao giờ echo chuỗi kết nối.
Mẹo vàng: Đặt ra "hợp đồng" trong đội: không migration nào được merge nếu nó không tương thích ngược với phiên bản code đang chạy trên production. Đây là quy tắc đơn giản nhưng loại bỏ 90% sự cố migration.
Bài tập thực hành
Bài 1 — Thiết kế Expand-Contract. Bạn cần đổi cột status (kiểu INT: 0,1,2) trong bảng orders sang cột status_text (kiểu VARCHAR: 'pending','paid','shipped'). Hãy viết ra chuỗi các migration và các lần deploy code theo mô hình Expand-Contract, chỉ rõ ở mỗi bước schema tương thích với code nào.
Bài 2 — Viết pipeline tách bước. Dựng một workflow GitHub Actions (hoặc GitLab CI) có ba job: migrate-staging, test-staging, và migrate-deploy-production, trong đó production chỉ chạy sau khi staging pass. Đảm bảo migration chạy trước deploy code trong môi trường production.
Bài 3 — Phân tích sự cố. Cho tình huống: đội bạn thêm UNIQUE constraint lên cột email của bảng users 5 triệu dòng, migration thất bại vì có 1.200 email trùng. Hãy mô tả quy trình bạn sẽ dùng để (a) làm sạch dữ liệu trùng an toàn và (b) thêm constraint mà không gây downtime.
Tóm tắt
Database migration là mắt xích dễ bị bỏ quên nhất nhưng lại nguy hiểm nhất trong CD pipeline, vì database là stateful — sai lầm để lại hậu quả về dữ liệu, không thể "rollback sạch" như code. Ba nguyên tắc cốt lõi cần khắc cốt ghi tâm: tương thích ngược (schema mới phải chạy được với cả code cũ), tách bước migration khỏi deploy code (migration chạy trước, ở một nơi duy nhất), và forward-fix thay vì cố rollback schema. Với mọi thay đổi phá vỡ tương thích — xóa cột, đổi tên, thêm ràng buộc — kỹ thuật Expand-Contract là kim chỉ nam: chia một thay đổi lớn thành chuỗi các bước nhỏ tương thích ngược, mỗi bước một lần deploy. Kết hợp thêm backup trước migration nguy hiểm, chạy qua staging trước, test trên dữ liệu cỡ production, và xác minh sau migration, bạn sẽ biến việc "khấn cho migration không lỗi lúc nửa đêm" thành một quy trình tự động, dự đoán được và an toàn.