在订单明细、用户标签、库存流水这类业务表里,数据的唯一性往往不是由单个字段决定的,而是由多个字段组合决定的。比如一条库存记录可能要靠“仓库ID+商品ID”才能唯一确定,一条用户标签可能要靠“用户ID+标签名”才能唯一确定。往这类表里插入数据时,如果只靠先查询再判断再插入的老办法,在高并发场景下很容易出现重复数据或者报唯一约束冲突的错误。PostgreSQL从9.5版本开始支持的INSERT ... ON CONFLICT语法,正是为解决这个问题而生的,它可以在一条语句内完成“不存在则插入、存在则更新或跳过”的操作,天然具备原子性。本文重点讲清楚多列冲突的完整用法。

一、准备多列唯一约束或唯一索引
ON CONFLICT要正常工作,前提是表上存在与之匹配的唯一约束或唯一索引。对于多列冲突,需要先在多个字段上建立组合唯一索引。假设有一张库存表,由warehouse_id和product_id两个字段共同决定唯一性,建表语句如下:
CREATE TABLE stock (
id BIGSERIAL PRIMARY KEY,
warehouse_id INT NOT NULL,
product_id INT NOT NULL,
quantity INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT uq_stock UNIQUE (warehouse_id, product_id)
);
上面的写法是在建表时直接声明表级唯一约束,这是最常见也最直观的方式。如果表已经存在,事后补充也可以,用ALTER TABLE加约束或者直接建唯一索引都行:
-- 方式一:添加表级唯一约束 ALTER TABLE stock ADD CONSTRAINT uq_stock UNIQUE (warehouse_id, product_id); -- 方式二:直接创建唯一索引,效果等价 CREATE UNIQUE INDEX uq_idx_stock ON stock (warehouse_id, product_id);
这两种方式在功能上都能支撑ON CONFLICT,细微区别在于:唯一约束是一种逻辑层面的约束定义,会记录在系统目录中,方便跨库迁移工具识别;唯一索引则是更偏物理实现的手段。日常开发中两者任选其一即可,但如果后续要配合DO UPDATE做条件更新,用唯一索引能更灵活地控制索引条件(比如部分索引)。另外要注意,组合唯一索引的字段顺序不影响冲突判断本身,但会影响索引的查询性能,通常建议把选择性高的字段放在前面。
二、ON CONFLICT多列冲突目标的基本语法
多列冲突的冲突目标需要把多个列名用逗号隔开,写在ON CONFLICT后面的括号里。完整的语法形态是这样的:
INSERT INTO stock (warehouse_id, product_id, quantity) VALUES (1, 100, 50) ON CONFLICT (warehouse_id, product_id) DO UPDATE SET quantity = stock.quantity + EXCLUDED.quantity;
这条语句的含义是:向stock表插入一条仓库1、商品100、数量50的记录。如果(warehouse_id, product_id)这个组合在表里已经存在,就不插入,转而执行DO UPDATE分支,把已有记录的quantity在原值基础上加上本次要插入的50。注意冲突目标括号里的列必须和表上的唯一索引或唯一约束完全对应,列的个数、列本身都要匹配,否则PostgreSQL会直接报“there is no unique or exclusion constraint matching the ON CONFLICT specification”这类错误,这是新手最容易踩的坑。
这里的EXCLUDED是一个虚拟表,它代表“本次试图插入但因冲突而被排除的那一行”。通过它可以拿到新值,而表名(这里是stock)代表已存在的那一行旧值。理解了这两个引用,就能写出所有常见的upsert逻辑。比如想以新值覆盖旧值,写法就是SET quantity = EXCLUDED.quantity;想做累加,就是SET quantity = stock.quantity + EXCLUDED.quantity。多数情况下建议顺带更新一下时间戳字段:
INSERT INTO stock (warehouse_id, product_id, quantity)
VALUES (1, 100, 50)
ON CONFLICT (warehouse_id, product_id)
DO UPDATE SET
quantity = stock.quantity + EXCLUDED.quantity,
updated_at = now();
三、DO NOTHING与DO UPDATE的选择及进阶用法
ON CONFLICT后面可以跟两种策略。第一种是DO NOTHING,遇到冲突直接跳过这一行,什么都不做,适合“只在第一次出现时插入”的场景,比如注册邀请码、首次绑定的设备记录。第二种是DO UPDATE,遇到冲突就更新,适合计数器累加、状态同步这类场景。两者的写法差异如下:
-- 冲突时跳过,不报错 INSERT INTO stock (warehouse_id, product_id, quantity) VALUES (1, 100, 50) ON CONFLICT (warehouse_id, product_id) DO NOTHING; -- 冲突时更新 INSERT INTO stock (warehouse_id, product_id, quantity) VALUES (1, 100, 50) ON CONFLICT (warehouse_id, product_id) DO UPDATE SET quantity = EXCLUDED.quantity;
DO UPDATE还支持加WHERE条件,实现“只在满足条件时才更新”。这在数据同步场景特别有用,比如只允许数量增加、不允许回退:
INSERT INTO stock (warehouse_id, product_id, quantity) VALUES (1, 100, 50) ON CONFLICT (warehouse_id, product_id) DO UPDATE SET quantity = EXCLUDED.quantity WHERE stock.quantity < EXCLUDED.quantity;
当WHERE条件不满足时,这一行不会被更新,语句也不会报错,效果介于DO NOTHING和DO UPDATE之间。还有一个容易被忽略的点:如果一次INSERT插入多行,其中一部分冲突、一部分不冲突,ON CONFLICT会逐行判断,不冲突的正常插入,冲突的按策略处理,彼此互不影响。批量导入时配合RETURNING子句,还能拿到实际生效的行,方便上层业务确认结果。另外,如果表上存在部分唯一索引(带WHERE条件的唯一索引),ON CONFLICT还可以在冲突目标后面跟上相同的WHERE子句来匹配它,这在“仅对特定状态的记录保证唯一”的业务里非常实用,例如只对未删除的记录做唯一限制,软删除后可以再次插入新记录。
四、常见报错与排查思路
第一个高频报错是“no unique or exclusion constraint matching the ON CONFLICT specification”。原因基本就两种:要么冲突目标里的列组合和表上的唯一索引对不上,比如索引建的是三列而冲突目标只写了两列;要么表上根本没建唯一索引。排查时可以先执行\d 表名查看表结构中的索引信息,确认列组合是否一致。
第二个常见问题是并发下的死锁。当多个事务同时对同一批多列组合执行DO UPDATE,且加锁顺序不一致时,可能触发死锁。解决办法一是统一批量插入的行顺序(比如按组合键排序后再插入),二是把大事务拆小,减少持锁时间。第三个小坑是DO UPDATE的SET子句里如果引用了没加表前缀的字段,在字段同名时容易产生歧义,建议始终用表名.字段和EXCLUDED.字段的完整写法,可读性和安全性都更好。掌握这些细节后,多列冲突的upsert基本就能在生产环境里稳定落地了。
PostgreSQLON CONFLICT多列唯一索引修改时间:2026-09-13 01:08:31