MERGE Statement — ETL multi-rule sync trong một câu lệnh
PostgreSQL 15 cuối cùng cũng có MERGE sau hơn một thập kỷ. Hiểu khi nào MERGE thắng UPSERT, cú pháp WHEN clause, demo bulk sync TaskFlow 3 case, RETURNING trong PG 17, và pitfall duplicate source gây lỗi ngầm.
PostgreSQL 15 (tháng 10/2022) cuối cùng đã thêm MERGE — câu lệnh SQL chuẩn từ SQL:2003 mà PostgreSQL chậm hơn Oracle, SQL Server, và DB2 cả thập kỷ. Với ON CONFLICT (UPSERT) bạn đã biết từ bài 5 của module này, bạn có thể handle phần lớn trường hợp ghi idempotent. Nhưng khi ETL cần đồng thời INSERT, UPDATE, và DELETE trong một lần quét source table — đó là lúc MERGE thực sự cần thiết.
Bài này giải thích tại sao MERGE tồn tại, khi nào nó thắng UPSERT, cú pháp đầy đủ, demo thực tế TaskFlow bulk sync từ external PM tool, và những pitfall ẩn mà tài liệu ít nhắc.
1. Analogy — Đồng bộ danh sách contact
Hãy hình dung bạn có danh sách contact trên điện thoại (target) và danh sách contact trên server (source). Mỗi lần sync, có 3 trường hợp xảy ra cho mỗi contact:
| Tình huống | Ở điện thoại | Ở server | Hành động |
|---|---|---|---|
| Contact mới | Chưa có | Có | Thêm vào điện thoại (INSERT) |
| Contact đổi tên/số | Có, thông tin cũ | Có, thông tin mới | Cập nhật (UPDATE) |
| Contact bị xoá ở server | Có | Đánh dấu deleted=true | Xoá khỏi điện thoại (DELETE) |
| Contact không thay đổi | Có, thông tin giống | Có, thông tin giống | Bỏ qua (DO NOTHING) |
MERGE là "nhìn vào 2 danh sách, so sánh từng entry, rồi quyết định INSERT / UPDATE / DELETE / DO NOTHING — tất cả trong một lần đi qua". Thay vì viết 3-4 câu lệnh riêng lẻ, bạn mô tả toàn bộ logic đồng bộ trong một statement.
UPSERT = "nếu có thì update, nếu chưa có thì insert" — 2 case, 1 điều kiện conflict. MERGE = "với mỗi cặp source-target, áp dụng rule đầu tiên match" — N case, N điều kiện tùy ý, bao gồm cả DELETE.
2. UPSERT vs MERGE — khi nào MERGE thắng
Trước khi dùng MERGE, luôn tự hỏi: UPSERT có đủ không? UPSERT đơn giản hơn và cover 90% case.
| Aspect | UPSERT (ON CONFLICT) | MERGE |
|---|---|---|
| Match condition | UNIQUE / PK constraint | Arbitrary condition (ON t.key = s.key) |
| Số action clause | 1 (DO UPDATE hoặc DO NOTHING) | Nhiều WHEN MATCHED + WHEN NOT MATCHED |
| Action có thể dùng | INSERT, UPDATE, DO NOTHING | INSERT, UPDATE, DELETE, DO NOTHING |
| Có thể DELETE? | Không | Có |
| Độ phức tạp | Đơn giản hơn | Phức tạp hơn |
| Atomicity per-row | Có | Có |
| PG version | PG 9.5+ | PG 15+ |
| Use case điển hình | Idempotent ingestion, upsert 1 bảng | ETL multi-rule sync, SCD dimension |
3. Cú pháp MERGE — anatomy
MERGE INTO target_table t
USING source_table s -- hoac subquery, CTE
ON t.key = s.key -- join condition de xac dinh "match"
WHEN MATCHED AND s.deleted = true THEN
DELETE
WHEN MATCHED AND s.updated_at > t.updated_at THEN
UPDATE SET col1 = s.col1,
col2 = s.col2,
updated_at = s.updated_at
WHEN MATCHED THEN
-- catch-all: da match nhung khong thoa dieu kien tren -> skip
DO NOTHING
WHEN NOT MATCHED AND s.deleted = false THEN
INSERT (col1, col2, col3)
VALUES (s.col1, s.col2, s.col3);
-- Khong co semicolon sau WHEN cuoi; dat ; sau toan bo MERGE statement
Quy tắc quan trọng — Order matters:
- WHEN clauses được evaluate theo thứ tự từ trên xuống.
- First match wins per row — một source row chỉ trigger tối đa 1 WHEN clause.
WHEN MATCHED= ON condition cho kết quả true (target row tồn tại).WHEN NOT MATCHED= ON condition cho kết quả false (target row chưa tồn tại).WHEN NOT MATCHED BY SOURCE(PG 16+): target row tồn tại nhưng không có source row nào match.
4. Demo — TaskFlow bulk sync external PM tool
Bài toán: mỗi giờ, hệ thống pull task từ external project management tool (Jira, Linear, Asana) về staging table, rồi sync vào bảng tasks của TaskFlow. Source có thể chứa task mới, task cập nhật, và task bị xóa.
-- DDL tham khao TaskFlow (canonical)
-- tasks: id, external_id, project_id, title, status, assignee_id,
-- created_at, updated_at
-- Stage table tu external API response
CREATE TEMP TABLE staging_tasks (
external_id text NOT NULL,
title text NOT NULL,
status text NOT NULL,
assignee_id int,
is_deleted boolean NOT NULL DEFAULT false,
updated_at timestamptz NOT NULL
);
-- Gia su da INSERT data tu API vao staging_tasks...
-- Sync trong 1 MERGE statement: 3 case, 1 lan quet
MERGE INTO tasks t
USING staging_tasks s
ON t.external_id = s.external_id
WHEN MATCHED AND s.is_deleted = true THEN
-- Case 1: task bi xoa o source -> hard delete (hoac soft delete neu co deleted_at)
DELETE
WHEN MATCHED AND s.updated_at > t.updated_at THEN
-- Case 2: task ton tai + source moi hon -> update
UPDATE SET
title = s.title,
status = s.status,
assignee_id = s.assignee_id,
updated_at = s.updated_at
WHEN MATCHED THEN
-- Case 3: task ton tai + target da moi hon hoac bang -> skip
DO NOTHING
WHEN NOT MATCHED AND s.is_deleted = false THEN
-- Case 4: task chua co trong target + chua bi xoa -> insert
INSERT (external_id, project_id, title, status, assignee_id, created_at, updated_at)
VALUES (s.external_id, 1, s.title, s.status, s.assignee_id, now(), s.updated_at);
5. PG 17 — MERGE RETURNING
PostgreSQL 17 (tháng 9/2024) bổ sung RETURNING cho MERGE, kèm hàm đặc biệt merge_action() trả về action nào đã thực sự xảy ra cho mỗi row.
-- PG 17+: RETURNING + merge_action()
MERGE INTO tasks t
USING staging_tasks s
ON t.external_id = s.external_id
WHEN MATCHED AND s.is_deleted = true THEN
DELETE
WHEN MATCHED AND s.updated_at > t.updated_at THEN
UPDATE SET title = s.title, status = s.status, updated_at = s.updated_at
WHEN NOT MATCHED AND s.is_deleted = false THEN
INSERT (external_id, project_id, title, status, assignee_id, created_at, updated_at)
VALUES (s.external_id, 1, s.title, s.status, s.assignee_id, now(), s.updated_at)
RETURNING merge_action(), t.id, t.title, t.external_id;
-- merge_action() tra ve: 'INSERT' | 'UPDATE' | 'DELETE'
-- Row bi DO NOTHING khong xuat hien trong RETURNING
merge_action() trả về 'INSERT' / 'UPDATE' / 'DELETE' — row bị DO NOTHING không xuất hiện trong RETURNING. Dùng kết hợp với CTE để log audit trail hoặc downstream processing trong cùng một transaction (xem SelfCheck câu 4 cho pattern đầy đủ).
6. Pitfall — duplicate source, concurrent serialization, và khi nào không dùng MERGE
Pitfall 1 — Source có duplicate key:
-- Neu staging_tasks co 2 row cung external_id:
-- external_id='EXT-001' xuat hien 2 lan trong staging_tasks
-- MERGE se match cung 1 target row 2 lan -> loi runtime:
-- ERROR: MERGE command cannot affect row a second time
-- (PG raise exception khi detect duplicate target match)
-- Fix: dedupe source truoc MERGE bang DISTINCT ON
MERGE INTO tasks t
USING (
SELECT DISTINCT ON (external_id)
*
FROM staging_tasks
ORDER BY external_id, updated_at DESC -- giu ban moi nhat
) s
ON t.external_id = s.external_id
-- ... WHEN clauses ...
Pitfall 2 — Concurrent MERGE trên cùng target row:
Hai transaction chạy đồng thời cùng MERGE vào bảng tasks với overlapping source data có thể gặp serialization failure:
ERROR: could not serialize access due to concurrent update
SQLSTATE: 40001
Đây là expected behavior. Fix: retry pattern với exponential backoff ở application layer — PostgreSQL đảm bảo 1 trong 2 transaction thành công (xem SelfCheck câu 5).
Pitfall 3 — Dùng MERGE khi UPSERT là đủ:
Nếu chỉ cần "insert hoặc update, không cần delete" — ON CONFLICT DO UPDATE ngắn hơn nhiều và hoạt động từ PG 9.5+. Chỉ dùng MERGE khi thực sự cần DELETE hoặc multi WHEN clause phức tạp (xem bảng so sánh ở mục 2).
7. Applied — ETL Slowly-Changing Dimension Type 1
MERGE là pattern chuẩn cho data warehouse ETL — đặc biệt là Slowly-Changing Dimension (SCD). SCD Type 1 (overwrite — giữ giá trị mới nhất, bỏ history) là use case phổ biến nhất và fit hoàn hảo với MERGE.
-- Stage table tu ETL pipeline (raw extract tu source system)
CREATE TEMP TABLE staging_dim_customer (
customer_id int NOT NULL,
name text NOT NULL,
email text NOT NULL,
country text NOT NULL,
valid_from timestamptz NOT NULL
);
-- SCD Type 1: overwrite old value — dim_customer luon reflect latest version
MERGE INTO dim_customer t
USING staging_dim_customer s
ON t.customer_id = s.customer_id
WHEN MATCHED AND
(t.name, t.email, t.country) IS DISTINCT FROM
(s.name, s.email, s.country) THEN
-- Chi update khi thuc su co thay doi (tranh write amplification)
UPDATE SET
name = s.name,
email = s.email,
country = s.country,
updated_at = now()
WHEN NOT MATCHED THEN
-- Customer moi: insert
INSERT (customer_id, name, email, country, created_at, updated_at)
VALUES (s.customer_id, s.name, s.email, s.country, now(), now());
SCD Type 2 (giữ history qua valid_from / valid_to) phức tạp hơn — cần 2 MERGE chained hoặc PL/pgSQL function.
8. Deep Dive
- PostgreSQL 15 Release Notes — MERGE — ghi chép chính thức về việc thêm MERGE vào PG 15, bao gồm danh sách hạn chế so với SQL standard tại thời điểm release (ví dụ: chưa có RETURNING, chưa có
WHEN NOT MATCHED BY SOURCE). - PostgreSQL Documentation — MERGE Statement — reference đầy đủ: cú pháp, semantics của từng WHEN clause, behavior với concurrent access, và ví dụ từng case. Đọc section "Concurrency" để hiểu serialization behavior khi nhiều transaction cùng MERGE.
- PostgreSQL 17 Release Notes — MERGE RETURNING — ghi chép về việc thêm RETURNING và hàm
merge_action()trong PG 17. Tìm "MERGE" trong trang để xem đầy đủ các cải tiến trong PG 17.
9. Tóm tắt
- MERGE (PG 15+) xử lý INSERT, UPDATE, DELETE trong một statement — phù hợp cho ETL multi-rule sync mà UPSERT không cover.
- UPSERT (
ON CONFLICT, PG 9.5+) đơn giản hơn và đủ cho 90% case — chỉ chuyển sang MERGE khi thực sự cần DELETE hoặc nhiều hơn 1 condition rule. - WHEN clause được evaluate theo thứ tự từ trên xuống, first match wins per row — thứ tự viết quan trọng: DELETE check phải đứng trước UPDATE catch-all.
- Source duplicate (hai source row match cùng target row) gây runtime error
MERGE command cannot affect row a second time— luôn dedupe source trước MERGE vớiDISTINCT ON. - Concurrent MERGE trên cùng target row có thể gây serialization failure (SQLSTATE 40001) — implement retry ở application layer.
- PG 17 thêm
RETURNINGvàmerge_action()— cho phép track từng row được INSERT/UPDATE/DELETE sau khi MERGE, hữu ích cho audit log và downstream processing. - SCD Type 1 (overwrite) là pattern chuẩn của MERGE trong data warehouse ETL:
IS DISTINCT FROMđể so sánh composite value an toàn với NULL.
10. Tự kiểm tra
Q1Tại sao PostgreSQL raise lỗi 'MERGE command cannot affect row a second time' và điều kiện nào gây ra lỗi này? Viết cách fix dùng DISTINCT ON.▸
Lỗi xảy ra khi source table có 2 row trở lên cùng match vào 1 target row. MERGE không định nghĩa thứ tự áp dụng khi cùng target row bị match nhiều lần — PostgreSQL raise exception để bảo vệ tính nhất quán thay vì áp dụng kết quả không xác định.
Fix với DISTINCT ON:
MERGE INTO tasks t
USING (
SELECT DISTINCT ON (external_id)
*
FROM staging_tasks
ORDER BY external_id, updated_at DESC -- giu ban moi nhat
) s
ON t.external_id = s.external_id
WHEN MATCHED THEN UPDATE SET ...
WHEN NOT MATCHED THEN INSERT ...;DISTINCT ON (external_id) giữ lại đúng 1 row per external_id (row có updated_at lớn nhất nhờ ORDER BY), đảm bảo mỗi target row chỉ bị match tối đa 1 lần.
Q2Giải thích tại sao thứ tự WHEN clause trong MERGE quan trọng. Cho ví dụ một MERGE có DELETE và UPDATE trong đó thứ tự sai sẽ dẫn đến kết quả không đúng.▸
PostgreSQL evaluate WHEN clauses theo thứ tự từ trên xuống và dừng ở clause đầu tiên match (first match wins). Mỗi source row chỉ trigger tối đa 1 WHEN clause.
Thứ tự sai — UPDATE trước DELETE:
WHEN MATCHED THEN
UPDATE SET title = s.title -- luon match truoc, DELETE ben duoi khong bao gio chay!
WHEN MATCHED AND s.is_deleted = true THEN
DELETE -- dead code: MATCHED da bi bat o trenThứ tự đúng — DELETE trước UPDATE:
WHEN MATCHED AND s.is_deleted = true THEN
DELETE -- check specific condition truoc
WHEN MATCHED THEN
UPDATE SET title = s.title -- catch-all chi chay neu DELETE khong matchQuy tắc: đặt clause specific (có thêm điều kiện AND) trước catch-all (không có thêm điều kiện). DELETE và UPDATE đặc thù phải đứng trước WHEN MATCHED THEN DO NOTHING hoặc WHEN MATCHED THEN UPDATE không có điều kiện.
Q3So sánh MERGE và UPSERT (ON CONFLICT) cho bài toán SCD Type 1 — overwrite dimension table. Khi nào nên chọn cái nào? Tại sao IS DISTINCT FROM tốt hơn <> khi so sánh composite value?▸
UPSERT phù hợp hơn cho SCD Type 1 đơn giản (chỉ INSERT + UPDATE, không DELETE):
- Ngắn gọn hơn — 1 statement không có nhiều WHEN clause.
- Hỗ trợ từ PG 9.5+ (không cần PG 15+).
- Planner optimize tốt cho single-table conflict resolution.
MERGE phù hợp hơn khi cần DELETE (ví dụ: customer bị deactivate) hoặc nhiều hơn 2 case xử lý khác nhau.
IS DISTINCT FROM vs <> với NULL:
-- <> voi NULL: bat ky phep so sanh voi NULL tra ve UNKNOWN (false trong WHERE)
-- (t.name, t.email) <> (s.name, s.email) -> UNKNOWN neu bat ky gia tri nao la NULL
-- => row bi bo qua du thuc ra co thay doi
-- IS DISTINCT FROM: xu ly NULL dung
-- (t.name, t.email) IS DISTINCT FROM (s.name, s.email)
-- -> TRUE neu bat ky column nao khac nhau, KE CA khi mot ben la NULL
-- -> FALSE chi khi tat ca column deu bang nhau (NULL = NULL la FALSE trong IS DISTINCT FROM)Trong ETL thực tế, các column như country hoặc email có thể NULL — IS DISTINCT FROM đảm bảo detect thay đổi đúng ngay cả khi NULL xuất hiện.
Q4PG 17 thêm RETURNING cho MERGE với merge_action(). Viết pattern dùng CTE + MERGE RETURNING để vừa sync tasks vừa ghi audit log vào bảng sync_history trong cùng một transaction.▸
Pattern CTE + MERGE RETURNING:
WITH sync_result AS (
MERGE INTO tasks t
USING staging_tasks s ON t.external_id = s.external_id
WHEN MATCHED AND s.is_deleted = true THEN DELETE
WHEN MATCHED AND s.updated_at > t.updated_at THEN
UPDATE SET title = s.title, status = s.status,
updated_at = s.updated_at
WHEN NOT MATCHED AND s.is_deleted = false THEN
INSERT (external_id, project_id, title, status, created_at, updated_at)
VALUES (s.external_id, 1, s.title, s.status, now(), s.updated_at)
RETURNING merge_action() AS action, t.id, t.external_id
)
INSERT INTO sync_history (sync_run_id, action, task_id, external_id, synced_at)
SELECT $1, action, id, external_id, now()
FROM sync_result;Điểm quan trọng:
merge_action()trả về'INSERT','UPDATE', hoặc'DELETE'— row bịDO NOTHINGkhông xuất hiện trong RETURNING.- CTE + MERGE RETURNING chạy trong cùng 1 transaction — đảm bảo audit log nhất quán với data change.
- Tham số
$1làsync_run_id— cho phép trace toàn bộ kết quả của 1 sync run. - Chỉ PG 17+ — cần verify version trước khi dùng pattern này.
Q5Bạn đang chạy 2 concurrent transaction cùng MERGE vào bảng tasks với overlapping source data. Một transaction gặp lỗi SQLSTATE 40001. Đây là lỗi gì, tại sao xảy ra, và cách xử lý đúng là gì?▸
SQLSTATE 40001 là serialization failure — xảy ra khi PostgreSQL phát hiện 2 transaction concurrent có thể tạo ra kết quả không nhất quán nếu cả hai commit. PG abort 1 transaction và yêu cầu retry để đảm bảo kết quả giống như nếu 2 transaction chạy tuần tự.
Tại sao MERGE dễ gặp 40001: MERGE thực hiện read (đọc target để so sánh) và write (INSERT/UPDATE/DELETE) trong cùng statement. Khi 2 transaction cùng MERGE vào same target row, PG detect write conflict và abort 1 trong 2.
Cách xử lý đúng — retry pattern ở application layer:
-- Pseudo-code: retry voi exponential backoff
for attempt in 1..5:
try:
BEGIN;
MERGE INTO tasks ... ;
INSERT INTO sync_history ...;
COMMIT;
break -- thanh cong
except SQLSTATE 40001:
ROLLBACK;
if attempt == 5: raise -- het retry
sleep(0.1 * 2^attempt) -- exponential backoff: 0.2s, 0.4s, 0.8s...Đây là expected behavior, không phải bug của MERGE hay PostgreSQL. PG đảm bảo 1 trong 2 transaction sẽ thành công — chỉ cần retry transaction thất bại với SQLSTATE 40001.
Bài tiếp theo: Mini-challenge — sprint burndown chart (CTE + window + UPSERT)
Bài này có giúp bạn hiểu bản chất không?