Tìm hiểu cách thay đổi schema không gián đoạn bằng mô hình expand/contract: thêm cột an toàn, backfill theo lô, deploy code tương thích rồi mới xoá đường cũ.

Gián đoạn từ một thay đổi cơ sở dữ liệu không luôn là một sự cố rõ ràng. Với người dùng, nó có thể trông như một trang tải mãi, thanh toán thất bại, hoặc ứng dụng báo "đã xảy ra lỗi". Với đội, nó thể hiện qua cảnh báo, tỉ lệ lỗi tăng và một đống ghi thất bại cần dọn dẹp.
Thay đổi schema rủi ro vì cơ sở dữ liệu được chia sẻ bởi mọi phiên bản ứng dụng đang chạy. Trong một release bạn thường có mã cũ và mã mới tồn tại cùng lúc (rolling deploys, nhiều instance, job nền). Một migration trông có vẻ đúng vẫn có thể phá vỡ một trong các phiên bản đó.
Những chế độ lỗi thường gặp gồm:
Ngay cả khi mã ổn, release vẫn bị chặn vì vấn đề thực sự là timing và tính tương thích giữa các phiên bản. Thay đổi schema không gián đoạn quy về một quy tắc: mọi trạng thái trung gian phải an toàn cho cả mã cũ lẫn mã mới. Bạn thay đổi database mà không phá vỡ các thao tác đọc/ghi hiện tại, triển khai mã có thể xử lý cả hai hình dạng, rồi chỉ xóa đường dẫn cũ khi không còn ai phụ thuộc vào nó.
Nỗ lực thêm vào này xứng đáng khi bạn có lưu lượng thật, SLA nghiêm ngặt, hoặc nhiều instance và worker. Với một công cụ nội bộ nhỏ và DB yên ắng, một cửa sổ bảo trì đã đủ đơn giản.
Phần lớn sự cố từ công việc trên DB xảy ra vì ứng dụng mong đợi DB thay đổi ngay lập tức, trong khi việc thay đổi DB mất thời gian. Mô hình expand/contract tránh điều đó bằng cách chia một thay đổi rủi ro thành các bước nhỏ, an toàn.
Trong một thời gian ngắn, hệ thống của bạn hỗ trợ hai "đường ngôn ngữ" song song. Bạn giới thiệu cấu trúc mới trước, giữ cấu trúc cũ hoạt động, chuyển dữ liệu dần dần, rồi dọn dẹp.
Mô hình đơn giản như sau:
Điều này hoạt động tốt với rolling deploys. Nếu bạn cập nhật 10 máy chủ từng cái một, bạn sẽ chạy tạm thời mã cũ và mới cùng lúc. Expand/contract giữ cả hai tương thích với cùng một database trong khoảng chồng lắp đó.
Nó cũng làm việc rollback bớt đáng sợ. Nếu release mới có bug, bạn có thể rollback ứng dụng mà không phải rollback database, vì cấu trúc cũ vẫn tồn tại trong cửa sổ expand.
Ví dụ: bạn muốn tách trường PostgreSQL full_name thành first_name và last_name. Bạn thêm cột mới (expand), đưa mã có thể đọc/ghi cả hai hình dạng, backfill các hàng cũ, rồi drop full_name khi chắc chắn không còn ai dùng nó (contract).
Pha expand là về việc thêm lựa chọn mới, không phải loại bỏ cái cũ.
Một bước phổ biến là thêm cột mới. Trong PostgreSQL, an toàn nhất thường là thêm cột nullable và không đặt default. Thêm cột không-null với default có thể gây rewrite bảng hoặc khoá nặng hơn, tuỳ phiên bản Postgres và thay đổi cụ thể. Một chuỗi an toàn hơn là: thêm nullable, deploy mã dung nạp, backfill, rồi sau đó áp NOT NULL.
Indexes cũng cần cẩn trọng. Tạo index thông thường có thể chặn ghi lâu hơn bạn nghĩ. Khi có thể, hãy tạo index đồng thời (concurrent) để đọc và ghi vẫn chạy. Nó lâu hơn nhưng tránh khoá dừng release.
Expand cũng có thể nghĩa là thêm bảng mới. Nếu bạn chuyển từ một cột đơn sang quan hệ nhiều-nhiều, bạn có thể thêm bảng nối trong khi vẫn giữ cột cũ. Đường cũ tiếp tục hoạt động trong khi cấu trúc mới bắt đầu lưu dữ liệu.
Trên thực tế, expand thường bao gồm:
Sau expand, các phiên bản ứng dụng cũ và mới nên chạy cùng nhau mà không bất ngờ.
Hầu hết đau đầu khi release xảy ra ở giữa: một vài server chạy mã mới, vài server còn chạy mã cũ, trong khi DB đã thay đổi. Mục tiêu của bạn rõ ràng: bất kỳ phiên bản nào trong rollout cũng phải chạy được với cả schema cũ và schema đã expand.
Một cách phổ biến là dual-write. Nếu bạn thêm cột mới, mã mới ghi vào cả cột cũ và cột mới. Mã cũ vẫn chỉ ghi cột cũ, điều đó ổn vì cột cũ vẫn tồn tại. Giữ cột mới optional ban đầu, và hoãn các ràng buộc chặt chẽ cho đến khi chắc chắn mọi writer đã nâng cấp.
Đọc thường chuyển cẩn thận hơn ghi. Trong một thời gian, giữ đọc trên cột cũ (cột bạn biết đã được populate đầy đủ). Sau khi backfill và xác minh, chuyển đọc ưu tiên cột mới, với fallback về cột cũ nếu cột mới còn thiếu.
Cũng giữ API output ổn định khi DB thay đổi bên dưới. Dù bạn thêm trường nội bộ mới, tránh đổi shape phản hồi cho đến khi tất cả consumer (web, mobile, tích hợp) đã sẵn sàng.
Một rollout dễ rollback thường trông như sau:
Ý tưởng chính là bước không thể đảo ngược đầu tiên là drop cấu trúc cũ, nên bạn hoãn nó cho tới cuối.
Backfill là nơi nhiều "thay đổi schema không gián đoạn" gặp trục trặc. Bạn muốn điền cột mới cho các hàng hiện có mà không gây khoá dài, truy vấn chậm hay spike tải bất ngờ.
Phân lô (batching) quan trọng. Hướng tới các lô chạy nhanh (vài giây, không phải vài phút). Nếu mỗi lô nhỏ, bạn có thể tạm dừng, tiếp tục và điều chỉnh job mà không chặn release.
Để theo dõi tiến độ, dùng cursor ổn định. Trong PostgreSQL đó thường là primary key. Xử lý các hàng theo thứ tự và lưu id cuối cùng bạn hoàn thành, hoặc làm việc theo range id. Điều này tránh quét toàn bộ bảng tốn kém khi job khởi động lại.
Đây là một mẫu đơn giản:
UPDATE my_table
SET new_col = ...
WHERE new_col IS NULL
AND id > $last_id
ORDER BY id
LIMIT 1000;
Làm cho update có điều kiện (ví dụ WHERE new_col IS NULL) để job là idempotent. Chạy lại chỉ tác động những hàng còn thiếu, giảm ghi không cần thiết.
Lên kế hoạch cho dữ liệu mới đến trong khi backfill chạy. Thứ tự thông thường là:
Một backfill tốt là nhàm chán: ổn định, đo được và dễ tạm dừng nếu DB nóng.
Khoảnh khắc rủi ro nhất không phải là thêm cột mới. Mà là khi bạn quyết định tin dùng nó.
Trước khi chuyển sang contract, chứng minh hai điều: dữ liệu mới đã hoàn chỉnh, và production thực sự đã đọc dữ liệu đó an toàn.
Bắt đầu với các kiểm tra hoàn chỉnh nhanh và có thể lặp lại:
Nếu bạn đang dual-writing, thêm một kiểm tra nhất quán để bắt lỗi im lặng. Ví dụ, chạy một truy vấn hàng giờ tìm các hàng mà old_value <> new_value và cảnh báo nếu không bằng 0. Thường đó là cách nhanh nhất phát hiện có writer vẫn chỉ cập nhật trường cũ.
Theo dõi các tín hiệu production cơ bản khi migration chạy. Nếu thời gian truy vấn hoặc chờ khoá tăng, ngay cả truy vấn kiểm chứng "an toàn" cũng có thể thêm tải. Giám sát tỉ lệ lỗi cho bất kỳ đường đọc dùng cột mới, đặc biệt ngay sau deploy.
Nên giữ cả hai đường trong bao lâu? Đủ lâu để vượt qua ít nhất một chu kỳ release đầy đủ và một lần chạy lại backfill. Nhiều đội dùng 1–2 tuần, hoặc cho tới khi chắc chắn không còn phiên bản ứng dụng cũ nào đang chạy.
Contract là nơi các đội thường lo lắng vì cảm thấy đó là điểm không thể quay lại. Nếu expand làm đúng, contract chủ yếu là dọn dẹp, và bạn vẫn có thể chia nhỏ thành các bước rủi ro thấp.
Chọn thời điểm thận trọng. Đừng drop ngay sau khi backfill xong. Hãy cho ít nhất một chu kỳ release để job trễ và các trường hợp biên có thời gian lộ diện.
Trình tự contract an toàn thường như sau:
Nếu có thể, tách contract thành hai release: một release bỏ tham chiếu trong code (với logging bổ sung), và release sau drop đối tượng DB. Sự phân tách này giúp rollback và điều tra dễ dàng hơn.
Cần lưu ý các chi tiết của PostgreSQL ở đây. Drop cột chủ yếu là thay đổi metadata, nhưng vẫn lấy ACCESS EXCLUSIVE lock trong thời gian ngắn. Lên kế hoạch cho cửa sổ yên tĩnh và làm migration nhanh. Nếu bạn đã tạo index phụ, ưu tiên DROP INDEX CONCURRENTLY để tránh chặn ghi (câu lệnh này không chạy trong transaction block, nên tooling migration của bạn cần hỗ trợ).
Migration không gián đoạn thất bại khi DB và app không còn đồng thuận về những gì được cho phép. Mô hình chỉ hoạt động nếu mọi trạng thái trung gian an toàn cho cả mã cũ và mã mới.
Những sai lầm sau xảy ra thường xuyên:
Một kịch bản thực tế: bạn bắt đầu viết full_name từ API, nhưng một job nền tạo user vẫn chỉ set first_name và last_name. Nó chạy ban đêm, insert hàng với full_name = NULL, và sau đó mã khác giả định full_name luôn có mặt.
Xử lý mỗi bước như một release có thể chạy trong nhiều ngày:
Một checklist có thể lặp giúp bạn tránh deploy mã chỉ chạy với một trạng thái DB duy nhất.
Trước khi deploy, xác nhận DB đã có các phần mở rộng (cột/bảng mới, index được tạo theo cách ít khoá). Rồi xác nhận ứng dụng dung nạp: nó phải chạy với cả dạng cũ, dạng đã mở rộng và trạng thái backfill nửa chừng.
Giữ checklist ngắn:
Migration chỉ hoàn thành khi đọc dùng dữ liệu mới, ghi không còn duy trì dữ liệu cũ, và bạn đã xác minh backfill bằng ít nhất một kiểm tra đơn giản (đếm hoặc lấy mẫu).
Giả sử bạn có bảng PostgreSQL customers với cột phone lưu các giá trị rối (định dạng khác nhau, đôi khi trống). Bạn muốn thay bằng phone_e164, nhưng không thể chặn release hay tắt app.
Một chuỗi expand/contract sạch sẽ như sau:
phone_e164 nullable, không default, và chưa áp ràng buộc nặng.phone và phone_e164, nhưng giữ đọc trên phone để người dùng không thấy thay đổi.phone_e164 trước và fallback về phone nếu NULL.phone_e164, bỏ fallback, drop phone, rồi nếu cần áp ràng buộc chặt hơn.Rollback đơn giản khi mỗi bước backward-compatible. Nếu chuyển đọc gây vấn đề, rollback ứng dụng và DB vẫn có cả hai cột. Nếu backfill gây tải, tạm dừng job, giảm kích thước lô và tiếp tục sau.
Để đội giữ đồng bộ, ghi kế hoạch vào một chỗ: SQL chính xác, release nào lật read, cách đo hoàn thành (ví dụ phần trăm non-NULL phone_e164), và ai chịu trách nhiệm từng bước.
Expand/contract hiệu quả nhất khi trở nên thủ tục. Viết một runbook ngắn đội bạn có thể tái sử dụng cho mọi thay đổi schema, tối ưu nhất dài một trang và đủ cụ thể để một người mới làm theo được.
Một mẫu thực tế bao gồm:
Quy định chủ thể rõ ràng từ đầu. “Ai cũng nghĩ người khác sẽ làm contract” là lý do các cột cũ và feature flag sống vài tháng.
Ngay cả khi backfill chạy online, lên lịch khi lưu lượng thấp. Dễ giữ lô nhỏ, theo dõi tải DB và dừng nhanh nếu latency tăng.
Nếu bạn đang xây dựng và deploy với Koder.ai (koder.ai), Planning Mode có thể hữu ích để vạch các pha và điểm kiểm tra trước khi động đến production. Các quy tắc tương thích vẫn áp dụng, nhưng viết rõ các bước khiến khó bỏ qua những phần nhàm chán mà lại ngăn chặn outage.
Vì cơ sở dữ liệu được dùng chung bởi mọi phiên bản ứng dụng đang chạy. Trong quá trình triển khai theo kiểu rolling và với các job nền, mã cũ và mã mới có thể chạy cùng lúc, và một migration đổi tên, xóa cột hoặc thêm ràng buộc có thể phá vỡ phiên bản không tương thích với trạng thái schema đó.
Có nghĩa là bạn thiết kế migration sao cho mọi trạng thái trung gian của cơ sở dữ liệu đều hoạt động với cả mã cũ lẫn mã mới. Bạn thêm cấu trúc mới trước, chạy với cả hai đường dẫn trong một thời gian, rồi chỉ xoá các cấu trúc cũ sau khi không còn phụ thuộc vào chúng.
Expand là giai đoạn thêm cột, bảng hoặc index mới mà không xóa bất cứ thứ gì ứng dụng hiện tại cần. Contract là giai đoạn dọn dẹp, nơi bạn loại bỏ cột cũ, các đường đọc/ghi cũ và logic đồng bộ tạm thời sau khi đã chứng minh đường dẫn mới hoạt động đầy đủ.
Bắt đầu bằng cách thêm một cột nullable và không đặt default thường là an toàn nhất, vì tránh rewrite bảng nặng và giữ cho mã cũ tiếp tục hoạt động. Sau đó deploy mã có thể xử lý việc cột bị thiếu hoặc NULL, backfill dần dần, rồi mới siết ràng buộc như NOT NULL.
Khi phiên bản ứng dụng mới cùng lúc ghi vào cả trường cũ và trường mới trong quá trình chuyển đổi. Điều này giúp dữ liệu nhất quán trong khi vẫn có các instance hoặc job cũ chỉ biết tới trường cũ.
Backfill theo lô nhỏ, mỗi lô chạy nhanh, và làm cho mỗi lô idempotent để chạy lại chỉ cập nhật những hàng còn thiếu. Giữ mắt vào thời gian truy vấn, chờ khoá và replication lag, và sẵn sàng tạm dừng hoặc giảm kích thước lô nếu DB bắt đầu nóng.
Đầu tiên kiểm tra tính hoàn chỉnh, ví dụ còn bao nhiêu hàng có NULL trong cột mới. Rồi thực hiện kiểm tra nhất quán so sánh giá trị cũ và mới trên một mẫu (hoặc toàn bộ nếu rẻ), và theo dõi lỗi production ngay sau deploy để bắt các đường code vẫn dùng schema cũ.
NOT NULL hoặc ràng buộc mới siết quá sớm có thể chặn ghi, tạo rewrite bảng; tạo index thông thường có thể giữ khoá lâu hơn bạn tưởng. Đổi tên và xóa cũng rủi ro vì mã cũ có thể vẫn tham chiếu tới tên cũ khi rolling deploy diễn ra.
Chỉ sau khi bạn đã dừng dual-write, chuyển các reads sang trường mới không còn fallback, và chờ đủ lâu để chắc chắn không còn phiên bản ứng dụng hoặc worker cũ. Nhiều đội tách contract thành một release riêng để rollback vẫn đơn giản.
Nếu bạn chấp nhận một cửa sổ bảo trì và lưu lượng thấp, có thể làm một migration một lần. Nhưng với người dùng thực, nhiều instance, worker hoặc SLA, expand/contract thường đáng công sức vì giữ rollout và rollback an toàn hơn; trong Koder.ai Planning Mode, viết sẵn các pha và kiểm tra giúp tránh bỏ qua những bước “nhàm” nhưng cần thiết để ngăn outages.