INSERT … ON CONFLICT 让 PostgreSQL 在真正写入的语句内部处理插入冲突。它用明确的冲突策略和实际索引或约束,替代脆弱的“先查键是否存在,再决定插入”流程。但它不会把所有插入错误都变成更新成功,也不会替应用决定新旧数据应该如何合并。使用前,需要先确定唯一键和业务动作。

冲突目标定义处理范围

(name) 这样的目标,要求 PostgreSQL 推断匹配的唯一索引。表上必须实际存在适用的唯一性规则;写出 ON CONFLICT 并不会自动建立约束。如果键涉及表达式或部分索引,索引表达式和谓词也会影响匹配。目标没有匹配到索引时会报错,而不是悄悄改用另一种插入策略。可空业务键尤其需要注意:普通唯一索引把空值视为彼此不同,多次写入空键不一定冲突。若业务要求这些行相互冲突,应选择非空键,或明确采用符合需求的 NULLS NOT DISTINCT 语义。

DO UPDATE 必须指定目标,其裁决依据只能是受支持的非延迟唯一约束或唯一索引,排除约束不能作为 DO UPDATE 的冲突裁决依据。DO NOTHING 的处理范围更广,包括受支持的排除约束,也可以省略目标来处理适用的冲突规则。但这些能力都不会让外键、非空或检查约束失效,不能把它当成通用的错误忽略机制。

原子的插入或更新行为,解决的是并发争用同一键时的协调。语句仍可能等待其他事务持有的相关行或索引冲突,正常的事务错误也仍可能发生。原子性描述结果如何被协调,不保证完全无锁、不阻塞,也不意味着调用方永远不用处理事务级重试。

明确写出合并规则

目标表名或别名代表已有行,EXCLUDED 代表原本准备插入的行,其中也反映 BEFORE INSERT 触发器的相关影响。赋值为 EXCLUDED.total 是替换旧值;赋值为已有 total 加 EXCLUDED.total 是累加一次贡献。虽然语法相近,两者回答的业务问题不同,不能把其中一种默认当成“正确的 upsert”。

计数器直接在更新表达式中使用已有行,可以避免客户端先读旧值,再算一个可能过时的替换值。带版本的文档则可以用 WHERE 拒绝较旧版本。这个条件在找到并锁定冲突行后求值,因此,即使最终没有更新,它也可能与其他写入者争用锁。条件跳过不等于完全没有执行成本或并发影响。

RETURNING 只输出真正被插入或更新的行。DO NOTHING 不会返回已经存在的那条记录;DO UPDATE 的 WHERE 为假时,也不会返回行。所以空结果不能唯一推断出“走了 DO NOTHING”,更不能直接认定数据库中没有该记录。调用方应明确如何解释被跳过的写入,而不是把所有零行返回都当成同一种错误。

坏掉的样子

一个批次中可能有两条输入使用相同冲突键。DO UPDATE 不允许同一条语句对同一既有行更新两次,遇到这种情况会抛出基数错误。提交前应按业务规则合并或拒绝重复输入。随便保留最后一条,可能把原来的累加操作静默改成替换,也可能丢掉本来应该合计的贡献。

ON CONFLICT 也不自动提供幂等性。一次累加请求的响应不确定时,重试可能再次加上同一份数值。如果一个逻辑请求只能生效一次,需要另有明确的请求身份与唯一性契约。类似地,无条件替换也可能在旧请求晚到时覆盖较新的状态;原子执行不能替代版本或顺序规则。

其他约束错误仍然重要。键冲突不代表新数据中的非法值可以忽略,PostgreSQL 可能在执行 DO NOTHING 之前就拒绝 CHECK 违规。序列默认值也可能在冲突处理前求值,所以被跳过的插入仍可能消耗编号。这两种行为都不说明冲突处理器失效,只是说明它的处理范围并不覆盖整个写入生命周期。

快速验证

把下面内容保存为 upsert.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f upsert.sql。临时计数表只存在于实验事务中,末尾回滚会清除全部测试数据。

\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE tally (
  name text PRIMARY KEY,total integer NOT NULL CHECK (total>=0),version integer NOT NULL
);
INSERT INTO tally VALUES ('jobs',5,1) RETURNING *;
INSERT INTO tally VALUES ('jobs',3,2)
ON CONFLICT (name) DO UPDATE
SET total=tally.total+EXCLUDED.total,version=EXCLUDED.version
RETURNING *;
WITH changed AS (
  INSERT INTO tally VALUES ('jobs',100,3) ON CONFLICT (name) DO NOTHING RETURNING *
) SELECT count(*) AS do_nothing_returned FROM changed;
WITH changed AS (
  INSERT INTO tally VALUES ('jobs',100,1)
  ON CONFLICT (name) DO UPDATE SET total=EXCLUDED.total,version=EXCLUDED.version
  WHERE tally.version<EXCLUDED.version RETURNING *
) SELECT count(*) AS stale_update_returned FROM changed;
DO $$ BEGIN
  INSERT INTO tally VALUES ('jobs',-1,4) ON CONFLICT (name) DO NOTHING;
  RAISE EXCEPTION 'check violation was swallowed';
EXCEPTION WHEN check_violation THEN RAISE NOTICE 'CHECK still rejects invalid input';
END $$;
DO $$ BEGIN
  INSERT INTO tally VALUES ('jobs',1,4),('jobs',2,5)
  ON CONFLICT (name) DO UPDATE SET total=EXCLUDED.total;
  RAISE EXCEPTION 'same row updated twice';
EXCEPTION WHEN cardinality_violation THEN RAISE NOTICE 'duplicate input keys rejected for DO UPDATE';
END $$;
SELECT * FROM tally;
DO $$ BEGIN
  IF (SELECT total FROM tally WHERE name='jobs') IS DISTINCT FROM 8 OR
     (SELECT version FROM tally WHERE name='jobs') IS DISTINCT FROM 2 THEN
    RAISE EXCEPTION 'conflict action changed the wrong state';
  END IF;
END $$;
ROLLBACK;

本轮 PostgreSQL 18.6 单用户 SQL 重跑先插入总额五、版本一,再累加三,得到八、版本二;未测试并发写者或 psql 传输。DO NOTHING 和过期版本的条件更新都返回零行。违反 CHECK 的负数输入,以及包含重复键的批次,分别抛出预期错误;脚本只捕获明确指定的错误类型,不会把任意数据库失败当成符合预期的结果。

最终断言确认,被拒绝的工作没有替换总额或版本。这个实验覆盖几种不同冲突路径,并不是延迟基准,也不保证任意合并表达式都适用于并发。把语句用于真实工作流之前,应一起审阅更新表达式、目标唯一性规则和调用方重试语义;只确认 SQL 可以执行,还不足以证明业务动作只执行了一次或保留了最新状态。

来源

  • PostgreSQL INSERT,冲突裁决、原子处理、EXCLUDED 与 RETURNING,已核验 2026-10-15。
  • PostgreSQL 约束,唯一性、检查、非空与引用完整性,已核验 2026-10-15。