最近在跟进 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 ('alice@example.com', 'Alice') ON CONFLICT (email) DO NOTHING;这条语句的作用是:如果 email 已经存在,什么都不做。另一种写法:
INSERT INTO users (email, name) VALUES ('alice@example.com', 'Alice') ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name;冲突时把 name 更新为本次想要写入的值。这两个分支解决了很多问题,但有一个非常常见的需求它们都覆盖不到:冲突的时候,我不想改数据,我只想把当前已经存在的那条记录原样返回给客户端。
很多人会说你用 RETURNING 不就行了?但 RETRUNING 只在语句真正插入了一行时才有输出。如果走了 DO NOTHING,RETURNING 返回空结果;如果走 DO UPDATE,RETURNING 返回的是更新后的行,但数据已经被改过了。换句话说,目前没有任何原生语法能表达“冲突时给我查出旧数据,但别动它”这个动作。
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 CONFLICT:
INSERT INTO users (email, name) VALUES ('alice@example.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 ('alice@example.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 ('alice@example.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 ('alice@example.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 = 'alice@example.com' ), inserted AS ( INSERT INTO users (email, name) SELECT 'alice@example.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 ('alice@example.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 正式发布时,你就能第一时间判断这个新特性到底值不值得用,而不必跟着社区热度走。