SQL & Database — Thực chiến PostgreSQL/UPSERT — INSERT ... ON CONFLICT idempotent
~20 phútAdvanced query patterns lượt xem

UPSERT — INSERT ... ON CONFLICT idempotent

Webhook giao 2 lần, INSERT thứ 2 crash unique constraint. ON CONFLICT (PG 9.5+) giải quyết trong 1 câu: DO NOTHING để skip silent, DO UPDATE để merge. EXCLUDED virtual table, RETURNING để phân biệt insert/update, và pitfall race condition khi compute-from-current.

Webhook giao event webhook_evt_abc123 lần đầu — INSERT thành công. Network timeout, client retry — INSERT lần hai crash vì unique constraint. Bạn có thể try/catch trong app rồi ignore error. Hoặc dùng 1 câu SQL làm cả hai việc: INSERT nếu chưa có, UPDATE nếu có — đó là UPSERT.

Trước PostgreSQL 9.5 (2016), UPSERT đòi PL/pgSQL procedure với SAVEPOINTEXCEPTION handling — verbose, dễ sai. PG 9.5 thêm cú pháp ON CONFLICT chuẩn. Bài này đi qua toàn bộ variant của nó, cách đọc EXCLUDED, cách dùng RETURNING để biết row vừa insert hay update, và những pitfall ít tài liệu nhắc đến.

1. Analogy — notification dedupe trên mobile

Hãy nghĩ đến push notification. Mạng yếu, server gửi cùng một notification 2 lần (at-least-once delivery — hệ thống đảm bảo giao ít nhất 1 lần, nhưng không đảm bảo đúng 1 lần).

Hành viSQL tương đương
Phone hiển thị 2 notification giống nhauINSERT thứ 2 không có ON CONFLICT → crash hoặc duplicate
Phone phát hiện notification ID đã xử lý → bỏ quaON CONFLICT DO NOTHING
Phone cập nhật nội dung notification nếu đã hiển thịON CONFLICT DO UPDATE SET ...
Notification ID là "khoá" để nhận dạng duplicateColumn trong UNIQUE constraint / PRIMARY KEY
💡 Cách nhớ

UPSERT = "U" từ UPDATE + INSERT. ON CONFLICT là điều kiện khi nào bật hành vi đặc biệt. DO NOTHING = silent skip. DO UPDATE = merge giá trị mới. EXCLUDED = tên virtual table chứa row đáng lẽ INSERT — các giá trị bạn truyền vào nhưng chưa được ghi vì bị conflict.

2. Vấn đề — at-least-once delivery và duplicate INSERT

TaskFlow nhận comment từ external API qua webhook. Network at-least-once: cùng 1 event có thể giao 2 lần.

-- Setup: them external_id de track source cua comment
ALTER TABLE comments ADD COLUMN external_id TEXT;
ALTER TABLE comments ADD CONSTRAINT uq_comment_external
  UNIQUE (task_id, external_id);

-- Webhook delivery lan 1:
INSERT INTO comments (task_id, user_id, body, external_id)
VALUES (42, 5, 'Hello', 'webhook_001');
-- OK, comment_id = 100

-- Webhook delivery lan 2 (network timeout, client retry):
INSERT INTO comments (task_id, user_id, body, external_id)
VALUES (42, 5, 'Hello', 'webhook_001');
-- ERROR: duplicate key value violates unique constraint "uq_comment_external"
-- Detail: Key (task_id, external_id)=(42, webhook_001) already exists.

App có thể catch error này và ignore — nhưng phải biết phân biệt "unique violation lành tính" với "lỗi thật sự". Cách sạch hơn: dùng ON CONFLICT và để PostgreSQL xử lý.

3. DO NOTHING — skip silent, không error

INSERT INTO comments (task_id, user_id, body, external_id)
VALUES (42, 5, 'Hello', 'webhook_001')
ON CONFLICT (task_id, external_id) DO NOTHING;
-- Lan 1: insert thanh cong, 1 row affected
-- Lan 2: conflict phat hien, skip, 0 row affected -- khong error

DO NOTHING phù hợp khi: bạn chỉ cần đảm bảo "row tồn tại" — không cần cập nhật nội dung nếu đã có. Idempotent webhook handler là use case kinh điển: giao 10 lần hay 1 lần đều an toàn, DB chỉ giữ 1 bản.

Lưu ý: với DO NOTHING, RETURNING trả về rỗng khi skip — app cần handle case RETURNING empty (xem mục 6).

4. DO UPDATE — merge giá trị mới với EXCLUDED

