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ế 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):
- 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
SELECTdà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ảngorders. Truy vấn này giữ quyền khóa chia sẻ đọc (SHARED_READMDL). - 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:
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).
- 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ảngorderssau thời điểm lệnhALTERxuất hiện đều bị chặn lại và xếp hàng phía sau. - 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ỗi502 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:
-- 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
INSERTvào bảng vừa được thêm một cộtNOT NULLmà 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).
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 NULLhoặ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
UPDATEtoà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ật | MySQL 8.0 In-Place DDL | Percona pt-osc | GitHub gh-ost |
|---|---|---|---|
| Cơ chế hoạt động | Tí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/O | Thấp, tốc độ thực thi nhanh | Tải CPU cao do Triggers chạy đồng bộ trên từng thao tác DML | Rất nhẹ, không can thiệp trực tiếp vào tiến trình DML |
| Rủi ro Deadlock | Thấp, chỉ lấy MDL ngắn ở đầu và cuối | Tiề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 Lag | Tự độ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ừng | Khó kiểm soát | Hỗ 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-based | Bả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:
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:
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 \
--executeKhi 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ự:
- 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.
- 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.
- 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.
- 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.
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:
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 NULLvớ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 0hoặcDEFAULT '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
.ibdcủa bảng (ln orders.ibd orders.ibd.hdl), sau đó thực thi lệnhDROP 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ụtruncatetrong 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ế:
- Job migration chạy thành công lúc 10:00, thêm cột
loyalty_pointsvào bảngusers. - Phiên bản code mới v2 được triển khai và hoạt động trong 20 phút.
- 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. - 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ệnhALTER TABLE DROP COLUMN loyalty_pointssẽ được thực thi. - 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:
- Đ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?
- 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? - 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?
- 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?
- 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-osthoặcpt-osc)? - 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:downtự động khi xảy ra lỗi triển khai chưa? - 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:
- Diễn tập an toàn trên Staging: Quản lý và cô lập môi trường Staging với Production trên hạ tầng Docker để kiểm thử dữ liệu mà không ảnh hưởng người dùng thật.
- Kỹ thuật phát hành từng phần: Canary Release và tự động Rollback trong CI/CD giúp theo dõi tỷ lệ lỗi của phiên bản mới trước khi mở rộng toàn bộ traffic.
- Tiêu chuẩn kiểm định toàn diện: Checklist nghiệm thu pipeline CI/CD trước khi bàn giao để chuẩn hóa quy trình phát hành cho đội ngũ phát triển.



