PostgreSQL upsert 前几次成功,随后报 no unique or exclusion constraint
同一预编译upsert先成功后失败:核对部分唯一索引的参数化条件、通用计划与定制计划,把固定业务范围写在冲突目标中。
同一连接重复执行预编译的 INSERT ... ON CONFLICT,前几次能更新,第六次附近却报 there is no unique or exclusion constraint matching the ON CONFLICT specification。表上确实有唯一索引,重开连接又暂时恢复。若这个索引带 WHERE,先检查冲突目标里的条件是否使用了参数。
下面针对部分唯一索引与预编译语句,规则按 PostgreSQL 18 核对。类似表现曾出现在 14.2,但不能把“第六次失败”当成所有版本和查询的固定规律。
索引条件不是每行运行时再选择
普通唯一索引覆盖全部行,部分唯一索引只覆盖满足条件的行。ON CONFLICT 要在规划阶段找到能仲裁冲突的索引;条件必须足以推出索引的范围。运行时某个参数恰好符合条件,并不保证通用计划能作出这个判断。
例如索引定义为 WHERE active,冲突目标却写成 WHERE active = $3。当 $3 为假时,它覆盖的就不是这个索引的范围。通用计划保留参数,不会假定所有调用都传真。
默认的 plan_cache_mode = auto 会先使用五次定制计划,再根据成本比较决定是否采用通用计划。定制计划知道本次参数,通用计划需要适用于不同参数。切换可能暴露推断失败,但并非每条语句都会在第六次切换。
先保存实际 SQL、索引定义和脱敏参数,确认报错连接及 schema。下面是只读查询,public.account_state 需换为实际表:
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'account_state';
SELECT name, statement, generic_plans, custom_plans
FROM pg_prepared_statements;
预编译语句是会话对象;换一个管理连接查询,不一定能看到业务连接里的语句。计数只说明该语句采用过什么计划,不能独立证明索引就是错误原因。没有部分唯一索引或没有参数化条件时,应另查冲突键、表达式和真实对象。
固定冲突范围,业务值继续绑定参数
如果业务规则就是“每个账户只能有一条 active 记录”,让冲突目标固定描述这个范围;账户、状态文本等业务值仍使用参数。以下仅展示已存在表的语句模板,假定表有 account_id、active、state_text,且唯一索引为 ON public.account_state (account_id) WHERE active:
INSERT INTO public.account_state (account_id, active, state_text)
VALUES ($1, true, $2)
ON CONFLICT (account_id) WHERE active
DO UPDATE SET state_text = EXCLUDED.state_text;
这里新增行固定为 active,冲突条件也固定为 active。它不是接收任意 active 参数的通用模板。若应用还要写入历史或停用记录,应为那些入口设计符合业务语义的语句,不能把真假参数直接拼进 SQL;业务数据继续通过驱动绑定。
还要区分两个 WHERE:ON CONFLICT (...) WHERE ... 用于推断索引,DO UPDATE ... WHERE ... 用于限制已经找到冲突之后是否更新。把条件只放在后者,无法补上前者的索引推断。
不要把部分唯一索引名直接填进 ON CONSTRAINT。独立索引不一定是可按该名称引用的约束。也不要为了通过推断删除唯一索引,或直接改成 DO NOTHING;这可能丢掉本来必须更新的数据。
在同一连接里检查多次调用
修正后,在隔离数据上核对同一连接的重复调用、不同账户、已有 active 记录和历史记录。次数应覆盖先前失败点,也检查实际计划与最终保存的状态。语句不再抛异常,与更新了正确记录,需要分别确认。
强制 force_custom_plan 可以帮助定位计划差异,但会改变规划成本,不能代替固定业务范围的 SQL 设计,也不宜为了一个模板全局调整服务参数。保留旧 SQL 与连接配置;错误导致事务失败时,按应用事务边界回滚后再重试。回退代码不会恢复已被覆盖的状态文本。
如果错误是 cannot affect row a second time,应检查同一批输入重复影响目标行,它与本页的索引推断失败不同。


