Upsert với ON CONFLICT: "chưa có thì thêm, có rồi thì sửa" mà không lỗi 23505 Trung cấp
Thời gian đọc ước tính: 9 phút
Cần đọc trước
Vấn đề
Phần tiêu đề “Vấn đề”Trang sản phẩm ghi “user 7 đã xem sản phẩm 42” vào bảng product_views: mỗi cặp (user_id, product_id) một row, kèm view_count. Code ban đầu: SELECT; chưa có thì INSERT, có thì UPDATE bằng giá trị đọc + 1.
Người dùng mở hai tab: hai request cùng thấy “chưa có”, request thứ hai nhận 23505 unique_violation. Khi row đã có, cả hai đọc view_count = 1 rồi cùng ghi 2: mất một lượt đếm.
Câu hỏi: làm sao để một câu lệnh tự chọn INSERT hay UPDATE mà không lỗi và không mất cập nhật khi chạy đồng thời?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Chưa có thì thêm, có rồi bỏ qua | ON CONFLICT (cols) DO NOTHING |
Có unique index trên cols |
| Thêm hoặc cộng dồn / ghi đè | ON CONFLICT (cols) DO UPDATE SET ... |
Batch không trùng key |
| Chỉ ghi khi dữ liệu đổi | DO UPDATE ... WHERE t.col IS DISTINCT FROM EXCLUDED.col |
Row trùng vẫn bị khoá |
Cần id dù row mới hay cũ |
CTE + SELECT có retry, hoặc DO UPDATE “no-op” |
Xem mục RETURNING |
| Đồng bộ cả bảng từ nguồn, có xoá | MERGE |
Không ghi đồng thời cùng key, hoặc retry 23505 |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Upsert (“UPDATE hoặc INSERT”) trong PostgreSQL là INSERT ... ON CONFLICT.
| Thành phần | Ý nghĩa |
|---|---|
| Arbiter index | Unique index hoặc constraint NOT DEFERRABLE dùng để phát hiện conflict |
ON CONFLICT (user_id, product_id) |
Suy luận arbiter: mọi unique index có đúng các cột này |
ON CONFLICT ON CONSTRAINT name |
Chỉ định theo tên; docs ưu tiên suy luận |
ON CONFLICT (cols) WHERE pred |
Suy luận partial unique index; pred phải thoả predicate của index |
EXCLUDED |
Row đề xuất chèn, đã qua trigger BEFORE INSERT |
DO NOTHING / DO UPDATE |
Bỏ qua row trùng / cập nhật row đang có; DO UPDATE bắt buộc có conflict target |
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Vì sao SELECT rồi INSERT race, kể cả trong transaction Read Committed? SELECT không khoá “chỗ trống” nơi row sẽ xuất hiện; transaction khác vẫn chèn được key đó.
Request A tìm row (user 7, sản phẩm 42): chưa có. SELECT không giữ lock nào.
- A và B đều đọc 0 row; không ai giữ lock.
- A
INSERT: entry trong unique index thuộc transaction chưa commit. - B
INSERTcùng key: chờ A (demo đo đượcwait_event = transactionid). - A commit, B nhận
23505, transaction B bị huỷ.
Mức cao hơn không sửa được: chạy thử, Repeatable Read vẫn nhận 23505; Serializable đổi thành 40001 (B đã đọc key trước khi chèn), retry được nhưng vẫn là một lần thất bại. Với ON CONFLICT DO UPDATE:
A chạy upsert: chưa có row nên INSERT, view_count = 1, chưa commit.
Docs bảo đảm: ở Read Committed, mỗi row đề xuất hoặc được INSERT hoặc được UPDATE, trừ lỗi không liên quan, kể cả khi row trùng chưa hiển thị với snapshot.
SET view_count = pv.view_count + 1 đọc giá trị hiện tại của row sau khi khoá, nên không mất lượt đếm.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”Đang tải diagram…
- Chỉ Read Committed có bảo đảm trên. Ở Repeatable Read/Serializable, nếu row trùng thuộc transaction ngoài snapshot, cả
DO UPDATElẫnDO NOTHINGném40001(demo bước 10). Phải retry cả transaction. - Sequence nhảy số:
nextval(kể cả identity) chạy trước khi phát hiện conflict và không thu hồi. Demo: ba lần conflict, row kế tiếp nhậnid = 5. DO UPDATE ... WHERE: điều kiện được đánh giá sau cùng; row không thoả vẫn bị khoá đến hết transaction.- Batch trùng key:
VALUES (7, 50), (7, 50)vớiDO UPDATEném21000“cannot affect row a second time”;DO NOTHINGchỉ chèn 1 row. Gộp bằngGROUP BYtrước. - Partial index: thiếu
WHEREkhớp predicate thì nhận42P10“there is no unique or exclusion constraint matching the ON CONFLICT specification”. - Trong lúc
CREATE INDEX CONCURRENTLYchạy trên unique index,ON CONFLICTcó thể ném unique violation.
RETURNING khi conflict
Phần tiêu đề “RETURNING khi conflict”Với DO NOTHING, row bị bỏ qua không có trong RETURNING (demo: rowCount=0). Cách lấy id không ghi thêm:
WITH ins AS ( INSERT INTO product_views (user_id, product_id) VALUES (7, 42) ON CONFLICT (user_id, product_id) DO NOTHING RETURNING id)SELECT id FROM insUNION ALLSELECT id FROM product_viewsWHERE user_id = 7 AND product_id = 42 AND NOT EXISTS (SELECT 1 FROM ins);Lỗ hổng: nếu row trùng do transaction khác commit trong lúc câu lệnh chạy, nhánh SELECT dùng cùng snapshot nên không thấy. Demo đo được 0 row; phải retry.
Cách còn lại là DO UPDATE SET user_id = EXCLUDED.user_id RETURNING id: luôn có id, nhưng mỗi lần gọi là một UPDATE thật. Demo đo ctid (0,2) → (0,4), xmin đổi, trigger AFTER UPDATE chạy 1 lần.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo dùng hai connection pg:
npx --prefix examples ts-node examples/src/upsert-on-conflict/demo.tsOutput trên PostgreSQL 17 (rút gọn; ctid, xmin, id đổi mỗi lần chạy)
--- 1. SELECT rồi INSERT (Read Committed)B đang chờ: { wait_event_type: 'Lock', wait_event: 'transactionid' }B INSERT lỗi: 23505 duplicate key value violates unique constraint "product_views_user_id_product_id_key"--- 2. SELECT view_count rồi UPDATE = giá trị đọc + 1A đọc 1, B đọc 1; sau hai lượt xem view_count = 2--- 4. DO NOTHING + RETURNINGCTE + UNION ALL khi A commit giữa chừng: 0 row--- 6. Identity bị tiêu tốn khi conflictid=1 product=42, id=5 product=43--- 9. MERGE khi có concurrent INSERTB MERGE lỗi: 23505 duplicate key value ...--- 10. ON CONFLICT ở Repeatable ReadB upsert lỗi: 40001 could not serialize access due to concurrent updateMERGE không thay được ON CONFLICT khi ghi đồng thời
Phần tiêu đề “MERGE không thay được ON CONFLICT khi ghi đồng thời”MERGE có từ PostgreSQL 15. Bản 17 thêm RETURNING với merge_action() và WHEN NOT MATCHED BY SOURCE (xử lý row có ở bảng đích nhưng không có ở nguồn).
Docs Read Committed ghi rõ: MERGE có cả INSERT và UPDATE không bảo đảm một trong hai xảy ra. Nếu nhánh INSERT gặp row do transaction khác chèn đồng thời, MERGE ném unique violation, không quay lại WHEN MATCHED.
Demo bước 9: B nhận 23505, chạy lại thì merge_action = UPDATE. Dùng MERGE cho đồng bộ hàng loạt từ bảng staging; dùng ON CONFLICT cho ghi từ request song song.
Trade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
-
Bảo đảm INSERT hoặc UPDATE ở Read Committed, không
23505trên arbiter. -
Cộng dồn trên row hiện tại, tránh lost update; một round-trip.
< slot="cons"> >
-
Cần unique index đúng cột và predicate.
-
Sequence tiêu tốn mỗi lần conflict; key nóng sinh nhiều row version và tranh lock.
-
RETURNINGkhông trả row bị bỏ qua; không xử lý xoá nhưMERGE.
Nhược điểm
Checklist production
Phần tiêu đề “Checklist production”- Mỗi upsert có unique index tương ứng; conflict target viết bằng tên cột, kèm
WHEREnếu là partial index. - Cộng dồn bằng
SET col = t.col + EXCLUDED.col, không đọc ở app rồi ghi lại. - Thêm
WHERE t.col IS DISTINCT FROM EXCLUDED.colcho dữ liệu đồng bộ lặp lại. - Batch: gộp key trùng trước, sắp xếp theo key để giảm deadlock giữa các batch chồng nhau.
- Mẫu CTE +
UNION ALLphải retry khi trả 0 row. - Chạy upsert ở Read Committed; ở mức cao hơn phải retry
40001. - Không dựa vào sequence liên tục; trigger
UPDATEphải chịu được việc chạy khi giá trị không đổi.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Lỗi phổ biến nhất là tin transaction chống được race.
Kiểm tra trước rồi mới chèn trong transaction
Hay gặp
BEGIN;SELECT id FROM product_views WHERE user_id = 7 AND product_id = 42; -- 0 rowINSERT INTO product_views (user_id, product_id) VALUES (7, 42); -- 23505 nếu bị chèn trướcCOMMIT;Nên viết
INSERT INTO product_views AS pv (user_id, product_id) VALUES (7, 42)ON CONFLICT (user_id, product_id)DO UPDATE SET view_count = pv.view_count + 1, last_viewed_at = EXCLUDED.last_viewed_at;Vì sao
Transaction không khoá key chưa tồn tại; chỉ unique index tuần tự hoá việc chèn cùng key. Sau 23505, transaction hỏng đến khi ROLLBACK (xem savepoint ở Transaction trong code).
Lỗi thứ hai là coi bản “no-op” là miễn phí:
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
MERGE là upsert an toàn đồng thời |
23505 khi hai request cùng chèn |
Dùng ON CONFLICT hoặc retry |
id liên tục vì conflict không chèn |
Số đứt quãng | Đánh số nghiệp vụ bằng cơ chế khác |
| Batch nhận mảng thô từ client | 21000 khi có key trùng |
Gộp trước khi upsert |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Vì sao B chờ A trước khi nhận
23505, thay vì lỗi ngay khiINSERT? - Vì sao
SET view_count = pv.view_count + 1không mất lượt đếm? - Khi nào mẫu CTE +
UNION ALLtrả 0 row, và vì saoSELECTtrong cùng câu lệnh không thấy row đó?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- PostgreSQL 17 — INSERT, ON CONFLICT: conflict target,
EXCLUDED,WHERE, cardinality violation,RETURNING. - PostgreSQL 17 — Read Committed: bảo đảm của
ON CONFLICTvà vì saoMERGEgặp unique violation. - PostgreSQL 17 — MERGE:
NOT MATCHED BY SOURCE,RETURNING,merge_action(). - PostgreSQL 17 — Sequence Functions: vì sao
ON CONFLICTtạo gap. - PostgreSQL 17 — Release Notes:
MERGEtrong bản 17.