Sự Cố Production Bạn Chưa Từng Lường Trước
Đổi tên một cột trên database đang chạy có vẻ rất đơn giản cho đến khi pipeline deployment của bạn được kích hoạt. Bạn chạy migration ALTER TABLE trên PostgreSQL hoặc MySQL. Vài giây sau, bảng điều khiển Datadog hoặc Grafana của bạn tăng đột biến với hàng loạt lỗi HTTP 500. Session của người dùng bị ngắt, connection pool của database bị tắc nghẽn, và các transaction đang chạy nối đuôi nhau rollback.
Tại sao điều này lại xảy ra? Hầu hết các script deployment đều giả định rằng code ứng dụng và bản cập nhật database schema sẽ cập bến cùng một mili-giây. Trên thực tế, các đợt triển khai trên cloud hiện đại chạy theo cơ chế rolling update qua các pod Kubernetes hoặc cluster EC2 auto-scaling. Các instance cũ vẫn tiếp tục phục vụ lưu lượng truy cập song song với các instance mới trong vài phút. Nếu schema của bạn xóa hoặc đổi tên một cột trong khi code cũ vẫn đang truy vấn cột đó, các request đó sẽ lập tức crash ngay tại chỗ.
Database quan hệ thực thi schema rất nghiêm ngặt trên mọi connection đang hoạt động. Khi một service đang chạy thực thi SELECT full_name FROM users, nhưng migration của bạn vừa đổi tên cột đó thành name, câu truy vấn sẽ thất bại với lỗi column "full_name" does not exist.
Lên lịch bảo trì vào lúc 2:00 sáng có thể chấp nhận được với một công cụ nội bộ, nhưng các nền tảng hướng tới khách hàng với cam kết SLA 99.99% không thể làm vậy. Để triển khai các thay đổi liên tục một cách an toàn, các đội ngũ kỹ thuật dựa vào pattern expand and contract (còn được gọi là parallel run hoặc phased migration).
Các Khái Niệm Cốt Lõi: Tìm Hiểu Pattern Expand and Contract
Chiến lược expand and contract chia nhỏ một thay đổi schema mang tính phá hủy thành các bản release tăng dần và tương thích ngược (backwards-compatible). Thay vì thực hiện một thao tác rủi ro duy nhất, bạn tách migration thành các giai đoạn deployment riêng biệt. Điều này đảm bảo code cũ và code mới phát hành có thể cùng tồn tại mượt mà.
Giai Đoạn 1: Expand (Mở Rộng)
Đầu tiên, hãy giới thiệu thành phần schema mới mà không chạm vào hoặc xóa bỏ cấu trúc hiện có. Nếu bạn cần đổi tên một cột hoặc thay đổi kiểu dữ liệu, hãy tạo cột mới song song với cột cũ. Đảm bảo cột mới chấp nhận giá trị NULL hoặc có một giá trị mặc định an toàn. Giờ đây, database của bạn đã đáp ứng được cả code hiện tại lẫn các bản build sắp tới.
Giai Đoạn 2: Transition (Chuyển Tiếp – Ghi Kép và Backfill)
Tiếp theo, hãy ship một bản release code trung gian. Mọi thao tác insert và update lúc này sẽ ghi đồng thời vào cả hai trường (dual-writing). Các thao tác đọc từ ứng dụng tiếp tục lấy dữ liệu từ cột cũ hoặc fallback về cột cũ một cách an toàn. Trong khi production đang chạy logic ghi kép này, một background worker script sẽ copy các bản ghi lịch sử hiện có sang cột mới theo từng batch riêng biệt.
Giai Đoạn 3: Contract (Thu Hẹp)
Khi bạn đã xác minh rằng 100% bản ghi lịch sử đã được migrate xong, hãy chuyển các thao tác đọc của ứng dụng sang cột mới. Sau khi theo dõi toàn bộ hệ thống dưới tải thực tế, hãy loại bỏ toàn bộ các tham chiếu đến cột cũ trong codebase của bạn. Cuối cùng, drop cột cũ khỏi database schema. Không lock bảng, không suy giảm dịch vụ, và hoàn toàn không làm gián đoạn transaction nào của khách hàng.
Thực Hành: Đổi Tên Cột Không Cần Downtime
Hãy cùng đi qua ví dụ về một nền tảng thương mại điện tử với bảng users có 15 triệu hàng. Hệ thống hiện đang lưu trữ số điện thoại trong cột phone_number. Chúng ta cần đổi tên cột này thành contact_phone để hỗ trợ tiêu chuẩn định dạng quốc tế E.164.
Bước 1: Expand Schema
Thêm cột mới vào bảng của bạn. Luôn thiết lập cột chấp nhận giá trị NULL hoặc cung cấp một giá trị mặc định. Nếu bạn khai báo là NOT NULL mà không có giá trị mặc định, các container hiện tại đang chạy code cũ sẽ ngay lập tức ném ra lỗi constraint mỗi khi có người dùng mới đăng ký.
-- PostgreSQL / MySQL (InnoDB)
ALTER TABLE users
ADD COLUMN contact_phone VARCHAR(32) DEFAULT NULL;
Trong PostgreSQL hiện đại (phiên bản 11+) và MySQL 8.0, việc thêm một cột cho phép null hoặc một cột có giá trị mặc định không đổi sẽ được thực thi tức thì chỉ dưới dạng thay đổi metadata. Các production pod hiện tại tiếp tục truy vấn phone_number mà không gặp bất kỳ gián đoạn nào.
Bước 2: Deploy Logic Ứng Dụng Ghi Kép (Dual-Writing)
Cập nhật tầng truy cập dữ liệu của bạn và deploy thay đổi. Bất cứ khi nào người dùng tạo hoặc chỉnh sửa profile của họ, hãy ghi vào cả phone_number lẫn contact_phone. Đối với các truy vấn đọc, hãy kiểm tra contact_phone trước và fallback về phone_number nếu bản ghi đó chưa được backfill.
Dưới đây là một pattern triển khai bằng Python sử dụng SQLAlchemy hoặc raw SQL:
def save_user_phone(db_session, user_id: int, phone: str):
# Ghi kép giúp duy trì tính nhất quán giữa cả hai thế hệ code
query = """
UPDATE users
SET phone_number = :phone,
contact_phone = :phone
WHERE id = :id
"""
db_session.execute(query, {"phone": phone, "id": user_id})
db_session.commit()
def get_user_phone(db_session, user_id: int) -> str:
query = """
SELECT contact_phone, phone_number
FROM users
WHERE id = :id
"""
row = db_session.execute(query, {"id": user_id}).fetchone()
if not row:
return ""
# Ưu tiên cột mới, sau đó mới fallback về cột cũ
return row["contact_phone"] or row["phone_number"] or ""
Triển khai bản release này lên toàn bộ server của bạn. Mọi khách hàng mới hoặc profile được cập nhật giờ đây sẽ điền dữ liệu vào cả hai cột.
Bước 3: Backfill Dữ Liệu Cũ Theo Từng Batch
Đừng chạy câu lệnh UPDATE users SET contact_phone = phone_number; không giới hạn trên 15 triệu hàng. Một lệnh update quy mô lớn như vậy sẽ lock hàng triệu bản ghi, làm phình to transaction log (WAL hoặc undo log), làm cạn kiệt bộ đệm bộ nhớ, và gây tăng vọt replication lag trên các read replica. Thay vào đó, hãy migrate dữ liệu theo từng batch có kích thước hợp lý dựa trên index.
#!/usr/bin/env bash
# Script backfill theo từng batch sử dụng psql với keyset pagination
BATCH_SIZE=2000
LAST_ID=0
echo "Đang bắt đầu backfill..."
while true; do
# Lấy và cập nhật dữ liệu dựa trên primary key index thay vì full table scan
UPDATED_INFO=$(psql -d production_db -t -A -c "
WITH batch AS (
SELECT id FROM users
WHERE id > $LAST_ID
AND contact_phone IS NULL
AND phone_number IS NOT NULL
ORDER BY id ASC
LIMIT $BATCH_SIZE
)
UPDATE users
SET contact_phone = users.phone_number
FROM batch
WHERE users.id = batch.id
RETURNING users.id;
")
# Xác định số lượng trong batch
COUNT=$(echo "$UPDATED_INFO" | sed '/^$/d' | wc -l)
if [ "$COUNT" -eq 0 ]; then
echo "Backfill hoàn tất thành công."
break
fi
LAST_ID=$(echo "$UPDATED_INFO" | tail -n 1)
echo "Đã migrate $COUNT hàng đến ID $LAST_ID..."
# Dừng 250ms để giải phóng I/O và giảm độ trễ replica lag
sleep 0.25
done
Bước 4: Chuyển Sang Đọc Cột Mới và Xóa Các Tham Chiếu Cũ
Xác minh xem việc migration dữ liệu đã hoàn tất 100% hay chưa bằng cách chạy câu lệnh kiểm tra: SELECT COUNT(*) FROM users WHERE contact_phone IS NULL AND phone_number IS NOT NULL;. Khi số lượng này trở về 0, hãy phát hành một bản release ứng dụng sạch sẽ, chỉ đọc và ghi duy nhất vào cột contact_phone.
def save_user_phone(db_session, user_id: int, phone: str):
query = "UPDATE users SET contact_phone = :phone WHERE id = :id"
db_session.execute(query, {"phone": phone, "id": user_id})
db_session.commit()
def get_user_phone(db_session, user_id: int) -> str:
query = "SELECT contact_phone FROM users WHERE id = :id"
row = db_session.execute(query, {"id": user_id}).fetchone()
return row["contact_phone"] if row else ""
Theo dõi hệ thống tracking lỗi và dashboard APM của bạn qua một hoặc hai chu kỳ deployment đầy đủ. Xác nhận rằng không còn worker hoặc script chạy ngầm nào còn truy vấn vào phone_number.
Bước 5: Contract Database Schema
Khi các tham chiếu cũ đã được dọn sạch khỏi codebase, bạn có thể xóa bỏ cột thừa này. Trước tiên, hãy kiểm tra xem có database view, stored procedure, hay query báo cáo nào còn sót lại không. Sau đó tiến hành drop cột:
-- Xóa cột cũ
ALTER TABLE users DROP COLUMN phone_number;
Tổng Kết và Những Điểm Cốt Lõi
Cập nhật database không downtime đòi hỏi quá trình tiến hóa schema phải được tách biệt hoàn toàn khỏi các đợt phát hành ứng dụng. Pattern expand and contract loại bỏ xung đột khóa (lock contention) và bảo vệ các kết nối đang hoạt động trong quá trình rolling deployment.
Hãy luôn ghi nhớ những nguyên tắc cơ bản sau:
- Tuyệt đối không chỉnh sửa hoặc xóa cột trong cùng một pull request giới thiệu phần thay thế của nó.
- Luôn chia nhỏ các thao tác update dữ liệu lịch sử thành từng batch sử dụng primary key có index để tránh trễ read replica và nghẽn tài nguyên.
- Cấu trúc mọi thay đổi có tính phá hủy thành một chu kỳ deployment ba bước tuần tự trên toàn bộ hệ thống của bạn.

