Sửa cơ sở dữ liệu đang chạy: cách giảm nguy cơ khách đứng hình

Vì sao một lệnh đổi cấu trúc bảng nhanh vẫn làm treo ứng dụng PostgreSQL, và cách giảm nguy cơ bằng lock_timeout cùng các bước nhỏ, theo tài liệu PostgreSQL, Supabase và Martin Fowler.
Hình dung một cửa hàng online thêm cột "ghi chú giao hàng" vào bảng đơn hàng. Lập trình viên chạy 1 lệnh chỉ gồm 1 dòng vào giờ cao điểm, và trang đặt hàng đứng hình trong lúc lệnh chờ. Đây là tình huống minh họa, không phải sự cố của khách hàng nào.
Bài này nói về việc đổi cấu trúc bảng (migration) trên PostgreSQL khi hệ thống vẫn phục vụ khách. Nguồn chính là tài liệu PostgreSQL bản 18, tài liệu Supabase và bài về parallel change trên martinfowler.com, đối chiếu ngày 26/10/2026.
Bài trả lời 3 câu: vì sao lệnh nhanh vẫn làm treo ứng dụng, lệnh nào tốn thời gian, và nên chia thay đổi lớn thành những bước nào. Bài không hứa loại bỏ hết rủi ro, chỉ nêu cách giảm nó.
Điểm chính
- Theo tài liệu PostgreSQL 18,
ALTER TABLElấy khóa mạnh nhất (ACCESS EXCLUSIVE) trừ khi tài liệu ghi riêng. Lệnh đang chờ khóa còn làm truy vấn đến sau xếp hàng, nên lệnh nhanh vẫn làm ứng dụng đứng được. - Hãy đặt
lock_timeoutcho phiên chạy migration để lệnh tự hủy thay vì chờ mãi, rồi thử lại sau. Mặc định là 0, nghĩa là tắt. - Thêm cột có mặc định không biến động (non-volatile) thì không ghi lại bảng. Đổi kiểu cột thường ghi lại cả bảng và chỉ mục, tạm thời cần tới gấp đôi dung lượng đĩa.
- Tạo chỉ mục bằng
CONCURRENTLYđể không chặn việc ghi, đổi lại xây chậm hơn và có thể để lại chỉ mụcINVALID. - Thay đổi không tương thích nên theo mẫu mở rộng rồi thu hẹp (expand and contract): thêm cái mới, chuyển dữ liệu từng lô, chuyển mã, rồi mới xóa cái cũ.

Vì sao một lệnh ngắn có thể làm cả hệ thống đứng
Vì lệnh phải chờ khóa, và trong lúc chờ nó chặn luôn những người đến sau. Khóa (lock) là cách PostgreSQL ngăn 2 việc dẫm chân nhau trên cùng một bảng.
Theo trang Explicit Locking của tài liệu PostgreSQL 18 (không ghi ngày), SELECT thường chỉ giữ khóa nhẹ nhất là ACCESS SHARE, chỉ xung đột với ACCESS EXCLUSIVE. Trang ALTER TABLE cho biết lệnh này lấy đúng khóa ACCESS EXCLUSIVE trừ khi tài liệu ghi riêng một dạng nhẹ hơn.
Đây là hàng đợi khóa. Tài liệu PostgreSQL mô tả nó ở hàm pg_blocking_pids: một phiên có thể bị chặn "cứng" bởi phiên đang giữ khóa xung đột, hoặc chặn "mềm" bởi phiên đang chờ khóa xung đột và đứng trước nó trong hàng đợi.
GoCardless, công ty thanh toán, kể trên blog (sửa lần cuối 6/2024) rằng họ có khoảng 15 giây API không phản hồi khi xóa và tạo lại ràng buộc khóa ngoại. Theo họ, một truy vấn đọc chạy lâu va chạm với lệnh đang chờ khóa ACCESS EXCLUSIVE, còn trên bản sao lưu lệnh chỉ mất vài trăm mili giây. Đây là lời tự kể của công ty.
Chú thích trong bài nhắc PostgreSQL 9.4, còn bản 18 dùng khóa nhẹ hơn cho khóa ngoại (xem mục sau), nên ca này minh họa cơ chế hàng đợi, không phải hành vi của bản 18.
Đặt lock_timeout trước khi chạy
Theo trang Client Connection Defaults, lock_timeout hủy câu lệnh nếu nó chờ khóa quá thời gian đặt. Giá trị 0 là mặc định và nghĩa là tắt.
Theo trang SET, SET LOCAL chỉ có tác dụng đến hết giao dịch hiện tại, nên hợp để đặt trong giao dịch migration.
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE customers ADD COLUMN phone_number text;
COMMIT;
Con số 3 giây chỉ là ví dụ. Xata, hãng làm công cụ migration pgroll (nguồn thứ cấp, cập nhật 2/5/2025), nói giá trị dưới 2 giây là thường gặp và PostgreSQL không tự thử lại lệnh bị hủy.
Lệnh bị hủy nghĩa là migration chưa chạy. Gợi ý của Click Star, theo mục backoff and retry của bài Xata: chờ vài giây rồi chạy lại, giới hạn số lần, và tìm giao dịch mở lâu trong pg_stat_activity.
Lệnh nào nhanh, lệnh nào ghi lại cả bảng
Điều cần hỏi là lệnh có phải đọc hoặc ghi lại cả bảng không, vì khi đó thời gian giữ khóa tăng theo kích thước bảng.