INSERT INTO comments (task_id, user_id, body, external_id)
VALUES (42, 5, 'Updated body', 'webhook_001')
ON CONFLICT (task_id, external_id) DO UPDATE
SET body       = EXCLUDED.body,
    updated_at = now()
WHERE comments.body IS DISTINCT FROM EXCLUDED.body;
-- Chi update khi body thuc su thay doi (tranh write khong can thiet)

EXCLUDEDvirtual table chứa row đáng lẽ INSERT nhưng bị conflict — tức là các giá trị bạn truyền vào VALUES(...). Để truy cập giá trị hiện tại của row trong bảng, dùng tên bảng: comments.<col>.

TênÝ nghĩa
EXCLUDED.bodyGiá trị body bạn muốn insert (input)
comments.bodyGiá trị body đang có trong bảng (target row)
now()Expression bình thường — không cần prefix

WHERE comments.body IS DISTINCT FROM EXCLUDED.body — chỉ update khi body thực sự khác. IS DISTINCT FROM xử lý NULL đúng (NULL IS DISTINCT FROM 'text' = TRUE, NULL IS DISTINCT FROM NULL = FALSE). Nếu condition false → no-op (không update, không skip insert lần đầu).

5. Match by — column UNIQUE, composite, ON CONSTRAINT, partial index

ON CONFLICT cần biết conflict theo gì. Có 4 cách chỉ định:

-- Single column UNIQUE hoac PRIMARY KEY
INSERT INTO tasks (id, title, status)
VALUES (1, 'New title', 'todo')
ON CONFLICT (id) DO UPDATE
SET title  = EXCLUDED.title,
    status = EXCLUDED.status,
    updated_at = now();

-- Composite UNIQUE (task_id, external_id)
INSERT INTO comments (task_id, user_id, body, external_id)
VALUES (42, 5, 'Hello', 'webhook_001')
ON CONFLICT (task_id, external_id) DO UPDATE
SET body = EXCLUDED.body;

-- By constraint name -- ro rang hon, chong sai ten column
INSERT INTO comments (task_id, user_id, body, external_id)
VALUES (42, 5, 'Hello', 'webhook_001')
ON CONFLICT ON CONSTRAINT uq_comment_external DO UPDATE
SET body = EXCLUDED.body;

-- Partial index -- phai match chinh xac WHERE clause cua partial index
-- Vi du: CREATE UNIQUE INDEX uq_email_active ON users(email) WHERE deleted_at IS NULL;
INSERT INTO users (email, name)
VALUES ('[email protected]', 'Alice')
ON CONFLICT (email) WHERE deleted_at IS NULL DO UPDATE
SET name = EXCLUDED.name;

Quy tắc: ON CONFLICT chỉ match UNIQUE constraint hoặc PRIMARY KEY — không match CHECK constraint hay EXCLUDE constraint. Nếu cần handle conflict trên constraint không phải UNIQUE/PK, phải dùng SAVEPOINT + EXCEPTION trong PL/pgSQL.

6. RETURNING — app biết row vừa INSERT hay UPDATE

-- Full UPSERT + RETURNING
INSERT INTO comments (task_id, user_id, body, external_id)
VALUES (42, 5, 'Hello', 'webhook_001')
ON CONFLICT (task_id, external_id) DO UPDATE
SET body = EXCLUDED.body, updated_at = now()
RETURNING id, (xmax = 0) AS was_inserted;
-- xmax = 0  -> row moi (INSERT): tuple chua tung bi update
-- xmax != 0 -> row da ton tai (UPDATE): xmax duoc set boi transaction UPDATE

xmax là system column — transaction ID của lần UPDATE gần nhất trên tuple. Tuple mới (INSERT) có xmax = 0. Sau UPDATE, xmax chứa txid. Đây là cách low-level để phân biệt insert/update trong 1 statement.

Workaround đơn giản hơn — thêm column synthetic:

-- Neu co created_at va updated_at:
INSERT INTO comments (task_id, user_id, body, external_id, created_at, updated_at)
VALUES (42, 5, 'Hello', 'webhook_001', now(), now())
ON CONFLICT (task_id, external_id) DO UPDATE
SET body = EXCLUDED.body, updated_at = now()
RETURNING id, (created_at = updated_at) AS was_inserted;
-- created_at = updated_at -> INSERT (vua tao, chua update)
-- created_at != updated_at -> UPDATE

DO NOTHING + RETURNING:

INSERT INTO comments (task_id, user_id, body, external_id)
VALUES (42, 5, 'Hello', 'webhook_001')
ON CONFLICT (task_id, external_id) DO NOTHING
RETURNING id;
-- Lan 1 (insert): RETURNING tra ve {id: 100}
-- Lan 2 (skip):   RETURNING tra ve 0 row (empty result set)

-- App phai handle: neu RETURNING empty -> SELECT lai theo unique key
SELECT id FROM comments
WHERE task_id = 42 AND external_id = 'webhook_001';

Hoặc dùng DO UPDATE SET col = col (no-op update) để luôn có RETURNING:

INSERT INTO comments (task_id, user_id, body, external_id)
VALUES (42, 5, 'Hello', 'webhook_001')
ON CONFLICT (task_id, external_id) DO UPDATE
SET external_id = EXCLUDED.external_id  -- no-op: ghi lai gia tri cu
RETURNING id;
-- Luon co RETURNING, du insert hay "skip" (thuc chat la update no-op)

7. Pitfall — chỉ UNIQUE/PK, race với compute, DO NOTHING return trống

Pitfall — 3 lỗi phổ biến với UPSERT

Pitfall 1 — ON CONFLICT không match CHECK hay EXCLUDE constraint:

-- CHECK constraint: CHECK (price > 0)
-- ON CONFLICT khong bat duoc CHECK violation
INSERT INTO products (id, price) VALUES (1, -5)
ON CONFLICT (id) DO NOTHING;
-- ERROR: new row for relation "products" violates check constraint
-- ON CONFLICT chi match UNIQUE/PRIMARY KEY conflict
-- Fix: validate input truoc INSERT, hoac SAVEPOINT + EXCEPTION trong PL/pgSQL

Pitfall 2 — Race condition với compute-from-current:

-- Atomic, an toan: UPSERT single row
INSERT INTO page_views (page, view_count)
VALUES ('/dashboard', 1)
ON CONFLICT (page) DO UPDATE
SET view_count = page_views.view_count + 1;
-- OK: doc va ghi xay ra trong cung 1 SQL statement, atomic per row

-- KHONG atomic: SELECT-then-UPDATE trong app code
-- Session A: SELECT view_count -> 100
-- Session B: SELECT view_count -> 100
-- Session A: UPDATE SET view_count = 101
-- Session B: UPDATE SET view_count = 101  <- mat 1 increment
-- Fix: giu toan bo compute trong 1 UPSERT statement

Pitfall 3 — DO NOTHING + RETURNING trả về 0 row khi skip:

INSERT INTO events (external_id, payload)
VALUES ('evt_001', '{}')
ON CONFLICT (external_id) DO NOTHING
RETURNING id;
-- Lan dau: {id: 42}
-- Lan hai: empty result set -- app nhan duoc []

-- App PHAI check: neu RETURNING empty, co 2 lua chon:
-- (a) SELECT lai theo external_id de lay id
-- (b) Dung DO UPDATE no-op de luon co RETURNING:
--     ON CONFLICT (external_id) DO UPDATE SET external_id = EXCLUDED.external_id

WHERE clause trong DO UPDATE cũng cần lưu ý: ... DO UPDATE SET col = X WHERE condition — nếu condition false, không update và không return row (kể cả với RETURNING). Không nhầm với "DO NOTHING": đây là update bị skip vì WHERE false, khác với skip vì conflict.

8. Applied — idempotent worker, counter increment, sync API

-- 1) Idempotent webhook handler
--    "Neu chua xu ly -> insert, return id de trigger downstream"
--    "Neu da xu ly  -> skip, RETURNING empty -> app skip downstream"
INSERT INTO events (external_id, payload, received_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (external_id) DO NOTHING
RETURNING id;
-- Neu RETURNING co row -> xu ly lan dau -> trigger downstream logic
-- Neu RETURNING trong  -> da xu ly truoc -> no-op, safe to retry


-- 2) Counter increment atomic (khong can transaction rieng)
CREATE TABLE page_views (
  page       TEXT PRIMARY KEY,
  view_count BIGINT NOT NULL DEFAULT 0,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO page_views (page, view_count, updated_at)
VALUES ('/dashboard', 1, now())
ON CONFLICT (page) DO UPDATE
SET view_count = page_views.view_count + 1,
    updated_at = now();
-- Atomic per row: doc va ghi trong 1 statement
-- Khong co race giua 2 concurrent request
-- Khong can SELECT-then-UPDATE


-- 3) Sync external API data -- chi update khi data thay doi
INSERT INTO products (sku, name, price, last_synced)
SELECT sku, name, price, now()
FROM external_products          -- source tu ETL hoac COPY
ON CONFLICT (sku) DO UPDATE
SET name        = EXCLUDED.name,
    price       = EXCLUDED.price,
    last_synced = now()
