作为一个常年把 SQLite 当本地配置中心和离线缓存在用的开发者,我写的最多的 SQL 可能不是 SELECT,恰恰是 INSERT。原因很直白:业务上一旦涉及新增数据、初始化表、同步增量,几乎所有写入口最后都会落到这条语句上。它看起来就一行 INSERT INTO ... VALUES ...,简单到很多教程一句带过,可真到项目里,值插错列、约束冲突没察觉、想更新却用了 REPLACE 导致关联数据被删、批量写入慢到怀疑人生——这些问题我全踩过。所以这篇东西不想只罗列语法,我想把 INSERT 在 SQLite 里从基础到进阶的完整脉络讲清楚,尤其是那些语法树上没写、但实际开发里绕不开的细节,比如事务边界、冲突策略、跨表搬运、自增 ID 取回,以及高频报错怎么定位。适合刚开始用 SQLite 写本地存储的移动端开发者,也适合那些已经写了很多 INSERT 但偶尔被灵异现象坑一把的桌面端和嵌入式从业者。
1. 一条 INSERT 的基本生命周期:从最小语句到列映射规则
1.1 最小可用语句与表结构的关系
创建一张最普通的用户表,整个 INSERT 的讨论都围绕它展开:
sql复制CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
age INTEGER DEFAULT 18,
email TEXT UNIQUE,
created_at TEXT DEFAULT (datetime('now'))
);
最直观的插入写法:
sql复制INSERT INTO users (name, age, email)
VALUES ('张三', 28, 'zhangsan@example.com');
注意这里我显式列出了 name, age, email 三列,VALUES 里依次对应。列的顺序不一定非要和建表语句一致,比如写成 INSERT INTO users (email, age, name) 也完全合法,只要 VALUES 的顺序和括号里的列顺序匹配就行。这个特性在做数据迁移时特别有用:源表字段顺序和目标表字段顺序往往不一样,只要列名写清楚,就不会插错。
如果完全不写列名,直接写:
sql复制INSERT INTO users VALUES ('李四', 22, 'lisi@example.com');
这种写法要求 VALUES 必须严格按照建表时从左到右的字段顺序,一个都不能少,连 id 和 created_at 都得给出来。日常开发我基本不推荐这种写法,因为表结构一旦调整,比如中间加一列,这条 SQL 就挂了,而且挂得毫无预兆。业务系统里最稳妥的永远是显式列出列名。
1.2 省略列时发生什么
不写列名时不代表数据库会报错,相反,SQLite 对“没说到的列”有一套默认逻辑:
- 列在建表时声明了
DEFAULT,就用默认值; - 没有声明的列,填 NULL;
- 该列是
INTEGER PRIMARY KEY或自增主键,SQLite 会自动分配一个未使用过的整数 ID。
用我们这张 users 表举例:
sql复制INSERT INTO users (name) VALUES ('王五');
这条语句插进去的结果是:id 自动分配,age 用默认值 18,email 为 NULL,created_at 用当前时间。这个自动分配和默认值机制让 INSERT 语句可以写得很短,但也容易让人忽略一个事实:如果你省略的列在业务上本来不该为空,那 SQLite 不会管你业务上的“不该为空”,它只会管建表语句里有没有 NOT NULL 约束。
比如把 email 改成 NOT NULL 的常见做法:
sql复制CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL
);
INSERT INTO users (name) VALUES ('王五');
这时候 SQLite 会直接抛错:NOT NULL constraint failed: users.email。这个错误大家应该都见过,但很多时候不是因为我们不想写 email,而是业务对象的 email 字段恰好为空,ORM 或者我们自己拼 SQL 的时候没过滤。所以排查这条错误时,先别急着看 SQL 语法,先去检查那些“被省略的列”为什么是空。
1.3 SQLite 的弱类型在 INSERT 里的真实表现
SQLite 是弱类型数据库,这直接影响 INSERT 行为。如果表不是 STRICT 模式,你把一个数字插进 TEXT 列,或者把一个字符串插进 INTEGER 列,SQLite 最终存什么取决于它的类型亲和性规则。
sql复制CREATE TABLE demo (
a INTEGER,
b TEXT
);
INSERT INTO demo (a, b) VALUES ('123', 456);
这条语句在普通表里不会报错。因为 a 列有 INTEGER 亲和性,字符串 '123' 能被无损转成整数 123,于是 a 存的是整数;b 列有 TEXT 亲和性,整数 456 会转成文本 '456'。但如果你写:
sql复制INSERT INTO demo (a, b) VALUES ('abc', 456);
'abc' 无法转成整数,SQLite 就会按 TEXT 存进 INTEGER 列。这类“插入成功但类型和预期不一致”的情况,是很多坑的源头。比如你用 WHERE a = 123 查询,可能查不到那行,因为存进去的是字符串 'abc';再比如应用层读出来强转 Int 时直接崩溃。
从 3.37.0 版本开始,SQLite 提供了 STRICT 表:
sql复制CREATE TABLE demo_strict (
a INTEGER,
b TEXT
) STRICT;
此时 INSERT INTO demo_strict (a) VALUES ('abc') 会直接报错,而不是默默存一个不同类型的值。新项目里我强烈建议默认建 STRICT 表,尤其是需要长期维护的数据结构。老项目该不该改?如果表结构稳定、存量数据没有脏类型,也可以考虑迁移,但迁移成本主要不在 Insert,而在于所有历史写入必须重放一遍校验。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 打破逐条提交的误区:多值插入与事务边界
2.1 一次插入多条记录的正确姿势
业务上经常会遇到初始化一批数据,比如导入 100 条用户记录。最容易想到的写法是程序里循环执行 100 次单条 INSERT。不是不行,但在 SQLite 这种嵌入式数据库里,效率往往惨不忍睹。更标准的做法是使用多值语法:
sql复制INSERT INTO users (name, age, email) VALUES
('张三', 28, 'zhangsan@example.com'),
('李四', 22, 'lisi@example.com'),
('王五', 30, 'wangwu@example.com');
一条语句就插入了三行。SQLite 对 VALUES 里能放宽多少行没有硬性限制,真正限制它的是另一个参数:SQLITE_MAX_VARIABLE_NUMBER,默认值是 32766(老版本是 999)。如果你用参数绑定而不是拼字面量,那每个 ? 占一个变量,三列的数据插 20000 行,就需要 60000 个变量,直接超出上限,报错:
text复制too many SQL variables
所以“多值插入是不是行数越多越好”这个问题没有固定回答。用绑定参数时,一般批量控制在每批 500 到 1000 行比较稳妥。按每行 3 个变量算,1000 行就是 3000 个变量,离 32766 还远,执行时间和事务开销也更可控。
2.2 事务是批量写入的真正利器
如果说多值语法是把 100 条 INSERT 合并成 1 条 SQL,那事务就是把 N 条 SQL 的执行过程用一个原子操作包起来。SQLite 在默认的自动提交模式下,每执行一条 INSERT,都要等数据真正落到磁盘才返回。磁盘刷盘是这里最大的性能瓶颈——即使后台有页面缓存,事务提交时的 fsync 仍然很昂贵。
比较直观的两种写法:
sql复制-- 写法一:逐条自动提交,预期很慢
INSERT INTO users (name) VALUES ('A1');
INSERT INTO users (name) VALUES ('A2');
-- ... 重复 1000 次
sql复制-- 写法二:显式包一个事务,量级提升
BEGIN;
INSERT INTO users (name) VALUES ('A1');
INSERT INTO users (name) VALUES ('A2');
-- ... 重复 1000 次
COMMIT;
两种写法最终都会插入 1000 行,但耗时能差出一个量级。原因是写法二只做了一次磁盘刷盘,写法一做了 1000 次。实际工程里我并不建议手动拼这么长的 SQL 文本,而是用语言驱动提供的事务 API,比如 Python 的 sqlite3:
python复制import sqlite3
conn = sqlite3.connect("app.db")
cursor = conn.cursor()
data = [("user_%d" % i, 20 + i) for i in range(10000)]
cursor.execute("BEGIN")
try:
cursor.executemany(
"INSERT INTO users (name, age) VALUES (?, ?)",
data
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
executemany 配合显式 BEGIN/COMMIT 是把 10000 行数据写进 SQLite 的最常规路径。基于 C 语言的 sqlite3_prepare 绑定接口也是同样的思路:循环调用 sqlite3_bind_text/bind_int,绑定完成一组执行一次,全部完成后再提交。
2.3 事务不能随便乱开,尤其是长事务
既然事务能大幅提升性能,那是不是把几千条 INSERT 全放进一个事务里就高枕无忧了?长事务有它的代价。SQLite 的写锁是数据库级锁,一个写事务持有锁期间,其他连接的写操作会被阻塞或者返回 SQLITE_BUSY。如果你的导入逻辑在事务里还夹杂了网络请求、人工确认这类耗时操作,那整个数据库在事务结束前都处于“只读”状态,这在高并发客户端场景下会引发严重的“database is locked”报错。
所以我的习惯是:
- 纯批量插入、没有外部 IO 的流程,可以放心用大事务;
- 如果一批插入里需要逐条调用外部接口做校验,就把大任务切成若干小批次,每批 200-500 条一个事务,避免长时间持锁。
提示:SQLite 同一时刻只允许一个写事务。哪怕你用 WAL 模式,也只是读写并发变好了,写写之间仍然是互斥的。
3. 主键重复了怎么处理:UPSERT 的几种路由和 OR REPLACE 的坑
相关热门搜索词里有一条很典型:sqlite 存在就更新不存在就新增。这个需求几乎所有业务系统都有,比如同步远端配置到本地表,可能那条记录已经存在,直接 INSERT 会撞 UNIQUE 约束,先 SELECT 再判断再 UPDATE 又是又慢又容易出并发问题的土办法。SQLite 里正确姿势是 UPSERT。
3.1 ON CONFLICT 是正统方案
官方叫法是 UPSERT,语法核心是 ON CONFLICT ... DO UPDATE。继续用 users 表,email 上有一个 UNIQUE 约束,当 email 已经存在时,我们希望更新用户的 age 和 name,而不是插入新行:
sql复制INSERT INTO users (name, age, email)
VALUES ('张三-改', 29, 'zhangsan@example.com')
ON CONFLICT(email) DO UPDATE SET
name = excluded.name,
age = excluded.age;
这里的 excluded 是一个特殊概念,表示“如果没有发生冲突、本来要被插入的那一行”。我可以把 excluded 理解成一个虚拟行,它包含本次 INSERT 打算写入的所有字段。DO UPDATE SET age = excluded.age 的意思就是把原行的 age 改成“这次想新插入的那行的 age”。
如果更新的字段很多,可以简写成:
sql复制ON CONFLICT(email) DO UPDATE SET
name = excluded.name,
age = excluded.age,
created_at = excluded.created_at;
SQLite 没有 MySQL 那种 ON DUPLICATE KEY UPDATE 的一键“更新所有列”语法,你需要明确写出要更新哪些列。如果嫌麻烦,可以把所有业务列都列出来,反正查重依赖的是冲突目标那一列或那几列。
3.2 判断冲突目标:单列 vs 复合唯一键
ON CONFLICT(email) 这个写法叫冲突目标,它必须命中一个真实的 UNIQUE 约束或 PRIMARY KEY。如果表上有多个独立 UNIQUE 字段,比如 email 和 name 都设了 UNIQUE,而你的 INSERT 可能同时撞上其中一个,那就不能只写 ON CONFLICT(email),否则另一个唯一键冲突照样报错。
解决方式有两种。一种是在 INSERT 前先精准判断本次业务上最可能冲突的键,把 ON CONFLICT(email) 写对,如果撞了 name,让程序 catch 异常再处理。另一种是使用多个 ON CONFLICT 子句?SQLite 的 INSERT 语句里 ON CONFLICT 只能出现一次,所以没有办法在一个 UPSERT 里同时处理两个不同的唯一键冲突。如果确实想全覆盖,可以先查一遍再决定走插入还是更新,或者退而求其次,用 DO NOTHING。
3.3 OR REPLACE 并不是更新的代名词
有些老代码里能见到这样写:
sql复制INSERT OR REPLACE INTO users (name, age, email)
VALUES ('张三-改', 29, 'zhangsan@example.com');
它确实实现了“存在就更新、不存在就插入”的表面效果,但底层机制是另外一回事:当 UNIQUE 或 PRIMARY KEY 冲突时,SQLite 先删除旧行,然后再插入新行。这件事说大不大,说小不小,后果主要有两个:
- 如果
id是自增主键,REPLACE 之后的 id 大概率会变,因为旧行被删了,新行会拿到一个新的 id; - 如果其他表通过外键引用了旧行的 id,删除旧行可能触发级联删除或者外键约束错误,轻则把关联数据删没,重则 REPLACE 本身失败。
我之前在一个标签系统里就吃过这个亏。taggings 表外键指向 tag 表,我用 INSERT OR REPLACE 同步标签改名,结果所有引用了旧标签 ID 的文章标签关系全部被级联清空。那之后我对所有“看起来像 UPDATE 的 INSERT”都保留警惕。
如果你真的只是想“UPDATE 撞到的行,但保留原主键不动”,请务必用 ON CONFLICT ... DO UPDATE 而不是 OR REPLACE。两句话的意图天壤之别。
3.4 INSERT OR IGNORE 与 DO NOTHING 的适用场景
还有一种需求是“数据已经存在就跳过,不要报错”,最常见于把外部数据灌入本地做增量缓存:
sql复制INSERT OR IGNORE INTO users (name, age, email)
VALUES ('张三', 28, 'zhangsan@example.com');
它等价于:
sql复制INSERT INTO users (name, age, email)
VALUES ('张三', 28, 'zhangsan@example.com')
ON CONFLICT DO NOTHING;
从版本演进上看,INSERT OR IGNORE 是老牌写法,ON CONFLICT DO NOTHING 是后来 UPSERT 语法里补充的,两者效果基本一致。运行时遇到任何 UNIQUE、PRIMARY KEY、NOT NULL 或 CHECK 约束冲突都不抛异常,直接跳过这一行。
提示:OR IGNORE 忽略的不只是唯一键冲突,也包括 NOT NULL 约束和 CHECK 约束冲突。这个“宽泛性”有时候会掩盖真正的脏数据问题,所以日志里应当额外记录被忽略的行数,而不是盲目相信全部插入成功了。
几种冲突策略我整理成了一张表:
| 写法 | 冲突时行为 | 适合场景 | 需要注意 |
|---|---|---|---|
INSERT OR IGNORE / ON CONFLICT DO NOTHING |
跳过不报错 | 增量同步、幂等写入 | 可能掩盖脏数据 |
INSERT OR REPLACE |
删除旧行再插新行 | 很少推荐 | 自增 ID 改变、外键级联风险 |
ON CONFLICT(...) DO UPDATE |
原地更新指定列 | 真正的业务 UPSERT | 冲突目标必须命中唯一键 |
| 不带冲突处理 | 直接报错 | 要求数据绝对不能重复 | 需要调用方处理异常 |
4. INSERT INTO ... SELECT:跨表搬数据,少了全都要
另一个很实用的场景是导入存量数据。比如业务表结构升级,想要把老表的数据清洗后搬到新表;或者把 SQLite 里的某个临时结果集固化到正式表。这时候一条一条 SELECT 再 INSERT 是脱裤子放屁,直接用 INSERT INTO ... SELECT。
4.1 最基础的跨表复制
假设有一张旧用户表 users_old,结构和新表 users 不一样,但核心字段一致:
sql复制INSERT INTO users (name, age, email)
SELECT name, age, email FROM users_old;
SQLite 会执行 SELECT 子查询,把结果逐行插入目标表。这里的匹配逻辑和普通 INSERT 完全一致:SELECT 返回的列必须和语句前方括号列出的列一一对应,顺序不同时靠前面的列名列表来保证。
如果新表和旧表结构完全一致,连列顺序都一样,可以更省事:
sql复制INSERT INTO users SELECT * FROM users_old;
这种写法我建议只在一次性迁移脚本里用。只要表一多一列,SELECT * 就当场崩,比如“table users has 5 columns but 4 values were supplied”。长期维护的代码里,宁可把列名写全,也不要贪图短。
4.2 利用 SELECT 做清洗和过滤
INSERT INTO ... SELECT 最爽的地方在于,SELECT 部分可以是任意查询,所以可以做 WHERE 过滤、JOIN 关联、字段拼接、类型转换。
比如只把已经激活的用户迁到新表:
sql复制INSERT INTO users (name, age, email)
SELECT name, age, email
FROM users_old
WHERE status = 'active';
再比如把旧表的电话号码中的空格和横线去掉:
sql复制INSERT INTO users (name, age, email)
SELECT
name,
age,
replace(phone, '-', '') AS phone_clean
FROM users_old;
这些转换如果放在应用层做,你得先 SELECT 到内存,再逐条 INSERT,IO 压力大且代码冗余。而直接塞进一条 SQL 里,逻辑集中,执行效率也最高。
4.3 搬数据时怎么避免重复
把数据从临时表搬到正式表时,最容易出现的问题就是重复执行脚本导致唯一键冲突。给 INSERT INTO ... SELECT 配一个 WHERE NOT EXISTS 是很常见的做法:
sql复制INSERT INTO users (name, age, email)
SELECT name, age, email
FROM users_old o
WHERE NOT EXISTS (
SELECT 1 FROM users u WHERE u.email = o.email
);
这种写法在目标表数据量不大时毫无问题。但如果 users 表有几十万行,并且 email 没有索引,NOT EXISTS 子查询就变成逐行扫全表,性能会很难看。所以大规模迁移前,务必给关联字段建好唯一索引,或者直接用 UPSERT 把重复行转成更新。
4.4 临时表与正式表之间的经典场景:数据清洗
实际开发里我经常用这个模式处理“脏数据入库”。SQLite 里可以先建一个临时表,把外部文件(比如 JSON、CSV)加载进去,然后通过一条 INSERT INTO ... SELECT 把它清洗到正式表。
举个例子,JSON 数据先全部导入 temp_import,再向正式表 users 插入时做字段校验:
sql复制CREATE TEMP TABLE temp_import (
raw_name TEXT,
raw_age TEXT,
raw_email TEXT
);
-- 假设已经把 JSON 内容逐行写入 temp_import
INSERT INTO users (name, age, email)
SELECT
trim(raw_name),
CAST(raw_age AS INTEGER),
lower(raw_email)
FROM temp_import
WHERE raw_name IS NOT NULL
AND raw_email GLOB '*@*';
CAST 和 WHERE 都放到 SQL 层执行,比 Python/Java 里逐行清洗更符合 SQLite 的使用习惯。临时表在连接关闭后会自动消失,不需要手动清理,特别适合写一次性迁移脚本。
5. 插入后把自增 ID 拿回来:RETURNING 与 last_insert_rowid 的取舍
做业务系统的人还有一个高频需求:INSERT 之后立即拿回自增主键,因为后面可能还要插入子表的关联记录。比如先插入一个订单主表,再用返回的订单 ID 去插订单明细。SQLite 里有两种常见做法:last_insert_rowid() 和 RETURNING 子句。
5.1 最传统的方式:last_insert_rowid()
在 ANSI SQL 里,这通常叫“取最后插入 ID”,SQLite 中对应的函数是 last_insert_rowid()。它是连接级的,不是数据库级的:
sql复制INSERT INTO users (name, age) VALUES ('赵六', 25);
SELECT last_insert_rowid();
第二条语句返回的是该连接上最后一次成功的 INSERT 所产生的 rowid。注意几个使用边界:
- 它记录的是“最近一次 INSERT”,如果连接上还有别人通过同一个连接执行过 INSERT,那拿到的就不是你想要的那行 ID;
- 它对多行 INSERT 只会返回多行中第一行的 rowid,不是一个数组;
- 如果在执行 INSERT 之后、调用 last_insert_rowid() 之前,连接上又执行了其他写操作,包括建临时表、插入日志表,ID 就可能变成其他表的。
所以在同一个事务内,紧跟着 INSERT 立刻调用,是最安全的方式。
5.2 现代做法:RETURNING 子句
SQLite 从 3.35.0 版本(2021-03-12)开始支持 RETURNING,这让拿回插入行变得尤其直观:
sql复制INSERT INTO users (name, age, email)
VALUES ('赵六', 25, 'zhaoliu@example.com')
RETURNING id;
不仅返回 id,还能返回任意列:
sql复制INSERT INTO users (name, age, email)
VALUES ('赵六', 25, 'zhaoliu@example.com')
RETURNING id, name, created_at;
多行插入时,RETURNING 会为每一行都返回一条结果:
sql复制INSERT INTO users (name, age) VALUES
('A', 20),
('B', 21),
('C', 22)
RETURNING id;
结果集中会有三行,分别对应三行的自增 ID。这一点是 last_insert_rowid() 做不到的。如果后续需要把每一个 ID 都关联到子表,用 RETURNING 就非常舒服。
RETURNING 还可以配合 UPSERT 一起用:插入成功返回新行,冲突更新后返回更新行,并且可以判断是插入还是更新:
sql复制INSERT INTO users (name, age, email)
VALUES ('张三', 30, 'zhangsan@example.com')
ON CONFLICT(email) DO UPDATE SET age = excluded.age
RETURNING id;
5.3 怎么选
我自己的习惯是:
- 如果目标环境 SQLite 版本较新(3.35 以上),能上 RETURNING 就上 RETURNING,它的语义直观,也能拿回路受影响的多行;
- 如果目标环境是老版本,比如要兼容 Android 系统自带的老版 SQLite,那就只能用
last_insert_rowid(); - 如果拿回 ID 只是为了“尽量打个日志”,两个都行,但千万别在高并发多连接的场景下依赖 last_insert_rowid() 跨连接取 ID,它是连接私有状态,另一个连接的插入不会影响当前连接的返回值,反过来也一样。
提示:
RETURNING返回的是结果集,不是隐式单数。用 Python 的cursor.execute()执行带 RETURNING 的 INSERT 后,必须用fetchone()或fetchall()去读取,而不是直接访问cursor.lastrowid。我见过不少人在这里踩坑,RETURNING 的值根本没取出来,代码却不报错。
6. 高频插入报错的定位思路:从 NOT NULL 到 database is locked
最后这部分可能是大家直接搜到这篇文章的主要原因——INSERT 报错了,到底哪一步出了问题?我根据自己线上和本地开发里遇到的场景,把高频错误归类成几个方向。
6.1 约束类错误:NOT NULL、UNIQUE、CHECK、FOREIGN KEY
这一类错误的特点是报错信息非常明确,问题几乎都出在数据本身。
| 报错信息 | 含义 | 排查思路 |
|---|---|---|
NOT NULL constraint failed: table.column |
某列不能为 NULL,但插入时没给值 | 检查 INSERT 语句里的列列表是否漏掉该列;程序传入值是否为 None/null |
UNIQUE constraint failed: table.column |
某列有唯一约束,插入值和已有值重复 | 确认是走 UPSERT 还是先查重;检查重复数据来源 |
CHECK constraint failed: table |
插入数据不满足 CHECK 条件 | 回看建表语句的 CHECK 条件,比如年龄范围、状态枚举值 |
FOREIGN KEY constraint failed |
外键指向的父表主键不存在 | 先插入父表,或检查父表 ID 是否真的存在,别被 OR REPLACE 删掉父行 |
有个容易忽略的检查点是:SQLite 默认不强制开启外键约束,除非每次连接时执行 PRAGMA foreign_keys = ON;。很多新手项目里外键没生效,插入了一堆孤儿数据,等某天开了开关,批量脚本才开始疯狂报 FOREIGN KEY 错误。因此排查这个问题前先确认 PRAGMA 状态。
6.2 结构类错误:列数不匹配和缺列
text复制table users has 5 columns but 4 values were supplied
table users has no column named xxx
这类错误基本是 INSERT 语句里列名列表和 VALUES 不一致造成的。第一种常见于 INSERT INTO users VALUES (...) 不写列名的写法;第二种常见于拼 SQL 时列名写错,比如把 create_at 写成 created_at。要避免这类低级错误,建议在代码层把表结构映射收敛到一个统一模块里,不要到处手写裸 SQL。
6.3 锁类错误:database is locked 与 database table is locked
SQLite 的并发模型比较特殊。它允许同一时刻多个连接读数据库,但同一时刻只允许一个连接写数据库。当两个连接同时尝试写,或者一个连接持有写事务时间过长,另一个连接就可能收到:
text复制database is locked
这不是 SQL 写错了,是并发控制。常见解法包括:
- 开启 WAL 模式:
PRAGMA journal_mode=WAL;,读写并发会好很多,但只解决读写互斥,不解决写写互斥; - 缩短写事务时间,不要在事务里做慢查询或外部 IO;
- 给写操作增加重试机制,SQLite 官方的 busy_timeout 可以缓解短时锁:
sql复制PRAGMA busy_timeout = 3000;
程序里也可以在收到 SQLITE_BUSY 后等几十毫秒重新尝试。
6.4 绑定参数过多:too many SQL variables
前面提过,SQLite 对单条 SQL 中可用的绑定变量数量有上限,默认是 32766。当程序用循环拼接超长多值 INSERT 时很容易踩中。解决方案不是调大上限,而是分批插入:
python复制BATCH_SIZE = 500
for i in range(0, len(data), BATCH_SIZE):
batch = data[i:i + BATCH_SIZE]
conn.executemany(
"INSERT INTO users (name, age) VALUES (?, ?)",
batch
)
配合外层事务,吞吐量一样很好。
6.5 INT 主键“被占用”的怪异问题
关于自增主键,还有一个特别奇怪的报错场景。明明是 INTEGER PRIMARY KEY,插入时没指定 id,却报:
text复制UNIQUE constraint failed: users.id
大概率是你手动插入过很大的 id,比如 INSERT INTO users (id, name) VALUES (99999, '测试'),SQLite 的 rowid 分配策略是“取当前最大 rowid + 1”,一旦最大 rowid 撞上了还没提交的事务里某个显式 ID,或者和外部的 AUTOINCREMENT 序列不同步,就会出问题。AUTOINCREMENT 关键字能保证不使用已删除的最大 rowid,从而减少这类诡异冲突。如果表已经建成普通 INTEGER PRIMARY KEY,可以重新导入数据或者用 sqlite_sequence 清理,但最省事的还是建表时想清楚:需要严格递增、不允许重用最大 ID 的,加 AUTOINCREMENT;纯粹要个主键的,普通 INTEGER PRIMARY KEY 反而性能更好、占用更小。
结尾补充几个实战心得
文章最后我不想做那种“整体回顾”,因为上面的每个模块其实已经够实操了。这里只补几个现场里我认为最值得记住的经验。
第一个是关于“存在就更新、不存在就新增”的选型。很多新人一上来就是 INSERT OR REPLACE,直到某天关联数据被莫名删除才意识到问题。我这里给一条明确规则:凡是表被外键引用,或者你不想改变已有行的主键值,就不要用 OR REPLACE,老老实实写 ON CONFLICT(...) DO UPDATE。这条规则能帮你挡住八成与 REPLACE 相关的生产事故。
第二个经验是关于批量插入的节奏控制。SQLite 写入性能并不差,真正影响性能的是每次提交时的磁盘同步。批量任务我习惯的做法是:每 500 条一个事务,或者按数据总量每 50MB 一个事务。数据量太小,事务提交次数多,同步开销占比高;数据量太大,持有锁时间太长,容易干扰其他连接。
第三个经验是关于迁移脚本。任何带 INSERT INTO ... SELECT 或 UPDATE 的脚本,在跑之前先把数据库文件复制一份备份。SQLite 一个文件就是一个库,备份成本极低,而脚本一旦因为逻辑疏漏把线上数据洗坏,没有备份就只能从上次备份点完全重来。顺手备份一下,是我干这行这么久以来最便宜的风险对冲手段。
最后一个建议:写 INSERT 之前多看一眼表结构设计。是否用了 STRICT 表、主键是否自增、唯一约束在哪里、外键有没有开 PRAGMA,这些决定了 INSERT 后面的所有行为。SQLite 的语法本身已经足够简单,真正决定一个项目写入层质量的,永远是表结构设计和异常处理策略。把这些想清楚了,INSERT 就真的只是一行普通 SQL。