Thêm cột. Theo trang ALTER TABLE, thêm cột không có mặc định hoặc có mặc định không biến động (non-volatile, tính 1 lần lúc chạy lệnh) thì không phải ghi lại bảng, nên nhanh dù bảng lớn. Ghi chú phát hành PostgreSQL 11 (18/10/2018) cho biết tính năng này bắt đầu từ bản 11, nên lời khuyên cũ "đừng thêm cột có mặc định" có thể đã lỗi thời.
Ngược lại, mặc định biến động (volatile) như clock_timestamp(), cột sinh lưu trữ, cột identity hoặc kiểu miền có ràng buộc sẽ ghi lại toàn bộ bảng và chỉ mục.
Đổi kiểu cột. Thường ghi lại cả bảng và chỉ mục. Ngoại lệ là khi kiểu cũ tương thích nhị phân (binary coercible) với kiểu mới và USING không đổi nội dung: khi đó không cần ghi lại bảng.
Chỉ mục vẫn bị xây lại trừ khi hệ thống chứng minh được chỉ mục mới tương đương; tài liệu ghi đổi text sang varchar không cần xây lại chỉ mục. Với bảng lớn, việc xây lại mất đáng kể thời gian và tạm thời cần tới gấp đôi dung lượng đĩa.
Thêm ràng buộc. CHECK hoặc NOT NULL đòi quét bảng nhưng không ghi lại, và các cập nhật khác phải chờ đến khi lệnh xong. Với NOT VALID, lệnh bỏ qua bước quét; ràng buộc vẫn áp cho dòng mới. Sau đó VALIDATE CONSTRAINT quét dòng cũ, chỉ lấy khóa SHARE UPDATE EXCLUSIVE.
Nhưng việc thêm ràng buộc vẫn lấy khóa trong chốc lát, kể cả với NOT VALID: theo trang ALTER TABLE, đa số dạng ADD ràng buộc cần ACCESS EXCLUSIVE, và GoCardless ghi chú điều tương tự. Vì vậy bước này cũng cần lock_timeout.
SET NOT NULL cũng quét cả bảng, trừ khi đã có CHECK hợp lệ chứng minh không có NULL. Thêm khóa ngoại lấy khóa SHARE ROW EXCLUSIVE trên bảng đang sửa và cả trên bảng được tham chiếu, khóa vẫn chặn việc ghi.
Tạo chỉ mục. Theo trang CREATE INDEX, cách thường chặn việc ghi (không chặn đọc) cho đến khi xong. Với CONCURRENTLY, PostgreSQL không chặn ghi nhưng quét bảng 2 lần và chờ giao dịch cũ kết thúc, nên lâu hơn đáng kể.
Cần nhớ 3 điều: lệnh không chạy được trong khối giao dịch; mỗi bảng chỉ xây 1 chỉ mục kiểu này mỗi lúc; lỗi giữa chừng sẽ để lại chỉ mục INVALID vẫn tốn công cập nhật, và tài liệu khuyên xóa rồi tạo lại.
Mở rộng rồi thu hẹp: đổi cấu trúc qua nhiều lần triển khai
Đổi tên cột bằng 1 lệnh làm mã còn gọi tên cũ hỏng ngay. Hãy chia thay đổi như vậy thành 3 pha: mở rộng, chuyển, thu hẹp. Mẫu này tên là parallel change.
Theo Danilo Sato trên martinfowler.com (13/5/2014), pha mở rộng hỗ trợ cả bản cũ lẫn mới, pha chuyển đưa mọi nơi dùng sang bản mới, pha thu hẹp bỏ bản cũ. Bài nói đa số việc tái cấu trúc cơ sở dữ liệu đi theo mẫu này.

