#DevOps

UPSERT 模式:ON CONFLICT 与幂等写入

插入还是更新?UPSERT 一条语句搞定。本文讲清 PostgreSQL 的 INSERT ON CONFLICT(指定冲突列/DO UPDATE/DO NOTHING)、MySQL 的 ON DUPLICATE KEY UPDATE、幂等写入场景(同步/重试不重复),以及 ON CONFLICT 的陷阱(部分索引/多列冲突)。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年2月13日·更新于 2026年7月23日

UPSERT 模式:ON CONFLICT 与幂等写入

写一个"如果不存在就插入、存在就更新"的逻辑,初级写法是先 SELECT 判断有没有,没有就 INSERT,有就 UPDATE。三句话、两次往返、还埋着并发竞态——两个请求同时 SELECT 都说"没有",然后双双 INSERT,唯一约束直接炸给你看。修这个坑,要么上事务加锁,要么写一堆重试。其实一条 SQL 就能解决,这就是 UPSERT。

UPSERT(UPDATE or INSERT)是每个后端都该熟练掌握的写入模式。它把"判断 + 插入 + 更新"合成一条原子语句,既消除了竞态,又天然支持幂等——重试多少次结果都一致。本文把 PostgreSQL 的 INSERT ON CONFLICT、MySQL 的 ON DUPLICATE KEY UPDATE、幂等场景的设计、以及实战中的陷阱(部分索引、多列冲突、并发锁行为)一次讲清。

一、UPSERT 是什么:Insert 与 Update 的原子合一

UPSERT 不是一个 SQL 关键字,而是一种语义:给定一行数据,若冲突则更新,不冲突则插入。"冲突"指的是违反某个唯一约束(主键或 UNIQUE)。

先看问题有多烦。假设有张 user_stats 表,业务想"按 user_id 累加登录次数":

sql
-- 朴素写法(错误示范)
SELECT count FROM user_stats WHERE user_id = 42;
-- 应用层判断:查到了就 UPDATE,没查到就 INSERT

这套逻辑有三个毛病:

  1. 非原子:SELECT 和 INSERT/UPDATE 之间有时间窗,并发下两个请求都"没查到",都去 INSERT,第二个撞唯一约束报错
  2. 多一次往返:一次操作至少两次 DB round-trip,延迟翻倍
  3. 重试不幂等:网络抖动重试一次,可能多 INSERT 一条脏数据

UPSERT 把这三件事一次性解决。它告诉数据库:"你替我判断有没有冲突,有就按我给的方案更新,没有就插入,整个过程在一条语句、一个原子操作里完成。" 数据库自己处理冲突检测,根本不存在"判断和插入之间的时间窗"——竞态从根上消失了。

各主流库的实现:

数据库语法
PostgreSQL (9.5+)INSERT ... ON CONFLICT (col) DO UPDATE SET ... / DO NOTHING
MySQLINSERT ... ON DUPLICATE KEY UPDATE col=...
SQLiteINSERT ... ON CONFLICT(col) DO UPDATE SET ...(3.24+,语法接近 PG)
OracleMERGE INTO ... WHEN MATCHED THEN UPDATE WHEN NOT MATCHED THEN INSERT
SQL ServerMERGE ...(同上,但官方建议慎用,有语义坑)

PG 的 ON CONFLICT 是这一票里设计最干净的——它明确告诉你"按哪一列判断冲突",而 MySQL 的 ON DUPLICATE KEY UPDATE 是隐式的(自动找命中的唯一键),这是后文要讲的陷阱之一。下面先从 PG 讲起。

二、PostgreSQL:INSERT ON CONFLICT 三段式

PG 的 UPSERT 语法长这样:

sql
INSERT INTO user_stats (user_id, login_count, last_login, updated_at)
VALUES (42, 1, now(), now())
ON CONFLICT (user_id)          -- 冲突判定列
DO UPDATE SET                  -- 冲突时做什么
    login_count = user_stats.login_count + 1,
    last_login = now(),
    updated_at = now();

可以拆成三段来读:

  1. INSERT INTO ... VALUES (...):正常插入语句,该怎么写怎么写
  2. ON CONFLICT (user_id):指定"用哪一列/哪个约束来判断冲突"。这里告诉 PG:插入时如果 user_id 上发生唯一约束冲突(即已存在 user_id=42 的行),就别报错,走下面的分支
  3. DO UPDATE SET ...:冲突时的更新动作。这里能引用两个特殊表:EXCLUDED 代表"正准备插入的那行"(即 VALUES 里的新值),而 user_stats(表名)代表"已存在的那行"(旧值)

