ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PostgreSQL v19 新特性解读:INSERT ON CONFLICT DO SELECT 与 UPSERT 语义补全

PostgreSQL v19 新特性解读:INSERT ON CONFLICT DO SELECT 与 UPSERT 语义补全 最近在跟进 PostgreSQL 新版本动态时我用 DeepSeek 把社区里零零散散的讨论梳理了一遍最值得展开聊的一条是 v19 的 INSERT ... ON CONFLICT ... DO SELECT。刚开始我也以为这只是 UPSERT 语法多了一个分支后来把邮件列表、commitfest 议题和几个开发者的思路串起来看才发现它真正要补的是数据库写入语义里一个一直没有正规化的场景冲突发生时我只想安全地把现状读回来而不是修改任何数据。这个特性如果在 v19 落地对做幂等接口、数据导入、轻量锁业务的人来说可以少写不少应用层代码。先说清楚时间线PostgreSQL 保持每年一个大版本的节奏v19 预计在 2026 年 9 月左右正式发布。现在你在很多地方看到的“v19 新特性”往往还处于提案和设计阶段语法细节没有定稿。所以这篇文章里出现的 SQL 都属于基于当前社区思路的推演示例只用来帮你理解语义真正上线前一定要以官方 release notes 和文档为准。适合的读者包括被 UPSERT 的返回值问题折腾过的后端工程师、需要处理唯一键冲突时读回数据的 DBA以及所有对数据库语法演进感兴趣的人。1. 为什么需要 DO SELECT从 UPSERT 的语义缺口说起1.1 先复习一下 ON CONFLICT 现在能做什么PostgreSQL 从 9.5 开始支持 ON CONFLICT也就是大家常说的 UPSERT。它的基本语法是给 INSERT 语句挂一个冲突处理动作目前只有两个分支DO NOTHING 和 DO UPDATE。DO NOTHING 的含义是当违反唯一约束时直接跳过这条插入不修改任何数据DO UPDATE 则是把冲突的那一行找出来执行更新。比如一张用户表 email 字段有唯一索引INSERT INTO users (email, name) VALUES (aliceexample.com, Alice) ON CONFLICT (email) DO NOTHING;这条语句的作用是如果 email 已经存在什么都不做。另一种写法INSERT INTO users (email, name) VALUES (aliceexample.com, Alice) ON CONFLICT (email) DO UPDATE SET name EXCLUDED.name;冲突时把 name 更新为本次想要写入的值。这两个分支解决了很多问题但有一个非常常见的需求它们都覆盖不到冲突的时候我不想改数据我只想把当前已经存在的那条记录原样返回给客户端。很多人会说你用 RETURNING 不就行了但 RETRUNING 只在语句真正插入了一行时才有输出。如果走了 DO NOTHINGRETURNING 返回空结果如果走 DO UPDATERETURNING 返回的是更新后的行但数据已经被改过了。换句话说目前没有任何原生语法能表达“冲突时给我查出旧数据但别动它”这个动作。1.2 冲突时我只想把那条老数据拿出来动手写几个业务场景你会发现这个需求其实特别常见。最典型的是用户注册客户端提交一个邮箱服务端希望如果这个邮箱已经注册过就直接把已有用户信息返回而不是报错。用现在的语法写逻辑通常是先 SELECT 一次再 INSERT再根据结果决定要不要 SECOND SELECT。三步操作在两个事务里中间一旦有并发结果就是不可靠的。更麻烦的是幂等 API。支付回调、消息重试、Webhook 推送这类接口天然要求同一个请求重复提交时返回一致的结果。假如你用一个业务单据号做唯一键第一次插入成功第二次再提交时你收到的应该还是第一次那条记录。用现有语法怎么实现先 INSERT ... ON CONFLICT DO NOTHING然后如果返回行数为 0再发一条 SELECT 去查旧数据。这个方案能跑但存在两个问题第一是两条语句之间数据可能被其他事务改掉除非你在同一事务里并加锁第二是无论如何都会多一次网络往返。在 API 响应时间敏感的场景里这一步是不必要的开销。DO SELECT 的想象语义就是这样一个动作命中唯一约束冲突时不作任何写入而是执行一段查询并把结果返回给调用方。语法落地之后上面所有场景都能收敛成一条语句应用层不再需要自己处理“插入失败后查回来”这个分支。1.3 这个缺口已经存在很多年了从数据库设计者的角度看这个缺口其实一直存在。SQL 标准里的 MERGE 语句能处理“存在则更新、不存在则插入”同样没有“存在则返回、不存在则插入”的原生表达。PostgreSQL 的 INSERT ON CONFLICT 已经是各种数据库里 UPSERT 语法中比较好用的了但它的思想仍然是“冲突后要做出一个写动作”DO NOTHING 只是写动作的退化形态。换句话说当前模型里没有“只在少数情况下读数据”这个选项这导致使用者不得不在一堆变通手段里选一个先 SELECT 再 INSERT然后根据结果再 SELECT缺点是需要额外加锁或者接受竞态。用 DO UPDATE 做无变化更新缺点是会产生 WAL、变更版本号、触发表上的 UPDATE 触发器。用 DO NOTHING 配合外层 CTE 做兜底查询缺点是可读性差、执行计划不稳定。这三种方案我都实际试过最后每一种都因为额外的锁、日志或者逻辑复杂度付出过代价。这也是为什么一看到 v19 讨论里提到 DO SELECT我立刻觉得这是补在正确位置的语法糖。2. DO SELECT 的语法猜想与设计取舍2.1 社区里流传的几种写法推演由于 v19 还在开发期官方文档里没有最终语法社区里对 DO SELECT 的写法主要有几种设想。第一种最直观把它当作一个独立的动作分支写进 ON CONFLICTINSERT INTO users (email, name) VALUES (aliceexample.com, Alice) ON CONFLICT (email) DO SELECT * FROM users WHERE users.email EXCLUDED.email;这种写法和 DO UPDATE 的结构很对称DO SELECT 后面接一条可以引用 EXCLUDED 的查询EXCLUDED 代表本次原本想要插入但被冲突拦下的那组值。冲突时执行这条 SELECT把目标表里的旧行读出来。第二种设想更激进一点复用 RETURNING 的写法让冲突分支直接输出结果INSERT INTO users (email, name) VALUES (aliceexample.com, Alice) ON CONFLICT (email) DO SELECT RETURNING id, email, name, created_at;这种写法的好处是简洁但坏处是语义上和现有 RETRUNING 容易混淆RETURNING 本来作用于 INSERT 成功后的新行如果它在冲突分支里也代表“返回冲突行”就需要执行器做特殊处理。第三种设想是两套语法同时支持DO SELECT 负责产生记录集RETURNING 负责控制投影字段INSERT INTO users (email, name) VALUES (aliceexample.com, Alice) ON CONFLICT (email) DO SELECT * FROM users WHERE users.email EXCLUDED.email RETURNING id, email;不管最后官方选哪种核心思想是一致的冲突分支不再是空操作或者更新操作而是变成一个查询操作。从语法推演的角度看我认为第一种和第二种会打架因为 PG 现有语法里 RETURNING 只能出现在 INSERT 末尾一次如果再让 DO SELECT 后面也能跟 RETURNING会造成一个语句里有两个 RETURNING 的动作点解析器处理起来比较麻烦。社区更可能采取第一种结构让 DO SELECT 自带结果集再和外层 RETURNING 合并输出。2.2 和 RETURNING 的关系怎么理这里有一个必须解决的设计难题INSERT 成功分支和冲突分支返回的行结构可能不一样。假设你写的是INSERT INTO users (email, name, age) VALUES (aliceexample.com, Alice, 30) ON CONFLICT (email) DO SELECT id, email, created_at FROM users WHERE users.email EXCLUDED.email;成功分支里你可以直接 RETURNING id, email, created_at冲突分支的 SELECT 也是 id、email、created_at二者结构一致客户端处理起来很舒服。但如果成功分支想把 age 也返回而冲突分支不想返回 age整个语句的结果集列数就不确定了。设计者必须明确是让两个分支的返回列一致强制用户把 SELECT 和 RETURNING 写成相同投影还是在解析时做动态列合并允许两边的字段不同靠别名对齐。从 PG 一贯的强类型风格推断它更可能要求两个分支的投影保持一致。原因是 CURRENT 版本的 INSERT RETURNING 已经确定了结果集类型执行器要为整条语句准备目标元组描述符如果冲突分支返回的列结构不同这个描述符就没法静态确定。所以如果你真的在 v19 里用 DO SELECT大概率会遇到“冲突分支的列必须和 INSERT 的 RETURNING 列一致”这样的限制。这个限制看起来有点死板但实际用起来反而简单你在设计时就把需要返回的字段定好成功和失败走同一套字段列表客户端不用写两套解析逻辑。2.3 为什么不能用 DO NOTHING RETURNING 凑合有人可能会说我们已经有 DO NOTHING 了冲突时不改数据再给它加个 RETURNING让冲突时返回旧行不就是 DO SELECT 吗理论上可以但语义上会产生一个很大的歧义RETURNING 现在同时要负责“返回新插入的行”和“返回冲突时被无视的行”一条 INSERT 正常执行时到底走哪种分支应用层只能靠返回行数去猜。行数为 1 可能是插入了新行也可能是冲突后返回了旧行客户端完全分不清。而独立成 DO SELECT 之后语义是清楚的成功分支返回新行冲突分支返回旧行。用户要求的是两种不同的数据系统必须给出明确的区分方式。也许是返回一个 status 标记列也许是依靠 DO SELECT 和 RETURNING 的输出节点分开处理。总之一旦把“读回旧行”提升为一等公民执行器就可以在两套结果路径上做正确的类型匹配和输出处理而不需要在 DO NOTHING 这个模糊地带里打补丁。这也是我觉得这个特性值得等 v19 而不是自己写变通代码的原因它不是一个简单的语法糖而是对执行器输出语义的一次补全。3. 落到业务上能带来哪些收益3.1 幂等 API 可以直接少两行代码我手头维护的一个支付回调服务接口幂等逻辑用的是“插入订单号唯一键冲突则返回已有订单”的模式。现状是写两轮先 INSERT ... ON CONFLICT DO NOTHING如果影响行数是 0再 SELECT 查一次。每次回调多一个查询高峰期这个查询占了数据库读 QPS 的 5% 左右。DO SELECT 落地后这段逻辑可以改成一条语句INSERT INTO callback_logs (order_no, payload, status) VALUES ($1, $2, processing) ON CONFLICT (order_no) DO SELECT id, order_no, status, created_at FROM callback_logs WHERE callback_logs.order_no EXCLUDED.order_no;对应用层来说返回值就是这次回调最终应该看到的记录状态。重复投递时不再有“请求中间又有一次查询”的窗口也不用在事务外做二次条件判断实现。实际写过这类接口的人都知道最难受的不是多一条 SQL而是“两条 SQL 之间数据被修改了怎么办”。你把 INSERT 和 SELECT 分在两个请求里就必须在上游加分布式锁或数据库事务。放在一条语句里如果 DO SELECT 读的是当前语句快照它天然避开了一个请求拆成两步时的竞态窗口整个 API 的幂等强度会上一个台阶。3.2 批量写入去重不再需要二次查询批量导入是另一个受益场景。假设你要导入一批用户且希望重复邮箱不要报错已有的保留没有的插入。现在如果用 INSERT ... ON CONFLICT DO NOTHING导入方无法从返回值里区分哪些是新插入的、哪些是重复的只能事后用时间戳或者其他字段再对一遍。更常见的做法是 DO UPDATE 把已有记录更新一遍这样至少能拿到 RETURNING 结果但代价是每次重复导入都会刷一遍大表的索引和行版本。DO SELECT 的语义在这里很有价值它可以支持“导入时遇到重复数据直接读回现有行不触发任何写路径”的处理逻辑。批量导入阶段关心的本来就不是修改旧数据而是确认“这条数据当前长什么样”。在数据校验、数据迁移、ETL 管道里这种处理方式能显著减少无意义的写放大。特别是表里还有大量二级索引的场景DO UPDATE 会导致每个索引都跟着刷一遍DO SELECT 完全绕开了这些工作成本只发生在冲突检测那一次唯一索引查找上。3.3 对写放大和 WAL 的影响写放大是数据库里一个很容易被低估的隐藏成本。一条 UPDATE 即使只改一个字段也会触发整行新版本写入并更新所有相关的二级索引项同时产生对应的 WAL 记录然后这些 WAL 会被同步到备机继续触发流复制延迟。DO NOTHING 不走写路径但因为没有返回值用途有限。DO UPDATE 无变化更新是一种常见的节流手段然而它实际上仍然会创建一个新的元组版本只是因为列值相同MVCC 层面看起来变化不大但 WAL、索引维护、vacuum 负担一样不少。DO SELECT 如果落地冲突路径上应该是纯读操作唯一索引检查发现冲突然后根据冲突元组的位置去表里取数据整个路径不产生 WAL不被流复制传播不需要修改任何索引页。对于只读为主、偶发写入冲突的高并发接口这是立竿见影的性能优化方向。不过要提醒一句这建立在“冲突分支不做任何写动作”的设计前提下如果未来 PG 为了容错在冲突路径上加一些临时锁或事务状态变更这部分收益会打折扣。以 PG 一贯保守的作风大概率会把 DO SELECT 实现成尽量轻量的读路径。特性数据是否变化是否产生WAL返回内容典型用途DO NOTHING无无空静默去重DO UPDATE有有更新后的行存在则更新DO SELECT无无冲突前的旧行存在则返回3.4 并发场景下的行为变化并发写入唯一键冲突时PostgreSQL 的行为有它自己的脾气。在 READ COMMITTED 隔离级别下一个事务插入时发现唯一索引被另一个未提交事务占用会等对方提交或回滚然后重新评估。DO SELECT 也会继承这个行为冲突检测阶段不会因为你想读就跳过锁等待它同样需要等那个持有锁的事务出结果。也就是说DO SELECT 不会让你在有真正写冲突时避免等待它省的是冲突发生后的二次查询以及无意义的索引维护。还有一点值得注意如果 DO SELECT 实现为“读到冲突行”它拿到的是当前已经提交的数据版本还是最近被修改但未提交的版本按 MVCC 规则它只能看到已提交版本。这在并发场景下是安全的但也意味着如果你在事务里先插入一条记录随后在同一个事务里再次执行同一条 DO SELECT 语句可能读不到自己刚插入的那条行——因为它在另一个未提交的事务快照里。这个细节如果你要在 v19 上做“事务内重复调用幂等接口”一定要做一次真实的并发压测否则很容易出现逻辑判断错误。4. 在 v17/v18 上先体验“伪 DO SELECT”4.1 用 CTE 写一个原子化的先查后插在 v19 正式发布之前生产环境如果确实需要类似语义我们能做的最接近方案就是用数据修改 CTE 把两步拼成一条语句。思路是先把冲突数据查出来再执行插入最后对外输出一个合并结果。下面是一个可以工作的写法WITH existing AS ( SELECT id, email, name FROM users WHERE email aliceexample.com ), inserted AS ( INSERT INTO users (email, name) SELECT aliceexample.com, Alice WHERE NOT EXISTS (SELECT 1 FROM existing) RETURNING id, email, name ) SELECT id, email, name FROM existing UNION ALL SELECT id, email, name FROM inserted;这个写法在单条语句内完成了“查、插、返回”看起来和 DO SELECT 的效果很接近。但它有几个硬伤一开始的 SELECT 没有加锁并发下两个事务可能同时发现 existing 为空同时走到插入分支结果仍然撞唯一键。要真正解决并发问题你得加锁或将整个操作放进一个带锁的 CTE复杂度立刻上来了。所以它只适合并发要求不高的低冲突场景不能作为生产级幂等方案的替代。4.2 用 DO UPDATE 的“原地更新”把旧行读回来另一个我在项目里实际用过的土办法是把 DO UPDATE 写成“无变化更新”利用 RETURNING 拿回冲突行的当前值INSERT INTO users (email, name) VALUES (aliceexample.com, Alice) ON CONFLICT (email) DO UPDATE SET email EXCLUDED.email RETURNING id, email, name;因为 SET 语句把 email 设置成和原来一样数据内容没有变化但 PostgreSQL 仍然会走完整的 UPDATE 路径创建一个新的行版本、更新该行的索引项、写入 WAL、触发 UPDATE 触发器。如果表上有 NOTIFY 机制或者审计触发器你会惊讶地发现每次幂等请求都会触发一遍业务逻辑。在我的一个项目里这种写法导致了一个“订阅重复更新”的 bug排查到后面才发现是触发器在搞鬼。所以这个方法只能用来临时顶上上线前必须评估表上的触发器、外键、级联更新逻辑是不是你能接受的。如果你的表没有任何触发器访问也很低频它倒是一个少改应用代码的办法。但一旦数据量上来、写并发变高这种“假更新”会造成不必要的真空负担和流复制压力到时候再反过来改就很麻烦。4.3 模拟方案为什么还是差点意思上面这些现版本方案核心痛点在于它们都不是“读路径”而 DO SELECT 的本质是读路径。CTE 方案读的是旧快照更新开销低但原子性不够DO UPDATE 方案原子性够但更新开销高副作用多。鱼和熊掌不可兼得。另外模拟方案的执行计划不具备稳定性。CTE 方案里 PostgreSQL 优化器可能会根据统计信息调整 existing 和 inserted 的连接顺序导致查询计划在数据量变化后发生变化响应时间忽高忽低。DO UPDATE 方案的性能特征则受索引数量、触发器数量、当前事务大小影响很难做统一调优。DO SELECT 如果进入 v19执行器可以在冲突检测点直接返回旧行这个路径稳定、轻量、可预期这是模拟方案再怎么技巧高超都比不了的。5. 常见问题与踩坑记录5.1 v19 到底什么时候能用现在能装吗版本节奏上PostgreSQL 每个大版本大约在每年 9 月发布正式版v19 预计 2026 年 9 月。现在想尝试新特性只能编译 git master 上的开发代码或者等 beta 阶段再装。如果你是生产环境用户我强烈建议不要因为 DO SELECT 就去用开发版。新语法上线前可能调整语义开发版的数据文件格式也可能变化升级路径不稳。我见过不少人在新特性刚出 beta 时就在生产环境尝鲜结果遇到序列、索引、复制槽不兼容最后只能回滚恢复。想玩新特性的人优先准备一套独立的 PostgreSQL 测试环境最好是容器化随手能销毁重建。版本正式发布前所有网上看到的语法都可能改包括我今天这篇里推演的写法所以测试环境再乱都没关系生产环境必须稳。5.2 冲突时拿到的数据可能不是你以为的那条如果你在一个事务里先通过 ON CONFLICT DO UPDATE 修改了某行然后又用 DO SELECT 去读它你拿到的可能取决于快照时间点。DO SELECT 的设计意图是读已提交数据而你可能想要的是“当前事务修改后的最新值”。两者在简单场景下一样但一旦处于可重复读或串行化隔离级别事务快照在你第一条语句执行时就已经固定后续无法看到其他事务提交的新版本。这意味着DO SELECT 并不保证返回“物理上最新”的数据只保证按当前事务快照可见的那一条冲突行。做支付回调、库存扣减这种对数据新鲜度敏感的业务时你不能把“冲突后读到的行”和“系统当前真实状态”画等号。这个坑和现有 PG 的 MVCC 行为一脉相承只是 DO SELECT 让这个特性更容易被忽略。5.3 会不会引入新的死锁场景DO SELECT 理论上不更新数据所以相比 DO UPDATE锁冲突面更小。但它仍然要参与唯一索引冲突检测这个检测过程会获取索引元组上的锁等待其他事务完成提交或回滚。如果你的 INSERT 语句同时涉及多个唯一键且多行插入顺序不一致仍然可能与别的事务形成循环等待。举例来说事务 A 先插入 email 为 a 的行再插入 b事务 B 先插入 b 再插入 a两个事务的 DO SELECT 分支都要等待对方释放唯一索引锁数据库会检测到死锁并终结其中一个事务。要避免这个问题批量写入时尽量让多行之间的顺序全局一致或者把单次 INSERT ... VALUES 拆成一次一行执行减少锁持有范围。这些经验在现有版本里就有DO SELECT 并不会把它变成更大的问题但你要记住读路径不代表无锁路径。5.4 后续从哪里跟进这个特性的进展要判断 DO SELECT 能不能最终进入 v19最靠谱的渠道是 PostgreSQL 官方邮件列表和 CommitFest 页面。每年的一大堆提案会在 CommitFest 里经历“待定、提交、退回、重新提交”几轮任何宣称的“v19 新特性”最终必须出现在正式发布时的 release notes 里才算数。对于英文阅读没障碍的读者建议养成每周扫一遍 -hackers 邮件列表摘要的习惯不喜欢英文社区的也可以关注几个长期做 PG 新特性解析的中文博主版本发布前通常会有系统性介绍。我的做法是直接把 git master 源码拉下来每两周编译一次用git log看 launcher 和 executor 文件夹里有没有新增关于 ON CONFLICT 的提交记录。代码比任何二手介绍都准确。如果你想深入了解 DO SELECT 到底怎么实现搜索“insert on conflict”在哥大和 PG 学术论文里也有相关讨论可以从那里找到冲突检测路径的原始设计文档。最后再分享一点我个人在等这个特性期间的实际体会语法层面的小改进有时候比一堆性能参数调整更能改变代码质量。DO SELECT 这个特性发布与否其实不影响你今天能把业务写好但它确实能让一批原本要用“先查后插”或“假更新”来硬凑的接口设计变成一条干净明了的语句。如果你也在维护高并发写入的服务建议先把上面 CTE 和 DO UPDATE 两种模拟方案跑一遍把语义边界和性能特征摸清楚等 v19 正式发布时你就能第一时间判断这个新特性到底值不值得用而不必跟着社区热度走。
返回列表