Ví dụ đổi tên cột phone thành phone_number trong bảng customers, chia thành 5 lần triển khai (khung tham khảo của Click Star, không lấy từ nguồn ngoài).
- Mở rộng: thêm cột
phone_numbercho phép NULL, cólock_timeout. - Ghi kép: sửa mã để ghi vào cả 2 cột, mã đọc vẫn đọc cột cũ.
- Chuyển dữ liệu cũ sang cột mới theo từng lô.
- Chuyển mã đọc sang cột mới, theo dõi một thời gian, rồi thêm ràng buộc
NOT VALID(cólock_timeout) vàVALIDATE. - Thu hẹp: khi không còn mã nào dùng cột cũ và đã có bản sao lưu, mới xóa cột
phone.
Đổi kiểu cột đi theo cùng các bước, với nhiều lần triển khai hơn.
Chuyển dữ liệu từng lô
Đừng cập nhật cả bảng bằng 1 lệnh UPDATE. Đó là 1 giao dịch dài, khóa dòng giữ đến khi nó kết thúc (trang Explicit Locking), nên người khác sửa cùng dòng phải chờ. Theo trang Routine Vacuuming, UPDATE giữ phiên bản dòng cũ đến khi dọn, nên cập nhật hàng loạt để lại nhiều dòng chết.
Cách gọn là cập nhật từng lô nhỏ, mỗi lô tự chốt giao dịch, lặp đến khi hết dòng cần chuyển. Ví dụ dùng dữ liệu giả.
UPDATE customers SET phone_number = phone
WHERE id IN (
SELECT id FROM customers
WHERE phone_number IS NULL
AND phone IS NOT NULL
ORDER BY id
LIMIT 1000
);
Chạy lặp câu lệnh này bằng một script nhỏ cho đến khi báo 0 dòng. Điều kiện phone IS NOT NULL là bắt buộc: thiếu nó, dòng có phone rỗng (NULL) khớp lại ở mọi vòng nên số dòng không bao giờ về 0.
Click Star kiểm tra trên PostgreSQL 18 (PGlite) với 5.000 dòng giả: thiếu điều kiện thì báo 1000 dòng mãi, đủ điều kiện thì báo 1000, 750, rồi 0. Số 1000 chỉ là ví dụ.
Thử trên bản sao, thứ tự triển khai và đường lui
Với Supabase, tài liệu Database Migrations (không ghi ngày) mô tả quy trình: mỗi thay đổi là 1 tệp migration, kiểm bằng supabase db reset trên máy, rồi supabase db push. Không sửa trực tiếp trên bản thật, vì lịch sử sẽ lệch.
Thứ tự triển khai là đề xuất của Click Star, không lấy từ nguồn ngoài. Thêm cột: migration trước, rồi mới ra mã dùng nó. Bỏ cột: ra mã không còn dùng nó trước, rồi mới xóa. Mã phải chạy được với cả 2 cấu trúc trong khoảng giữa.
Về đường lui, trang Database Backups của Supabase (không ghi ngày) cho biết khi khôi phục, dự án không truy cập được và thời gian ngừng tăng theo kích thước dữ liệu. Vì vậy mỗi bước cần có sẵn lệnh hoàn tác. Bước mở rộng hoàn tác bằng xóa cột mới; bước xóa cột cũ thì không, nếu thiếu sao lưu, nên luôn đứng cuối (khung của Click Star).
Với doanh nghiệp Việt Nam: điều này có nghĩa là gì
Đánh giá của Click Star. Một số hệ thống vừa và nhỏ dùng chung 1 cơ sở dữ liệu cho website, CRM và trang quản trị, nên 1 migration hỏng ảnh hưởng nhiều nơi. Chạy giờ ít người dùng giảm rủi ro nhưng không loại bỏ, vì hàng đợi khóa xuất hiện lúc nào có 1 truy vấn dài. Vì thế lock_timeout, bước nhỏ và sao lưu quan trọng hơn việc chọn giờ.
Click Star làm dịch vụ phần mềm theo yêu cầu, CRM và CDP, nên quan điểm này có yếu tố lợi ích.
Việc nên làm trước khi chạy migration

