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:

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ớ:

  1. Thêm cái gì → thêm trước, dùng sau.
  2. Xoá cái gì → ngừng dùng trước, xoá sau (≥1 release, tốt nhất là vài ngày).
  3. Đổi tên / đổi kiểu = thêm cái mới + copy dữ liệu + xoá cái cũ. Không bao giờ RENAME trực tiếp trên hệ thống đang chạy.
  4. 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 COLUMNDEFAULT 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 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ộ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 NULL luôn quét toàn bảng dưới ACCESS EXCLUSIVE → trên bảng lớn thì chỉ giữ CHECK constraint, đừng bật NOT NULL thậ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 TO chỉ 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 usersKết quả
INSERT INTO users (name) VALUES (…)OK — bigserialDEFAULT now() của bảng thật vẫn áp dụng
UPDATE / DELETEOK
RETURNING idOK (Laravel insertGetId() dựa vào cái này)
INSERT … ON CONFLICT … DO UPDATEOK
Trigger AFTER INSERT ON accountsCó chạy — ghi thật sự xảy ra trên bảng thật
FK từ bảng khác trỏ vào accountsVẫ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:

  1. 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 exist
    

    Nặng hơn: nếu cột mới là NOT NULL không có default thì mọi INSERT qua 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 constraint
    

    Chữ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ồi DROP VIEW.

  2. 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ết REFERENCES users(id) sẽ fail.

  3. 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"
    
  4. View không phải bảng — vài lệnh từ chối thẳng. Câu SELECT/INSERT/UPDATE/DELETE thì 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';
    --  0
    

    Nghĩa là seeder/test nào dùng truncate trê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 (intbigint), đổ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ệnhLock trên bảng CONLock trên bảng CHAChặ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:

Ba cái bẫy hay dính

  1. Quên copy ON DELETE / ON UPDATE action. FK cũ là ON DELETE CASCADE, FK mới quên khai → mặc định thành NO 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';
    
  2. DROP TABLE ... CASCADE khi dọn bảng cũ. Bảng cũ còn 5 FK trỏ vào thì DROP TABLE sẽ báo lỗi — đó là Postgres đang cứu bạn. Đừng thêm CASCADE cho 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.

  3. Giai đoạn song song không có "một nguồn sự thật". Trong lúc bảng con có cả user_id lẫn account_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ố release2–33–4, nhân với số bảng con nếu làm rời
Đụng vào FKKhông5 FK × (thêm cột + backfill + 2 lệnh constraint)
BackfillKhôngCó, trên cả 5 bảng con
RollbackRename ngược, 1 giâyKhó — bảng mới đã nhận dữ liệu mới
Khi nào dùngChỉ đổ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:

  1. Code v1 vẫn còn SELECT cột đó → lỗi. Giải: chỉ xoá ở release sau khi release "ngừng dùng cột" đã chạy ổn định.
  2. 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 intbigint)

ALTER COLUMN TYPE viết lại toàn bộ bảng 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 (intbigint, textjsonb, đổ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:

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:


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:


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 VALIDVALIDATE. 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 intbigint, 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_timeout rồ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 VALID rồi VALIDATE, NOT NULL thì mượn CHECK constraint để PG 12 bỏ qua bước quét bảng."