理解 EXCLUDED 这个伪表是写好 PG UPSERT 的关键。它就是"触发冲突的那条 INSERT 数据"。看个对比:

sql
-- 想用新值覆盖旧值:引用 EXCLUDED
INSERT INTO config (key, value, updated_at)
VALUES ('timeout', '30s', now())
ON CONFLICT (key)
DO UPDATE SET
    value = EXCLUDED.value,          -- 用新值
    updated_at = EXCLUDED.updated_at;

-- 想累加/合并:引用表本身(旧值)
INSERT INTO user_stats (user_id, login_count)
VALUES (42, 1)
ON CONFLICT (user_id)
DO UPDATE SET
    login_count = user_stats.login_count + EXCLUDED.login_count;
-- 旧值 + 新值

一个常见踩坑点:在 DO UPDATE SET 里引用 VALUES(1) 那个字面量是写不进去的,你得通过 EXCLUDED.列名 拿到它。很多人第一反应写 login_count = login_count + 1(想引用 INSERT 里那个 1),PG 会报 column reference is ambiguous 或者干脆引用到表里的列。记住:DO UPDATE 子句里,表名 = 旧行,EXCLUDED = 新行。

另一个要点:ON CONFLICT (user_id) 里的列,必须存在一个唯一约束或唯一索引(UNIQUE 或 PRIMARY KEY 都行)。如果你指定了一列但没有唯一约束,PG 会直接报错 there is no unique or exclusion constraint matching the ON CONFLICT specification。这是 PG 的硬性要求——它得知道按什么判定冲突。

三、DO NOTHING:幂等的"静默"版本

DO UPDATE 是"冲突就改",有时你不需要改,只需要"别报错就行"。比如记录一个幂等的 webhook 事件——同一个 event_id 来十次,只该入库一次,后面的全忽略。这时候用 DO NOTHING:

sql
INSERT INTO webhook_events (event_id, payload, received_at)
VALUES ('evt_123', '{"type":"ping"}', now())
ON CONFLICT (event_id) DO NOTHING;

DO NOTHING 字面意思:发生冲突时,什么也不做(既不报错也不更新),直接把这条 INSERT 丢掉。等价于"这个事件已经在库里了,忽略本次"。

这比 DO UPDATE SET col = col(假装更新一下)干净得多——DO NOTHING 不会触发触发器、不会更新 updated_at、不会产生 WAL 日志写、不会让逻辑复制把这条变更同步到从库。它是真正的"静默忽略"。

DO NOTHING 最经典的用途就是幂等消费。消息队列、webhook、回调这些天然会重复投递的渠道,配合一个幂等键 + ON CONFLICT DO NOTHING,就能保证"多次处理 = 一次处理"。这是分布式系统里最便宜、最可靠的幂等方案之一,比在应用层维护 Redis 去重集合省心得多。

注意一个细节:ON CONFLICT DO NOTHING 不指定列时,表示"任何唯一约束冲突都忽略"。但实战中我更推荐显式指定列(ON CONFLICT (event_id) DO NOTHING),让意图明确——否则如果表上有多个唯一约束,任意一个命中都会被忽略,可能掩盖 bug。

四、MySQL:ON DUPLICATE KEY UPDATE

MySQL 的 UPSERT 语法不同:

