代码 SQLite 3.53 ALTER COLUMN SET NOT NULL:六次写入替代全表重建

2026-10-04 20:01:52

SQLite 3.53 ALTER COLUMN SET NOT NULL:六次写入替代全表重建

用 SQLite 的这些年里,给一个已有列加 NOT NULL 约束一直意味着那套十二步流程:建一张带目标约束的新表,把每一行拷过去,删掉旧表,重命名新表,再把你刚才顺手弄丢的索引重建一遍。2026 年 4 月发布的 SQLite 3.53.0 加入了 ALTER TABLE ... ALTER COLUMN ... SET NOT NULL 和 DROP NOT NULL,所以我建了几张测试表,实测一下到底什么变了。

测试用的是真实的 3.53.4 命令行工具,不是 Ubuntu apt 源里那个——源里最高还停在 3.45.1,完全不认识新语法。

重建的耗时

我生成了一张 200 万行的表,并在即将加约束的那一列上建了索引,每次运行前清空操作系统页缓存(echo 3 > /proc/sys/vm/drop_caches),两种方式各测三次。

旧法:

CREATE TABLE t_new(id INTEGER PRIMARY KEY, email TEXT NOT NULL, created_at TEXT);
INSERT INTO t_new SELECT * FROM t;
DROP TABLE t;
ALTER TABLE t_new RENAME TO t;
CREATE INDEX idx_email ON t(email);

新法:

ALTER TABLE t ALTER COLUMN email SET NOT NULL;
方式第 1 次第 2 次第 3 次
重建(旧法)1.788s1.495s1.594s
SET NOT NULL(带索引列)0.0093s0.0113s0.0097s

大约快 150 到 180 倍,而且这不是四舍五入造成的错觉:重建还得从头重建 idx_email,旧法要求你记得手动做这一步。忘了这一步,查询就会悄悄退化成全表扫描。SET NOT NULL 则让索引原地不动。

为什么这么快:它几乎不写盘

这个速度差让我起疑,于是我在同一数据库的一份新副本上,用 strace -c 统计 pwrite64 调用来跑两种方式。

=== OLD WAY ===
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
100.00    1.001682          14     69843           pwrite64

=== NEW WAY ===
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         6           pwrite64

六次写入,共 8,716 字节;重建则是 69,843 次写入。重建会把每一行碰两遍——一次写进新表,重建索引时再来一遍。SET NOT NULL 只重写 sqlite_master 里的 schema 记录;数据页根本不动。

代价:仍然要读每一行,除非有索引

下面这个发现和标题上的数字是相冲突的。SQLite 自己的发布说明提到,为新的 NOT NULL 约束执行 ALTER TABLE 的耗时「与表中数据量成正比」,因为每一行现存数据都必须被检查。在我第一张表上这一点完全看不出来——200 万行只要 0.01 秒,好得不像真的,所以我又在一个没有索引的列上跑了一遍。

=== indexed column, 8M rows: read syscalls ===
calls
13 pread64

=== unindexed column, 8M rows: read syscalls ===
calls
25372 pread64

当目标列上有索引时,SQLite 直接沿着索引找 NULL——在 SQLite 的 B 树里 NULL 排在最前,所以只需要看最左侧边缘,无论表多大都是 13 次页读取。没有索引时,它必须扫描每一个数据页:800 万行的表就是 25,372 次读取。

在热缓存下,这个差异在墙上时钟上几乎消失(两者都远不到一秒,因为 Linux 直接从内存里取页)。冷缓存的数据更能说明问题:

列上有索引?冷缓存耗时(800 万行,created_at / email)
有~0.010s
无~0.11s

两种情况下都还是比重建快得多,但文档里那句「与表大小成正比」只有在被约束的列没有索引支撑时才会真正咬人。

它会拒绝什么,以及报错有多不友好

给一张已有 NULL 的表执行,它按预期拒绝:

$ sqlite3 t.db "ALTER TABLE t ALTER COLUMN b SET NOT NULL;"
Error in 2nd command line argument: constraint failed

对比一下通过普通 INSERT 触发同样违规时的报错:

$ sqlite3 t.db "INSERT INTO t VALUES (1, NULL);"
Error near line 1: NOT NULL constraint failed: t.b

INSERT 的报错会点明表名和列名。ALTER TABLE 的报错只有一句 constraint failed,别的什么都没有——不说哪一列,不说哪种约束,不说有多少行不合格、是哪一行。在一张有多个可空列、你想逐个收紧的真实表上,这条信息根本没法告诉你出问题的是哪一个。你得自己去找违规行,比如:

SELECT rowid FROM t WHERE email IS NULL LIMIT 5;

一个能用但文档里没有的功能

测 CHECK 约束时,我习惯性地试了 ANSI SQL 风格的语法,本以为会解析报错,因为 SQLite 自己的 lang_altertable.html 页面只记录了 SET NOT NULL / DROP NOT NULL,并且说 CHECK 约束只能通过 ADD COLUMN 添加。

ALTER TABLE t ADD CONSTRAINT age_positive CHECK (age >= 0);

