upsert:插入一行,或者更新已经在那儿的那一行

处理可能已存在的行时,各数据库使用不同的 UPSERT 语法。以下示例对同一条心愿单记录执行相同操作,并比较结果。

这一页会修改数据,改的是你浏览器里那份自己的示例数据库。想重来就从欢迎页重新载入示例。

SQLite 和 PostgreSQL

两边都用 ON CONFLICT,语法也一样——为其中一个写的查询在另一个上照样跑。

1

-- Insert, or update the row that is already there.
INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '2026-01-01 00:00:00')
ON CONFLICT (customer_id, product_id)
DO UPDATE SET added_at = excluded.added_at;
在 Planche 中运行

1 行被修改

浏览器会另开一个标签页打开 Planche,示例数据库已经载入,这条查询也已经放好。本页其他示例都会复用同一个标签页;在你按下 F9 之前,什么都不会执行。

你指定会冲突的列,而 excluded 指的是你试图插入的那一行。跑两遍看看:第一遍是插入,第二遍是更新。

2

SELECT customer_id, product_id, added_at
FROM wishlists
WHERE customer_id = 1 AND product_id = 288;
在 Planche 中运行

结果 — 1 行

customer_idproduct_idadded_at
12882026-01-01 00:00:00

另一个分支是"已有的行原样不动":

3

-- "Insert if it is not there, otherwise leave it alone." No error.
INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '1999-12-31 00:00:00')
ON CONFLICT (customer_id, product_id) DO NOTHING;
在 Planche 中运行

0 行被修改

什么都没变,也没有报错。这就是它和普通 INSERT 的区别——后者会在主键上失败。

PostgreSQL

INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '2026-01-01 00:00:00')
ON CONFLICT (customer_id, product_id)
DO UPDATE SET added_at = EXCLUDED.added_at;

-- DO NOTHING is the same idea as SQLite's.
INSERT INTO wishlists (customer_id, product_id)
VALUES (1, 288)
ON CONFLICT DO NOTHING;

PostgreSQL — 放在这里供对照。浏览器里的示例库是 SQLite,这条语句在这里跑不了。

PostgreSQL 习惯把 excluded 写成大写,但两种都接受。它还允许在更新分支上加 WHERE,于是可以只在满足某个条件时才更新。

MySQL 和 MariaDB

MySQL / MariaDB

-- MySQL 8.0.20 and later: alias the new row instead of VALUES().
INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '2026-01-01 00:00:00') AS new
ON DUPLICATE KEY UPDATE added_at = new.added_at;

-- Older MySQL and MariaDB:
INSERT INTO wishlists (customer_id, product_id, added_at)
VALUES (1, 288, '2026-01-01 00:00:00')
ON DUPLICATE KEY UPDATE added_at = VALUES(added_at);

MySQL / MariaDB — 放在这里供对照。浏览器里的示例库是 SQLite,这条语句在这里跑不了。

这个子句不只是名字不同。它响应的是任意唯一约束被违反,而不是你指定的那一个,所以在有多个唯一键的表上,语句会按实际冲突的那个键去更新——而那未必是你心里想的那个。MySQL 8.0.20 弃用了 VALUES(col),改为给新行起别名,所以新代码应该用 AS new 的形式;MariaDB 目前仍然要 VALUES()。

SQL Server 和 Oracle

SQL Server / Oracle

MERGE INTO wishlists AS target
USING (SELECT 1 AS customer_id, 288 AS product_id, '2026-01-01' AS added_at) AS source
    ON target.customer_id = source.customer_id
   AND target.product_id  = source.product_id
WHEN MATCHED THEN
  UPDATE SET added_at = source.added_at
WHEN NOT MATCHED THEN
  INSERT (customer_id, product_id, added_at)
  VALUES (source.customer_id, source.product_id, source.added_at);

SQL Server / Oracle — 放在这里供对照。浏览器里的示例库是 SQLite,这条语句在这里跑不了。

MERGE 是 SQL 标准给出的答案,也是啰嗦得最厉害的一个:进来的那一行要写成一条查询,和目标表连接,每个分支都得写出来。它也更通用——加上 WHEN NOT MATCHED BY SOURCE THEN DELETE 就成了一次完整同步。Oracle 在 PL/SQL 里不需要结尾分号,SQL Server 则要求有,这通常就是拷来的 MERGE 第一次跑不起来的原因。

在 SQL Server 上只处理一行时,很多团队宁可在事务里先 UPDATE、再用 @@ROWCOUNT 保护一条 INSERT,因为 MERGE 积压着一长串并发与触发器相关的缺陷报告。如果确实要用它,就把它放进隔离级别正确的事务里,别把它本身当成原子操作。

REPLACE 不是 upsert

SQLite 和 MySQL 都有 REPLACE,看上去像是这件事的简写。它不是同一个操作。

4

-- REPLACE deletes the conflicting row and inserts a new one, so
-- added_at is not kept — it falls back to its default. Run the check
-- above again afterwards and compare.
INSERT OR REPLACE INTO wishlists (customer_id, product_id)
VALUES (1, 288);
在 Planche 中运行

1 行被修改

再跑一遍检查查询,added_at 已经不是那个 2026 年的日期,而是当前时间了。REPLACE 会删掉冲突的那一行再插入一行新的,所以你没写出来的每个列都会退回默认值。在列更多的表上,这会悄悄丢掉真实数据,而且那次删除会触发删除触发器,并顺着外键级联到子表的行。

只有当你真的想"把旧行扔掉"时才用它。要"插入或更新",用 ON CONFLICT。