sql
INSERT INTO user_stats (user_id, login_count, last_login, updated_at)
VALUES (42, 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE
    login_count = login_count + 1,
    updated_at = NOW();

语义和 PG 类似——插入时若触发任何 UNIQUE/PRIMARY KEY 冲突,就执行 UPDATE。但有几个关键差异要注意:

1. 冲突判定是隐式的。 MySQL 不允许你指定"按哪一列判断",它自动匹配"导致冲突的那个唯一键"。如果表上有多个唯一键,可能撞哪个就走哪个。这是 MySQL 这套语法最大的设计缺陷——行为不确定。PG 的 ON CONFLICT (col) 是显式指定的,确定性强。后文陷阱一节会展开。

2. 引用新值用 VALUES() 函数(MySQL 8.0.19 之前)或 AS 新行名(8.0.20+)。 这也是和 PG 的 EXCLUDED 对应的概念,但写法曲折:

sql
-- MySQL 8.0.19 及之前:用 VALUES() 函数引用新插入的值
INSERT INTO config (key, value) VALUES ('timeout', '30s')
ON DUPLICATE KEY UPDATE value = VALUES(value);   -- 用新值覆盖

-- MySQL 8.0.20+:VALUES() 函数被废弃,推荐用别名
INSERT INTO config (key, value) VALUES ('timeout', '30s') AS new
ON DUPLICATE KEY UPDATE value = new.value;

这里有个大坑:MySQL 8.0.20 把 VALUES() 函数标记为废弃,8.0.21 开始在某些场景会告警。如果你的项目还在用老写法 col = VALUES(col),升级 MySQL 版本时要留意。新项目直接用 AS new 别名语法,更清晰也对齐了 PG 的 EXCLUDED 思路。

3. 默认会触发 INSERT 的所有副作用。 即使走了 UPDATE 分支,MySQL 仍可能触发 BEFORE/AFTER INSERT 触发器(再走 UPDATE 触发器),auto_increment 计数器在某些版本也会推进——这会导致 ID 跳号。这是 MySQL 实现的怪癖,PG 不会有这种行为。

五、幂等场景:为什么 UPSERT 是重试的救星

UPSERT 最值钱的地方不是少写两行代码,而是它让"重试"变得安全。

分布式系统里"失败重试"是家常便饭:HTTP 请求超时了重试一次、消息队列消费失败重新投递、ETL 任务跑挂了重跑、定时任务被误触发了两次。重试的前提是操作幂等——执行一次和执行十次,最终状态一致。否则重试就等于"重复扣款/重复下单/重复发消息"。

UPSERT 天然提供行级幂等。典型场景:

场景一:数据同步(外部系统 → 我方库)。每天把上游的用户表同步过来。同一个 user_id 同步十次,结果应该和同步一次一样:

sql
INSERT INTO users (id, name, email, synced_at)
VALUES (:id, :name, :email, now())
ON CONFLICT (id) DO UPDATE SET
    name = EXCLUDED.name,
    email = EXCLUDED.email,
    synced_at = now();

全量同步任务重跑多少次都安全——不存在则插入、存在则用最新值覆盖。比"先 DELETE 再 INSERT"安全得多(DELETE 后崩溃会丢数据),比"SELECT 后判断"高效得多(一条 SQL 搞定)。

场景二:webhook / 消息消费的幂等键。Stripe、GitHub、企业微信回调都可能重复投递同一个事件。给每个事件一个唯一 event_id,入库时 ON CONFLICT DO NOTHING:

sql
INSERT INTO received_events (event_id, source, payload, created_at)
VALUES (:event_id, :source, :payload, now())
ON CONFLICT (event_id) DO NOTHING;
-- 应用层看影响行数:1 表示首次,0 表示重复 → 重复直接跳过

这是处理"至少一次投递"消息系统的标准范式。比在应用层用 Redis SETNX 做去重更持久(Redis 重启会丢)、也更不容易出竞态。

场景三:ETL / 批处理的断点续跑。一个跑了一半挂掉的导入任务,重跑时不能重复插入已经导过的行。按业务主键 UPSERT,重跑自然就是覆盖,不用手动维护"已处理到哪"的 checkpoint。

场景四:计数器与统计聚合。每分钟聚合一次埋点数据写进统计表,由于上游可能重放,用 UPSERT 保证多次聚合结果一致:

sql
INSERT INTO daily_stats (stat_date, metric, value)
VALUES ('2026-07-22', 'login_count', 1234)
ON CONFLICT (stat_date, metric) DO UPDATE SET
    value = EXCLUDED.value;   -- 用最新全量值覆盖,而非累加

注意这里用"覆盖"还是"累加"取决于上游语义:如果上游每次都送全量值(最终态),就覆盖;如果上游送增量,就累加(value = daily_stats.value + EXCLUDED.value)。UPSERT 本身不保证幂等,保证幂等的是"你怎么用 EXCLUDED"。累加式 UPSERT 重跑会重复累加——这不叫幂等,叫 bug。

一句话总结这一节:凡是需要"可重试"的写入,第一反应就该是 UPSERT,而不是 SELECT-then-INSERT。前者把幂等性下沉到数据库,后者把竞态风险留在应用层。

六、ON CONFLICT 的约束来源:UNIQUE、PK 与部分索引

PG 的 ON CONFLICT 不仅仅能指定列名,还能利用一些不那么显然的约束形式。理解它的"约束匹配"规则,能解锁更高级的用法。

形式一:直接指定列。最常见,要求该列上有 UNIQUE 或 PRIMARY KEY:

sql
ON CONFLICT (user_id)

形式二:指定约束名。直接点名某个具体约束,更明确:

sql
ON CONFLICT ON CONSTRAINT users_email_key DO UPDATE SET ...

这在你表上有多个唯一约束、想精确控制走哪个时很有用。

形式三:指定复合列。多列唯一约束:

sql
CREATE TABLE favorites (user_id int, article_id int, PRIMARY KEY (user_id, article_id));

INSERT INTO favorites (user_id, article_id) VALUES (42, 7)
ON CONFLICT (user_id, article_id) DO NOTHING;

注意 ON CONFLICT (a, b) 必须匹配一个 (a, b) 的唯一约束——它不是"按 a 或 b 冲突",而是"按 (a, b) 组合冲突"。

形式四:部分索引(Partial Index),这是最容易被忽略的杀手锏。 PG 允许在带 WHERE 的部分索引上做 ON CONFLICT:

sql
-- 每个 user 只能有一条"激活状态"的订阅,但允许有多条历史记录
CREATE UNIQUE INDEX active_subscription_unique
ON subscriptions (user_id)
WHERE status = 'active';

INSERT INTO subscriptions (user_id, status, plan) VALUES (42, 'active', 'pro')
ON CONFLICT (user_id) WHERE status = 'active'
DO UPDATE SET plan = EXCLUDED.plan, updated_at = now();

这个写法非常强大:它只对 status = 'active' 的行强制唯一性,而其他状态的行互不影响。UPSERT 时也只匹配这个部分索引。实现"软唯一约束"(带条件的唯一)时这是唯一干净的方案,比在应用层加锁优雅一百倍。

踩坑点:ON CONFLICT 的目标必须与某个索引/约束完全匹配,包括 WHERE 子句。 你不能对一个 (user_id) WHERE status='active' 的部分索引写 ON CONFLICT (user_id)(缺 WHERE)——PG 会报错说找不到匹配的约束。必须写 ON CONFLICT (user_id) WHERE status = 'active',逐字对齐。

如果你不确定某个 UPSERT 走的是哪个索引,用 EXPLAIN 看执行计划,里面会出现 On Conflict 节点和它选用的冲突仲裁索引,一目了然。

七、陷阱:多列冲突、RETURNING 判定与并发锁行为

UPSERT 看起来简单,但有几个陷阱不踩过不知道疼。这一节是全文最值钱的部分。

陷阱一:MySQL 多唯一键,冲突行为不确定

PG 的 ON CONFLICT (col) 显式指定了冲突列,确定性很强。MySQL 的 ON DUPLICATE KEY UPDATE 是隐式的——它找"第一个命中的唯一键"。如果表上同时有 PRIMARY KEY 和另一个 UNIQUE KEY,而你的 INSERT 数据同时违反了两个约束,MySQL 只会按它内部判定的某一个走 UPDATE,另一个被"顺便"忽略。

sql
CREATE TABLE t (
  id INT PRIMARY KEY,
  email VARCHAR(100) UNIQUE,
  name VARCHAR(100)
);

-- 假设已有 id=1, email='[email protected]'
INSERT INTO t (id, email, name) VALUES (1, '[email protected]', 'Bob')
ON DUPLICATE KEY UPDATE name = 'Bob';
-- 主键和 email 都冲突,MySQL 只走其中一个的 UPDATE 分支

这种"两个键同时冲突"的情况下,你的 UPDATE 可能基于"主键冲突"也可能基于"email 冲突",业务语义会变得模糊。工程上的对策:每个表只留一个"语义权威"的唯一键用于 UPSERT,其他唯一键如果非加不可,确保业务上不会同时命中。或者直接换 PG,把冲突列写明白。

陷阱二:RETURNING 判断是 INSERT 还是 UPDATE

RETURNING 子句在 UPSERT 后返回数据很方便,但它默认不告诉你这次是插入还是更新:

sql
INSERT INTO user_stats (user_id, login_count) VALUES (42, 1)
ON CONFLICT (user_id) DO UPDATE SET login_count = user_stats.login_count + 1
RETURNING user_id, login_count;
-- 不管是插入还是更新,都返回一行,你看不出是哪种

应用层经常需要知道"这次到底是新增还是更新"(比如决定要不要发"新用户注册"通知)。PG 给了一个非常优雅的解法——xmax 系统列:

sql
INSERT INTO user_stats (user_id, login_count) VALUES (42, 1)
ON CONFLICT (user_id) DO UPDATE SET
    login_count = user_stats.login_count + 1,
    updated_at = now()
RETURNING
    user_id,
    (xmax = 0) AS inserted;   -- true = 这次是 INSERT,false = 是 UPDATE

原理:xmax 是 PG 内部的事务 ID 字段。新插入的行 xmax = 0(还没被任何事务修改过),而被 UPDATE 命中的旧行 xmax 是发起更新的当前事务 ID(非 0)。所以 xmax = 0 能精确判定"是新插入"。这是 PG 圈广泛使用的惯用法,记下来能省一堆判断逻辑。

MySQL 没有 RETURNING(8.0.21+ 才有,且功能受限),判断插入还是更新要靠应用层看 affected_rows——MySQL 的 affected_rows 在 UPSERT 里有特殊语义:实际 INSERT 算 1,实际 UPDATE 算 2(不是 1),未变更的 UPDATE 算 0。这个"UPDATE = 2"的计数是历史遗留,靠它判断也很反直觉,慎用。

陷阱三:并发 UPSERT 的锁与死锁

UPSERT 本身是原子的,但并发执行多条 UPSERT 仍可能死锁。典型场景:两个事务同时 UPSERT 同一批行的不同子集,且顺序不同。

sql
-- 事务 A
INSERT INTO t (id, val) VALUES (1, 'a'), (2, 'b') ON CONFLICT (id) DO UPDATE SET val = EXCLUDED.val;
-- 事务 B
INSERT INTO t (id, val) VALUES (2, 'x'), (1, 'y') ON CONFLICT (id) DO UPDATE SET val = EXCLUDED.val;

A 先锁 id=1 再锁 id=2,B 先锁 id=2 再锁 id=1——经典死锁。PG 会在检测到死锁时 abort 其中一个事务(deadlock detected)。对策很简单:批量 UPSERT 时按冲突列排序,保证所有事务加锁顺序一致:

sql
-- 应用层拼接前先按 id 排好序
INSERT INTO t (id, val) VALUES (1, ...), (2, ...) ON CONFLICT (id) DO UPDATE SET ...;

另外 PG 的 UPSERT 在冲突时需要先"插入"再"发现冲突",然后用".speculative insertion"机制处理——它会先尝试插入一个带投机标记的行版本,发现没冲突才正式提交,有冲突则回退投机插入转去做 UPDATE。这套机制保证了 UPSERT 在高并发下不会因为竞态漏掉冲突,但代价是比单纯 INSERT 慢一些。如果你的场景里"绝大多数都是新增",UPSERT 的开销会略高于裸 INSERT——这是用通用性换安全性。

实务建议:对热点行(被高频 UPSERT 的同一行),考虑用应用层排队或 SELECT FOR UPDATE 显式串行化,比让数据库反复处理 speculative insertion 高效。对冷数据批量同步,UPSERT 是最佳选择。

八、实战:批量 UPSERT,ETL 的瑞士军刀

单行 UPSERT 是基础,真正在生产里刷数据的是批量 UPSERT——一条语句处理几百上千行,是 ETL 和数据同步的主力武器。

PG 批量 UPSERT

sql
INSERT INTO products (sku, name, price, stock, updated_at)
VALUES
    ('A001', 'Mouse', 29.9, 100, now()),
    ('A002', 'Keyboard', 99.0, 50, now()),
    ('A003', 'Monitor', 1299.0, 20, now()),
    ('A004', 'Webcam', 199.0, 0, now())
ON CONFLICT (sku) DO UPDATE SET
    name  = EXCLUDED.name,
    price = EXCLUDED.price,
    stock = EXCLUDED.stock,
    updated_at = EXCLUDED.updated_at
WHERE products.price IS DISTINCT FROM EXCLUDED.price
   OR products.stock IS DISTINCT FROM EXCLUDED.stock
   OR products.name IS DISTINCT FROM EXCLUDED.name;

这里有两个进阶技巧值得讲:

技巧一:EXCLUDED 在批量场景下自动对应每一行。 每条 VALUES 里的数据各自和它触发的旧行配对,不需要你操心。10 条数据进来,有 3 条是新增、7 条是更新,PG 自动分别处理,一条语句搞定。

技巧二:WHERE 子句实现"有变化才更新"。 默认情况下,DO UPDATE 会对所有冲突行执行更新,哪怕新旧值一模一样。这会带来副作用:updated_at 被刷新、触发器被触发、WAL 日志被写、逻辑复制把这条"无变化的更新"同步到从库。加一个 WHERE products.列 IS DISTINCT FROM EXCLUDED.列 条件,只有真正变了才更新。IS DISTINCT FROM 比 <> 更安全——它把 NULL 也参与比较(NULL <> NULL 是 NULL,会漏判;NULL IS DISTINCT FROM NULL 是 false,正确)。

这个小优化在大表上收益巨大。我接手过一个同步任务,每天全量 UPSERT 50 万行,加上 WHERE ... IS DISTINCT FROM 之后,实际更新的行数从 50 万降到平均 2000(绝大多数数据没变化),执行时间从 8 分钟掉到 30 秒,从库的延迟也消失了。

MySQL 批量 UPSERT

sql
INSERT INTO products (sku, name, price, stock, updated_at)
VALUES
    ('A001', 'Mouse', 29.9, 100, NOW()),
    ('A002', 'Keyboard', 99.0, 50, NOW())
ON DUPLICATE KEY UPDATE
    name  = VALUES(name),
    price = VALUES(price),
    stock = VALUES(stock),
    updated_at = NOW();

MySQL 不支持 WHERE 过滤"无变化不更新",所以每次冲突都会真改一次——这是 MySQL 这套语法的又一个硬伤。变通方案是用触发器或在应用层比对,都不优雅。如果这个开销对你有影响,认真考虑迁到 PG。

批量分批与事务边界

实战中不要把几万行塞进一条 UPSERT。单条 SQL 太大会撑爆 max_allowed_packet(MySQL)或导致单事务过长、锁持有时间过长(PG)。推荐分批,每批 500-2000 行:

python
# 伪代码
BATCH = 1000
for i in range(0, len(rows), BATCH):
    chunk = rows[i:i+BATCH]
    db.execute(build_upsert_sql(chunk))

是否每批一个事务看场景:单批失败要回滚整批,用事务;允许部分成功(幂等重跑会补上),可以不开事务。对幂等 UPSERT 而言,我倾向于不开大事务,分批裸执行——反正重跑能补,单批失败重试那一批即可,避免长事务持锁。

ORM 里的 UPSERT

大多数 ORM 都封装了 UPSERT,但接口参差:

  • Prisma:prisma.user.upsert({ where: { id }, create: {...}, update: {...} }),注意它单条 upsert 内部是先 SELECT 再 INSERT/UPDATE,不是原生的 ON CONFLICT,并发和性能都比原生 SQL 差。批量场景请用 $executeRaw 直接写 SQL。
  • SQLAlchemy:session.sync_insert(...) 或 dialect.insert(...).on_conflict_do_update(),能生成原生 ON CONFLICT。
  • TypeORM:repository.upsert(...)(较新版本)走原生语法。

一个原则:单条、低频、不在意性能的 UPSERT 用 ORM 方法即可;批量、高频、对性能敏感的场景,直接裸写 SQL。UPSERT 的价值很大程度在于 SQL 层面的原子性,套一层 ORM 反而稀释了它。

小结

  1. UPSERT = Insert + Update 的原子合一:一条语句完成"不存在则插入、存在则更新",消除了 SELECT-then-INSERT 的竞态和多往返,是处理可重试写入的首选
  2. PG 的 ON CONFLICT 比 MySQL 的 ON DUPLICATE KEY 设计更干净:PG 显式指定冲突列,确定性高,还支持部分索引;MySQL 隐式判定多唯一键行为不确定,且不支持"无变化不更新"
  3. EXCLUDED(PG)/ VALUES()(MySQL)是引用新值的关键:累加用 table.col + EXCLUDED.col,覆盖用 EXCLUDED.col;DO NOTHING 是真正的静默忽略,不触发触发器、不写 WAL,最适合幂等消费
  4. 幂等是设计出来的,不是 UPSERT 白送的:覆盖式 UPSERT 重跑安全,累加式 UPSERT 重跑会重复累加——选错语义,UPSERT 反而帮倒忙
  5. 注意三大陷阱:MySQL 多唯一键行为不定、用 (xmax = 0) 在 RETURNING 里判断插入还是更新、批量 UPSERT 按冲突列排序规避死锁

一句话总结:SELECT 判断再决定 INSERT/UPDATE,是把竞态风险留给自己;UPSERT,是把原子性交给数据库。能用一条 SQL 解决的"有就改、没有就插",就别拆成三条。

Related

相关推荐