TungDaDev's Blog

Zero-Downtime Database Migration: Chiến lược Expand & Contract cho Hệ thống Triệu User

Photo 1544383835 bda2bc66a55d?auto=format&fit=crop&w=1600&q=80
Published on
/6 mins read/

Treo bảng thông báo "Hệ thống bảo trì từ 0h đến 4h sáng" để chạy migration database là tàn dư của quá khứ. Trong kỷ nguyên thương mại toàn cầu và High Availability 99.99%, mọi thay đổi cấu trúc dữ liệu đều phải diễn ra trơn tru mà người dùng không hề hay biết.

1. Nỗi sợ hãi khi sửa đổi Database Schema trên Production

Thay đổi code backend và deploy lên Kubernetes với Rolling Update hay Blue-Green là chuyện rất bình thường. Nhưng Database là trạng thái (Stateful), bạn không thể rollback một câu lệnh ALTER TABLE DROP COLUMN chỉ bằng cách checkout lại commit cũ!

Những tai nạn kinh điển thường gặp:

  • Table Lock: Chạy câu lệnh ALTER TABLE users ADD COLUMN phone VARCHAR(20) NOT NULL DEFAULT '' trên bảng 50 triệu dòng khiến MySQL/PostgreSQL lock toàn bộ bảng trong 15 phút. Toàn bộ API ghi đơn hàng đứng im và sập server.
  • Incompatible Deployment: Release phiên bản mới (V2) đã đổi tên cột name thành full_name. Trong quá trình Rolling Update, các Pod cũ (V1) vẫn đang chạy và query cột name, lập tức quăng SQLException: Column 'name' not found vào mặt khách hàng!

Để đạt được Zero-Downtime Migration, kiến trúc sư phải áp dụng mẫu hình Expand and Contract (Parallel Change Pattern).


2. Bản chất của mẫu hình Expand and Contract

Nguyên tắc cốt lõi: Không bao giờ thực hiện một thay đổi phá vỡ tương thích (Breaking Change) trong một bước duy nhất. Mọi thay đổi cấu trúc bảng luôn được chia làm 4 giai đoạn độc lập:


3. Case Study thực tế: Đổi tên trường phone thành phone_number

Giả sử bảng customers đang có 30 triệu records. Chúng ta muốn đổi tên cột phone thành phone_number và chuyển kiểu dữ liệu từ VARCHAR(15) sang chuẩn E.164 VARCHAR(20).

Bước 1: Expand (Mở rộng Database)

Viết script Flyway/Liquibase để thêm cột mới phone_number với thuộc tính NULLABLE (tuyệt đối không để NOT NULL ở bước này vì code cũ chưa biết đến cột mới).

-- V1.1__add_phone_number_column.sql
ALTER TABLE customers ADD COLUMN phone_number VARCHAR(20) NULL;

Lưu ý với MySQL/PostgreSQL: Luôn kiểm tra xem database engine có hỗ trợ Instant Add Column hay không để tránh table copy.

Bước 2: Deploy Code V1 (Dual Write - Ghi cả hai nơi)

Cập nhật tầng Repository/Entity trong backend:

  • Khi Đọc (Read): Vẫn đọc từ cột cũ phone.
  • Khi Ghi (Write/Update): Ghi đồng thời vào cả phone và phone_number.
@Entity
@Table(name = "customers")
public class Customer {
    @Id
    private Long id;
 
    @Column(name = "phone")
    private String phone;
 
    @Column(name = "phone_number")
    private String phoneNumber;
 
    public void updatePhoneNumber(String newPhone) {
        // Dual write đảm bảo cả 2 cột luôn đồng bộ
        this.phone = newPhone;
        this.phoneNumber = newPhone;
    }
}

Lúc này, mọi khách hàng mới hoặc được update thông tin đều đã có dữ liệu ở cả 2 cột.

Bước 3: Backfill Data (Di chuyển dữ liệu cũ)

Dữ liệu của 30 triệu khách hàng cũ vẫn đang có phone_number IS NULL. Chúng ta chạy một background worker (script hoặc Spring Batch) để sync dữ liệu từng batch (ví dụ mỗi batch 5,000 dòng):

-- Chạy theo batch theo Primary Key để không gây Lock hoặc nghẽn Replication Lag
UPDATE customers
SET phone_number = phone
WHERE id BETWEEN ? AND ? AND phone_number IS NULL;

Tuyệt đối không chạy 1 câu UPDATE customers SET phone_number = phone; vì sẽ làm lock cả bảng và tràn transaction log!

Bước 4: Deploy Code V2 (Đọc từ cột mới)

Khi 100% dữ liệu cũ đã được backfill xong:

  • Cập nhật logic backend chuyển sang đọc trực tiếp từ phone_number.
  • Tiếp tục duy trì dual-write trong vài ngày để quan sát (phòng trường hợp khẩn cấp phải rollback code V2 về V1).

Bước 5: Contract (Dọn dẹp cột cũ)

Sau khi hệ thống V2 hoạt động ổn định 1-2 tuần:

  1. Deploy code V3: Xóa bỏ hoàn toàn thuộc tính phone trong Java Entity.
  2. Chạy migration cuối cùng để drop cột cũ:
-- V1.2__drop_old_phone_column.sql
ALTER TABLE customers DROP COLUMN phone;

Toàn bộ quy trình hoàn tất mà không có bất kỳ một giây downtime nào, không có bất kỳ request nào bị lỗi!


4. Những nguyên tắc vàng cho Zero-Downtime Migration

4.1. Không chạy Migration tự động lúc Container khởi động (flyway.enabled=true)

Trong môi trường Kubernetes chạy nhiều replica:

  • Nếu 10 pods cùng scale lên một lúc và cùng cố gắng chạy Migration, chúng sẽ cạnh tranh khóa bảng flyway_schema_history, dẫn đến deadlocks hoặc crash container.
  • Best Practice: Tách migration thành một Kubernetes Job chạy riêng biệt trong pipeline CI/CD trước khi kích hoạt rollout Pods mới.

4.2. Tạo Index trên bảng lớn phải dùng CONCURRENTLY (PostgreSQL)

Lệnh tạo index thông thường sẽ lock bảng chống thao tác ghi (Write):

-- NGUY HIỂM: Gây lock bảng!
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
 
-- AN TOÀN TRÊN PRODUCTION:
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders(customer_id);

4.3. Đổi kiểu dữ liệu (Data Type Change)

Nếu muốn đổi INT thành BIGINT (do sắp chạm ngưỡng tràn 2.1 tỷ ID):

  • Áp dụng kỹ thuật Expand-Contract bằng cách tạo cột id_bigint BIGINT.
  • Hoặc sử dụng các công cụ online schema change chuyên dụng như gh-ost (của GitHub) hoặc pt-online-schema-change (Percona Toolkit) cho MySQL.

5. Kết luận

Zero-downtime database migration đòi hỏi nhiều bước hơn, kỷ luật cao hơn và thời gian kiên nhẫn qua từng đợt release. Nhưng cái giá nhận lại là sự tin cậy tuyệt đối của khách hàng và giấc ngủ ngon của các kỹ sư vận hành vào ban đêm.

Đừng bao giờ đánh cược dữ liệu và thời gian uptime của doanh nghiệp vào những câu lệnh ALTER TABLE mạo hiểm!


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.