Docker & Application

Database Migration trong CI/CD: Kỹ thuật cập nhật không khóa bảng

Hướng dẫn cập nhật database không gián đoạn trong CI/CD: kỹ thuật Online Schema Change, mô hình Expand/Contract hai pha và cơ chế rollback an toàn.

Database Migration trong CI/CD: Kỹ thuật cập nhật không khóa bảng
MỤC LỤC

Cập nhật cấu trúc cơ sở dữ liệu (Database Migration) là mắt xích nhạy cảm nhất trong chu trình phát hành phần mềm tự động (CI/CD). Trong khi mã nguồn ứng dụng có thể thay thế tức thì bằng các container mới thông qua Zero-Downtime Deployment với Docker: Blue/Green và Rolling Update, dữ liệu trong cơ sở dữ liệu là tài nguyên trạng thái dùng chung. Một câu lệnh thay đổi cấu trúc bảng (ALTER TABLE) vô tình yêu cầu khóa độc quyền có thể làm nghẽn toàn bộ hàng đợi kết nối, khiến hàng loạt máy chủ web bị treo và làm gián đoạn hệ thống.

Bài viết này HTS Việt sẽ cùng bạn phân tích cơ chế khóa Metadata Lock (MDL), áp dụng mô hình thiết kế tương thích ngược Expand and Contract, so sánh các công cụ Online Schema Change và thiết lập pipeline CI/CD an toàn thông qua Self-Hosted Runner trong mạng nội bộ Private VPC.


Rủi ro Metadata Lock khi chạy Migration tự động trong CI/CD

Nhiều đội ngũ kỹ thuật từng gặp tình huống khó hiểu: một câu lệnh thêm cột mới chạy trên môi trường phát triển chỉ mất chưa đầy một giây, nhưng khi đưa vào pipeline CI/CD trên môi trường Production thì website bắt đầu trả về lỗi 502 Bad Gateway hàng loạt.

Nguyên nhân cốt lõi không nằm ở thời gian ghi đĩa của câu lệnh DDL (Data Definition Language), mà nằm ở cơ chế Metadata Lock (MDL) của hệ quản trị cơ sở dữ liệu (như MySQL InnoDB hoặc PostgreSQL).

Cơ chế Metadata Lock và hiện tượng nghẽn dây chuyền

Cơ chế hình thành hàng đợi nghẽn dây chuyền (Cascading MDL Block)

Để bảo toàn tính nhất quán dữ liệu, hệ quản trị cơ sở dữ liệu phân tách rõ ràng giữa khóa dữ liệu từng dòng (Row Lock) và khóa định nghĩa bảng (Metadata Lock):

  1. Giai đoạn 1 (Truy vấn đọc chậm đang giữ khóa): Trước khi migration diễn ra, có một truy vấn SELECT dài (ví dụ báo cáo thống kê, tìm kiếm văn bản hoặc truy vấn chưa tối ưu chỉ mục) đang chạy trên bảng orders. Truy vấn này giữ quyền khóa chia sẻ đọc (SHARED_READ MDL).
  2. Giai đoạn 2 (Lệnh DDL yêu cầu khóa độc quyền): Pipeline CI/CD tự động kích hoạt lệnh:
sql
   ALTER TABLE orders ADD COLUMN tracking_code VARCHAR(64);

Để sửa định nghĩa bảng, MySQL yêu cầu khóa độc quyền (EXCLUSIVE MDL). Vì bảng đang có tiến trình giữ SHARED_READ, lệnh ALTER TABLE không thể lấy khóa và buộc phải chuyển sang trạng thái chờ (Waiting for table metadata lock).

  1. Giai đoạn 3 (Nghẽn hàng đợi): Cơ chế điều phối khóa ưu tiên câu lệnh thay đổi cấu trúc hơn các câu lệnh đọc mới gửi tới sau đó. Hệ quả là mọi truy vấn đọc/ghi bình thường (SELECT, INSERT, UPDATE) từ người dùng gửi tới bảng orders sau thời điểm lệnh ALTER xuất hiện đều bị chặn lại và xếp hàng phía sau.
  2. Giai đoạn 4 (Cạn kiệt Connection Pool): Hàng chục Web Node trong cụm Multi-Web Node tiếp tục nhận traffic người dùng và mở thêm kết nối tới Database Node (Tách Database sang VPS riêng qua Private VPC). Chỉ sau 15 đến 30 giây, số lượng kết nối chạm trần max_connections, dẫn tới lỗi Too many connections trên database. Các tiến trình xử lý web (PHP-FPM, Node.js worker) cạn kiệt và Nginx trả về lỗi 502 Bad Gateway.

