Upsert ด้วย ON CONFLICT
บางครั้งคุณอยากจะ insert row แต่ถ้ามี row ที่ตรงกันอยู่แล้ว คุณอยาก update มากกว่าล้มเหลว การทำด้วยมือ — ตรวจก่อน แล้ว insert หรือ update — มี race: สอง request เห็น “ไม่มี row” พร้อมกันแล้วต่างพยายาม insert PostgreSQL แก้เรื่องนี้แบบ atomic ด้วย INSERT ... ON CONFLICT ที่มักเรียกว่า upsert (update บวก insert)
ทุกอย่างด้านล่างรันบน PostgreSQL 18.6 (postgres:18.6, aarch64, Docker Desktop บน Mac, กันยายน 2026) ตัวอย่าง session เดียวคือ fence sql ที่รันจากบนลงล่างในฐานข้อมูลเปล่าหนึ่งตัว ตัวอย่างเรื่อง concurrency เป็นการรันจริงสอง session (สองโปรเซส psql ที่เริ่มพร้อมกัน) พิมพ์ตามที่ตอบกลับมาเป๊ะ ตัวเลขเวลามาจาก Mac เครื่องนี้
ทำไมไม่ตรวจก่อน?
หัวข้อที่มีชื่อว่า “ทำไมไม่ตรวจก่อน?”นี่คือ check-then-insert กับสอง session บน table ที่ไม่มี unique constraint แต่ละตัวตรวจว่าอีเมลยังไม่ถูกใช้ คิดหนึ่งวินาที แล้ว insert ทั้งคู่เห็นศูนย์ row จึง insert ทั้งคู่:
-- session A: scriptBEGIN;SELECT count(*) AS a_sees FROM signups WHERE email = '[email protected]';\o /dev/nullSELECT pg_sleep(1);\oINSERT INTO signups (email) VALUES ('[email protected]');COMMIT;-- session A: psql outputBEGIN a_sees-------- 0(1 row)
INSERT 0 1COMMIT
-- session B (same script, run at the same time): scriptBEGIN;SELECT count(*) AS b_sees FROM signups WHERE email = '[email protected]';\o /dev/nullSELECT pg_sleep(1);\oINSERT INTO signups (email) VALUES ('[email protected]');COMMIT;-- session B (same script, run at the same time): psql outputBEGIN b_sees-------- 0(1 row)
INSERT 0 1COMMIT
-- afterwards, from any session:SELECT email, count(*) FROM signups GROUP BY 1; email | count-----------------+-------(1 row)สอง row ที่อีเมลเดียวกัน นี่คือ race ที่ unique constraint บวก statement เดียวแบบ atomic ขจัดได้
unique constraint
หัวข้อที่มีชื่อว่า “unique constraint”สำหรับตัวอย่างเหล่านี้ table books ได้ unique constraint บน title จึงไม่มีหนังสือสองเล่มใช้ชื่อเดียวกันเป๊ะ การ insert ซ้ำธรรมดาตอนนี้ล้มเหลวด้วย 23505:
-- sql/setup.sqlCREATE TABLE authors ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, born int);CREATE TABLE books ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title text NOT NULL, author_id bigint REFERENCES authors (id), published int, copies_sold int NOT NULL DEFAULT 0);CREATE TABLE sales ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, book_id bigint REFERENCES books (id), region text NOT NULL, copies int NOT NULL);INSERT INTO authors (name, born) VALUES ('Mara Linde', 1968), ('Owen Pryce', 1975), ('Sela Vance', 1990);INSERT INTO books (title, author_id, published, copies_sold) VALUES ('Quiet Harbors', 1, 2003, 450), ('The Glass Orchard', 1, 2018, 1250), ('Tidal Notes', 2, 2011, 600), ('Unlinked Draft', NULL, 2020, 0);INSERT INTO sales (book_id, region, copies) VALUES (2, 'East', 900), (3, 'West', 400), (1, 'West', 400), (3, 'East', 200), (2, 'West', 150), (1, 'East', 50);-- sql/books-title-unique.sqlALTER TABLE books ADD CONSTRAINT books_title_key UNIQUE (title);-- @expect-error 23505INSERT INTO books (title, author_id, published) VALUES ('The Glass Orchard', 1, 2018);ERROR: duplicate key value violates unique constraint "books_title_key"DETAIL: Key (title)=(The Glass Orchard) already exists.upsert ตัดสินอย่างไร
หัวข้อที่มีชื่อว่า “upsert ตัดสินอย่างไร”เมื่อการ insert จะฝ่าฝืน unique constraint ที่ระบุ PostgreSQL ไม่ error แต่รัน action ใน clause ON CONFLICT ของคุณ — อาจ update row ที่มีอยู่ หรือไม่ทำอะไรอย่างเงียบ ๆ
flowchart TD
A["INSERT row"] --> B{"Conflicts with unique constraint?"}
B -- No --> C["Insert the new row"]
B -- Yes --> D{"ON CONFLICT action"}
D -- DO UPDATE --> E["Update the existing row"]
D -- DO NOTHING --> F["Leave the existing row unchanged"] DO UPDATE กับ EXCLUDED
หัวข้อที่มีชื่อว่า “DO UPDATE กับ EXCLUDED”คุณระบุ column (หรือ constraint) ที่นิยามความขัดแย้ง แล้วอธิบายวิธี update ภายใน update มี table พิเศษ EXCLUDED เก็บค่าที่คุณ พยายาม insert เพื่อคัดลอกเข้า row ที่มีอยู่ ตัวอย่างด้านล่างแต่ละอันอยู่ใน transaction ที่ rollback ชุดข้อมูลจึงคงเดิม
-- sql/upsert-do-update.sqlBEGIN;INSERT INTO books (title, author_id, published, copies_sold)VALUES ('The Glass Orchard', 1, 2018, 1300)ON CONFLICT (title)DO UPDATE SET published = EXCLUDED.published, copies_sold = EXCLUDED.copies_soldRETURNING id, title, copies_sold;ROLLBACK;const res = await pool.query( `INSERT INTO books (title, author_id, published, copies_sold) VALUES ($1, $2, $3, $4) ON CONFLICT (title) DO UPDATE SET published = EXCLUDED.published, copies_sold = EXCLUDED.copies_sold RETURNING id, title, copies_sold`, ['The Glass Orchard', 1, 2018, 1300],);cur.execute( """ INSERT INTO books (title, author_id, published, copies_sold) VALUES (%s, %s, %s, %s) ON CONFLICT (title) DO UPDATE SET published = EXCLUDED.published, copies_sold = EXCLUDED.copies_sold RETURNING id, title, copies_sold """, ("The Glass Orchard", 1, 2018, 1300),)rows, err := conn.Query(ctx, `INSERT INTO books (title, author_id, published, copies_sold) VALUES ($1, $2, $3, $4) ON CONFLICT (title) DO UPDATE SET published = EXCLUDED.published, copies_sold = EXCLUDED.copies_sold RETURNING id, title, copies_sold`, "The Glass Orchard", 1, 2018, 1300)let rows = sqlx::query( "INSERT INTO books (title, author_id, published, copies_sold) VALUES ($1, $2, $3, $4) ON CONFLICT (title) DO UPDATE SET published = EXCLUDED.published, copies_sold = EXCLUDED.copies_sold RETURNING id, title, copies_sold",).bind("The Glass Orchard").bind(1_i64).bind(2018_i32).bind(1300_i32).fetch_all(&pool).await?;BEGIN id | title | copies_sold----+-------------------+------------- 2 | The Glass Orchard | 1300(1 row)
INSERT 0 1ROLLBACKเพราะมีหนังสือชื่อ “The Glass Orchard” อยู่แล้ว (id 2) การ insert จึงกลายเป็น update ของ row นั้น รัน statement เดียวกันกับ title ใหม่เอี่ยม จะไม่มี conflict มัน insert ตามปกติและคืน id ที่เพิ่งสร้าง id นั้นคือ 7 ไม่ใช่ 5: insert ที่ล้มเหลวข้างบนและความพยายามก่อนหน้าแต่ละครั้งใช้ค่า identity ไป (ดูเพิ่มเติมด้านล่าง)
-- sql/upsert-new-row.sqlBEGIN;INSERT INTO books (title, author_id, published, copies_sold)VALUES ('Salt Road', 3, 2024, 10)ON CONFLICT (title)DO UPDATE SET copies_sold = EXCLUDED.copies_soldRETURNING id, title, copies_sold;ROLLBACK;BEGIN id | title | copies_sold----+-----------+------------- 7 | Salt Road | 10(1 row)
INSERT 0 1ROLLBACKใน pgAdmin: รัน statement สองครั้งใน Query Tool — ครั้งแรก insert ครั้งที่สองชน conflict แล้ว update grid Data Output แสดง row ที่คืนมาทุกครั้ง
แยก insert จาก update ในผลลัพธ์ได้อย่างไร? RETURNING old.x, new.x ของ PostgreSQL 18 ทำได้: old เป็น NULL เมื่อ row ถูก insert เทคนิคเก่าคือ xmax = 0 (system column ที่เป็น 0 สำหรับ row ที่เพิ่ง insert) แสดงในคอลัมน์สุดท้าย:
-- sql/upsert-returning-old-new.sqlBEGIN;INSERT INTO books (title, author_id, published, copies_sold) VALUES ('The Glass Orchard', 1, 2018, 1300), ('Salt Road', 3, 2024, 10)ON CONFLICT (title)DO UPDATE SET copies_sold = EXCLUDED.copies_soldRETURNING title, old.copies_sold AS was, new.copies_sold AS now, (xmax = 0) AS inserted;ROLLBACK;BEGIN title | was | now | inserted-------------------+------+------+---------- The Glass Orchard | 1250 | 1300 | f Salt Road | | 10 | t(2 rows)
INSERT 0 2ROLLBACKWHERE บน DO UPDATE จำกัดว่าเมื่อไหร่ update จะเกิดขึ้น ด้านล่าง ตรวจพบ conflict แต่ guard เป็นเท็จ และไม่มีอะไรเปลี่ยน: ไม่มี row ถูก update และ RETURNING ไม่คืนอะไร (ดังนั้น RETURNING ของ upsert ว่างได้แม้เกิด conflict):
-- sql/upsert-where-guard.sqlBEGIN;INSERT INTO books (title, author_id, published, copies_sold)VALUES ('The Glass Orchard', 1, 2018, 100)ON CONFLICT (title)DO UPDATE SET copies_sold = EXCLUDED.copies_soldWHERE EXCLUDED.copies_sold > books.copies_soldRETURNING id, title, copies_sold;SELECT copies_sold FROM books WHERE id = 2;ROLLBACK;BEGIN id | title | copies_sold----+-------+-------------(0 rows)
INSERT 0 0 copies_sold------------- 1250(1 row)
ROLLBACKDO NOTHING
หัวข้อที่มีชื่อว่า “DO NOTHING”ถ้าอยาก insert เฉพาะเมื่อ row ใหม่ และข้ามค่าซ้ำอย่างเงียบ ๆ ใช้ DO NOTHING ไม่มี error ไม่มี update
INSERT INTO books (title, author_id, published)VALUES ('The Glass Orchard', 1, 2018)ON CONFLICT (title) DO NOTHING;const res = await pool.query( 'INSERT INTO books (title, author_id, published) VALUES ($1, $2, $3) ON CONFLICT (title) DO NOTHING', ['The Glass Orchard', 1, 2018],);console.log(res.rowCount); // 0 when the row already existedcur.execute( "INSERT INTO books (title, author_id, published) VALUES (%s, %s, %s) ON CONFLICT (title) DO NOTHING", ("The Glass Orchard", 1, 2018),)# cur.rowcount is 0 when nothing was insertedtag, err := conn.Exec(ctx, "INSERT INTO books (title, author_id, published) VALUES ($1, $2, $3) ON CONFLICT (title) DO NOTHING", "The Glass Orchard", 1, 2018)// tag.RowsAffected() is 0 when the row already existedlet result = sqlx::query( "INSERT INTO books (title, author_id, published) VALUES ($1, $2, $3) ON CONFLICT (title) DO NOTHING",).bind("The Glass Orchard").bind(1_i64).bind(2018_i32).execute(&pool).await?;// result.rows_affected() is 0 when nothing was insertedเมื่อ row มีอยู่แล้ว ศูนย์ row ได้รับผลกระทบ (INSERT 0 0) และ RETURNING ว่าง เมื่อเป็น row ใหม่ หนึ่ง row ถูก insert (INSERT 0 1) และ id ของมันกลับมา statement ที่สอง insert หนังสือใหม่ แล้วตัวที่สามลบมันออก:
-- sql/upsert-do-nothing.sqlINSERT INTO books (title, author_id, published)VALUES ('The Glass Orchard', 1, 2018)ON CONFLICT (title) DO NOTHINGRETURNING id;INSERT INTO books (title, author_id, published)VALUES ('Salt Road', 3, 2024)ON CONFLICT (title) DO NOTHINGRETURNING id;DELETE FROM books WHERE title = 'Salt Road'; id----(0 rows)
INSERT 0 0 id---- 12(1 row)
INSERT 0 1DELETE 1ทั้งสี่ driver รันหนึ่งครั้งกับฐานข้อมูลชั่วคราว (สองแบบข้างบน พร้อมจำนวน row สำหรับ DO NOTHING):
$ (each snippet run once against the course dataset in a scratch database, reset before every run)[DO UPDATE ... RETURNING] Node.js (pg 8.23.0): [ { id: '2', title: 'The Glass Orchard', copies_sold: 1300 } ][DO UPDATE ... RETURNING] Python (psycopg 3.3.6): [(2, 'The Glass Orchard', 1300)][DO UPDATE ... RETURNING] Go (pgx v5.11.0): [2 The Glass Orchard 1300][DO UPDATE ... RETURNING] Rust (sqlx 0.9.0): 2 The Glass Orchard 1300[DO NOTHING (rows affected)] Node.js (pg 8.23.0): 0[DO NOTHING (rows affected)] Python (psycopg 3.3.6): 0[DO NOTHING (rows affected)] Go (pgx v5.11.0): 0[DO NOTHING (rows affected)] Rust (sqlx 0.9.0): 0error ที่คุณจะเจอ
หัวข้อที่มีชื่อว่า “error ที่คุณจะเจอ”ON CONFLICT DO UPDATE ต้องรู้ว่า unique constraint ตัวไหนนิยาม “row เดียวกัน” ถ้าไม่มี target จะถูกปฏิเสธ (42601) ถ้าใช้ column ที่ไม่มี unique constraint จะล้มเหลวด้วย 42P10 (DO NOTHING ละ target ได้ แต่ target ที่ระบุต้องตรงกับ unique index):
-- @expect-error 42601INSERT INTO books (title, author_id, published) VALUES ('Salt Road', 3, 2024)ON CONFLICT DO UPDATE SET published = EXCLUDED.published;ERROR: ON CONFLICT DO UPDATE requires inference specification or constraint nameLINE 2: ON CONFLICT DO UPDATE SET published = EXCLUDED.published; ^HINT: For example, ON CONFLICT (column_name).-- @expect-error 42P10INSERT INTO books (title, author_id, published) VALUES ('Salt Road', 3, 2024)ON CONFLICT (published) DO NOTHING;ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specificationอีกข้อ เมื่อ statement เดียวเสนอสอง row ที่ชน row เดิมตัวเดียวกัน PostgreSQL จะไม่ update row เดียวสองครั้งในคำสั่งเดียว (21000):
-- @expect-error 21000INSERT INTO books (title, author_id, published, copies_sold) VALUES ('Tidal Notes', 2, 2011, 1), ('Tidal Notes', 2, 2011, 2)ON CONFLICT (title) DO UPDATE SET copies_sold = EXCLUDED.copies_sold;ERROR: ON CONFLICT DO UPDATE command cannot affect row a second timeHINT: Ensure that no rows proposed for insertion within the same command have duplicate constrained values.upsert กับช่องว่างของ identity
หัวข้อที่มีชื่อว่า “upsert กับช่องว่างของ identity”การ insert ถูกพยายามก่อนที่จะตรวจพบ conflict row ที่ชนจึงยังใช้ค่า identity ไปหนึ่งค่า สอง conflict ที่นี่เผา id 3 และ 4 และ insert จริงครั้งถัดไปได้ 5 (books ข้างบนกระโดดด้วยเหตุผลเดียวกัน) identity column ไม่ซ้ำ แต่ไม่ไร้ช่องว่าง:
-- sql/identity-gap.sqlCREATE TABLE tags (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, label text NOT NULL UNIQUE);INSERT INTO tags (label) VALUES ('sql'), ('postgres');INSERT INTO tags (label) VALUES ('sql'), ('postgres') ON CONFLICT (label) DO NOTHING;INSERT INTO tags (label) VALUES ('upsert') RETURNING id;CREATE TABLEINSERT 0 2INSERT 0 0 id---- 5(1 row)
INSERT 0 1Atomicity ที่พิสูจน์แล้ว
หัวข้อที่มีชื่อว่า “Atomicity ที่พิสูจน์แล้ว”การตั้งค่าสอง session เหมือนตัวอย่างที่มี race แต่ตอนนี้มี primary key และ ON CONFLICT DO NOTHING session A insert และเปิด transaction ค้างไว้ 2 วินาที session B ลองอีเมลเดียวกันครึ่งวินาทีหลัง B ไม่ล้มเหลวและไม่ซ้ำ: มันรอ A แล้วเจอ conflict และไม่ insert อะไร:
-- session A: scriptBEGIN;INSERT INTO signups (email, name) VALUES ('[email protected]', 'from A') ON CONFLICT (email) DO NOTHING;\o /dev/nullSELECT pg_sleep(2);\oCOMMIT;-- session A: psql outputBEGININSERT 0 1COMMIT
-- session B (started 0.5 s later): script\o /dev/nullSELECT pg_sleep(0.5);\o\timing onINSERT INTO signups (email, name) VALUES ('[email protected]', 'from B') ON CONFLICT (email) DO NOTHING;-- session B (started 0.5 s later): psql outputTiming is on.INSERT 0 0Time: 1505.512 ms (00:01.506)
-- afterwards:SELECT * FROM signups; email | name-----------------+-------- [email protected] | from A(1 row)B รอ 1.5 วินาที (จน A commit) รายงาน INSERT 0 0 และ table มีหนึ่ง row นั่นคือคำรับประกัน: insert เพียงครั้งเดียว ไม่ว่า session ไหนชนะ
isolation level สำคัญ ที่ REPEATABLE READ transaction ทำงานจาก snapshot ถ้า row ที่ชนถูกคนอื่นเปลี่ยนหลัง snapshot นั้น DO UPDATE ไม่สามารถ update row ที่ transaction ไม่เคยเห็นในสถานะนั้นได้อย่างเงียบ ๆ มันจึงส่ง serialization failure และคุณต้อง retry ทั้ง transaction (docs):
-- session A (starts 1 s in): script\o /dev/nullSELECT pg_sleep(1);\oUPDATE signups SET name = 'changed by A' WHERE email = '[email protected]';-- session A (starts 1 s in): psql outputUPDATE 1
-- session B: scriptBEGIN ISOLATION LEVEL REPEATABLE READ;SELECT name AS b_snapshot_sees FROM signups;\o /dev/nullSELECT pg_sleep(2);\oINSERT INTO signups (email, name) VALUES ('[email protected]', 'from B')ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name;COMMIT;-- session B: psql outputBEGIN b_snapshot_sees----------------- ada(1 row)
ERROR: could not serialize access due to concurrent updateROLLBACKMERGE: ญาติแบบ SQL มาตรฐาน
หัวข้อที่มีชื่อว่า “MERGE: ญาติแบบ SQL มาตรฐาน”MERGE (PostgreSQL 15+ และมี RETURNING กับ merge_action() ตั้งแต่ 17) แสดงเจตนาเดียวกันด้วย SQL มาตรฐานและกว้างกว่า: หลายกิ่ง WHEN, เงื่อนไข และ DELETE ไม่ต้องมี unique constraint ราคาคือมันไม่ใช่ upsert แบบ atomic:
-- sql/merge.sqlBEGIN;MERGE INTO books AS bUSING (VALUES ('The Glass Orchard', 1, 2018, 1300), ('Salt Road', 3, 2024, 10)) AS v (title, author_id, published, copies_sold)ON b.title = v.titleWHEN MATCHED THEN UPDATE SET copies_sold = v.copies_soldWHEN NOT MATCHED THEN INSERT (title, author_id, published, copies_sold) VALUES (v.title, v.author_id, v.published, v.copies_sold)RETURNING merge_action() AS action, b.id, b.title, b.copies_sold;ROLLBACK;BEGIN action | id | title | copies_sold--------+----+-------------------+------------- UPDATE | 2 | The Glass Orchard | 1300 INSERT | 15 | Salt Road | 10(2 rows)
MERGE 2ROLLBACKสอง session ที่รัน MERGE เดียวกันสำหรับ key ที่ยังไม่มี ต่างเข้ากิ่ง NOT MATCHED และผู้แพ้ได้ unique violation แทนการข้ามอย่างเงียบ ๆ ของ DO NOTHING:
-- session A: scriptBEGIN;MERGE INTO signups s USING (VALUES ('[email protected]', 'from A')) v(email, name) ON s.email = v.emailWHEN MATCHED THEN UPDATE SET name = v.nameWHEN NOT MATCHED THEN INSERT (email, name) VALUES (v.email, v.name);\o /dev/nullSELECT pg_sleep(2);\oCOMMIT;-- session A: psql outputBEGINMERGE 1COMMIT
-- session B (started 0.5 s later): script\o /dev/nullSELECT pg_sleep(0.5);\o\timing onMERGE INTO signups s USING (VALUES ('[email protected]', 'from B')) v(email, name) ON s.email = v.emailWHEN MATCHED THEN UPDATE SET name = v.nameWHEN NOT MATCHED THEN INSERT (email, name) VALUES (v.email, v.name);-- session B (started 0.5 s later): psql outputTiming is on.ERROR: duplicate key value violates unique constraint "signups_pkey"DETAIL: Key (email)=([email protected]) already exists.Time: 1502.088 ms (00:01.502)ใช้ ON CONFLICT สำหรับ “insert หรือ update key นี้ พร้อมกัน หนึ่งครั้งพอดี” ใช้ MERGE สำหรับการ sync แบบเซตจากแหล่งที่คุณควบคุม และพร้อม retry เมื่อเจอ 23505
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”ผู้ช่วย AI ถูกบอกว่า table unique ต่อ title และ author และเขียน upsert ที่คอมเมนต์บอกตรงนั้นเป๊ะ:
🐛 หา bug ในโค้ดนี้
-- AI comment: "editions are unique per (title, author) - upsert on title"CREATE TABLE editions (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,title text NOT NULL,author_id bigint NOT NULL,price numeric(8, 2) NOT NULL,UNIQUE (title, author_id));
INSERT INTO editions (title, author_id, price)VALUES ('Quiet Harbors', 1, 12.00)ON CONFLICT (title) DO UPDATE SET price = EXCLUDED.price;เฉลย
คอมเมนต์ถูกและโค้ดผิด ON CONFLICT (title) ต้องตรงกับ unique index บน title เท่านั้น แต่ table มีเฉพาะบน (title, author_id) statement ล้มเหลวทันทีที่รัน บน production ด้วย 42P10:
-- sql/editions.sqlCREATE TABLE editions ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title text NOT NULL, author_id bigint NOT NULL, price numeric(8, 2) NOT NULL, UNIQUE (title, author_id));-- @expect-error 42P10INSERT INTO editions (title, author_id, price)VALUES ('Quiet Harbors', 1, 12.00)ON CONFLICT (title) DO UPDATE SET price = EXCLUDED.price;ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specificationวิธีแก้คือระบุ conflict target จริง: รายการ column ของ unique index หรือชื่อ constraint ทั้งสองรูปแบบ upsert row เดียวกัน:
-- sql/editions-fixed.sqlINSERT INTO editions (title, author_id, price)VALUES ('Quiet Harbors', 1, 12.00)ON CONFLICT (title, author_id) DO UPDATE SET price = EXCLUDED.priceRETURNING id, title, price;INSERT INTO editions (title, author_id, price)VALUES ('Quiet Harbors', 1, 14.50)ON CONFLICT ON CONSTRAINT editions_title_author_id_key DO UPDATE SET price = EXCLUDED.priceRETURNING id, title, price; id | title | price----+---------------+------- 1 | Quiet Harbors | 12.00(1 row)
INSERT 0 1 id | title | price----+---------------+------- 1 | Quiet Harbors | 14.50(1 row)
INSERT 0 1Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”upsert คือ insert ที่พกคำตอบของตัวเองสำหรับ “ถ้า key นี้มีอยู่แล้วล่ะ?” ฐานข้อมูลทำโดย insert ลง unique index ก่อน ซึ่งทำให้ผู้เขียนพร้อมกันบน key เรียงเป็นแถว: ผู้เขียนคนที่สองรอคนแรก แล้วเห็น conflict นั่นคือเหตุผลที่มัน atomic ในขณะที่ check-then-insert ไม่ใช่ ทำไมต้องมี unique index ให้ชี้ และทำไมทุกความพยายาม ไม่ว่าสำเร็จหรือไม่ ใช้ค่า identity ไป
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- upsert ต้องมี unique constraint หรือ unique index เพื่อตรวจ conflict และ target ของ
ON CONFLICTต้องตรงเป๊ะ (ไม่เช่นนั้น42P10) - table
EXCLUDEDอ้างถึง row ที่คุณพยายาม insert ใช้ในDO UPDATEเพื่อดึงค่าใหม่เข้า row ที่มีอยู่ - เพิ่ม
WHEREให้DO UPDATEเพื่อ update เฉพาะเงื่อนไขบางอย่าง เช่นDO UPDATE SET copies_sold = EXCLUDED.copies_sold WHERE EXCLUDED.copies_sold > books.copies_soldrow นั้นจะไม่ถูก update และไม่ถูกคืน DO NOTHINGคืนศูนย์ row และRETURNINGว่างเมื่อ conflict จึงตรวจจำนวน row ถ้าต้องรู้ว่า insert เกิดขึ้นจริงไหม- statement เดียวชน row เดิมตัวเดียวกันสองครั้งไม่ได้ (
21000) ให้กำจัดข้อมูลซ้ำใน input ก่อน - ภายใต้
REPEATABLE READหรือSERIALIZABLEพร้อม retry เมื่อเจอ40001 - ส่งค่าผ่าน placeholder ที่นี่ด้วย upsert ยังเป็น
INSERTและกฎ SQL injection เดิมใช้ได้
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
INSERT ... ON CONFLICT | Atomic, round trip เดียว ไม่มี race condition (พิสูจน์ข้างบน) | ต้องมี unique constraint หรือ unique index ให้ conflict ชี้ และเผาค่า identity เมื่อ conflict |
| check-then-insert ในโค้ดแอป | อ่านง่าย ตรรกะตรงไปตรงมา | race condition คลาสสิกเมื่อหลาย request ทำงานพร้อมกัน (สอง row ข้างบน) |
DO UPDATE เมื่อ conflict | ข้อมูลซิงก์กับค่าล่าสุดเสมอ | อาจเขียนทับค่าที่มีอยู่โดยไม่ตั้งใจถ้าไม่ระวัง |
DO NOTHING เมื่อ conflict | ปลอดภัย ไม่เคยเขียนทับข้อมูลเดิม | event ซ้ำถูกทิ้งเงียบ ๆ แม้ที่จริงต้อง update |
MERGE | SQL มาตรฐาน หลายกิ่ง ไม่ต้องมี unique constraint | การรันพร้อมกันอาจล้มด้วย unique violation (พิสูจน์ข้างบน) |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “ON CONFLICT DO NOTHING แปลว่าไม่มีอะไรเกิดขึ้น” การ insert ยังถูกพยายาม: ค่า identity ถูกใช้ และอาจต้องรอ lock (B รอ 1.5 วินาที)
- “RETURNING คืน row เสมอ” ไม่ใช่กับ
DO NOTHINGและไม่ใช่เมื่อDO UPDATE ... WHEREเป็นเท็จ - “MERGE เป็น upsert ที่ดีกว่า” มันกว้างกว่า ไม่ได้ atomic กว่า (การรัน race4)
- “column unique ใดก็เป็น target ได้” target ต้องตรงกับ unique index เป๊ะ index แบบผสมไม่รองรับ target column เดียว
- “upsert ปลอดภัยทุก isolation level” ที่
REPEATABLE READมันล้มด้วย40001ได้และต้องมีลูป retry