ARTICLE DETAIL

资讯详情

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

PostgreSQL UPSERT新特性:INSERT ON CONFLICT DO SELECT 解析

PostgreSQL UPSERT新特性:INSERT ON CONFLICT DO SELECT 解析 最近整理数据库迁移方案翻到一份 DeepSeek 整理的 PostgreSQL v19 新特性清单。说实话版本号本身我持保留态度v19 还没到正式发布阶段网上流传的特性多半是草案和社区讨论。但清单里INSERT ... ON CONFLICT ... DO SELECT这个语法组合确实让我一下子来了兴趣。这个特性如果落地它解决的是我一直觉得别扭的问题做幂等写入时常常既要插入新数据又想在唯一键冲突时把已有的那行读出来。以前要拆成两条 SQL或者用 DO UPDATE 附带一次无意义的更新。DO SELECT 等于给 UPSERT 增加了一个只读分支直接把冲突行返回给调用方。对后端接口、消息队列消费、ETL 同步这类场景都很实用。下面的内容我会先梳理 ON CONFLICT 的演进再拆解 DO SELECT 的语法和执行原理然后用当前稳定版本能用的等价写法做验证。不管 PostgreSQL v19 最终是否原样收录这套思路都能帮你在日常开发里少踩几个坑。1. 从数据库 ON CONFLICT 家族说起的定位1.1 先快速回顾 ON CONFLICT 的过去要说清楚 DO SELECT得先看看 PostgreSQL 里INSERT ... ON CONFLICT这个家族是怎么一步步走过来的。早在 9.5 版本PostgreSQL 就正式支持了 upsert 语义核心是两条分支DO NOTHING和DO UPDATE。INSERT INTO users (email, display_name) VALUES (aliceexample.com, Alice) ON CONFLICT (email) DO NOTHING;DO NOTHING的意思很直白遇到唯一键冲突这一行就不插了整体语句静默完成不报错也不返回任何行。这在早期用来做“有就不管、没有就插入”的初始化脚本非常常见。DO UPDATE则更进一步冲突时执行一次更新操作配合EXCLUDED关键字可以引用本次尝试插入的值INSERT INTO users (email, display_name) VALUES (aliceexample.com, Alice Updated) ON CONFLICT (email) DO UPDATE SET display_name EXCLUDED.display_name RETURNING *;加上RETURNING *你就能拿到更新后的整行。很多后端同学把这个手法当万金油用但它有个隐藏问题它一定会产生写操作即使业务逻辑其实只是想知道“现在这行已经是什么样了”。1.2 冲突后“想查又不想改”的痛点我最早遇到这个痛点是在做支付回调对账的时候。回调接口收到一条消息先得确认业务单据在不在。如果单号已经存在我需要立刻知道它的当前状态然后决定是补发通知还是当作重复请求丢弃。代码上通常这么写SELECT * FROM payment_orders WHERE order_no PO-20250101-001; -- 查不到再 INSERT但“先查再插”在高并发下有个天然的竞态窗口两个请求同时发现查不到同时去插结果一个成功后另一个撞了唯一约束。要处理这个异常你还得在应用层写重试逻辑。于是很多老手改成了这样WITH ins AS ( INSERT INTO payment_orders (order_no, status, amount) VALUES (PO-20250101-001, PROCESSING, 100) ON CONFLICT (order_no) DO UPDATE SET order_no EXCLUDED.order_no RETURNING * ) SELECT * FROM ins;这条 SQL 能保证线程安全但它有一个令人抓狂的副作用为了拿到已有行我被迫执行了一次“什么也没改”的更新。DO UPDATE 哪怕设置的列值和原来一样在 MVCC 体系下也会产生新版本的元组带来写放大甚至把明明没变化的updated_at搞得一团糟。所以社区里一直有人呼吁能不能在冲突时直接走一条只读路径把冲突行选出来给我就行不要动不动就写一遍。1.3 DO SELECT 与 DO NOTHING / DO UPDATE 的对比按我对草案的理解DO SELECT 不是替代另外两个分支而是补上它们之间的空白。下面这张表是我自己整理的对比等 v19 正式文档出来之后大概率也是这个方向分支语法冲突时行为是否产生写操作能否拿到已有行典型用途DO NOTHING静默跳过不返回行否否初始化灌数据、幂等占位DO UPDATE执行 UPDATE 并可通过 RETURNING 返回是可以但是更新后的行覆盖写入、计数器累加DO SELECT执行 SELECT 并返回冲突行否是幂等查询、冲突审计、状态获取打个比方DO NOTHING 是“看到柜子里有人我就不放东西了”DO UPDATE 是“不管里面是谁我把这格东西替换掉”DO SELECT 则是“我看到里面有人我不动它但我把里面那人的照片拍给你看”。这个分支一旦出现很多场景真的可以告别“先查后插”或者“空更新”。2. 语法细节与执行原理2.1 DO SELECT 的预期语法长什么样既然 v19 还没发布我只能按社区讨论稿和一般的语法设计习惯来推演。预期形态可能长这样INSERT INTO target_table (col1, col2, ...) VALUES (val1, val2, ...) ON CONFLICT (conflict_target) DO SELECT select_list [ WHERE condition ];conflict_target还是老规矩可以写列名也可以用ON CONSTRAINT constraint_name指定约束名。select_list可以写*也可以只挑你关心的列。后面甚至可以跟一个WHERE意味着不是所有冲突都要返回只有满足条件时才走这个只读分支否则等同于DO NOTHING。举个例子假设我们有一个库存预约表要求同一商品同一批次只能有一条生效记录INSERT INTO stock_reservations (product_id, batch_no, qty, status) VALUES (1001, B20250201, 5, ACTIVE) ON CONFLICT (product_id, batch_no) DO SELECT id, qty, status WHERE status ACTIVE;这样的好处很明显我想知道有没有活跃的预约记录如果有直接把它的数量、状态拿回来如果没有冲突正常插入。整个操作一条 SQL 搞定不需要先 SELECT 再 INSERT也不需要在应用层拼两个请求。2.2 语句执行时内部到底发生了什么从执行原理的角度看DO SELECT 的判断流程应该是这样的PostgreSQL 先尝试插入新行走常规的索引查找和唯一性检查。如果发现了冲突不会真正执行写入而是沿着冲突的索引或者约束定位到那条已经存在的行。按照select_list指定的列对这条已有行做一次投影计算然后返回给客户端。如果不满足 WHERE 条件则直接返回空结果集但语句整体不报错。这个流程里最关键的一点是冲突行本身不会产生新的版本。它不像 DO UPDATE 那样要在堆上生成新元组也不会有旧的版本被标记删除。根据我对 PostgreSQL 实现风格的了解这种“只读分支”会尽量复用已有的索引扫描能力。冲突目标是什么就用什么去定位。比如你写了ON CONFLICT (email)它会使用 email 上的唯一索引找到冲突行然后回头从表里读取需要的列。这也意味着DO SELECT 的执行计划大概率比 DO UPDATE 更短因为没有 UPDATE 算子也没有可能触发的触发器。性能上应该会比过去用 DO UPDATE 做“假更新”好不少。2.3 从 WAL、锁和索引看性能代价先聊 WAL。PostgreSQL 的崩溃恢复依赖 WAL 日志任何写操作都会产生 WAL 记录。DO UPDATE 无论改没改数据新元组和可见性信息都要写进 WAL。DO SELECT 走的是纯读取路径理论上只读不写不会引入 WAL 负担。再聊锁。冲突检测本身一定会对已有的行加锁因为要防止其他事务在“你刚刚确定它是冲突行”的这一刻把它删掉或者改掉。DO UPDATE 需要加行级排他锁因为你要修改它DO SELECT 在草案里更可能是加行级共享锁表示“我读你但我不动你”。这个区别在长事务里会被放大。如果一个事务里跑了很多 DO SELECT它持有的共享锁不会阻塞正常的 read-only 查询但可能会和某些以FOR UPDATE方式扫描的写事务冲突。如果你打算在业务里大量使用一定要关注锁等待别等到线上压测才发现问题。最后看索引。DO SELECT 返回的列如果在索引里已经包含了那 PostgreSQL 可以通过 index-only scan 直接返回连表都不用回。比如你只SELECT id, status而id和status都在覆盖索引里执行计划会非常漂亮。这个点在我们后面做优化排查时会再提。3. 实操手记在当前版本里模拟 DO SELECT3.1 实验环境怎么搭最省事我知道很多人看到“v19 新特性”的第一反应是去哪搞一个 v19 来玩实际上没必要v19 现在连测试版都不算。我建议直接用当前稳定版做语法推演后续等官方 release candidate 出来再看真实行为。版本选择上我自己的习惯是生产环境用稳定大版本比如 16 或 17本地实验图省事就用 Docker 镜像一条命令就能起来docker run --name pg17-lab -e POSTGRES_PASSWORDpostgres -p 5432:5432 -d postgres:17如果你不想装 Docker也可以去找 PostgreSQL 16 便携版解压就能用省去 Windows 上手动安装、服务启动这一堆折腾。不管选哪个先确认版本SELECT version();只要能跑后面的验证逻辑都一样。3.2 用 CTE 临时复刻 DO SELECT 的行为当前版本没有 DO SELECT但我们可以用 CTE 把它的核心行为模拟出来先定位冲突行再无条件尝试插入最后返回定位到的行。WITH input_row AS ( SELECT aliceexample.com::text AS email, Alice::text AS display_name ), existing AS ( SELECT u.* FROM users u JOIN input_row i ON u.email i.email ) INSERT INTO users (email, display_name) SELECT i.email, i.display_name FROM input_row i ON CONFLICT (email) DO NOTHING; SELECT * FROM existing;注意这条 SQL 并不是真正的原子操作它依然存在细小竞态窗口但它能帮我们理解 DO SELECT 的语义插入成功则 existing 为空插入冲突则 existing 里有那一行。做实验、写演示文档完全够用。等 v19 真出了 DO SELECT你会发现官方语法比这段 CTE 更简洁也天然解决竞态问题。3.3 场景一用户唯一键冲突时拿到已有记录用户注册是取消“先查后插”的经典场景。我们希望邮箱已经被注册时不要报错而是把已有用户的关键字段返回让上层决定是提示“该邮箱已注册”还是引导登录。表结构CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL, display_name TEXT NOT NULL, status TEXT NOT NULL DEFAULT ACTIVE ); INSERT INTO users (email, display_name, status) VALUES (aliceexample.com, Alice, ACTIVE);如果 v19 已支持我们只需要INSERT INTO users (email, display_name, status) VALUES (aliceexample.com, Another Alice, ACTIVE) ON CONFLICT (email) DO SELECT id, email, display_name, status;当前版本里就用 3.2 的 CTE 写法验证结果会拿到 aliceexample.com 的原始行。我们发现唯一键冲突时display_name 没有被改成 Another Alice这正符合“只读不写”的预期。3.4 场景二幂等消息处理的去重判断消息队列的消费者最怕重复投递。以前的做法是在业务表里建唯一索引靠数据库把重复消息挡掉。现在我们关心的是如果这条消息已经处理过那把处理结果读出来避免再查一次数据库。假设有一张消息处理记录表CREATE TABLE event_records ( event_key TEXT PRIMARY KEY, state TEXT NOT NULL, retry_count INT NOT NULL DEFAULT 0, last_updated_at TIMESTAMPTZ NOT NULL DEFAULT now() );消费者处理逻辑的伪代码可以变成INSERT INTO event_records (event_key, state, retry_count) VALUES (message_key, PROCESSING, 0) ON CONFLICT (event_key) DO SELECT state, retry_count;如果返回空说明这次插入成功消息由当前消费者处理如果返回一行说明别人已经处理过或者上次处理过但发生了重复投递直接根据 state 判断是否需要跳过。这样就把“去重”和“状态查询”合并到了一起。3.5 场景三数据导入时的冲突清单收集做数据迁移或者批量导入时我们往往需要知道哪些行因为唯一键冲突被跳过了。以前要么提前跑一遍全量比对要么导入完再对一下差异。如果有 DO SELECT理论上我们可以在导入过程中直接把冲突行捞出来INSERT INTO temp_import (code, name) SELECT code, name FROM external_data ON CONFLICT (code) DO SELECT code, name WHERE is_archived true;我们可以在导入工具里设定冲突且满足某个条件时把行收集到内存队列最后统一写入冲突报告。这样省去了一次全表 JOIN还能让导入主流程保持线性推进。这个场景在数据量大的时候尤其舒服因为不需要为“核对冲突”单独扫一遍整表也不需要在导入前后对快照。4. 常见报错与排查经验4.1 版本不支持时的报错特征如果你现在就把文章里的代码贴到 PostgreSQL 17 上执行会见到类似这样的报错ERROR: syntax error at or near SELECT LINE 3: ON CONFLICT (email) DO SELECT ...这不是你写错了而是当前版本根本不认识这个语法。排查第一步永远是确认版本不要对着 Postgres 14 的天花板硬写 v19 的语法。我的建议是把这类前瞻语法的实验集中到一个专门的库或者容器里和生产环境完全隔离。不然同事接手代码看到一条当前版本跑不了的 SQL第一反应肯定是把锅扣在你头上。4.2 ON CONFLICT 目标匹配不上的原因这是从 9.5 时代就存在的经典报错ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification原因通常有两个你写的冲突列不是唯一索引或者虽然列上建了唯一索引但它是部分索引、表达式索引不能直接通过列名匹配。DO SELECT 将来也会继承这个限制。你要写的冲突目标必须精确匹配一个真实的唯一约束或者唯一索引。如果表里只有复合唯一索引(product_id, batch_no)你就不能只写ON CONFLICT (product_id)。排查时可以用系统目录确认索引存在性SELECT indexdef FROM pg_indexes WHERE tablename your_table;然后对着索引定义去写冲突目标。4.3 并发事务下读到的版本可能和你想的不一样DO SELECT 读的是“当前事务能看见的版本”。在 READ COMMITTED 隔离级别下同一个事务里的两条 DO SELECT 之间如果中间有其他事务提交了更新第二条可能读到新版本。举个例子时间线事务 A事务 BT1尝试插入 email冲突DO SELECT 读取到 statusACTIVET2UPDATE users SET statusDISABLED WHERE email...; 提交T3再次 DO SELECT 相同 keyT4可能读到 statusDISABLED这不算什么 bug而是 MVCC 的正常表现。要获得跨语句一致快照请把整个流程放进 REPEATABLE READ 隔离级别的事务里。这个点我踩过一次当时以为 DO SELECT 返回的旧状态是稳定的结果在同一事务内被并发更新打脸导致审计日志里记录的状态和最终业务状态对不上。4.4 和 RETURNING 一起用要注意返回列如果未来语法允许这样写INSERT INTO users (email, display_name) VALUES (aliceexample.com, Alice) ON CONFLICT (email) DO SELECT *;你会发现它和DO UPDATE ... RETURNING *有个微妙区别DO UPDATE 返回的是“当前事务修改后的新行”而 DO SELECT 返回的是“冲突前已经存在的旧行”。如果你在同一个事务里已对该行做过修改那么 DO SELECT 返回的可能是你事务修改前的旧快照不要和 RETURNING 的行为混淆。建议在应用层约定清楚DO SELECT 的返回结果只代表“本次插入尝试时本该存在的那行”不代表“处理完之后的最终状态”。4.5 性能排查的标准动作如果将来 DO SELECT 在线上慢第一反应不要关掉特性先用EXPLAIN ANALYZE看执行计划EXPLAIN ANALYZE INSERT INTO users (email, display_name) VALUES (bobexample.com, Bob) ON CONFLICT (email) DO SELECT id, email, status;观察里面有没有用上对应的唯一索引扫描。如果出现 Seq Scan说明你的冲突目标写错了或者表上的统计信息有问题。然后看锁等待SELECT pid, wait_event_type, wait_event, state, query FROM pg_stat_activity WHERE wait_event IS NOT NULL;如果大量会话卡在冲突行的锁上说明业务并发模型有问题需要控制请求速率而不是盲目调数据库参数。5. 这个特性会怎么影响你的项目5.1 从“两步走”到“一步到位”的架构变化过去实现“不存在则插入存在则返回”需要应用层写两条 SQL或者搞一个笨重的条件更新。DO SELECT 一旦落地数据访问层的边界会更清晰。我见过不少接口为了处理唯一键冲突在应用层用 Redis 加锁锁完再查库查完再插库复杂度暴涨。DO SELECT 把冲突检测和结果返回收敛到数据库内部应用层不需要知道乐观锁、悲观锁这些细节只要掌握一个核心概念冲突分支到底返回了哪一行。这句话听起来轻描淡写但对代码结构的影响不小。尤其是 golang、Java 这类强类型语言一个接口从两三次网络往返压缩到一次 SQL超时率、资源占用、代码可读性都会明显改善。5.2 后端接口和 ETL 脚本能简化到什么程度后端接口最典型的例子是“创建或获取”。没有 DO SELECT 时RESTful 语义里的PUT /users/{email}你很难在一个事务里优雅实现。有了 DO SELECT语义可以映射成插入成功返回新纪录冲突返回已有纪录。HTTP 200 始终给调用方一个资源实例但通过响应体里的一个标记位调用方知道这次到底是新建还是复用。ETL 脚本也一样。数据管道里最怕的就是主键冲突导致整个批任务失败。以前的做法是把冲突容错交给DO NOTHING但DO NOTHING不给你任何反馈你不知道到底跳过了多少。DO SELECT 把冲突行显性返回ETL 框架可以直接统计冲突比例超过阈值就告警完全不需要额外的比对任务。5.3 团队接入的稳妥路线虽然 v19 还没发布但你们团队现在就可以做三件事第一梳理现有代码里“先 SELECT 再 INSERT”的 TODO 列表标记哪些是真正的竞态风险点等特性可用时优先替换。第二把 UAT 环境预置一个 PostgreSQL 17或 16的备库用 3.2 的 CTE 模拟脚本建立基准测试。记录延迟、WAL 增长、锁等待作为将来 DO SELECT 上线的参照系。第三关注 PostgreSQL 官方 release notes 和 commitfest 动态不要轻信二手没出处的“新特性总结”。我看到的这份 DeepSeek 总结也只是给了一个很好的引子具体语法和边界条件最终要以官方文档为准。5.4 给 DBA 的监控提醒如果未来团队真的启用 DO SELECTDBA 的监控面板最好加上几项冲突事件的频率可以借助pg_stat_database和扩展日志统计锁等待时间分布尤其关注行级共享锁在长事务中的累积效应索引大小变化确认没有因为过度设计覆盖索引导致写放大。DO SELECT 虽然只读不写但它依然严重依赖索引设计。返回列如果不在索引里就要回表冲突目标如果没有唯一索引连执行资格都没有。所以提前把唯一索引设计做好比纠结语法本身重要得多。结尾关于这套思路我的一点私货我个人在实际项目里最受益的一点不是那个炫酷的语法而是它逼着我把“插入”和“读取”重新想了一遍。以前写 upsert默认就会去 DO UPDATE哪怕业务只是想通过冲突拿到一个状态。这种惯性消耗了非常多不必要的数据库写入也让很多接口的语义变得模糊。如果你现在还不能用 v19先试着把手里的“空 UPDATE”换成本地 CTE 的模拟写法至少能立刻降低写放大。等将来 DO SELECT 正式发布你迁移过去就是顺理成章的事。工具版本会过期但这个“只读分支”的思路在业务设计里永远不会过时。最后再分享一个小习惯每次看到新的数据库语法我会先在空表上跑一次 EXPLAIN再看官方文档再写业务代码。顺序反了很容易被网上的二手解读带偏。
返回列表