Biện pháp phòng vệ tức thì: Cấu hình lock_wait_timeout

Để tránh nguy cơ câu lệnh migration làm tắc nghẽn hàng đợi kết nối của hệ thống khi gặp truy vấn đọc dở dang, bạn nên thiết lập thời gian chờ khóa ngắn ở cấp phiên làm việc (Session-level) ngay trước khi thực thi DDL:

sql
-- Chỉ cho phép chờ lấy Metadata Lock tối đa 5 giây
SET SESSION lock_wait_timeout = 5;

-- Thực thi câu lệnh thay đổi cấu trúc
ALTER TABLE orders ADD COLUMN tracking_code VARCHAR(64);

Nếu sau 5 giây lệnh ALTER TABLE chưa thể lấy được EXCLUSIVE MDL do có truy vấn chậm khác đang chiếm giữ, MySQL sẽ chủ động trả về lỗi Lock wait timeout exceeded và kết thúc phiên migration. Toàn bộ các truy vấn người dùng phía sau sẽ được giải phóng ngay lập tức mà không gây nghẽn kết nối diện rộng.


Nguyên lý tương thích ngược: Quy tắc Expand and Contract

Khi áp dụng mô hình triển khai không gián đoạn Rolling Update với Docker, luôn tồn tại một khoảng thời gian mà các máy chủ Web Node chạy phiên bản mã nguồn mới (v2) và các máy chủ chạy phiên bản cũ (v1) cùng hoạt động song song và truy cập vào cùng một cơ sở dữ liệu.

Nếu bạn thực hiện các thao tác phá vỡ cấu trúc (Breaking Changes) như xóa cột, đổi tên cột hoặc thêm cột NOT NULL không có giá trị mặc định, phiên bản ứng dụng đối ứng rất dễ phát sinh lỗi:

  • Ứng dụng bản cũ v1 sẽ gặp lỗi khi cố gắng đọc hoặc ghi vào một cột vừa bị xóa.
  • Ứng dụng bản cũ v1 sẽ ném exception khi thực hiện lệnh INSERT vào bảng vừa được thêm một cột NOT NULL mà bản thân v1 không hề hay biết để gán giá trị.

Để giải quyết bài toán này, quy tắc thiết kế kiến trúc chuẩn mực được áp dụng là mô hình Expand and Contract (Mở rộng và Thu hẹp).

Chu trình 3 pha Expand và Contract tương thích ngược

Ba giai đoạn triển khai mô hình Expand and Contract

Mô hình Expand and Contract chia một thay đổi cấu trúc lớn thành ba giai đoạn thực thi riêng biệt trải qua ít nhất hai chu kỳ phát hành mã nguồn:

Giai đoạn 1: Mở rộng (Expand)

Trong giai đoạn này, cấu trúc cơ sở dữ liệu được mở rộng để tiếp nhận trường dữ liệu mới nhưng vẫn bảo toàn nguyên vẹn cấu trúc cũ:

  • Thêm cột mới với thuộc tính DEFAULT NULL hoặc giá trị mặc định dạng hằng số (DEFAULT constant).
  • Giữ nguyên cột cũ trong database.
  • Triển khai phiên bản ứng dụng v2: Ứng dụng bắt đầu cơ chế ghi đồng thời (Dual-Write). Khi người dùng tạo hoặc cập nhật bản ghi, mã nguồn sẽ ghi dữ liệu vào cả cột cũ lẫn cột mới. Luồng đọc dữ liệu vẫn ưu tiên đọc từ cột cũ (hoặc fallback sang cột mới nếu cột cũ trống).
  • Tại thời điểm này, dù container chạy v1 hay v2 đều hoạt động bình thường mà không phát sinh lỗi tương thích.

