PostgreSQL 实战:详解 UPSERT(INSERT ON CONFLICT)

文章目录

    • [一、UPSERT 基础](#一、UPSERT 基础)
      • [1.1 为什么需要UPSERT?- 传统方案的缺陷](#1.1 为什么需要UPSERT?- 传统方案的缺陷)
      • [1.2 替代方案对比](#1.2 替代方案对比)
      • [1.3 跨数据库兼容性](#1.3 跨数据库兼容性)
      • [1.4 UPSERT 使用建议](#1.4 UPSERT 使用建议)
    • 二、基本使用
      • [2.1 核心语法:`INSERT ... ON CONFLICT`](#2.1 核心语法:INSERT ... ON CONFLICT)
      • [2.2 突目标(Conflict Target)详解](#2.2 突目标(Conflict Target)详解)
      • [2.3 返回结果:`RETURNING` 子句](#2.3 返回结果:RETURNING 子句)
    • 三、高级技巧:精细化控制更新逻辑
      • [3.1 条件更新(避免无意义写入)](#3.1 条件更新(避免无意义写入))
      • [3.2 部分字段更新(保留原值)](#3.2 部分字段更新(保留原值))
      • [3.3 累加操作(计数器场景)](#3.3 累加操作(计数器场景))
      • [3.4 DO NOTHING:静默忽略冲突](#3.4 DO NOTHING:静默忽略冲突)
      • [3.5 性能优化:索引与执行计划](#3.5 性能优化:索引与执行计划)
    • 四、常见陷阱与避坑指南
      • [陷阱 1:冲突目标未命中索引](#陷阱 1:冲突目标未命中索引)
      • [陷阱 2:在 DO UPDATE 中引用非冲突列](#陷阱 2:在 DO UPDATE 中引用非冲突列)
      • [陷阱 3:忽略 NULL 值的特殊性](#陷阱 3:忽略 NULL 值的特殊性)
      • [陷阱 4:触发器行为异常](#陷阱 4:触发器行为异常)
    • [五、Python + SQLAlchemy 实战](#五、Python + SQLAlchemy 实战)
      • [5.1 原生 SQL 方式(推荐)](#5.1 原生 SQL 方式(推荐))
      • [5.2 SQLAlchemy 2.0 Core 方式](#5.2 SQLAlchemy 2.0 Core 方式)

在现代应用开发中,"存在则更新,不存在则插入" 是极其常见的数据操作模式,例如:

  • 用户首次访问时创建记录,后续访问更新最后登录时间
  • 电商商品库存的累加(而非覆盖)
  • 实时统计指标(如 PV/UV 计数器)
  • 缓存写入(缓存穿透场景)

PostgreSQL 从 9.5 版本开始 提供了标准 SQL 的 INSERT ... ON CONFLICT 语法(即 UPSERT ),彻底解决了这一痛点。本文将从基础用法、高级技巧、性能优化、避坑指南四个维度,带你全面掌握 UPSERT 的精髓。


一、UPSERT 基础

1.1 为什么需要UPSERT?- 传统方案的缺陷

在没有 UPSERT 之前,开发者通常采用两种方式:

1、方案 A:先查后插(Race Condition 风险)

python 复制代码
# 伪代码
if not db.exists(user_id):
    db.insert(user_id, ...)
else:
    db.update(user_id, ...)
  • 问题:高并发下可能多次插入(违反唯一约束)
  • 后果:程序崩溃或数据不一致

2、方案 B:捕获异常(性能差 + 逻辑复杂)

sql 复制代码
BEGIN;
INSERT INTO users VALUES (1, 'Alice');
EXCEPTION WHEN unique_violation THEN
    UPDATE users SET name = 'Alice' WHERE id = 1;
END;
  • 问题:频繁抛异常开销大,代码冗长

UPSERT 的价值
原子性 + 高性能 + 简洁语法,一行 SQL 解决所有问题。

1.2 替代方案对比

方案 优点 缺点 适用场景
UPSERT 原子性、高性能、标准 SQL 需 PG ≥ 9.5 绝大多数场景首选
MERGE (SQL:2003) 标准更通用 PG 15+ 才支持 跨数据库兼容
先查后插 + 锁 逻辑清晰 性能差、易死锁 极低频操作
Rule 系统 自动重定向 复杂、难维护 遗留系统

结论坚持使用 INSERT ... ON CONFLICT,它是 PostgreSQL 社区验证的最佳实践。

1.3 跨数据库兼容性

数据库 UPSERT 语法
PostgreSQL INSERT ... ON CONFLICT
MySQL INSERT ... ON DUPLICATE KEY UPDATE
SQLite INSERT ... ON CONFLICT ... DO UPDATE
SQL Server MERGE
Oracle MERGE

若需跨数据库,可封装适配层,或使用 Django ORM / SQLAlchemy 的方言抽象。

1.4 UPSERT 使用建议

场景 推荐做法
基础插入/更新 ON CONFLICT (col) DO UPDATE SET ...
避免无意义更新 添加 WHERE 条件(如时间比较)
计数器累加 SET counter = table.counter + 1
静默忽略 DO NOTHING
高性能批量写入 多值 VALUES 或临时表
索引优化 为冲突目标建唯一索引(CONCURRENTLY
Python 集成 使用原生 SQL 或 SQLAlchemy Core

💡 终极心法
"UPSERT 不是魔法,而是精心设计的原子操作。"

正确使用它,你的应用将获得数据一致性、高并发能力和简洁代码三重收益。


二、基本使用

2.1 核心语法:INSERT ... ON CONFLICT

1、基本结构

sql 复制代码
INSERT INTO table_name (column1, column2, ...)
VALUES (value1, value2, ...)

ON CONFLICT [conflict_target]
DO UPDATE SET
    column1 = excluded.column1,
    column2 = excluded.column2,
    ...
[WHERE condition];

关键组件解析:

组件 说明
conflict_target 冲突检测目标(唯一索引/约束)
excluded 虚拟表,代表尝试插入但冲突的行
DO UPDATE SET 冲突时执行的更新操作
WHERE 可选条件,控制是否更新

2、最简示例:存在则更新所有字段

假设用户表:

sql 复制代码
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(100),
    last_login TIMESTAMP
);

UPSERT 操作:

sql 复制代码
INSERT INTO users (email, name, last_login)
VALUES ('alice@example.com', 'Alice', NOW())

ON CONFLICT (email)  -- 冲突目标:email 唯一索引
DO UPDATE SET
    name = excluded.name,
    last_login = excluded.last_login;

✅ 效果:

  • email 不存在 → 插入新行
  • email 已存在 → 更新 namelast_login

💡 excluded.name 表示"本次 INSERT 语句中提供的 name 值"

2.2 突目标(Conflict Target)详解

1、指定列(最常用)

sql 复制代码
ON CONFLICT (email)  -- 基于 email 列的唯一约束

2、指定约束名(更精确)

sql 复制代码
-- 先创建命名约束
ALTER TABLE users 
ADD CONSTRAINT uk_users_email UNIQUE (email);

-- 使用约束名
ON CONFLICT ON CONSTRAINT uk_users_email

3、部分索引(Partial Index)冲突

sql 复制代码
-- 创建部分唯一索引:仅对 active=true 的记录生效
CREATE UNIQUE INDEX idx_active_email ON users (email) WHERE active = true;

-- UPSERT 时指定该索引
INSERT INTO users (email, name, active) 
VALUES ('bob@example.com', 'Bob', true)

ON CONFLICT (email) WHERE active = true  -- 必须匹配部分索引条件
DO UPDATE SET name = excluded.name;

注意:WHERE active = true 必须与索引定义一致,否则无法触发冲突检测!

2.3 返回结果:RETURNING 子句

UPSERT 支持 RETURNING,可获取实际插入或更新的行

sql 复制代码
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')

ON CONFLICT (email)
DO UPDATE SET name = excluded.name

RETURNING id, email, name, 'inserted' AS action;  -- 但无法区分是插入还是更新!

如何区分插入 vs 更新?

方法 1:使用 CTE + 标记

sql 复制代码
WITH upsert AS (
    INSERT INTO users (email, name)
    VALUES ('alice@example.com', 'Alice')
    ON CONFLICT (email) 
    DO UPDATE SET name = excluded.name
    RETURNING *, 'updated' AS action
),
inserted AS (
    INSERT INTO users (email, name)
    SELECT 'alice@example.com', 'Alice'
    WHERE NOT EXISTS (SELECT 1 FROM users WHERE email = 'alice@example.com')
    RETURNING *, 'inserted' AS action
)
SELECT * FROM upsert
UNION ALL
SELECT * FROM inserted;

复杂且有竞态风险,不推荐

方法 2:应用层判断(推荐)

  • 执行 UPSERT 前先查是否存在
  • 或通过业务逻辑推断(如首次注册 vs 登录)

现实建议 :大多数场景无需区分,直接使用 RETURNING 获取最新数据即可。


三、高级技巧:精细化控制更新逻辑

3.1 条件更新(避免无意义写入)

场景:只在新登录时间 > 旧时间时才更新

sql 复制代码
INSERT INTO users (email, last_login)
VALUES ('alice@example.com', '2026-01-25 10:00:00')

ON CONFLICT (email)
DO UPDATE SET
    last_login = excluded.last_login
WHERE users.last_login < excluded.last_login;  -- 仅当新时间更新时才更新

3、优势:

  • 减少 WAL 日志
  • 避免触发不必要的触发器
  • 提升性能(尤其高频更新场景)

3.2 部分字段更新(保留原值)

场景:只更新 last_login,不修改 name

sql 复制代码
INSERT INTO users (email, name, last_login)
VALUES ('alice@example.com', 'OldName', NOW())  -- name 值会被忽略

ON CONFLICT (email)
DO UPDATE SET
    last_login = excluded.last_login;  -- 不更新 name

💡 即使 INSERT 中提供了 name,只要 DO UPDATE SET 不包含它,就不会被修改。


3.3 累加操作(计数器场景)

场景:用户访问次数 +1

sql 复制代码
INSERT INTO user_visits (user_id, visit_count)
VALUES (123, 1)

ON CONFLICT (user_id)
DO UPDATE SET
    visit_count = user_visits.visit_count + 1;  -- 累加而非覆盖

安全替代:

sql 复制代码
-- 更健壮:防止初始值为 NULL
DO UPDATE SET
    visit_count = COALESCE(user_visits.visit_count, 0) + 1;

3.4 DO NOTHING:静默忽略冲突

场景:只插入新记录,冲突时不做任何操作

sql 复制代码
INSERT INTO logs (event_id, data)
VALUES ('evt_001', '{"action":"click"}')

ON CONFLICT (event_id)
DO NOTHING;  -- 冲突时直接跳过

返回:受影响行数为 0(可通过 RETURNING * 验证是否插入)

3.5 性能优化:索引与执行计划

1、必建索引

UPSERT 的性能完全依赖冲突目标上的索引

sql 复制代码
-- 对 ON CONFLICT (email) 必须有唯一索引
CREATE UNIQUE INDEX CONCURRENTLY idx_users_email ON users(email);

使用 CONCURRENTLY 避免锁表(生产环境必备)

2、执行计划分析

sql 复制代码
EXPLAIN (ANALYZE, BUFFERS)
INSERT INTO users (email, name) 
VALUES ('test@example.com', 'Test')
ON CONFLICT (email) 
DO UPDATE SET name = excluded.name;

关键观察点:

  • Index Only Scan:理想情况(仅扫描索引)
  • Heap Fetches:越少越好(表示需回表)
  • Buffersshared hit 高表示缓存命中率高

3、批量 UPSERT(高性能写入)

单条 UPSERT 有网络开销,批量操作更高效:

sql 复制代码
-- 方式 1:多值插入
INSERT INTO users (email, name)
VALUES 
    ('a@example.com', 'A'),
    ('b@example.com', 'B'),
    ('c@example.com', 'C')
ON CONFLICT (email)
DO UPDATE SET name = excluded.name;

-- 方式 2:从临时表导入
CREATE TEMP TABLE temp_users (email TEXT, name TEXT);
-- ... 填充临时表
INSERT INTO users 
SELECT * FROM temp_users
ON CONFLICT (email)
DO UPDATE SET name = excluded.name;

性能对比(10万条):

方式 耗时
单条循环 ~30 秒
批量 VALUES ~2 秒
临时表 + COPY ~1 秒

四、常见陷阱与避坑指南

陷阱 1:冲突目标未命中索引

sql 复制代码
-- 表有唯一索引 (email, status)
-- 但 UPSERT 只指定 (email)
ON CONFLICT (email)  -- ❌ 无法触发冲突!

✅ 解决:冲突目标必须与唯一索引完全匹配

陷阱 2:在 DO UPDATE 中引用非冲突列

sql 复制代码
-- 唯一索引是 (email)
-- 但更新时引用了 id(非冲突列)
DO UPDATE SET id = excluded.id  -- ❌ 可能导致主键冲突!

✅ 解决:只更新非唯一约束列

陷阱 3:忽略 NULL 值的特殊性

sql 复制代码
-- 唯一索引允许 NULL 重复
INSERT INTO t (nullable_col) VALUES (NULL);
INSERT INTO t (nullable_col) VALUES (NULL); -- 不会冲突!

✅ 理解:PostgreSQL 中 NULL != NULL,唯一索引允许多个 NULL

陷阱 4:触发器行为异常

  • BEFORE INSERT 触发器在冲突时不会执行
  • BEFORE UPDATE 触发器在 DO UPDATE 时会执行
  • 需要测试触发器逻辑是否符合预期

五、Python + SQLAlchemy 实战

5.1 原生 SQL 方式(推荐)

python 复制代码
from sqlalchemy import text

def upsert_user(session, email, name):
    stmt = text("""
        INSERT INTO users (email, name, last_login)
        VALUES (:email, :name, NOW())
        ON CONFLICT (email)
        DO UPDATE SET
            name = EXCLUDED.name,
            last_login = EXCLUDED.last_login
        RETURNING id;
    """)
    result = session.execute(stmt, {"email": email, "name": name})
    return result.scalar()

5.2 SQLAlchemy 2.0 Core 方式

python 复制代码
from sqlalchemy import insert

stmt = (
    insert(users_table)
    .values(email="alice@example.com", name="Alice")
    .on_conflict_do_update(
        index_elements=["email"],
        set_=dict(name="Alice", last_login=func.now())
    )
    .returning(users_table.c.id)
)

注意:SQLAlchemy ORM 不直接支持 UPSERT,需用 Core 层或原生 SQL。


相关推荐
重生之绝世牛码1 小时前
Linux软件安装 —— PostgreSQL高可用集群安装(postgreSQL + repmgr主从复制 + keepalived故障转移)
大数据·linux·运维·数据库·postgresql·软件安装·postgresql高可用
June bug1 小时前
(#数组/链表操作)寻找两个正序数组的中位数
数据结构·python·算法·leetcode·面试·职场和发展·跳槽
源力祁老师2 小时前
Odoo日志系统核心组件_logger
网络·数据库·php
李昊哲小课2 小时前
奶茶店销售额预测模型
python·机器学习·线性回归·scikit-learn
电商API&Tina2 小时前
电商API接口的应用与简要分析||taobao|jd|微店
大数据·python·数据分析·json
向前V2 小时前
Flutter for OpenHarmony轻量级开源记事本App实战:笔记编辑器
开发语言·笔记·python·flutter·游戏·开源·编辑器
snow_star_dream2 小时前
(笔记)VSC python应用--函数补全注释添加
笔记·python
郝学胜-神的一滴3 小时前
Python中的Mixin继承:灵活组合功能的强大模式
开发语言·python·程序人生
叫我:松哥3 小时前
基于python强化学习的自主迷宫求解,集成迷宫生成、智能体训练、模型评估等
开发语言·人工智能·python·机器学习·pygame