编程 同一个 DELETE,changes() 返回 1,total_changes 涨了 3

2026-11-07 00:05:59

同一个 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_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_total_changes

五个探针:把差值跑出来

下面的脚本可以在 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 月)。

参考:expo/expo#3163

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 判断冲突,只能看影响行数;而影响行数是“命中了几行”,不是“数据是否真的变了”。

参考:uptrace/bun#556

各宿主暴露的口径

同一个概念,不同驱动的实现路径并不一样:

  • 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 不适合做业务判据。它随连接生命周期单调增长,连接池复用的又是同一条连接,累计值里混着此前所有请求的改动,拿来判断“这次操作成没成”没有意义。它适合做两件事:调试时观察触发器/级联有没有真的发生,以及在同一连接内做前后差值对比。

推荐文章

程序员茄子在线接单