Giai đoạn 2: Chuyển đổi và đồng bộ ngầm (Transition & Backfill)

Sau khi ứng dụng đã ghi nhận dữ liệu mới vào cả hai cột, bạn cần đồng bộ dữ liệu lịch sử từ cột cũ sang cột mới cho các bản ghi đã tồn tại trước đó:

  • Sử dụng background worker hoặc script xử lý theo từng lô nhỏ (Batch Processing, ví dụ: 1,000 bản ghi mỗi lô) thay vì chạy một câu lệnh UPDATE toàn bảng.
  • Đặt khoảng nghỉ ngắn (nghỉ 50ms giữa các lô) để CPU và I/O đĩa của máy chủ cơ sở dữ liệu không bị quá tải.
  • Khi tiến trình backfill hoàn tất toàn bộ dữ liệu lịch sử, bạn cấu hình ứng dụng chuyển toàn bộ luồng đọc sang cột mới, trong khi vẫn duy trì dual-write để dự phòng.

Giai đoạn 3: Thu hẹp và dọn dẹp (Contract)

Giai đoạn này thường diễn ra ở chu kỳ phát hành tiếp theo (Release v3):

  • Gỡ bỏ hoàn toàn logic dual-write khỏi mã nguồn; ứng dụng chỉ còn đọc và ghi duy nhất vào cột mới.
  • Sau khi kiểm tra qua hệ thống giám sát và chắc chắn không còn câu truy vấn nào tham chiếu đến cột cũ, thực thi migration dọn dẹp để xóa bỏ cột cũ (ALTER TABLE DROP COLUMN) vào khung giờ thấp điểm.

So sánh công cụ Online DDL, pt-osc và GitHub gh-ost

Tùy thuộc vào dung lượng dữ liệu, phiên bản cơ sở dữ liệu và lưu lượng ghi đồng thời, việc lựa chọn công cụ thay đổi cấu trúc bảng sẽ quyết định mức độ an toàn của hệ thống.

Tiêu chí kỹ thuậtMySQL 8.0 In-Place DDLPercona pt-oscGitHub gh-ost
Cơ chế hoạt độngTích hợp trong engine InnoDB (ALGORITHM=INPLACE)Tạo bảng tạm (_new), dùng Database Trigger để copy dữ liệuĐọc trực tiếp từ MySQL Binlog stream, giả lập như một Replica
Tác động CPU & I/OThấp, tốc độ thực thi nhanhTải CPU cao do Triggers chạy đồng bộ trên từng thao tác DMLRất nhẹ, không can thiệp trực tiếp vào tiến trình DML
Rủi ro DeadlockThấp, chỉ lấy MDL ngắn ở đầu và cuốiTiềm ẩn nguy cơ khi bảng có lưu lượng ghi cao (Database Deadlock)Không xảy ra Deadlock do không sử dụng Trigger
Điều tiết tự động (Throttling)Không hỗ trợ tự điều tiếtĐiều tiết cơ bản theo Replication LagTự động điều tiết theo CPU, số kết nối đang chạy và Replication Lag
Hỗ trợ tạm dừng (Pause)Không thể tạm dừngKhó kiểm soátHỗ trợ tạm dừng tức thì bằng file cờ (touch /tmp/ghost.throttle)
Quy mô khuyến nghịBảng vừa và nhỏ (< 5 triệu bản ghi)Bảng trung bình khi chưa bật Binlog Row-basedBảng rất lớn (> 10 triệu bản ghi), hệ thống thương mại điện tử

1. MySQL 8.0 In-Place DDL

Từ phiên bản MySQL 8.0, engine InnoDB hỗ trợ hầu hết các thao tác thêm cột, thêm index mà không cần khóa bảng thông qua cú pháp:

sql
ALTER TABLE orders 
  ADD COLUMN payment_status VARCHAR(32) DEFAULT 'pending',
  ALGORITHM=INPLACE, 
  LOCK=NONE;

