gh-ost: Thay đổi cấu trúc bảng MySQL không downtime — Giải pháp của GitHub thay thế pt-osc

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

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

  1. Xác nhận backup đã chạy và restore được trong ngày hôm đó
  2. Chạy --dry-run trên staging với data tương tự production
  3. Ước tính thời gian: 1M row ≈ 5–10 phút tùy server và chunk-size
  4. Chọn thời điểm traffic thấp nhất — dùng postpone flag nếu cần linh hoạt
  5. Set --panic-flag-file--postpone-cut-over-flag-file từ đầu
  6. Monitor load server trong suốt quá trình migration
  7. Sau khi cut-over, verify schema mới và xóa 2 bảng ghost tạm (_orders_gho, _orders_ghc)

Share: