PostgreSQL Bài 48: Quản lý Schema Migration, Rollback an toàn và CI/CD cho Database
Trong quá trình phát triển phần mềm, mã nguồn ứng dụng (Source code) luôn được quản lý phiên bản chặt chẽ bằng Git. Tuy nhiên, cơ sở dữ liệu lại thường bị bỏ quên. Việc một DBA hoặc lập trình viên kết nối trực tiếp vào Production và gõ lệnh ALTER TABLE thủ công là công thức hoàn hảo cho một thảm họa: không có lịch sử thay đổi, không thể đồng bộ giữa các môi trường (Dev, Staging, Prod), và cực kỳ rủi ro khi xảy ra sự cố.
Để đưa cơ sở dữ liệu vào quy trình chuẩn công nghiệp, chúng ta sử dụng Schema Migration (Di trú lược đồ) kết hợp với CI/CD Pipeline. Bài học này sẽ hướng dẫn cách quản lý vòng đời cơ sở dữ liệu một cách an toàn tuyệt đối trên PostgreSQL.
1. Bản chất của Schema Migration
Schema Migration biến cơ sở dữ liệu thành Code (Database-as-Code). Mỗi sự thay đổi cấu trúc (thêm bảng, xóa cột, tạo index) đều được viết thành một file kịch bản (script) và được đánh số phiên bản tăng dần.
Một file migration tiêu chuẩn thường bao gồm 2 phần:
-
Up Script: Các lệnh SQL để áp dụng sự thay đổi (ví dụ:
CREATE TABLE). -
Down Script (Rollback): Các lệnh SQL để hoàn tác lại sự thay đổi của phần Up (ví dụ:
DROP TABLE).
Các công cụ Migration phổ biến như Flyway, golang-migrate, hoặc các ORM tích hợp sẵn (như Laravel Migrations) hoạt động theo nguyên lý:
-
Tạo một bảng hệ thống ngầm (ví dụ:
schema_migrations) trong database để ghi nhớ mốc phiên bản hiện tại. -
Quét thư mục chứa code migration.
-
So sánh và chỉ chạy các file migration có phiên bản lớn hơn phiên bản đang lưu trong database.
2. Siêu năng lực độc quyền của PostgreSQL: Transactional DDL
Nếu bạn đã từng dùng MySQL hay Oracle, bạn sẽ biết một sự thật đau lòng: Các lệnh DDL (Data Definition Language) như ALTER TABLE, CREATE TABLE không thể được đưa vào Transaction. Ngay khi lệnh DDL chạy, nó sẽ tự động COMMIT. Nếu bạn đang chạy một script dài 10 lệnh, đến lệnh thứ 5 bị lỗi cú pháp, 4 lệnh đầu tiên vẫn bị ghi chết vào hệ thống và không thể hoàn tác, để lại database ở trạng thái "nửa vời".
PostgreSQL hỗ trợ Transactional DDL hoàn chỉnh. Nghĩa là bạn hoàn toàn có thể bọc các lệnh thay đổi cấu trúc bên trong khối BEGIN ... COMMIT.
SQL
BEGIN;
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE users ADD COLUMN address TEXT;
-- Giả sử dòng dưới bị lỗi đánh máy (bảng department không tồn tại)
ALTER TABLE department ADD COLUMN status INT;
COMMIT;
Trên PostgreSQL, nếu lệnh thứ 3 thất bại, toàn bộ khối lệnh sẽ bị ROLLBACK. Cột phone và address của bảng users sẽ biến mất, trả lại database nguyên vẹn 100% như chưa từng có cuộc chạm trán. Tính năng này giúp các công cụ Migration trên PostgreSQL hoạt động an toàn hơn bất kỳ RDBMS nào khác.
3. Cạm bẫy Migration gây Downtime (Zero-Downtime Migration)
Khi hệ thống có hàng ngàn user đang hoạt động, một file migration viết sai cách có thể khóa cứng (Lock) bảng dữ liệu trong nhiều phút. Dưới đây là các kỹ thuật viết Migration an toàn (Zero-Downtime) đặc thù cho PostgreSQL.
3.1. Thêm cột mới có giá trị mặc định (Default Value)
-
Sai lầm (Ở các bản PG cũ < 11):
ALTER TABLE orders ADD COLUMN status TEXT DEFAULT 'pending';Lệnh này sẽ bắt engine đi cập nhật lại toàn bộ hàng triệu dòng hiện có trên đĩa cứng, chiếm khóaAccessExclusiveLockgây sập hệ thống. -
Thực tế (Từ PostgreSQL 11+): PostgreSQL đã tối ưu hóa điều này. Lệnh trên giờ đây cực kỳ nhanh vì giá trị default chỉ được ghi vào catalog hệ thống, không quét lại bảng. Tuy nhiên, nếu bạn gọi một hàm động (ví dụ:
DEFAULT gen_random_uuid()), nó vẫn sẽ quét lại toàn bộ bảng.
3.2. Tạo Index trên bảng dữ liệu lớn
-
Sai lầm:
CREATE INDEX idx_user_email ON users(email);Lệnh này khóa toàn bộ các tác vụ GHI (INSERT,UPDATE,DELETE) trên bảnguserscho đến khi index được build xong. -
Cách làm đúng: Luôn sử dụng từ khóa
CONCURRENTLY.SQL
CREATE INDEX CONCURRENTLY idx_user_email ON users(email);Lưu ý cốt tử: Lệnh
CONCURRENTLYKHÔNG THỂ đặt bên trong một khối Transaction (BEGIN ... COMMIT). Khi viết file migration cho lệnh này, bạn phải cấu hình công cụ migration chạy file đó ở chế độ Non-transactional.
3.3. Đổi kiểu dữ liệu của cột
-
Sai lầm:
ALTER TABLE users ALTER COLUMN phone TYPE VARCHAR(50);(Trong một số trường hợp ép kiểu phức tạp, nó sẽ rewrite lại toàn bộ bảng). -
Cách làm an toàn (Phased Migration):
-
Migration 1: Thêm cột
phone_new VARCHAR(50). -
Triển khai code Backend: Ghi dữ liệu vào cả 2 cột, đọc từ cột cũ.
-
Chạy script backfill ở background để copy dữ liệu từ cột cũ sang cột mới.
-
Triển khai code Backend: Đọc và ghi hoàn toàn vào cột mới.
-
Migration 2: Xóa cột
phonecũ và đổi tênphone_newthànhphone.
-
4. Kiến trúc CI/CD Pipeline cho Database
Để ngăn chặn hoàn toàn việc con người thao tác trực tiếp trên Production, quy trình Migration phải được tự động hóa qua CI/CD (GitHub Actions, GitLab CI).
Một Pipeline chuẩn cho Database bao gồm các bước sau:
Giai đoạn 1: Linter & Dry-run (Trên nhánh Pull Request)
-
Lập trình viên tạo nhánh mới, thêm file
V202610101200__add_phone_to_users.sql. -
Push lên GitHub. Pipeline tự động kích hoạt.
-
Database Linting: Sử dụng các công cụ như Squawk để phân tích cú pháp SQL, phát hiện các lệnh nguy hiểm (như
CREATE INDEXthiếuCONCURRENTLY, hoặc lệnhDROPkhông an toàn). -
Test Apply: Khởi tạo một container PostgreSQL tạm thời (Dùng Docker/Testcontainers), chạy toàn bộ từ V1 đến file V mới nhất xem có bị xung đột hay lỗi thực thi không.
Giai đoạn 2: Staging & Production Deployment
-
Khi Pull Request được Merge vào nhánh
main, hệ thống CI/CD bắt đầu triển khai code Backend. -
Pipeline kết nối vào cơ sở dữ liệu Production (thông qua Bastion Host hoặc mạng VPC kín).
-
Công cụ Migration (như golang-migrate) thực thi các file SQL mới.
-
Nếu Migration thành công, Pipeline tiếp tục deploy phiên bản Backend mới. Nếu thất bại, quá trình deploy tự động hủy bỏ.
Nguyên tắc tương thích ngược (Backward Compatibility): Code Backend mới có thể chạy với Database cũ, và Code Backend cũ phải chạy được với Database mới. Luôn chạy Schema Migration trước khi tráo đổi ứng dụng Backend (Blue/Green Deployment) để đảm bảo không bị dán đoạn truy vấn.
5. Xử lý sự cố: Khi Migration thất bại giữa chừng
Mặc dù PostgreSQL có Transactional DDL, nhưng với các lệnh không nằm trong transaction (như CREATE INDEX CONCURRENTLY), nếu nó bị đứt kết nối mạng giữa chừng, bạn sẽ bị kẹt với một Index rác (Invalid Index).
Nếu chạy lệnh \d users, bạn sẽ thấy: "idx_user_email" INVALID
Cách khắc phục an toàn:
-
Không bao giờ chạy lại lệnh
CREATE INDEX CONCURRENTLYngay lập tức (sẽ báo lỗi trùng tên). -
Viết một lệnh DROP index cũ đi trước:
DROP INDEX CONCURRENTLY IF EXISTS idx_user_email; -
Sau đó chạy lại lệnh tạo Index chuẩn.
All rights reserved