Cấu hình LOCK=NONE yêu cầu MySQL chỉ thực thi nếu thao tác đó cho phép các câu lệnh đọc và ghi tiếp tục diễn ra đồng thời. Nếu thao tác đòi hỏi khóa bảng độc quyền, câu lệnh sẽ báo lỗi ngay lập tức thay vì âm thầm khóa bảng.

2. GitHub gh-ost (GitHub Online Schema Transmogrifier)

Với các bảng dữ liệu lớn hàng chục triệu dòng chịu tải ghi liên tục, gh-ost là giải pháp hàng đầu hiện nay. Khác với pt-online-schema-change sử dụng database triggers dễ dẫn tới tình trạng nghẽn hàng đợi hoặc deadlock, gh-ost kết nối vào máy chủ database như một replica và đọc các thay đổi từ binary log (binlog) để áp dụng vào bảng tạm.

Lệnh thực thi gh-ost mẫu:

bash
gh-ost \
  --user="gh_user" \
  --password="${DB_PASSWORD}" \
  --host="10.0.0.20" \
  --database="production_app" \
  --table="orders" \
  --alter="ADD COLUMN tracking_code VARCHAR(64)" \
  --max-load="Threads_running=30" \
  --critical-load="Threads_running=60" \
  --max-lag-millis=1000 \
  --approve-renamed-list \
  --execute

Khi tải máy chủ tăng cao hoặc replication lag vượt quá 1,000ms, gh-ost sẽ tự động giảm tốc độ sao chép hoặc tạm dừng để ưu tiên tài nguyên cho luồng phục vụ người dùng.


Phối hợp cập nhật mã nguồn và Schema cơ sở dữ liệu

Để hạn chế rủi ro gián đoạn khi đưa migration vào pipeline CI/CD, chu trình phát hành nên được chia thành 4 bước tuần tự:

  1. Bước 1: Chạy Pre-deployment Migration: Thực thi các câu lệnh thay đổi cấu trúc mang tính mở rộng (Expand) không phá vỡ tính tương thích. Bước này chạy trước khi cập nhật code ứng dụng.
  2. Bước 2: Triển khai Rolling Update: Cập nhật container mã nguồn mới lên các Web Node theo cơ chế từng phần. Lúc này code mới đọc ghi tương thích với schema vừa mở rộng.
  3. Bước 3: Chạy Data Backfill ngầm: Chạy job đồng bộ dữ liệu lịch sử sang cột mới trong nền.
  4. Bước 4: Chạy Post-deployment Migration: Dọn dẹp các trường dữ liệu và ràng buộc cũ sau khi toàn bộ hệ thống đã ổn định trên phiên bản mã nguồn mới.

Tích hợp Job Migration an toàn vào GitHub Actions Pipeline

Khi thiết lập pipeline tự động hóa với GitHub Actions, việc đặt job migration đúng vị trí và cấu hình các tham số bảo vệ là yếu tố quyết định độ ổn định của hệ thống.

Kiến trúc thực thi Migration an toàn trong Pipeline CI/CD

Nguyên tắc bảo mật kết nối

  • Job migration nên chạy trên Self-Hosted Runner nội bộ (HTS-103), kết nối tới Database Node thông qua mạng riêng Private VPC (10.0.0.20:3306).
  • Tránh mở cổng cơ sở dữ liệu ra ngoài Internet công cộng để chạy migration từ GitHub-hosted runner.
  • Tạo tài khoản database riêng biệt chuyên dùng cho CI/CD với quyền hạn giới hạn trên schema ứng dụng, không dùng tài khoản root.

Cấu hình Workflow mẫu (.github/workflows/deploy.yml)

Dưới đây là ví dụ cấu hình job migration trong pipeline CI/CD:

yaml
name: Deploy Production Stack

on:
  push:
    branches:
      - main

