Bài toán: ALTER TABLE là ác mộng với bảng lớn
Bạn có một bảng orders với 50 triệu row đang chạy production. Product Manager yêu cầu thêm cột payment_verified_at. Bạn gõ ALTER TABLE orders ADD COLUMN payment_verified_at TIMESTAMP NULL;, nhấn Enter — và website bắt đầu timeout sau 30 giây.
Vấn đề là MySQL phải lock toàn bộ bảng trong suốt quá trình rebuild. Không có cách bypass. Với 50 triệu row trên hardware bình thường, đó là 30–60 phút downtime. Mọi query đọc/ghi đều phải chờ, connection pool cạn kiệt, người dùng thấy màn hình trắng.
Mình từng gặp sự cố database corruption lúc 3 giờ sáng và phải restore từ backup — mất gần 2 tiếng mới xong. Từ đó mình không bao giờ động vào schema production mà không có kế hoạch rõ ràng. Bài này sẽ chỉ bạn cách làm đúng từ đầu.
So sánh 3 approach thay đổi schema MySQL
Approach 1: ALTER TABLE trực tiếp
-- Chỉ dùng được với bảng nhỏ (dưới 500K row)
ALTER TABLE users ADD COLUMN phone_verified_at TIMESTAMP NULL;
Đơn giản, không cần cài thêm gì. Nhưng MySQL lock toàn bộ bảng trong suốt quá trình — bảng 10M row dễ mất 10 phút downtime. Không thể dừng giữa chừng nếu phát hiện vấn đề.
Approach 2: pt-online-schema-change (Percona Toolkit)
pt-osc là lựa chọn quen thuộc của cộng đồng MySQL nhiều năm qua. Cơ chế: tạo bảng shadow _users_new, đặt 3 triggers (INSERT/UPDATE/DELETE) trên bảng gốc để sync data mới, copy từng chunk dữ liệu cũ sang, rồi rename khi xong.
- Ưu điểm: Không lock table, đã kiểm chứng nhiều năm, cộng đồng lớn.
- Nhược điểm: Triggers thêm overhead cho mọi write operation. Không tương thích với Galera hoặc NDB Cluster. Khó pause/resume. Không điều chỉnh được tốc độ khi đang chạy.
Approach 3: gh-ost (GitHub Online Schema Transmogrifier)
GitHub build gh-ost vì pt-osc không đủ mạnh ở quy mô database của họ — hàng tỷ row, write throughput cực cao, không thể chịu thêm overhead từ triggers. Giải pháp: thay vì triggers, gh-ost kết nối như một MySQL replica và đọc binlog trực tiếp để bắt kịp thay đổi trong lúc copy data.
- Ưu điểm: Không dùng triggers, không overhead thêm cho write. Pause/resume bất kỳ lúc nào. Điều chỉnh tốc độ realtime. Dry-run mode để test trước. Kiểm soát chính xác thời điểm rename bảng.
- Nhược điểm: Yêu cầu binlog format ROW. Cần quyền REPLICATION SLAVE. Setup phức tạp hơn pt-osc một chút.
Phân tích ưu nhược — Chọn tool nào?
| Tiêu chí | ALTER trực tiếp | pt-osc | gh-ost |
|---|---|---|---|
| Bảng nhỏ (<500K row) | ✅ Tốt nhất | Overkill | Overkill |
| Bảng lớn (production) | ❌ Không dùng | ✅ Được | ✅ Tốt nhất |
| Có triggers sẵn trên bảng | OK | ❌ Conflict | ✅ OK |
| Galera / PXC Cluster | Tùy | Hạn chế | ❌ Không hỗ trợ |
| Pause / Resume | ❌ | ❌ Khó | ✅ Dễ |
| Throttle realtime | ❌ | Hạn chế | ✅ Đầy đủ |
| Kiểm soát thời điểm rename | ❌ | ❌ | ✅ Postpone flag |
Vậy chọn cái nào? ALTER trực tiếp cho bảng nhỏ hoặc môi trường dev. gh-ost cho production với bảng lớn. pt-osc khi bạn dùng Galera hoặc môi trường không hỗ trợ binlog streaming.
Hướng dẫn triển khai gh-ost từng bước
Bước 1: Cài đặt gh-ost
gh-ost là single binary, không cần runtime dependencies:
# Download binary từ GitHub Releases (kiểm tra phiên bản mới nhất)
VERSION="1.1.6"
wget https://github.com/github/gh-ost/releases/download/v${VERSION}/gh-ost-binary-linux-amd64-${VERSION}.tar.gz
tar -xzf gh-ost-binary-linux-amd64-${VERSION}.tar.gz
sudo mv gh-ost /usr/local/bin/
chmod +x /usr/local/bin/gh-ost
# Kiểm tra
gh-ost --version
Bước 2: Kiểm tra MySQL binlog
gh-ost yêu cầu binlog bật ở format ROW:
-- Kiểm tra cấu hình hiện tại
SHOW VARIABLES LIKE 'log_bin';
SHOW VARIABLES LIKE 'binlog_format';
-- Nếu cần bật, thêm vào /etc/mysql/my.cnf hoặc /etc/my.cnf
-- [mysqld]
-- log_bin = /var/log/mysql/mysql-bin.log
-- binlog_format = ROW
-- server_id = 1
-- Sau đó restart MySQL
Bước 3: Dry-run trước khi làm thật
Luôn luôn dry-run trước. Lệnh này kiểm tra kết nối, quyền, cấu hình binlog — nhưng không thay đổi gì cả:
gh-ost \
--host="127.0.0.1" \
--port=3306 \
--user="root" \
--password="your_password" \
--database="myapp" \
--table="orders" \
--alter="ADD COLUMN payment_verified_at TIMESTAMP NULL" \
--dry-run \
--verbose
Bước 4: Chạy migration thật
gh-ost \
--host="127.0.0.1" \
--port=3306 \
--user="root" \
--password="your_password" \
--database="myapp" \
--table="orders" \
--alter="ADD COLUMN payment_verified_at TIMESTAMP NULL" \
--chunk-size=1000 \
--max-load="Threads_running=25" \
--critical-load="Threads_running=50" \
--switch-to-rbr \
--exact-rowcount \
--panic-flag-file=/tmp/ghost.panic \
--postpone-cut-over-flag-file=/tmp/ghost.postpone \
--execute
Mấy flag này cần nắm rõ trước khi chạy:
--chunk-size=1000: Copy 1000 rows mỗi batch. Server nhàn thì tăng lên 2000–5000, load cao thì giảm xuống 500.--max-load="Threads_running=25": gh-ost tự pause nếu MySQL quá tải, tự resume khi load giảm.--critical-load="Threads_running=50": Dừng khẩn cấp nếu tình trạng tệ hơn.--panic-flag-file=/tmp/ghost.panic: Tạo file này để dừng migration ngay lập tức.--postpone-cut-over-flag-file=/tmp/ghost.postpone: Tạo file này để trì hoãn bước rename bảng cuối cùng.
Bước 5: Theo dõi và điều khiển realtime
gh-ost tạo một Unix socket (đường dẫn theo format /tmp/gh-ost.{database}.{table}.sock), cho phép bạn tương tác trực tiếp khi migration đang chạy:
# Xem tiến trình
echo "status" | nc -U /tmp/gh-ost.myapp.orders.sock
# Tăng chunk-size khi server đang nhàn (không cần restart)
echo "chunk-size=3000" | nc -U /tmp/gh-ost.myapp.orders.sock
# Throttle thủ công (tạm giảm tốc)
echo "throttle" | nc -U /tmp/gh-ost.myapp.orders.sock
# Bỏ throttle
echo "no-throttle" | nc -U /tmp/gh-ost.myapp.orders.sock
Bước 6: Kiểm soát thời điểm cut-over
Đây là tính năng mình thích nhất ở gh-ost. Khi migration copy xong data, thay vì rename bảng ngay, bạn có thể kiểm soát chính xác thời điểm:
# Tạo file này từ đầu để gh-ost đợi sau khi copy xong
touch /tmp/ghost.postpone
# gh-ost sẽ in ra: "Postponing cut-over; waiting for /tmp/ghost.postpone to be deleted"
# Khi bạn sẵn sàng (ví dụ: đợi lúc traffic thấp nhất)
rm /tmp/ghost.postpone
# gh-ost sẽ thực hiện rename ngay lập tức
Tính năng này đặc biệt hữu ích khi cần deploy code mới cùng lúc với schema change — chạy migration trước, đợi code deploy xong, rồi mới cut-over.
Bước 7: Xử lý khi cần dừng khẩn cấp
# Dừng ngay lập tức (an toàn, bảng gốc không bị ảnh hưởng)
touch /tmp/ghost.panic
# gh-ost để lại 2 bảng tạm, xóa sau khi kiểm tra
# _orders_gho — bảng ghost đang build
# _orders_ghc — bảng changelog
gh-ost không bao giờ xóa bảng gốc cho đến khi rename hoàn tất thành công — đây là thiết kế cố ý. Dù migration bị interrupt vì bất kỳ lý do gì, bảng orders của bạn vẫn nguyên vẹn, không mất một row nào.
Các ALTER phổ biến với gh-ost
# Thêm cột
--alter="ADD COLUMN is_verified TINYINT(1) NOT NULL DEFAULT 0"
# Thêm index
--alter="ADD INDEX idx_status_created (status, created_at)"
# Nhiều thay đổi cùng lúc — tiết kiệm một lần copy toàn bộ data
--alter="ADD COLUMN notes TEXT NULL, ADD INDEX idx_user_status (user_id, status)"
# Đổi kiểu dữ liệu (cẩn thận với data truncation)
--alter="MODIFY COLUMN status VARCHAR(50) NOT NULL DEFAULT 'active'"
# Thêm cột với giá trị mặc định
--alter="ADD COLUMN tier ENUM('free','pro','enterprise') NOT NULL DEFAULT 'free'"
Workflow thực tế — Checklist trước khi migration
- Xác nhận backup đã chạy và restore được trong ngày hôm đó
- Chạy
--dry-runtrên staging với data tương tự production - Ước tính thời gian: 1M row ≈ 5–10 phút tùy server và chunk-size
- Chọn thời điểm traffic thấp nhất — dùng postpone flag nếu cần linh hoạt
- Set
--panic-flag-filevà--postpone-cut-over-flag-filetừ đầu - Monitor load server trong suốt quá trình migration
- Sau khi cut-over, verify schema mới và xóa 2 bảng ghost tạm (
_orders_gho,_orders_ghc)