WHERE products.price IS DISTINCT FROM EXCLUDED.price
   OR products.name  IS DISTINCT FROM EXCLUDED.name;
-- Chi ghi khi price hoac name thuc su thay doi
-- Giam write amplification + audit log gon hon
-- last_synced luon cap nhat (nen khong nam trong WHERE condition)

9. Deep Dive

Deep Dive — ON CONFLICT và UPSERT trong PostgreSQL
  • PostgreSQL Documentation — INSERT ... ON CONFLICT — official syntax reference đầy đủ: conflict_target, conflict_action, EXCLUDED, WHERE clause trong DO UPDATE, và các edge case với partial index target. Đây là nguồn chính xác nhất về semantic của từng variant.
  • PostgreSQL 9.5 Release Notes — UPSERT — ghi chép chính thức về lý do thiết kế ON CONFLICT thay vì MERGE (tại thời điểm đó). Tìm section "E.1.3.1. SQL Commands" → "INSERT". Hữu ích để hiểu trade-off giữa ON CONFLICTMERGE (bài 6 của module này).
  • PostgreSQL Documentation — RETURNING clause — giải thích RETURNING với INSERT/UPDATE/DELETE, bao gồm behavior với ON CONFLICT. Section ngắn nhưng quan trọng để hiểu tại sao DO NOTHING trả về empty RETURNING.
  • PostgreSQL Documentation Ch.6.4 "Updating Data" — context về UPDATE semantics, hữu ích khi đọc về DO UPDATE SET và tại sao UPSERT atomic per row nhưng không atomic cho multi-row compute patterns.

10. Tóm tắt

  • UPSERT = INSERT ... ON CONFLICT ... DO NOTHING | DO UPDATE — PG 9.5+. Giải quyết at-least-once delivery trong 1 câu SQL, không cần try/catch trong app.
  • DO NOTHING: silent skip khi conflict, 0 row affected, RETURNING trả về empty. Dùng cho idempotent handler khi không cần cập nhật nội dung.
  • DO UPDATE SET ...: merge giá trị mới vào row đã tồn tại. EXCLUDED.<col> = giá trị input (đáng lẽ insert). tablename.<col> = giá trị hiện tại trong bảng.
  • Match conflict bằng column UNIQUE/PRIMARY KEY, composite UNIQUE, ON CONSTRAINT name, hoặc partial index (phải match WHERE clause của index). ON CONFLICT không match CHECK hay EXCLUDE constraint.
  • WHERE trong DO UPDATE: chỉ update khi condition true. Nếu false → no-op (không update, RETURNING trống). Dùng IS DISTINCT FROM để handle NULL đúng.
  • RETURNING sau UPSERT: (xmax = 0) AS was_inserted phân biệt insert (xmax=0) vs update (xmax≠0). DO NOTHING + RETURNING = empty khi skip → app cần SELECT lại hoặc dùng no-op UPDATE trick.
  • Counter increment (view_count = table.view_count + 1) trong DO UPDATE là atomic per row — safe cho concurrent writes mà không cần transaction explicit.
  • Sync external data: kết hợp INSERT ... SELECT với ON CONFLICT DO UPDATE WHERE IS DISTINCT FROM để chỉ ghi khi data thực sự thay đổi — giảm write amplification.

11. Tự kiểm tra

Tự kiểm tra
Q1
Sự khác biệt giữa ON CONFLICT DO NOTHING và ON CONFLICT DO UPDATE SET col = col (no-op update) là gì? Khi nào bạn cần dùng no-op update thay vì DO NOTHING?

DO NOTHING: khi conflict xảy ra, PostgreSQL bỏ qua hoàn toàn row đó — 0 row affected, RETURNING trả về empty result set. App không biết id của row đã tồn tại mà không SELECT lại.

DO UPDATE SET col = col (no-op): khi conflict xảy ra, PostgreSQL thực hiện UPDATE ghi lại giá trị cũ — row "bị chạm" nhưng giá trị không đổi. RETURNING trả về row đó. App nhận được id ngay mà không cần SELECT lại.

Khi cần no-op update: (1) App cần id của row sau UPSERT trong mọi trường hợp (insert lẫn conflict). (2) Muốn tránh SELECT thêm sau DO NOTHING. Trade-off: no-op update tốn chi phí write row hơn DO NOTHING, và có thể trigger row-level trigger không mong muốn.

Q2
Tại sao UPSERT với `DO UPDATE SET count = table.count + 1` là atomic nhưng pattern SELECT-then-UPDATE trong app code lại có race condition? Giải thích cơ chế PostgreSQL đảm bảo atomicity.