jobs:
  pre-deploy-migration:
    name: Run Database Migration
    runs-on: [self-hosted, internal-vpc]
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Kiểm tra Replication Lag trước Migration
        run: |
          LAG=$(mysql -h 10.0.0.21 -u monitor_user -p"${{ secrets.DB_MONITOR_PASS }}" \
            -e "SHOW REPLICA STATUS\G" | grep "Seconds_Behind_Source" | awk '{print $2}')
          if [ "$LAG" != "NULL" ] && [ "$LAG" -gt 2 ]; then
            echo "Replication lag quá cao (${LAG}s), hoãn thực thi migration."
            exit 1
          fi

      - name: Thực thi Migration với Timeout bảo vệ
        env:
          DB_HOST: "10.0.0.20"
          DB_NAME: "production_db"
          DB_USER: "${{ secrets.CI_MIGRATION_USER }}"
          DB_PASS: "${{ secrets.CI_MIGRATION_PASS }}"
        run: |
          # Thiết lập lock_wait_timeout ở đầu session trước khi chạy lệnh
          docker run --rm \
            --net=host \
            -e DATABASE_URL="mysql://${DB_USER}:${DB_PASS}@${DB_HOST}:3306/${DB_NAME}?lock_wait_timeout=5" \
            internal-registry.local:5000/app-migration:latest \
            npm run db:migrate:up

  deploy-web-nodes:
    name: Rolling Update Web Services
    needs: pre-deploy-migration
    runs-on: [self-hosted, internal-vpc]
    steps:
      - name: Deploy Containers
        run: |
          # Triển khai rolling update lên từng Web Node
          ssh deploy@10.0.0.11 "docker compose pull && docker compose up -d"
          ssh deploy@10.0.0.12 "docker compose pull && docker compose up -d"

Kỹ thuật thay đổi Schema an toàn cho các tình huống thường gặp

Dưới đây là kinh nghiệm xử lý 4 kịch bản thay đổi cấu trúc phổ biến trong thực tế:

1. Thêm chỉ mục mới (Add Index)

Trên bảng lớn, việc tạo index bằng cú pháp thông thường sẽ khóa toàn bộ thao tác ghi.

  • Với MySQL: Luôn chỉ định ALGORITHM=INPLACE, LOCK=NONE.
  • Với PostgreSQL: Bạn nên sử dụng CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);. Lệnh này xây dựng chỉ mục trong nền mà không khóa thao tác ghi, dù thời gian hoàn thành có thể lâu hơn.

2. Thêm cột mới có giá trị mặc định (Add Column with Default)

  • Cần tránh: Thêm cột NOT NULL với giá trị mặc định là một hàm tính toán phức tạp hoặc subquery, vì điều này buộc database phải ghi đè lại toàn bộ dữ liệu trên đĩa.
  • Nên làm: Thêm cột nullable hoặc gán giá trị mặc định là hằng số (DEFAULT 0 hoặc DEFAULT 'active'). Trên MySQL 8.0, việc thêm cột có default hằng số là thao tác tức thời (Instant DDL) không tiêu tốn I/O.

3. Đổi tên cột (Rename Column)

Không bao giờ chạy trực tiếp ALTER TABLE orders RENAME COLUMN old_name TO new_name; trên hệ thống Production đang có tải, vì thao tác này lập tức làm gãy mọi câu truy vấn từ các container code phiên bản cũ. Hãy thực hiện theo 3 pha Expand and Contract: thêm cột mới \rightarrow ghi đồng thời \rightarrow dọn cột cũ.

4. Xóa bảng hoặc cột lớn (Drop Table / Drop Column)

Việc xóa một bảng chứa hàng chục triệu bản ghi có thể gây nghẽn I/O đĩa nghiêm trọng khi hệ điều hành thu hồi các extents dữ liệu:

  • Với MySQL: Tạo một hard link tạm thời tới file .ibd của bảng (ln orders.ibd orders.ibd.hdl), sau đó thực thi lệnh DROP TABLE orders; trong MySQL. Vì còn hard link, thao tác drop bảng trong MySQL diễn ra tức thì. Sau đó, sử dụng công cụ truncate trong Linux để giảm dung lượng file ngoài hệ điều hành từng bước một (ví dụ mỗi giây giảm 100MB) mà không làm nghẽn đĩa.

Chiến lược Roll-Forward: Giảm thiểu rủi ro mất dữ liệu

Một sai lầm thường gặp của các đội ngũ khi thiết lập CI/CD là tự động kích hoạt lệnh rollback cơ sở dữ liệu (npm run db:migrate:down hoặc php artisan migrate:rollback) mỗi khi pipeline gặp sự cố deploy mã nguồn.

