SQL & Database — Thực chiến PostgreSQL/MERGE Statement — ETL multi-rule sync trong một câu lệnh
~18 phútAdvanced query patterns lượt xem

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Ở serverHành động
Contact mớiChưa 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ớiCập nhật (UPDATE)
Contact bị xoá ở serverĐánh dấu deleted=trueXoá khỏi điện thoại (DELETE)
Contact không thay đổiCó, thông tin giốngCó, thông tin giốngBỏ 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.

💡 Cách nhớ

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.

AspectUPSERT (ON CONFLICT)MERGE
Match conditionUNIQUE / PK constraintArbitrary condition (ON t.key = s.key)
Số action clause1 (DO UPDATE hoặc DO NOTHING)Nhiều WHEN MATCHED + WHEN NOT MATCHED
Action có thể dùngINSERT, UPDATE, DO NOTHINGINSERT, UPDATE, DELETE, DO NOTHING
Có thể DELETE?Không
Độ phức tạpĐơn giản hơnPhức tạp hơn
Atomicity per-row
PG versionPG 9.5+PG 15+
Use case điển hìnhIdempotent ingestion, upsert 1 bảngETL 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 — 3 lỗi phổ biến với 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

Deep Dive — MERGE trong PostgreSQL
  • 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ới DISTINCT 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 RETURNINGmerge_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

Tự kiểm tra
Q1
Tạ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.

Q2
Giả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 tren

Thứ 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 match

Quy 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.

Q3
So 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.

Q4
PG 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 NOTHING khô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ố $1sync_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.
Q5
Bạ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 40001serialization 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?