Migration PostgreSQL — giảm downtime, giảm rủi ro, giữ backward-compatible
Tài liệu này hướng dẫn cách đổi schema (cấu trúc bảng/cột/index) của PostgreSQL trên hệ thống đang chạy production, sao cho:
- downtime (thời gian gián đoạn) gần bằng 0,
- backward-compatible (tương thích ngược — code version cũ vẫn chạy được với schema mới), vì lúc rolling/blue-green deploy luôn có khoảnh khắc 2 version code cùng dùng 1 database,
- rollback (quay lại bản cũ) được mà không mất dữ liệu.
Bổ sung cho chien-luoc-deployment.md — phần đó nói "migration phải backward-compatible", phần này nói làm bằng cách nào.
Phần vận hành migration (chạy ở stage nào trong pipeline, --isolated khoá
cái gì, rollback ra sao, có nên tách repo riêng) ở
migration-ci-cd.md.
Phần 0. Hai thứ phải hiểu trước
0.1. Lock queue — lý do downtime thật sự xảy ra
Đa số lệnh ALTER TABLE cần ACCESS EXCLUSIVE lock (khoá độc quyền — không
ai được đọc hay ghi bảng đó, kể cả SELECT). Vấn đề không nằm ở lệnh ALTER
nhanh hay chậm, mà ở hàng đợi lock:
sequenceDiagram participant A as Query A (SELECT 30 giây) participant T as Bảng orders participant CHG as ALTER TABLE (1 mili-giây) participant B as Query B, C, D, E... A->>T: giữ ACCESS SHARE lock CHG->>T: xin ACCESS EXCLUSIVE → xếp hàng đợi A xong B->>T: SELECT bình thường → xếp hàng SAU ALTER Note over B: bị chặn luôn!
Một lệnh ALTER "chạy 1ms" vẫn có thể làm sập site 30 giây, vì nó đứng chặn giữa hàng đợi. Đây là bẫy phổ biến nhất.
Cách chống: luôn đặt lock_timeout trước DDL (Data Definition Language —
lệnh đổi cấu trúc) và retry (thử lại):
SET lock_timeout = '3s'; -- không lấy được lock trong 3s thì bỏ, không xếp hàng lâu
SET statement_timeout = '0'; -- nhưng khi đã lấy được lock thì cho chạy tới xong
ALTER TABLE orders ADD COLUMN note text;
Nếu lỗi canceling statement due to lock timeout → chờ vài giây rồi chạy lại.
Trước khi chạy, soi transaction dài đang chặn:
SELECT pid, state, wait_event_type, now() - xact_start AS duration, query
FROM pg_stat_activity
WHERE state <> 'idle' AND now() - xact_start > interval '30 seconds'
ORDER BY duration DESC;
0.2. Table rewrite — lý do lock kéo dài
Có 2 loại thao tác:
| Loại | Nội dung | Thời gian giữ lock |
|---|---|---|
| Metadata-only (chỉ sửa catalog — sổ mô tả cấu trúc) | Postgres chỉ ghi 1 dòng mô tả | ~mili-giây |
| Table rewrite (viết lại toàn bộ bảng ra file mới) | Postgres copy lại 100% số dòng | phút → giờ, tuỳ kích thước bảng |
Nguyên tắc vàng: trên bảng lớn, không bao giờ chạy thao tác gây rewrite trong giờ làm việc. Luôn thay bằng đường vòng nhiều bước (xem Phần 2).
Phần 1. Expand–Migrate–Contract — khung sườn cho mọi thay đổi
Ý tưởng: không bao giờ đổi tại chỗ. Thay vào đó tách 1 thay đổi phá vỡ (breaking) thành nhiều release nhỏ, mỗi release đều tương thích ngược.
flowchart LR
A["<b>1. EXPAND</b><br/>Thêm cái mới<br/>(cột/bảng/index mới)<br/><i>Cái cũ vẫn nguyên</i>"]
--> B["<b>2. MIGRATE</b><br/>Code ghi cả 2 nơi<br/>+ backfill dữ liệu cũ<br/>+ chuyển đọc sang cái mới"]
--> C["<b>3. CONTRACT</b><br/>Bỏ ghi vào cái cũ<br/>rồi mới xoá cái cũ<br/><i>(release sau, thường sau vài ngày)</i>"]
Từng bước ở góc nhìn thời gian, khi rolling deploy (2 version code cùng chạy):
sequenceDiagram
participant Old as Code v1 (cũ)
participant New as Code v2 (mới)
participant DB as PostgreSQL
Note over DB: Release 1 — EXPAND (chỉ migration, chưa đổi code)
DB->>DB: ADD COLUMN full_name (nullable)
Old->>DB: vẫn đọc/ghi cột cũ → OK (cột mới nullable nên INSERT cũ không lỗi)
Note over DB: Release 2 — MIGRATE (deploy code v2)
New->>DB: ghi CẢ name cũ VÀ full_name mới (dual-write)
Old->>DB: vẫn chỉ ghi name cũ → OK
DB->>DB: backfill theo batch: full_name = name
Note over DB: Release 3 — chuyển đọc
New->>DB: đọc full_name, vẫn ghi cả 2
Note over DB: Release 4 — CONTRACT
New->>DB: chỉ dùng full_name
DB->>DB: DROP COLUMN name
Quy tắc rút gọn để nhớ:
- Thêm cái gì → thêm trước, dùng sau.
- Xoá cái gì → ngừng dùng trước, xoá sau (≥1 release, tốt nhất là vài ngày).
- Đổi tên / đổi kiểu = thêm cái mới + copy dữ liệu + xoá cái cũ. Không bao
giờ
RENAMEtrực tiếp trên hệ thống đang chạy. - Migration và deploy code là 2 bước tách rời, không bao giờ để cùng 1 release ràng buộc nhau chặt.
Phần 2. Bảng tra cứu nhanh theo từng thao tác
Cột "An toàn?" tính trên bảng lớn (hàng triệu dòng) và có traffic thật.
| Thao tác | Lock | Rewrite? | An toàn? | Cách làm đúng |
|---|---|---|---|---|
ADD COLUMN nullable, không default |
ACCESS EXCLUSIVE, ~1ms | Không | ✅ | Chạy thẳng, nhớ lock_timeout |
ADD COLUMN có DEFAULT hằng số (PG 11+) |
ACCESS EXCLUSIVE, ~1ms | Không | ✅ | Chạy thẳng |
ADD COLUMN default là hàm volatile (random(), gen_random_uuid()) |
ACCESS EXCLUSIVE | Có | ❌ | Thêm cột không default → backfill batch → SET DEFAULT |
ADD COLUMN ... NOT NULL |
ACCESS EXCLUSIVE + quét bảng | Tuỳ | ❌ | Xem 2.2 |
DROP COLUMN |
ACCESS EXCLUSIVE, ~1ms | Không (chỉ đánh dấu ẩn) | ⚠️ | Nguy hiểm ở phía code, không ở phía DB — xem 2.5 |
RENAME COLUMN / RENAME TABLE |
ACCESS EXCLUSIVE, ~1ms | Không | ❌ | Phá code cũ ngay lập tức — xem 2.3 (cột) / 2.4 (bảng có FK) |
ALTER COLUMN TYPE (int → bigint, …) |
ACCESS EXCLUSIVE | Có | ❌ | Cột mới + dual-write + backfill — xem 2.6 |
CREATE INDEX |
SHARE (chặn mọi ghi) | — | ❌ | Dùng CONCURRENTLY |
CREATE INDEX CONCURRENTLY |
SHARE UPDATE EXCLUSIVE | — | ✅ | Không chạy trong transaction — xem 2.7 |
DROP INDEX |
ACCESS EXCLUSIVE | — | ⚠️ | Dùng DROP INDEX CONCURRENTLY |
ADD CONSTRAINT CHECK |
ACCESS EXCLUSIVE + quét bảng | Không | ❌ | NOT VALID rồi VALIDATE — xem 2.8 |
ADD FOREIGN KEY |
SHARE ROW EXCLUSIVE + quét 2 bảng | Không | ❌ | NOT VALID rồi VALIDATE |
SET NOT NULL |
ACCESS EXCLUSIVE + quét bảng | Không | ❌ | Mượn CHECK constraint (PG 12+) — xem 2.2 |
ADD UNIQUE |
ACCESS EXCLUSIVE + build index | Không | ❌ | CREATE UNIQUE INDEX CONCURRENTLY + ADD CONSTRAINT ... USING INDEX |
SET DEFAULT / DROP DEFAULT / DROP NOT NULL |
ACCESS EXCLUSIVE, ~1ms | Không | ✅ | Chạy thẳng |
DROP TABLE |
ACCESS EXCLUSIVE | — | ⚠️ | Rename-park trước, xoá sau — xem 2.10 |
2.1. Thêm cột mới (case đơn giản nhất)
SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN discount_code varchar(32); -- nullable, an toàn
Vì sao backward-compatible: code cũ không biết cột này, INSERT của code cũ
không liệt kê cột → Postgres điền NULL. Không lỗi.
Bẫy cần tránh: code cũ dùng SELECT * rồi map cứng theo thứ tự cột, hoặc
INSERT INTO orders VALUES (...) không nêu tên cột. Cả 2 đều vỡ khi số cột
thay đổi. Trong Laravel/Eloquent thì hiếm gặp, nhưng raw query thì phải soi.
Có default hằng số cũng an toàn từ PG 11 trở lên (Postgres lưu giá trị default vào catalog, không viết lại bảng):
ALTER TABLE orders ADD COLUMN status varchar(20) DEFAULT 'pending'; -- ✅ ~1ms
ALTER TABLE orders ADD COLUMN token uuid DEFAULT gen_random_uuid(); -- ❌ rewrite cả bảng
2.2. Thêm cột NOT NULL
Đây là chỗ hay gây sự cố nhất, vì NOT NULL phá code cũ: code v1 INSERT
mà không có cột mới → lỗi ngay.
Cách đúng, 4 bước:
-- Bước 1 (release 1): thêm cột nullable + default cho dòng mới
ALTER TABLE orders ADD COLUMN channel varchar(20) DEFAULT 'web';
-- Từ đây INSERT của code cũ tự có 'web' → không lỗi.
-- Bước 2: backfill dòng cũ theo batch (xem Phần 3)
UPDATE orders SET channel = 'web' WHERE channel IS NULL AND id BETWEEN ? AND ?;
-- Bước 3 (release 2): thêm CHECK dạng NOT VALID — lock cực ngắn, không quét bảng
ALTER TABLE orders ADD CONSTRAINT orders_channel_nn CHECK (channel IS NOT NULL) NOT VALID;
-- rồi validate — chỉ lấy SHARE UPDATE EXCLUSIVE, KHÔNG chặn đọc/ghi
ALTER TABLE orders VALIDATE CONSTRAINT orders_channel_nn;
-- Bước 4: bật NOT NULL thật. PG 12+ thấy đã có CHECK hợp lệ nên BỎ QUA việc
-- quét lại bảng → lock ACCESS EXCLUSIVE nhưng chỉ ~1ms.
ALTER TABLE orders ALTER COLUMN channel SET NOT NULL;
ALTER TABLE orders DROP CONSTRAINT orders_channel_nn; -- dọn CHECK thừa
Với PG < 12,
SET NOT NULLluôn quét toàn bảng dưới ACCESS EXCLUSIVE → trên bảng lớn thì chỉ giữ CHECK constraint, đừng bậtNOT NULLthật.
2.3. Đổi tên cột
ALTER TABLE ... RENAME chạy trong 1ms, nhưng phá backward-compatibility
ngay lập tức: code v1 vẫn đang gọi tên cũ → lỗi column does not exist cho
tới khi rolling deploy xong. (Đổi tên bảng có thêm chuyện riêng — xem 2.4.)
Cách đúng: coi "đổi tên" = "thêm cột mới + bỏ cột cũ", tức là expand–contract.
flowchart TD
R1["Release 1<br/>ADD COLUMN full_name text<br/>(nullable)"]
R2["Release 2<br/>code ghi cả 2 cột (dual-write)<br/>+ đọc: COALESCE(full_name, name)"]
R3["Backfill batch<br/>UPDATE ... SET full_name = name<br/>WHERE full_name IS NULL"]
R4["Release 3<br/>code chỉ đọc/ghi full_name"]
R5["Release 4 (sau vài ngày)<br/>DROP COLUMN name"]
R1 --> R2 --> R3 --> R4 --> R5
Dual-write (ghi cả 2 nơi) làm ở đâu? Ưu tiên trong code (một chỗ, dễ đọc, dễ bỏ). Chỉ dùng trigger DB khi có nhiều service/nhiều ứng dụng cùng ghi vào bảng đó — vì lúc đó không sửa hết được code.
2.4. Đổi tên bảng — và FK từ các bảng khác trỏ vào thì sao?
Đây là chỗ hay bị hiểu sai. Câu hỏi thường gặp: "bảng users đang có FK từ 5
bảng con trỏ vào, đổi tên bảng thì 5 FK đó vỡ hết à?"
Không. Foreign key trong Postgres trỏ theo OID (số định danh nội bộ của bảng), không trỏ theo tên.
RENAME TOchỉ sửa tên trong catalog → cả 5 FK vẫn chạy nguyên vẹn, không cần làm gì thêm.
Kiểm chứng trên PostgreSQL 17:
CREATE TABLE users(id int primary key);
CREATE TABLE orders(id int primary key, user_id int references users(id));
ALTER TABLE users RENAME TO accounts;
SELECT conname, conrelid::regclass AS child, confrelid::regclass AS parent
FROM pg_constraint WHERE contype = 'f';
-- conname | child | parent
-- --------------------+--------+----------
-- orders_user_id_fkey | orders | accounts ← FK tự trỏ sang tên mới
INSERT INTO orders VALUES (2, 99);
-- ERROR: violates foreign key constraint "orders_user_id_fkey"
-- DETAIL: Key (user_id)=(99) is not present in table "accounts". ← vẫn chặn đúng
Cái duy nhất còn "lệch" là tên constraint: nó vẫn là
orders_user_id_fkey, mang tên cũ. Đây là vấn đề thẩm mỹ, không phải lỗi. Dọn
sau bằng lệnh chỉ sửa catalog (~1ms):
ALTER TABLE orders RENAME CONSTRAINT orders_user_id_fkey TO orders_account_id_fkey;
Vậy vấn đề thật sự nằm ở đâu?
Không phải ở FK, mà ở code v1 đang chạy: sau RENAME, mọi query gọi
FROM users chết ngay lập tức cho tới khi rolling deploy xong. Đó mới là
downtime.
Nên chia làm 2 nhánh, chọn theo việc bạn có đổi cấu trúc bảng hay không:
flowchart TD
Q{"Chỉ đổi TÊN bảng,
cấu trúc giữ nguyên?"}
Q -- "Có" --> A["Nhánh A: RENAME + view tên cũ
5 FK không phải đụng tới"]
Q -- "Không (đổi kiểu PK,
đổi khoá chính, chia partition)" --> B["Nhánh B: bảng mới thật
→ phải chuyển từng FK một"]
Nhánh A — chỉ đổi tên (chọn cái này nếu được)
-- Release 1: đổi tên + dựng view mang tên cũ, trong CÙNG 1 transaction
BEGIN;
SET lock_timeout = '3s';
ALTER TABLE users RENAME TO accounts; -- ACCESS EXCLUSIVE ~1ms
CREATE VIEW users AS SELECT * FROM accounts; -- code v1 vẫn chạy qua view
COMMIT;
-- Release 2: deploy code dùng tên "accounts"
-- Release 3 (sau vài ngày yên tĩnh): DROP VIEW users;
Chi phí đụng vào FK: bằng 0. 5 bảng con không cần một câu migration nào.
View bắc cầu chạy được tới đâu — và bốn cái bẫy
Trước hết, đính chính một cách hiểu sai hay gặp: view không đồng bộ dữ liệu
sang bảng thật, vì không có hai nơi chứa dữ liệu. View là một rewrite rule
(luật viết lại câu query): Postgres thay users bằng accounts ngay lúc phân
tích câu lệnh rồi chạy thẳng trên bảng thật. Không có bản sao → không có độ trễ,
không có nguy cơ lệch. Plan không hề nhắc tới view:
EXPLAIN SELECT * FROM users WHERE id = 1;
-- Index Scan using accounts_pkey on accounts (cost=0.15..8.17 rows=1 width=48)
Đo trên PostgreSQL 17, ghi qua view chạy đúng ở tất cả các đường code hay dùng:
Thao tác qua view users | Kết quả |
|---|---|
INSERT INTO users (name) VALUES (…) | OK — bigserial và DEFAULT now() của bảng thật vẫn áp dụng |
UPDATE / DELETE | OK |
RETURNING id | OK (Laravel insertGetId() dựa vào cái này) |
INSERT … ON CONFLICT … DO UPDATE | OK |
Trigger AFTER INSERT ON accounts | Có chạy — ghi thật sự xảy ra trên bảng thật |
FK từ bảng khác trỏ vào accounts | Vẫn chặn đúng |
Điều kiện để được như vậy: view phải auto-updatable (Postgres tự cho ghi) —
SELECT từ đúng một bảng, không JOIN, DISTINCT, GROUP BY,
HAVING, LIMIT, UNION, không window function. View đổi tên bảng
(SELECT * FROM accounts) luôn thoả.
Bốn cái bẫy, xếp theo mức hay dính:
View không tự bắt kịp cột mới. Đây là bẫy nguy hiểm nhất, vì nó nổ ở release khác chứ không phải release tạo view.
SELECT *được khai triển ngay lúc tạo view, không phải lúc chạy:ALTER TABLE accounts ADD COLUMN email text; \d users -- id | name | created_at ← không có email INSERT INTO users (name, email) VALUES ('a', 'a@b.c'); -- ERROR: column "email" of relation "users" does not existNặng hơn: nếu cột mới là
NOT NULLkhông có default thì mọiINSERTqua view chết ngay, kể cả câu lệnh không nhắc tới cột đó — vì view không có đường truyền giá trị vào:INSERT INTO users (name) VALUES ('b'); -- ERROR: null value in column "tier" of relation "accounts" -- violates not-null constraintChữa được bằng
CREATE OR REPLACE VIEW users AS SELECT * FROM accounts;(chỉ nối thêm cột vào cuối được — bỏ hay đổi thứ tự cột thì Postgres từ chối:ERROR: cannot drop columns from view). Nhưng đừng dựa vào việc nhớ chạy lệnh này. Nguyên tắc gọn hơn: trong lúc còn view bắc cầu, đóng băng schema bảng đó — không thêm, không đổi cột. View chỉ nên sống vài ngày, đủ để rolling deploy xong rồiDROP VIEW.FK không trỏ được vào view. Thử sẽ được
ERROR: referenced relation "users" is not a table. Nên trong lúc còn view, nếu cần thêm FK mới cho bảng con thì phải trỏ thẳng vào tên thật (accounts). Migration nào lỡ viếtREFERENCES users(id)sẽ fail.Quên dọn view. View là một lớp phải nhớ bỏ. Để lâu thì người sau nhìn schema thấy hai cái tên cho cùng một bảng và không biết cái nào là thật. Đặt hạn dọn ngay lúc tạo, và kiểm tra còn ai gọi tên cũ không trước khi bỏ:
-- view co con duoc dung khong? (seq_scan tang len = van con nguoi goi) SELECT seq_scan, idx_scan FROM pg_stat_user_tables WHERE relname = 'accounts'; -- chac an hon: bat log query cham/mau thu roi grep chu "users"View không phải bảng — vài lệnh từ chối thẳng. Câu
SELECT/INSERT/UPDATE/DELETEthì chạy bình thường, nhưng lệnh nào cần một bảng thật sẽ lỗi. Hai chỗ hay dính, đo trên PG 17:TRUNCATE users; -- ERROR: "users" is not a table -- view khong co primary key trong catalog: SELECT count(*) FROM information_schema.table_constraints WHERE table_name = 'users' AND constraint_type = 'PRIMARY KEY'; -- 0Nghĩa là seeder/test nào dùng
truncatetrên tên cũ sẽ gãy, và công cụ đọc schema (sinh model, dump schema, so sánh migration) có thể bỏ qua hoặc hiểu sai vì không thấy khoá chính. Chạy thử toàn bộ test suite với view đang bật trước khi dựng nó trên production — đây là thứ chỉ lộ ra lúc chạy thật, không lộ ra khi đọc migration.
Nhánh B — bảng mới thật, phải chuyển 5 FK
Chỉ rơi vào đây khi bảng mới là một bảng khác: đổi kiểu khoá chính
(int → bigint), đổi khoá chính sang cột khác, chuyển sang bảng partition.
Lúc đó OID khác → FK cũ vẫn trỏ vào bảng cũ, phải dựng lại từng cái.
Với mỗi bảng con (làm tuần tự từng bảng, không chạy song song):
flowchart TD
S1["1. ADD COLUMN account_id bigint
(nullable, không default) — lock ~1ms"]
S2["2. Code dual-write:
ghi cả user_id và account_id"]
S3["3. Backfill theo batch
UPDATE ... WHERE account_id IS NULL"]
S4["4. Bên bảng CHA: CREATE UNIQUE INDEX
CONCURRENTLY trên cột được trỏ tới"]
S5["5. ADD CONSTRAINT ... FOREIGN KEY ... NOT VALID
⚠ chặn ghi CẢ bảng con LẪN bảng cha"]
S6["6. VALIDATE CONSTRAINT
quét lâu nhưng KHÔNG chặn đọc/ghi"]
S7["7. Release sau: DROP CONSTRAINT fk cũ
rồi DROP COLUMN user_id"]
S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7
-- Bước 4: FK bắt buộc bên cha phải có unique index trên cột được trỏ tới
CREATE UNIQUE INDEX CONCURRENTLY uq_accounts_id ON accounts (id);
-- Bước 5: lock ngắn nhưng CHẶN GHI cả 2 bảng → luôn đặt lock_timeout
SET lock_timeout = '3s';
ALTER TABLE orders
ADD CONSTRAINT fk_orders_account
FOREIGN KEY (account_id) REFERENCES accounts(id)
ON DELETE CASCADE -- phải khai ĐÚNG như FK cũ, xem bẫy bên dưới
NOT VALID;
-- Bước 6: quét toàn bảng, nhưng không chặn ai
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_account;
Lock thật sự của từng bước (đo trên PostgreSQL 17)
| Lệnh | Lock trên bảng CON | Lock trên bảng CHA | Chặn gì |
|---|---|---|---|
ALTER TABLE ... RENAME TO |
— | ACCESS EXCLUSIVE | Chặn tất cả, nhưng ~1ms |
ADD CONSTRAINT FK ... NOT VALID |
SHARE ROW EXCLUSIVE | SHARE ROW EXCLUSIVE | Chặn INSERT/UPDATE/DELETE trên cả hai; SELECT vẫn chạy |
VALIDATE CONSTRAINT |
SHARE UPDATE EXCLUSIVE | ROW SHARE | Không chặn đọc lẫn ghi (nhưng chặn migration khác cùng bảng) |
DROP CONSTRAINT |
ACCESS EXCLUSIVE | ACCESS EXCLUSIVE | Chặn tất cả, chỉ sửa catalog → ~1ms |
Điểm dễ bị bất ngờ nằm ở dòng thứ hai: thêm FK khoá ghi cả bảng cha, không
chỉ bảng con. Có 5 bảng con nghĩa là bảng cha accounts bị chặn ghi 5 lần.
Mỗi lần rất ngắn, nhưng mỗi lần đều phải xếp hàng chờ lock — và trong lúc chờ
nó chặn luôn mọi transaction đến sau (xem lại 0.1). Nên:
- Chạy tuần tự, mỗi
ADD CONSTRAINTmột transaction riêng, không gộp 5 cái vào một migration dài. - Luôn
SET lock_timeout = '3s'— thà migration fail rồi chạy lại, còn hơn treo cả hệ thống. - Xếp giờ thấp điểm cho bước 5, còn bước 6 (
VALIDATE) chạy giờ nào cũng được.
Ba cái bẫy hay dính
Quên copy
ON DELETE/ON UPDATEaction. FK cũ làON DELETE CASCADE, FK mới quên khai → mặc định thànhNO ACTION. Trong giai đoạn song song, xoá một dòng cha sẽ hành xử khác nhau tuỳ FK nào đang tồn tại → dữ liệu mồ côi hoặc lỗi bất ngờ. Chép nguyên định nghĩa cũ ra trước khi viết cái mới:SELECT conrelid::regclass AS child, conname, pg_get_constraintdef(oid) FROM pg_constraint WHERE confrelid = 'users'::regclass AND contype = 'f';DROP TABLE ... CASCADEkhi dọn bảng cũ. Bảng cũ còn 5 FK trỏ vào thìDROP TABLEsẽ báo lỗi — đó là Postgres đang cứu bạn. Đừng thêmCASCADEcho nhanh: nó xoá luôn constraint bên 5 bảng con, và bạn mất ràng buộc mà không ai biết. Xoá FK cũ từng cái một cách có chủ đích trước.Giai đoạn song song không có "một nguồn sự thật". Trong lúc bảng con có cả
user_idlẫnaccount_id, không có gì bắt hai cột phải khớp nhau. Nếu sợ lệch, thêm tạm một CHECK và bỏ đi ở release cuối:ALTER TABLE orders ADD CONSTRAINT tmp_ck_ids CHECK (account_id = user_id) NOT VALID; ALTER TABLE orders VALIDATE CONSTRAINT tmp_ck_ids;
So sánh hai nhánh
| Nhánh A (RENAME + view) | Nhánh B (bảng mới) | |
|---|---|---|
| Số release | 2–3 | 3–4, nhân với số bảng con nếu làm rời |
| Đụng vào FK | Không | 5 FK × (thêm cột + backfill + 2 lệnh constraint) |
| Backfill | Không | Có, trên cả 5 bảng con |
| Rollback | Rename ngược, 1 giây | Khó — bảng mới đã nhận dữ liệu mới |
| Khi nào dùng | Chỉ đổi tên | Đổi kiểu PK, đổi khoá chính, chuyển sang partition |
Chốt lại: nếu chỉ đổi tên bảng thì đừng tạo bảng mới.
RENAME+ view mang tên cũ là đủ, và FK tự đi theo. Chỉ khi bảng mới thật sự là bảng khác (đổi cấu trúc) thì mới phải trả cái giá chuyển từng FK một.
2.5. Xoá cột
Về phía DB, DROP COLUMN rất nhẹ: Postgres chỉ đánh dấu cột là đã xoá trong
catalog, không viết lại bảng (chỗ đĩa chỉ được thu hồi dần khi VACUUM viết
lại các dòng, hoặc chạy pg_repack).
Rủi ro nằm ở code, và có 2 rủi ro tách biệt:
- Code v1 vẫn còn
SELECTcột đó → lỗi. Giải: chỉ xoá ở release sau khi release "ngừng dùng cột" đã chạy ổn định. - Không rollback được. Deploy lỗi, ta rollback code — nhưng cột (và dữ liệu trong đó) đã mất. Đây là lý do phải chờ vài ngày giữa "ngừng dùng" và "xoá thật".
Thứ tự an toàn:
Release N: code không còn đọc/ghi cột → deploy → theo dõi 2–7 ngày
Release N+1: ALTER TABLE ... DROP COLUMN old_col;
Bẫy phụ: nếu cột có NOT NULL và code v1 vẫn INSERT đầy đủ, hãy DROP NOT NULL trước ở release N — nếu không, khi rollback về v1 thì v1 không lỗi,
nhưng nếu bạn xoá cột sớm thì v1 lại lỗi. Thứ tự: bỏ ràng buộc trước, xoá sau.
2.6. Đổi kiểu dữ liệu (vd int → bigint)
ALTER COLUMN TYPE viết lại toàn bộ bảng và mọi index của bảng, giữ ACCESS
EXCLUSIVE suốt thời gian đó — bảng 100 triệu dòng có thể khoá vài chục phút.
Vài trường hợp Postgres không rewrite (an toàn): varchar(50) →
varchar(100) (nới rộng), varchar(n) → text, tăng precision của numeric.
Còn lại (int→bigint, text→jsonb, đổi timezone của timestamp) đều rewrite.
Cách đúng — vẫn là expand–contract:
-- 1. cột mới kiểu mới
ALTER TABLE events ADD COLUMN id_new bigint;
-- 2. code dual-write: ghi cả id và id_new
-- 3. backfill batch: UPDATE events SET id_new = id WHERE id_new IS NULL AND ...
-- 4. index/constraint cho cột mới bằng CONCURRENTLY
-- 5. đổi đọc sang id_new → release sau: bỏ cột cũ, rename cột mới về tên chuẩn
Với khoá chính (primary key) thì bước 5 phức tạp hơn (phải đổi cả FK trỏ tới) — nên lên kế hoạch riêng và làm vào giờ thấp điểm.
2.7. Tạo / sửa / xoá index
CREATE INDEX thường lấy SHARE lock: đọc vẫn được, ghi thì bị chặn suốt
thời gian build. Luôn dùng CONCURRENTLY:
CREATE INDEX CONCURRENTLY idx_orders_user_created ON orders (user_id, created_at DESC);
DROP INDEX CONCURRENTLY idx_orders_old;
Điều cần nhớ khi dùng CONCURRENTLY:
Không chạy được trong transaction block. Laravel mặc định bọc migration trong transaction trên PostgreSQL → phải tắt:
class AddOrdersUserCreatedIndex extends Migration { public $withinTransaction = false; // bắt buộc cho CONCURRENTLY public function up(): void { DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_user_created ON orders (user_id, created_at DESC)'); } public function down(): void { DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_orders_user_created'); } }Chậm hơn (quét bảng 2 lượt) và tốn CPU/IO hơn — chạy giờ thấp điểm.
Nếu thất bại giữa chừng, để lại index INVALID (index hỏng, query không dùng nhưng vẫn tốn chi phí mỗi lần ghi). Phải kiểm tra và dọn:
SELECT indexrelid::regclass AS index_name, indrelid::regclass AS table_name FROM pg_index WHERE NOT indisvalid; -- rồi: DROP INDEX CONCURRENTLY <tên>; hoặc REINDEX INDEX CONCURRENTLY <tên>;"Sửa index" = tạo mới rồi xoá cũ, theo đúng thứ tự đó. Tạo index mới trước, đợi valid, để planner (bộ lập kế hoạch truy vấn) chuyển sang dùng, rồi mới drop index cũ — làm ngược lại là tự tạo một khoảng thời gian không có index, đủ để làm nghẽn DB.
Đổi tên index thì
ALTER INDEX ... RENAME TOan toàn (lock ngắn, không ảnh hưởng code vì code không gọi tên index).
2.8. Thêm constraint (CHECK, FOREIGN KEY)
Thêm thẳng thì Postgres phải quét toàn bảng để kiểm tra dữ liệu cũ — giữ lock suốt lúc quét. Tách làm 2 bước:
-- Bước 1: lock ngắn, KHÔNG quét dữ liệu cũ. Ràng buộc áp dụng ngay cho
-- INSERT/UPDATE mới.
ALTER TABLE orders
ADD CONSTRAINT orders_amount_positive CHECK (amount > 0) NOT VALID;
-- Bước 2: quét kiểm tra dữ liệu cũ dưới SHARE UPDATE EXCLUSIVE
-- → KHÔNG chặn SELECT/INSERT/UPDATE/DELETE.
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_positive;
Foreign key y hệt:
ALTER TABLE order_items
ADD CONSTRAINT fk_order_items_order FOREIGN KEY (order_id) REFERENCES orders(id) NOT VALID;
ALTER TABLE order_items VALIDATE CONSTRAINT fk_order_items_order;
Nếu bước 2 báo lỗi → dữ liệu cũ đang vi phạm. Đừng vội xoá constraint: sửa/dọn
dữ liệu bẩn rồi validate lại. Constraint NOT VALID vẫn đang bảo vệ dữ liệu mới.
Thêm UNIQUE thì đi đường index:
CREATE UNIQUE INDEX CONCURRENTLY uq_users_email ON users (lower(email));
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE USING INDEX uq_users_email; -- lock ~1ms
Backward-compatibility của constraint: constraint chặt hơn thì code cũ có
thể vi phạm (v1 vẫn ghi amount = 0) → phải sửa code trước, deploy, rồi mới
thêm constraint. Ngược lại, nới lỏng constraint (drop CHECK, drop NOT NULL)
thì luôn an toàn với code cũ.
2.9. Đổi constraint đã có
Không có "ALTER CONSTRAINT" cho phần điều kiện. Cách làm: thêm cái mới trước, xoá cái cũ sau, để không có khoảng trống không được bảo vệ:
ALTER TABLE orders ADD CONSTRAINT orders_amount_ck2 CHECK (amount >= 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_ck2;
ALTER TABLE orders DROP CONSTRAINT orders_amount_ck1;
DROP CONSTRAINT lấy ACCESS EXCLUSIVE nhưng chỉ sửa catalog → ~1ms.
2.10. Xoá bảng
Giống xoá cột: rủi ro là không quay lại được. Dùng mẹo "rename-park" (đổi tên rồi để đó) để có đường lùi rẻ tiền:
-- Release N: code đã ngừng dùng bảng. Đổi tên thay vì xoá.
ALTER TABLE legacy_logs RENAME TO zz_legacy_logs_deprecated_20260817;
-- Nếu vài ngày sau phát hiện còn chỗ nào đó gọi tới → rename ngược lại trong 1 giây.
-- Release N+2 (sau 1–2 tuần yên tĩnh):
DROP TABLE zz_legacy_logs_deprecated_20260817;
Trước khi xoá, kiểm tra còn ai tham chiếu không:
-- FK trỏ tới bảng này?
SELECT conrelid::regclass AS from_table, conname
FROM pg_constraint WHERE confrelid = 'legacy_logs'::regclass;
-- bảng còn được đọc/ghi không? (số lượt scan tăng lên nghĩa là vẫn có người dùng)
SELECT seq_scan, idx_scan, n_tup_ins, n_tup_upd FROM pg_stat_user_tables
WHERE relname = 'legacy_logs';
Phần 3. Backfill dữ liệu lớn — chia batch, không chạy 1 phát
Một UPDATE trên 50 triệu dòng là thảm hoạ: transaction dài chặn autovacuum,
bảng phình (bloat), WAL (write-ahead log — nhật ký ghi trước) tăng vọt, replica
(bản sao đọc) trễ theo, và rollback thì mất nhiều thời gian hơn cả lúc chạy.
Chia nhỏ, mỗi batch là 1 transaction riêng:
-- lặp cho tới khi không còn dòng nào
WITH batch AS (
SELECT id FROM orders
WHERE channel IS NULL
ORDER BY id
LIMIT 5000
FOR UPDATE SKIP LOCKED -- bỏ qua dòng đang bị người khác khoá
)
UPDATE orders o SET channel = 'web'
FROM batch b WHERE o.id = b.id;
Nguyên tắc chạy backfill:
Batch 1.000–10.000 dòng, nghỉ 100–500ms giữa các batch (nhường IO cho traffic thật).
Chạy như một job nền (queue job / lệnh artisan), không nhét vào file migration — migration chạy trong pipeline deploy sẽ làm nghẽn cả lần deploy.
Idempotent (chạy lại nhiều lần vẫn ra cùng kết quả): điều kiện
WHERE ... IS NULLgiúp lần chạy sau chỉ đụng phần chưa xong.Theo dõi replica lag trong lúc chạy; lag tăng → tăng thời gian nghỉ:
SELECT client_addr, state, replay_lag FROM pg_stat_replication;
Phần 4. Chạy migration trong pipeline CI/CD
Thứ tự trong một lần deploy an toàn:
sequenceDiagram
participant CI as GitLab CI
participant DB as PostgreSQL
participant App as App instances
CI->>DB: 1. backup / snapshot (bắt buộc trước mọi DDL)
CI->>DB: 2. migration EXPAND (chỉ thêm, không xoá)
Note over DB: schema mới vẫn tương thích code cũ
CI->>App: 3. rolling deploy code mới
Note over App: v1 và v2 chạy song song — cả 2 đều OK
CI->>DB: 4. (job riêng, không chặn deploy) backfill theo batch
Note over CI,DB: 5. CONTRACT ở release sau vài ngày
Các quy tắc kèm theo:
Backup trước khi chạy DDL. Với cloud (RDS/Cloud SQL) thì snapshot; tự quản thì
pg_dumpschema + dữ liệu bảng bị đụng tới. Đây là điều kiện tiên quyết, không được bỏ vì "migration này nhỏ".Mỗi migration một thay đổi. File nhỏ dễ đọc, dễ bỏ, và khi lỗi thì biết chính xác cái nào lỗi.
down()phải thật sự chạy được, nhưng đừng tin nó là cách rollback dữ liệu —down()củaDROP COLUMNchỉ tạo lại cột rỗng, dữ liệu vẫn mất. Vì vậy rollback thật của thao tác phá huỷ là: đừng phá huỷ sớm.Timeout trong Laravel: đặt trước mỗi migration nặng.
public function up(): void { DB::statement("SET lock_timeout = '3s'"); Schema::table('orders', fn (Blueprint $t) => $t->string('discount_code', 32)->nullable()); }Đừng để migration chạy đồng thời từ nhiều container. Laravel migration có bảng
migrations+ lock, nhưng cách chắc chắn là tách thành một job riêng trong pipeline chạy trước bước deploy, không nhét vào entrypoint của container.
Phần 5. Checklist trước khi merge một migration
[ ] Migration này chỉ THÊM, hay có XOÁ/ĐỔI TÊN? (xoá/đổi tên → tách thành 2+ release)
[ ] Code version cũ (v1) có còn chạy được với schema mới không?
[ ] Code version mới (v2) có chạy được với schema CŨ không? (để rollback code được)
[ ] Có gây table rewrite không? (đổi kiểu, default volatile, ADD NOT NULL)
[ ] Có CREATE/DROP INDEX không dùng CONCURRENTLY không?
[ ] Constraint mới có dùng NOT VALID + VALIDATE không?
[ ] Có bảng nào bị FK trỏ vào không? (đổi tên bảng → FK tự theo; tạo bảng MỚI → phải chuyển từng FK)
[ ] FK mới có copy đúng ON DELETE / ON UPDATE của FK cũ chưa?
[ ] Đã SET lock_timeout chưa?
[ ] Backfill có tách khỏi migration và chia batch chưa?
[ ] Đã đo trên bảng có kích thước thật (staging có dữ liệu gần production) chưa?
[ ] Đã backup/snapshot trước khi chạy chưa?
Phần 6. Case thực tế có thể gặp và cách xử lý
Case 1 — Chạy migration lúc 10h sáng, cả site treo, connection đầy.
Một ALTER TABLE xin ACCESS EXCLUSIVE lock, phải đợi một query đang chạy;
mọi query đến sau xếp hàng phía sau nó (lock queue) — kể cả SELECT vốn
không xung đột với nhau.
Xử lý ngay: SELECT pid, query, state, wait_event_type FROM pg_stat_activity ORDER BY xact_start; → tìm pid của migration → SELECT pg_cancel_backend(pid);
(cancel hiền hơn, thử trước pg_terminate_backend). Hàng đợi tan trong vài
giây.
Ngăn lần sau: SET lock_timeout = '3s' ở đầu mọi migration — thà fail nhanh
rồi chạy lại còn hơn treo cả site.
Case 2 — Migration treo mà không thấy query nào nặng.
Thủ phạm quen thuộc: một session idle in transaction (ai đó mở transaction
trong pgAdmin/DBeaver rồi đi ăn trưa), hoặc job nền quên commit. Nó giữ lock
nhẹ trên bảng, đủ để chặn DDL.
Xử lý ngay: tìm state = 'idle in transaction' trong pg_stat_activity,
terminate session đó.
Ngăn lần sau: đặt idle_in_transaction_session_timeout ở mức phút cho user
ứng dụng và user con người.
Case 3 — CREATE INDEX CONCURRENTLY fail giữa chừng.
PostgreSQL để lại index ở trạng thái INVALID: nó tốn dung lượng, được cập
nhật khi ghi, nhưng planner không dùng — tệ nhất mọi đằng.
Xử lý: \d+ ten_bang (hoặc query pg_index với indisvalid = false) →
DROP INDEX CONCURRENTLY ten_index; → tạo lại. Không có cách "sửa" tại chỗ.
Ngăn lần sau: mỗi CONCURRENTLY một file migration riêng (nó không chạy
trong transaction được), và có bước kiểm tra index invalid sau khi chạy.
Case 4 — Backfill 20 triệu dòng làm replica lag 15 phút.
Một UPDATE lớn sinh khối WAL khổng lồ; replica đọc không kịp → tính năng đọc
từ replica trả dữ liệu cũ, user kêu "vừa lưu xong mà mất".
Xử lý ngay: dừng job backfill, đợi replica đuổi kịp (pg_stat_replication).
Ngăn lần sau: backfill theo batch vài nghìn dòng, sleep giữa các batch, và
đọc pg_stat_replication để tự giảm tốc khi lag vượt ngưỡng. Backfill là
job nền, không phải một câu lệnh trong migration.
Case 5 — Rollback code xong thì insert fail hàng loạt.
Release mới thêm cột NOT NULL không default; code cũ không biết cột đó nên
INSERT thiếu → vi phạm constraint.
Xử lý ngay: ALTER TABLE ... ALTER COLUMN x DROP NOT NULL (nhanh, chỉ sửa
catalog) để code cũ sống lại, rồi tính tiếp.
Ngăn lần sau: cột mới luôn nullable hoặc có default ở release thêm nó;
chỉ siết NOT NULL ở release sau, khi chắc chắn không còn code cũ nào chạy.
Case 6 — DROP COLUMN xong mới biết report/BI vẫn đọc cột đó.
Xử lý: dữ liệu chỉ lấy lại được từ backup/PITR (point-in-time recovery —
khôi phục về một mốc thời gian) rồi copy cột về, rất tốn thời gian.
Ngăn lần sau: trước khi xoá, đổi tên thành x_deprecated_YYYYMM và để yên
1–2 tuần. Không ai kêu thì mới xoá thật. Chi phí: gần bằng 0. Lợi ích: đổi một
sự cố mất dữ liệu thành một lần đổi tên ngược lại.
Case 7 — Migration chạy 2 lần cùng lúc.
Hai container app cùng gọi migrate trong entrypoint khi khởi động.
Xử lý ngay: Laravel có bảng migrations nhưng vẫn có race — thường thấy lỗi
"relation already exists" hoặc migration nửa vời.
Ngăn lần sau: migration là một job riêng trong pipeline, chạy từ một
container duy nhất, không nằm trong entrypoint. Nếu buộc phải chạy lúc khởi
động thì bọc bằng advisory lock (pg_advisory_lock).
Case 8 — Migration fail trong pipeline vì lock_timeout, deploy đứng giữa chừng.
Xử lý: đây là kết quả đúng như thiết kế — thà fail còn hơn khoá bảng.
Retry vài lần với backoff, hoặc dời sang giờ thấp điểm.
Ngăn lần sau: job migration tự retry 3–5 lần; nếu vẫn fail thì có nghĩa là
bảng đang bận thật, cần xem lại thời điểm deploy.
Case 9 — Sau backfill lớn, query chậm hẳn dù index vẫn còn.
UPDATE trong PostgreSQL tạo dòng mới và đánh dấu dòng cũ chết → bảng phình
(bloat), index trỏ tới nhiều dòng chết, số liệu thống kê lệch.
Xử lý ngay: ANALYZE ten_bang trước (rẻ, thường đủ). Bloat nặng thì
VACUUM — cần dọn triệt để thì dùng pg_repack (làm online, không khoá lâu),
tránh VACUUM FULL trên production vì nó khoá bảng suốt thời gian chạy.
Ngăn lần sau: batch nhỏ + để autovacuum có khoảng nghỉ giữa các batch.
Phần 7. Câu hỏi phụ interviewer hay hỏi tiếp
"PostgreSQL 11 trở lên ADD COLUMN ... DEFAULT đã nhanh rồi mà?"
Đúng, với default là hằng số — PG chỉ ghi vào catalog, không viết lại bảng.
Nhưng default volatile (now(), gen_random_uuid()) thì vẫn rewrite toàn
bảng. Đó là ranh giới cần nhớ, không phải "PG 11+ thì thoải mái".
"ACCESS EXCLUSIVE lock khác gì lock dòng?"
Lock dòng chỉ chặn ai đụng đúng dòng đó. ACCESS EXCLUSIVE là lock cả bảng
và xung đột với mọi thứ, kể cả SELECT. Nguy hiểm thật không nằm ở thời gian
giữ lock mà ở hàng đợi: DDL đợi lock sẽ chặn mọi query đến sau nó, nên một
DDL đợi 30 giây có thể làm cả site đứng 30 giây.
"Vì sao dùng lock_timeout chứ không phải statement_timeout?"
lock_timeout giới hạn thời gian chờ lock — đúng thứ gây ra hàng đợi.
statement_timeout giới hạn tổng thời gian chạy, nên nó có thể cắt ngang một
CREATE INDEX chạy lâu nhưng vô hại. Thường đặt lock_timeout ngắn (vài giây)
và statement_timeout dài hơn hoặc tắt cho migration.
"CONCURRENTLY chậm hơn, vậy sao không lúc nào cũng dùng bản thường?"
CONCURRENTLY quét bảng 2 lượt nên chậm hơn 2–3 lần và không chạy trong
transaction được, đổi lại không khoá ghi. Trên bảng nhỏ (vài chục nghìn
dòng) thì index thường chỉ mất mili-giây, dùng bản thường cho gọn. Bảng lớn
đang phục vụ traffic thì bắt buộc CONCURRENTLY.
"Rollback migration bằng php artisan migrate:rollback được không?"
Trên máy dev thì được. Trên production thì không nên: down() hiếm khi
được test, và với DROP COLUMN thì rollback không mang dữ liệu trở lại. Cách
an toàn là thiết kế để không cần rollback schema: chỉ thêm ở release này,
xoá ở release sau — rollback chỉ là rollback code.
"Migration chạy ở bước nào trong pipeline?" Job riêng, sau khi image mới đã sẵn sàng và trước khi traffic sang bản mới, chạy từ một container duy nhất. Vì lúc đó hai version code cùng sống, migration phải expand-only. Nếu migration fail, deploy dừng lại — bản cũ vẫn chạy bình thường vì schema chưa đổi theo hướng phá vỡ nó.
"Đổi tên một bảng đang có 5 bảng khác FK trỏ vào thì xử lý FK thế nào?"
Câu hỏi bẫy: RENAME TO không phá FK, vì FK trỏ theo OID chứ không theo
tên — cả 5 FK tự trỏ sang tên mới, không phải làm gì. Chỉ tên constraint là còn
mang tên cũ, dọn bằng RENAME CONSTRAINT. Rủi ro thật nằm ở code v1 đang gọi
tên cũ, nên đi kèm một view mang tên cũ cho tới khi deploy xong. Chỉ khi
tạo bảng mới thật (đổi kiểu khoá chính, chuyển sang partition) mới phải
chuyển từng FK: thêm cột → dual-write → backfill → ADD CONSTRAINT ... NOT VALID
→ VALIDATE. Lưu ý bước ADD CONSTRAINT khoá ghi cả bảng cha
(SHARE ROW EXCLUSIVE), nên 5 bảng con là 5 lần chặn ghi bảng cha — chạy tuần tự,
có lock_timeout. Chi tiết ở 2.4.
"Bảng 500 triệu dòng cần đổi int → bigint, làm sao?"
Không ALTER TYPE (rewrite cả bảng, khoá rất lâu). Làm expand-contract: thêm
cột id_big bigint, trigger ghi song song (dual write) cho dòng mới, backfill
theo batch cho dòng cũ, tạo index/constraint CONCURRENTLY/NOT VALID, đổi
code sang đọc cột mới, rồi mới đổi tên/xoá cột cũ ở release sau.
"Làm sao biết trước migration sẽ khoá bao lâu?"
Đo trên staging có dữ liệu gần bằng production — trên bảng 1000 dòng thì
mọi migration đều nhanh, số đó vô nghĩa. Ngoài ra kiểm tra loại thao tác: có
gây rewrite không, có CONCURRENTLY chưa. Và luôn có lock_timeout làm lưới
an toàn cho phần mình đoán sai.
"MySQL có giống vậy không?"
Nguyên tắc expand-contract giống hệt. Chi tiết thì khác: MySQL 8 có online DDL
với ALGORITHM=INPLACE, LOCK=NONE cho nhiều thao tác, nhưng không phải mọi
thao tác — và MySQL không có DDL trong transaction, nên migration fail
giữa chừng để lại trạng thái nửa vời thường xuyên hơn PostgreSQL.
Phần 8. Câu trả lời ngắn cho phỏng vấn
"Nguyên tắc của em là expand–contract: không bao giờ đổi tại chỗ. Thêm cột/bảng mới ở release trước, cho code ghi cả 2 nơi, backfill theo batch ở job nền, chuyển đọc sang cột mới, rồi vài ngày sau mới xoá cái cũ. Nhờ vậy lúc rolling deploy có 2 version code cùng chạy thì cả 2 đều làm việc được, và rollback code không mất dữ liệu.
Về phía PostgreSQL, em để ý 2 thứ: lock queue — một DDL đợi lock sẽ chặn toàn bộ query đến sau nó, nên luôn
SET lock_timeoutrồi retry; và table rewrite — tránh mọi thao tác viết lại bảng trên bảng lớn. Cụ thể: index thìCONCURRENTLY, constraint thìNOT VALIDrồiVALIDATE,NOT NULLthì mượn CHECK constraint để PG 12 bỏ qua bước quét bảng."