Tại sao không nên chạy lệnh rollback ngược trên Production?

Hãy xem xét tình huống thực tế:

  1. Job migration chạy thành công lúc 10:00, thêm cột loyalty_points vào bảng users.
  2. Phiên bản code mới v2 được triển khai và hoạt động trong 20 phút.
  3. Trong 20 phút đó, người dùng đã thực hiện hàng nghìn giao dịch và tích lũy điểm thưởng vào cột loyalty_points.
  4. Một lỗi logic nhỏ ở giao diện thanh toán khiến đội ngũ kỹ thuật quyết định rollback. Nếu pipeline tự động chạy down(), câu lệnh ALTER TABLE DROP COLUMN loyalty_points sẽ được thực thi.
  5. Hậu quả: Dữ liệu điểm thưởng phát sinh trong 20 phút vừa qua sẽ bị xóa mất do cột dữ liệu bị hủy, buộc đội ngũ phải trích xuất lại từ binlog để khôi phục.

Nguyên tắc xử lý: Roll-Forward và Code Rollback

  • Khi hệ thống gặp lỗi sau khi deploy: Chỉ thực hiện rollback container mã nguồn về phiên bản cũ v1.
  • Do cấu trúc database đã được chuẩn bị theo mô hình tương thích ngược (Expand), container v1 vẫn tiếp tục chạy ổn định trên cấu trúc database mới mà không phát sinh lỗi.
  • Đội ngũ kỹ thuật sửa lỗi mã nguồn trên máy phát triển, sau đó phát hành một bản vá mới tiến lên (Roll-Forward Patch) thay vì đảo ngược cấu trúc dữ liệu trên máy chủ Production.

Checklist nghiệm thu trước khi tích hợp Migration vào CI/CD

Trước khi phê duyệt một pipeline tự động hóa migration lên môi trường Production, hãy kiểm tra danh mục 7 tiêu chí sau:

  1. Đo lường thời gian thực thi trên Staging: Câu lệnh DDL đã được chạy thử nghiệm trên môi trường Staging có quy mô dữ liệu tương đương (Cô lập môi trường Staging với Production) chưa?
  2. Cấu hình timeout an toàn: Phiên chạy migration đã có SET SESSION lock_wait_timeout = 5; để chống nghẽn hàng đợi chưa?
  3. Kiểm tra tính tương thích ngược: Mã nguồn phiên bản hiện tại (v1) có tiếp tục hoạt động ổn định nếu database được áp dụng cấu trúc mới trước không?
  4. Kiểm tra độ trễ sao chép (Replication Lag): Pipeline có bước dừng khẩn cấp nếu các máy chủ Replica đang bị trễ dữ liệu quá ngưỡng cho phép không?
  5. Dung lượng ổ đĩa khả dụng: Ổ đĩa máy chủ database còn trống tối thiểu gấp 2.5 lần dung lượng bảng mục tiêu nếu sử dụng các công cụ tạo bảng tạm (gh-ost hoặc pt-osc)?
  6. Vô hiệu hóa lệnh rollback tự động: Pipeline CI/CD đã loại bỏ hoàn toàn các lệnh migrate:down tự động khi xảy ra lỗi triển khai chưa?
  7. Kế hoạch dọn dẹp (Contract phase): Đã lên lịch cho việc gỡ bỏ code dual-write và xóa cột cũ ở chu kỳ phát hành tiếp theo chưa?

Kế hoạch chuẩn bị cho bài toán cô lập môi trường

Database migration không khóa bảng là tiền đề kỹ thuật cốt lõi để xây dựng chu trình phát hành phần mềm liên tục và ổn định. Để hoàn thiện hệ thống CI/CD cho hạ tầng doanh nghiệp, bạn có thể tiếp tục tham khảo các chủ đề tiếp theo trong chuỗi bài viết:

Nội dung hướng dẫn này có hữu ích với hệ thống của bạn không?

Chia sẻ bài viết hữu ích

Gửi bài viết này cho đồng nghiệp

Đọc tiếp

Bài nên đọc tiếp

Xem tất cả bài về Docker & Application