在数据库日常操作中,向表里插入一条数据,如果这条数据已经存在(比如主键相同或者违反了唯一索引),常见的处理办法是先查询判断是否存在,存在则执行UPDATE,不存在则执行INSERT。这种“先查后写”的模式不仅代码繁琐,而且在并发场景下容易出错。UPSERT正是为了解决这个问题而生的语法:一条语句搞定“存在则更新、不存在则插入”。SQLite从3.24版本开始引入UPSERT,而在3.33版本中又对其进行了若干增强,本文就来详细讲解这些变化以及实际用法。

UPSERT的基本语法与工作原理
UPSERT并不是一个独立的关键字,而是INSERT语句的一种扩展写法,完整的语法形式是INSERT INTO ... ON CONFLICT ... DO UPDATE ...或者ON CONFLICT DO NOTHING。当INSERT触发了某个约束冲突(通常是PRIMARY KEY或UNIQUE约束),SQLite不会中止语句,而是转到ON CONFLICT后面的处理逻辑。
基本写法如下:
-- 存在则更新库存,不存在则插入新记录
INSERT INTO inventory (item_id, qty, price)
VALUES (1001, 50, 9.90)
ON CONFLICT(item_id) DO UPDATE SET
qty = qty + 50,
price = excluded.price;这段语句的含义是:如果item_id为1001的记录已存在,就把该记录的qty加上50,并把price更新为本次插入尝试的值;如果不存在,就正常插入。这里的excluded是一个虚拟表,代表“原本想插入但被冲突拒绝的那一行”,通过它可以引用新值,而裸列名(如qty)则引用表中已存在的旧行的值,二者配合即可实现灵活的增量更新。
需要注意ON CONFLICT后面括号里指定的冲突目标必须与实际的约束匹配。如果表中在item_id上建有唯一索引,那么写ON CONFLICT(item_id)是合法的;如果指定的列上根本没有唯一约束,SQLite会直接报语法错误,这一点与PostgreSQL的行为一致。
3.33版本的具体增强点
SQLite 3.33发布说明中明确提到,UPSERT在两个方面得到了改进。第一是解析歧义的消除:早期版本中,如果UPSERT语句是某个较大语句的一部分(例如出现在CREATE TRIGGER的触发体里,或者被C语言代码通过API拼接执行),语法分析器可能把ON CONFLICT子句错误地理解为旧式的INSERT OR CONFLICT动作。3.33调整了解析器规则,要求在可能产生歧义的上下文中使用带括号的冲突目标形式,从而避免误解析。简单来说,就是让UPSERT在触发器和复杂语句中的行为更加可靠、可预测。
第二个增强与last_insert_rowid()和DO UPDATE的配合有关。3.33之前的版本中,当UPSERT走的是DO UPDATE分支时,sqlite3_last_insert_rowid()返回的值可能不够直观;3.33修正了这一行为,使得在DO UPDATE执行后能更准确地获取相关行的rowid,方便应用程序在触发器或回调中追踪受影响的行。
下面这个例子展示了在触发器中使用UPSERT时3.33增强带来的写法上的确定性:
CREATE TABLE stats (
word TEXT PRIMARY KEY,
cnt INTEGER DEFAULT 0
);
-- 在触发器体中使用UPSERT,3.33保证了语法解析的准确性
CREATE TRIGGER log_word AFTER INSERT ON messages
BEGIN
INSERT INTO stats(word, cnt) VALUES (NEW.word, 1)
ON CONFLICT(word) DO UPDATE SET cnt = cnt + 1;
END;这种“词频统计”模式是UPSERT最经典的应用场景之一。每次messages表插入新消息,触发器就尝试向stats表写入一个词,如果这个词已经统计过,就把计数加一。整段逻辑没有任何条件判断,全部交给数据库原子完成,既简洁又避免了并发环境下的竞态问题。
UPSERT与INSERT OR REPLACE的区别
很多人会把UPSERT和INSERT OR REPLACE混为一谈,认为二者可以互换,实际上它们的语义差别很大。REPLACE在遇到冲突时的处理方式是:先删除引发冲突的旧行,再插入新行。这意味着旧行在逻辑上被“删掉了”,会触发DELETE触发器,其他未被赋值的列会回到默认值,而且如果有外键定义了ON DELETE CASCADE,级联删除也会被触发,可能把关联表的数据连带清掉,风险相当高。
而UPSERT的DO UPDATE是真正的更新操作:旧行一直存在,只是列值被修改,触发的UPDATE触发器而非DELETE,未出现在SET子句中的列保持原值不变,外键关系也不会被破坏。从数据安全角度看,UPSERT明显更可控。
用一个表格总结二者的核心差异:
| 对比项 | INSERT OR REPLACE | UPSERT (DO UPDATE) |
|---|---|---|
| 冲突时旧行的处理 | 删除旧行再插入新行 | 原位更新列值 |
| 触发的触发器类型 | DELETE和INSERT触发器 | UPDATE触发器 |
| 未赋值列的结果 | 变为默认值 | 保持原值 |
| 能否引用旧值做增量计算 | 不能 | 可以,直接使用裸列名 |
| 外键级联风险 | 可能触发级联删除 | 不会 |
此外还有一个容易被忽视的点:REPLACE无法实现像qty = qty + 50这样的增量更新,因为它根本拿不到旧值。只要业务中存在“在原有基础上累加”的需求,UPSERT就是唯一合理的选择。
完整实战示例与常见注意事项
下面通过一个完整的库存管理场景演示UPSERT的典型用法,包括建表、批量导入和条件更新:
-- 创建库存表
CREATE TABLE inventory (
item_id INTEGER PRIMARY KEY,
item_name TEXT NOT NULL,
qty INTEGER NOT NULL DEFAULT 0,
price REAL NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
-- 批量入库:存在则累加数量并更新价格,不存在则插入
INSERT INTO inventory (item_id, item_name, qty, price) VALUES
(1001, '键盘', 30, 199.00),
(1002, '鼠标', 50, 89.00),
(1003, '显示器', 10, 1299.00)
ON CONFLICT(item_id) DO UPDATE SET
qty = qty + excluded.qty,
price = excluded.price,
updated_at = datetime('now');
-- 仅当新价格更低时才更新,否则跳过
INSERT INTO inventory (item_id, item_name, qty, price)
VALUES (1001, '键盘', 20, 179.00)
ON CONFLICT(item_id) DO UPDATE SET
price = excluded.price,
updated_at = datetime('now')
WHERE excluded.price < inventory.price;最后一条语句展示了DO UPDATE后跟WHERE子句的用法:只有当本次尝试插入的价格低于库存中现有价格时才执行降价更新,否则这条冲突会被静默忽略。这个特性在做“取最低价”“取最新版本”之类的合并逻辑时非常实用。
使用UPSERT还有几点注意事项值得留意。其一,ON CONFLICT指定的冲突目标必须对应表上实际存在的PRIMARY KEY或UNIQUE约束,如果表上有多个唯一索引,必须明确指定处理哪一个,不指定冲突目标的写法只适用于DO NOTHING的简化形式。其二,由于历史原因,SQLite要求在可能产生解析歧义的场合(3.33之前)使用ON CONFLICT ROWID这类写法时要特别小心,升级到3.33之后解析规则更严谨,建议始终采用带括号的明确写法以保持前向兼容。其三,使用命令行工具测试时,可以通过SELECT sqlite_version();确认当前版本是否达到3.33,避免在旧版本上使用新行为导致结果与预期不符。
总的来说,UPSERT把原本需要“查询、判断、更新或插入”三步才能完成的逻辑压缩成一条原子语句,配合3.33版本在解析准确性和rowid行为上的增强,在触发器、批量数据同步、计数统计等场景下都更加可靠。如果你的项目还在用先查后写或者REPLACE的方式处理冲突数据,不妨迁移到UPSERT,代码会更简洁,数据也更安全。
SQLiteUPSERTINSERT ON CONFLICT修改时间:2026-09-01 01:40:42