Danh sách dưới là khung tham khảo của Click Star.
Trước khi chạy
- Có sao lưu và biết cách khôi phục.
- Đã chạy thử trên bản sao dữ liệu thật.
- Biết lệnh có ghi lại cả bảng không, theo tài liệu đúng phiên bản.
- Đã quét giao dịch mở lâu trong
pg_stat_activity.
Khi chạy
- Đặt
lock_timeoutcho phiên migration. - Chạy lúc ít người dùng và có người trực.
- Chuyển dữ liệu từng lô, dừng được giữa chừng.
- Tạo chỉ mục bằng
CONCURRENTLY.
Sau khi chạy
- Kiểm tra không còn chỉ mục
INVALID. - Theo dõi lỗi và độ trễ của ứng dụng.
- Để mã cũ chạy song song một thời gian.
- Chỉ xóa cột cũ khi không còn mã nào dùng.
Cách đo (khung của Click Star)
Đếm số lần lệnh bị lock_timeout hủy (lấy từ nhật ký lỗi của công cụ migration), so lỗi ứng dụng lúc chạy với ngày thường, và theo dõi số dòng còn chờ chuyển sau mỗi lô.
Những sai lầm thường gặp
- Chạy
ALTER TABLEgiờ cao điểm mà không đặtlock_timeout, nên lệnh chờ khóa và mọi truy vấn sau xếp hàng theo. - Tin rằng lệnh nhanh trên bảng nhỏ sẽ vẫn nhanh trên bảng lớn.
- Đổi tên hoặc xóa cột trong một lần triển khai khi mã cũ vẫn dùng cột đó.
- Cập nhật cả bảng bằng 1 câu
UPDATE, hoặc chia lô nhưng vòng lặp không bao giờ về 0 dòng. - Xóa cột cũ khi chưa có sao lưu hoặc khi mã vẫn còn dùng.
Điều chưa biết
Click Star không có số đo cho Việt Nam về tần suất hay thiệt hại do migration. Bài không nêu thời gian chạy hay ngưỡng "bảng lớn" vì chúng phụ thuộc phần cứng và tải.
Trong bài này, hậu quả thực tế của hàng đợi khóa chỉ đến từ Xata và GoCardless, là nguồn thứ cấp hoặc tự kể.
Bài GoCardless viết thêm khóa ngoại lấy AccessExclusive trên cả 2 bảng, còn tài liệu PostgreSQL 18 ghi SHARE ROW EXCLUSIVE.
Sự khác biệt có thể do phiên bản: chú thích của bài GoCardless nhắc PostgreSQL 9.4, còn ghi chú phát hành 9.5 có dòng giảm mức khóa của một số lệnh trigger và khóa ngoại. Click Star chưa kiểm từng thay đổi nên chưa chắc đó là nguyên nhân, và theo tài liệu bản 18.
PostgreSQL 19 RC 1 phát hành ngày 15/10/2026 theo banner trang tài liệu; Click Star chưa đọc ghi chú bản 19 và chưa so các bản 14 đến 17. Click Star cũng chưa kiểm cách công cụ của Supabase xử lý CREATE INDEX CONCURRENTLY.
Bài liên quan
Cần tư vấn cụ thể cho doanh nghiệp?
Click Star giúp anh/chị xây dựng giải pháp phù hợp ngành nghề và quy mô. Trả lời mọi câu hỏi trong giờ làm việc.