SELECT-then-UPDATE race condition: Session A và Session B cùng SELECT count = 100. Session A UPDATE SET count = 101, commit. Session B UPDATE SET count = 101, commit. Kết quả: 101 thay vì 102 — một increment bị mất.

UPSERT atomic: INSERT ... ON CONFLICT DO UPDATE SET count = table.count + 1 là một SQL statement duy nhất. PostgreSQL acquire row-level lock trên row bị conflict trước khi execute DO UPDATE. Mọi concurrent transaction muốn update cùng row phải đợi lock release. Read và write xảy ra trong cùng một lock scope — không có window cho race condition.

Về MVCC: DO UPDATE đọc giá trị hiện tại của row (latest committed version) trong cùng statement, không thể bị stale bởi concurrent read trước đó trong app code.

Q3
Bạn có partial unique index: `CREATE UNIQUE INDEX uq_email_active ON users(email) WHERE deleted_at IS NULL`. Viết UPSERT đúng cú pháp để conflict-match trên index này. Điều gì xảy ra nếu bạn bỏ WHERE clause trong ON CONFLICT?

Cú pháp đúng — phải match chính xác WHERE clause của partial index:

INSERT INTO users (email, name, deleted_at)
VALUES ('[email protected]', 'Alice', NULL)
ON CONFLICT (email) WHERE deleted_at IS NULL DO UPDATE
SET name = EXCLUDED.name;

Nếu bỏ WHERE clause: ON CONFLICT (email) DO UPDATE ... — PostgreSQL không tìm thấy UNIQUE constraint/index nào match (chỉ có partial index, không có full unique constraint trên email). Kết quả: ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification. PG yêu cầu conflict target phải match chính xác một constraint hoặc index, bao gồm cả WHERE predicate của partial index.

Q4
Webhook handler dùng `INSERT ... ON CONFLICT (external_id) DO NOTHING RETURNING id`. App nhận được RETURNING empty sau lần gọi thứ 2. Mô tả 2 cách app xử lý để lấy được id. So sánh trade-off của mỗi cách.

Cách 1 — SELECT lại sau khi RETURNING empty:

-- Sau INSERT ON CONFLICT DO NOTHING:
-- if len(returning_rows) == 0:
SELECT id FROM events WHERE external_id = $1;

Trade-off: 2 round-trip DB nếu conflict (SELECT thêm). Nhưng DO NOTHING không tốn write cost khi skip — phù hợp khi conflict rate thấp và không cần id trong mọi trường hợp.

Cách 2 — No-op UPDATE để luôn có RETURNING:

INSERT INTO events (external_id, payload, received_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (external_id) DO UPDATE
SET external_id = EXCLUDED.external_id  -- no-op
RETURNING id;

Trade-off: 1 round-trip duy nhất, luôn có id. Nhưng DO UPDATE tốn write cost (lock row, write WAL) ngay cả khi conflict — phù hợp khi conflict rate cao và app luôn cần id. Cũng có thể trigger row-level UPDATE trigger không mong muốn.

Q5
Trong pattern sync external API (`INSERT ... SELECT ... ON CONFLICT DO UPDATE WHERE IS DISTINCT FROM`), tại sao dùng `IS DISTINCT FROM` thay vì `!=` (not equal)? Cho ví dụ cụ thể với NULL.

Vấn đề với !=: trong SQL, bất kỳ so sánh nào với NULL đều trả về UNKNOWN, không phải TRUE/FALSE. NULL != 'something' = UNKNOWN. NULL != NULL = UNKNOWN. Nếu dùng WHERE products.price != EXCLUDED.price, và một trong hai là NULL, condition = UNKNOWN → PostgreSQL treat như FALSE → không update, kể cả khi giá trị thực sự khác nhau.

IS DISTINCT FROM: xử lý NULL đặc biệt. NULL IS DISTINCT FROM 'something' = TRUE (hai giá trị khác nhau). NULL IS DISTINCT FROM NULL = FALSE (hai NULL được coi là bằng nhau). Đây là semantic đúng cho "giá trị thực sự thay đổi".

Ví dụ cụ thể: sản phẩm có price = NULL trong DB, API trả về price = 99.0. products.price != EXCLUDED.price = NULL != 99.0 = UNKNOWN → không update (sai). products.price IS DISTINCT FROM EXCLUDED.price = NULL IS DISTINCT FROM 99.0 = TRUE → update đúng.

Bài tiếp theo: MERGE statement (PG 15+) — UPSERT cho complex sync logic

Bài này có giúp bạn hiểu bản chất không?