同一个 DELETE,changes() 返回 1,total_changes 涨了 3
SQLite 提供两个查看“改了几行”的接口,名字只差一个词,统计口径却差出整个触发器程序。工程里几乎所有“用 RowsAffected 判断是否命中”的 bug,都源于把这两个值当成同一个东西。
sqlite3_changes:只算语句直接改的行
sqlite3_changes() / sqlite3_changes64() 返回最近一条已完成的 INSERT、UPDATE、DELETE 直接修改的行数。执行其他类型的 SQL 不会改动这个值。
口径边界写得很死:
- 因触发器、外键动作或 REPLACE 约束消解产生的辅助变更,不算。
CREATE TABLE AS SELECT不算 INSERT,新建表插入的行不计。- 被 INSTEAD OF 触发器拦截的视图 DML 不计;对视图执行 INSERT/UPDATE/DELETE 之后立刻读
sqlite3_changes(),结果恒为 0。只有真实表的改动才算。
触发器内部的情况稍复杂。文档给出的顺序是:进入触发器程序前保存 sqlite3_changes() 的值,触发器程序结束后恢复原值;触发器程序内的每条 INSERT/UPDATE/DELETE 在完成时照常设置这个值,但不含子触发器的改动,因为子触发器运行完毕后会再保存和恢复一次。
换成实际操作看,这条规则很别扭:触发器内第一条 DML 语句里的 changes() 取到的是调用语句开始执行时的值,不是外层语句最终命中多少行;从第二条起,才取到同一次触发器内上一条 DML 的行数。因此,在触发器内用 changes() 判断外层语句命中几行,几乎一定会读错。
另外,如果有别的线程在同一连接上并发修改,返回值不可预测。
sqlite3_total_changes:加上触发器程序与外键动作,但不含 REPLACE
sqlite3_total_changes() / sqlite3_total_changes64() 返回的是连接打开以来所有已完成 INSERT/UPDATE/DELETE 影响的行数累计值。
和 changes() 的差别:
- 触发器程序内执行的语句计入。
- 外键动作(级联删除、级联更新、置空等)计入。
- 因 REPLACE 约束消解而删掉的旧行不计入。
- 被 INSTEAD OF 触发器拦截的视图 DML 同样不计。
它只统计连接 D 上跑过的语句,其他连接做的修改一律不算。若需检测别的连接改了数据库文件,改用 PRAGMA data_version 或 SQLITE_FCNTL_DATA_VERSION。多线程并发访问同一连接时,照样不可预测。
五个探针:把差值跑出来
下面的脚本可以在 sqlite3 probe.db 里逐段粘贴执行,注释里是每次 SELECT 的预期输出。
PRAGMA foreign_keys = ON;
CREATE TABLE parent(id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE child(
id INTEGER PRIMARY KEY,
parent_id INTEGER NOT NULL REFERENCES parent(id) ON DELETE CASCADE
);
CREATE TABLE audit(id INTEGER PRIMARY KEY, note TEXT);
INSERT INTO parent(id, name) VALUES (1,'a'),(2,'b');
INSERT INTO child(id, parent_id) VALUES (10,1),(11,1),(12,2);
SELECT changes(), total_changes(); -- 3|5 (上一条插了 3 行 child,累计 2+3)
A. 外键级联
DELETE FROM parent WHERE id = 1;
SELECT changes(), total_changes(); -- 1|8 (parent 1 行,级联删掉 2 行 child)
B. 触发器
CREATE TRIGGER trg_parent_del AFTER DELETE ON parent BEGIN
INSERT INTO audit(note) VALUES ('parent ' || OLD.id);
END;
DELETE FROM parent WHERE id = 2;
SELECT changes(), total_changes(); -- 1|11 (parent 1 + 级联 child 1 + 触发器写 audit 1)
C. REPLACE 冲突消解
CREATE TABLE kv(k TEXT PRIMARY KEY, v TEXT) WITHOUT ROWID;
INSERT INTO kv VALUES('a','1');
SELECT total_changes(); -- 12
REPLACE INTO kv VALUES('a','2');
SELECT changes(), total_changes(); -- 1|13 (旧行被顶掉,但这 1 行不计)
这一条最容易引起误判:行数只涨了 1,看起来像普通插入,实际上旧行已经被消解掉,表内容和计数并不一一对应。
D. INSTEAD OF 视图
CREATE VIEW v_parent AS SELECT id, name FROM parent;
CREATE TRIGGER trg_v_ins INSTEAD OF INSERT ON v_parent BEGIN
INSERT INTO parent(id, name) VALUES (NEW.id, NEW.name);
END;
INSERT INTO v_parent(id, name) VALUES (3, 'c');
SELECT changes(), total_changes(); -- 0|14 (changes 恒 0,total 计触发器里那 1 行)
E. 触发器内读 changes() 的保存/恢复
CREATE TABLE t(id INTEGER PRIMARY KEY);
CREATE TABLE log(id INTEGER PRIMARY KEY, note TEXT, seen INTEGER);
CREATE TRIGGER trg_t AFTER INSERT ON t BEGIN
INSERT INTO log(note, seen) VALUES ('first', changes()); -- 外层调用语句的上下文值
INSERT INTO log(note, seen) VALUES ('second', changes()); -- 上一条 log 插入的行数:1
END;
INSERT INTO t(id) VALUES (1),(2);
SELECT * FROM log;
-- 1|first |0
-- 2|second|1
-- 3|first |0
-- 4|second|1
同一个触发器里相邻两条语句读到不同值,就是保存、恢复顺序在起作用。把业务判断写在第一条 DML 的 changes() 上,拿到的是 0;写到第二条,拿到的是触发器自己的上一步行数。两种都不是“外层语句命中了几行”。
expo-sqlite:iOS 上曾经把 total_changes 当 rowsAffected
expo/expo#3163 记录的就是这个错误。iOS 端的 expo-sqlite 用 sqlite3_total_changes 实现 rowsAffected,作者指出:一个事务里连续跑多条 insert/update/delete,返回的计数会把触发器带来的改动一起算进去,调用方拿不到“这条语句本身改了几行”。
issue 里引用的正是两段文档原文:sqlite3_total_changes 含触发器程序,sqlite3_changes 只算直接改动、不含触发器/外键/REPLACE 辅助变更。修复方式是改用 sqlite3_changes,落在 PR #9317「[expo-sqlite] Correct rowsAffected result value on iOS」(2020 年 7 月)。
bun:ON CONFLICT DO NOTHING 返回 0 行,而不是 sql.ErrNoRows
go-pg 时代,OnConflict("(name) DO NOTHING").Returning("*").Insert() 在冲突时返回 pg.ErrNoRows,调用方可以直接用 err == pg.ErrNoRows 判断“撞了重复”。
换到 bun 后,pg.ErrNoRows 换成了 sql.ErrNoRows,但 db.NewInsert().Model(&m).Ignore().Returning("*").Exec(ctx) 在冲突时不返回任何 error,而是返回一个 RowsAffected() 为 0 的 sql.Result。也就是说,冲突信号从 error 挪到了计数上。issue 问这是不是预期行为,最终以「Answered in #752」关闭。
对写业务代码的人来说,这等于强制换判据:不能再用 err 判断冲突,只能看影响行数;而影响行数是“命中了几行”,不是“数据是否真的变了”。
各宿主暴露的口径
同一个概念,不同驱动的实现路径并不一样:
- Go
database/sql:Result.RowsAffected() (int64, error),语义由驱动决定。SQLite 驱动一般直接落到sqlite3_changes。 - better-sqlite3:
stmt.run()返回{ changes, lastInsertRowid },是语句级计数;累计值单独通过db.totalChanges暴露。 - node-postgres:
result.rowCount来自后端CommandComplete的命令标签(例如"DELETE 3"),是服务端给的数字,不是客户端自己数的。 - 引擎之间也不一致:MySQL 返回的影响行数含触发器修改的行;PostgreSQL 的 UPDATE 计数只算语句自己命中的行。同一个
RowsAffected字段,在不同引擎上回答的不是同一个问题。
判“命中”和判“真变”是两件事
把上面几条并起来看:
- 触发器写的数据不在
changes()里; - 外键级联在
changes()里看不到,只在total_changes里; - REPLACE 消解掉的行两边都不计;
- 视图 DML 的
changes()恒为 0; total_changes只是连接级累计值。
所以“这条语句有没有命中行”和“数据到底有没有变”必须当成两个信号分别处理。
前者可以用 changes() / RowsAffected 做快路径:命中 0 行就提前返回。但它只能回答“语句直接动了几个真实表的行”,回答不了“值是不是真的不一样了”。
后者要靠数据本身:加一个版本列,UPDATE ... SET v = ?, ver = ver + 1 WHERE k = ? AND ver = ?,命中后回读 ver 才能确认状态转移发生过;或者用幂等键 + 唯一约束,让重复请求在写入侧就被挡住,而不是依赖计数判断。
total_changes 不适合做业务判据。它随连接生命周期单调增长,连接池复用的又是同一条连接,累计值里混着此前所有请求的改动,拿来判断“这次操作成没成”没有意义。它适合做两件事:调试时观察触发器/级联有没有真的发生,以及在同一连接内做前后差值对比。