它成功了。它校验了现存行,之后拒绝了一次负数插入,并且报错里带上了正确的名字(CHECK constraint failed: age_positive),ALTER TABLE t DROP CONSTRAINT age_positive; 也能干净地把它移除。我在 ALTER TABLE 参考页的原始 HTML 里搜过字面量 "ADD CONSTRAINT" 和 "DROP CONSTRAINT"——两个方向都是零匹配。这是一个完整可用、可往返的功能,但我在 sqlite.org 上找不到任何地方记录它。

锁行为:读可以通过,写不行

我用一个 FIFO 保持一个 sqlite3 CLI 会话开着,其中有一个未提交的 BEGIN IMMEDIATE 写事务,然后从第二个连接对这个已锁定的数据库执行 ALTER。

--- ALTER with busy_timeout=0, while a write lock is held ---
Error in 2nd command line argument: database is locked
immediate-fail attempt wall=0.004s

--- ALTER with busy_timeout=3000, same lock held for 0.3s then released ---
wait-then-succeed attempt wall=0.982s

没有超时设置时它立刻失败。设了超时后,它会排队,锁一释放就执行——和其他写操作一样。不意外,但值得确认,因为这意味着跑这个迁移的连接必须设置 busy_timeout,和其他任何 schema 变更一样。

更有意思的是并发读而不是并发写。我在后台启动那个慢的(无索引、800 万行)SET NOT NULL,让它扫描进行到 150ms,然后从第二个连接用 busy_timeout=0 执行 SELECT count(*) FROM t,分别在默认的 rollback journal 模式和 WAL 模式下:

DELETE mode: reader wall=0.097s, returned 8000000, ALTER total wall=0.658s
WAL mode:    reader wall=0.111s, returned 8000000, ALTER total wall=0.322s

两种模式下读都立即成功,没有等 ALTER 结束。NOT NULL 扫描只在读阶段需要共享锁;直到最后那次很小的 schema 写入,它才拿排他锁。如果你的应用是读密集型的,即使表很大,跑这个迁移也不会卡住读。

no-op 的说法被验证了

文档说对一个已经是 NOT NULL 的列调用 SET NOT NULL 是 no-op。我连着跑了两次:

ALTER TABLE t ALTER COLUMN email SET NOT NULL;  -- 成功,加上约束
ALTER TABLE t ALTER COLUMN email SET NOT NULL;  -- 再次成功,退出码 0

第二次调用前后 schema 完全一致,而且第二次没有触发新约束才会有的那种 pread64 密集校验扫描。DROP NOT NULL 的表现也正确:删掉之后,往该列插入 NULL 又能成功了。

我在过程中踩的坑

第一次做并发测试时,我用的是一个后台 shell 循环,写任何东西之前先检查某个标志文件是否存在。循环和 touch 那个标志文件是两条独立命令,背靠背运行时,循环偶尔会先启动、检查文件、发现不存在、退出——而 touch 还没真正执行。结果就是测试报告「记录到 0 次写尝试」,看起来像完全没有并发活动,其实是被误导了。我改用命名管道持有一个常驻 sqlite3 会话,并显式 BEGIN IMMEDIATE,这样我拿到的是一把直接可控的锁,而不是指望循环能及时抢到的锁。

自己跑一遍

拿到真实的 CLI,发行版打包落后太多了:

curl -sS -L -o sqlite-tools.zip \
  https://www.sqlite.org/2026/sqlite-tools-linux-x64-3530400.zip
unzip -o -q sqlite-tools.zip
./sqlite3 --version   # 应输出 3.53.4 或更高

建一张测试表,对比两种方式:

./sqlite3 bench.db <<'EOF'
CREATE TABLE t(id INTEGER PRIMARY KEY, email TEXT, created_at TEXT);
WITH RECURSIVE seq(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM seq WHERE x < 2000000)
INSERT INTO t(id, email, created_at) SELECT x, 'user'||x||'@example.com', datetime('now') FROM seq;
CREATE INDEX idx_email ON t(email);
EOF
cp bench.db newway.db
time ./sqlite3 newway.db "ALTER TABLE t ALTER COLUMN email SET NOT NULL;"
cp bench.db oldway.db
time ./sqlite3 oldway.db "
  CREATE TABLE t_new(id INTEGER PRIMARY KEY, email TEXT NOT NULL, created_at TEXT);
  INSERT INTO t_new SELECT * FROM t;
  DROP TABLE t;
  ALTER TABLE t_new RENAME TO t;
  CREATE INDEX idx_email ON t(email);
"

想做写入次数对比的话,把 time 换成 strace -f -e trace=pwrite64 -c 跑同样的两条命令。

该怎么用

如果你在 SQLite 3.53 或更高版本上,需要收紧一个已经在生产里的 schema,可以放心用 ALTER TABLE ... ALTER COLUMN ... SET NOT NULL——它更快,不打扰索引,也不会阻塞并发读。但在对任何不能立刻重跑的东西执行之前,先自己用显式的 SELECT ... WHERE col IS NULL 检查目标列有没有 NULL:报错信息不会告诉你违规在哪里。如果你需要在活动表上加或删 CHECK 约束,ADD CONSTRAINT 和 DROP CONSTRAINT 已经能用,尽管手册还没提到它们——只是别意外,这更可能是文档缺口而不是永久特性,留意后续发布说明,以防这个语法在正式写进文档前发生变化。

复制全文 生成海报 SQLite 数据库 性能 Schema迁移 ALTER TABLE

推荐文章

程序员茄子在线接单