免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

SQLite Insert 性能优化与坑点解析:从单条写入到十万条批量插入

SQLite Insert 性能优化与坑点解析:从单条写入到十万条批量插入 搞了十多年数据库相关的东西我越来越觉得SQLite 里的 Insert 语句是那种“人人都认识字、但真上手就踩坑”的典型。就拿十万条数据来说有人用一条简单的 Python 循环插了十分钟还没结束换个人用事务包裹一下瞬间不到一秒同样的 SQL差别怎么这么大这就是 Insert 背后的执行机制和写法细节在起作用。这篇文章我会从最基础的 INSERT INTO 语法讲起把多行插入、插入时字段类型的变化、十万条数据批量写入的性能对比、命令行和 DB Browser for SQLite 的实操、以及高频报错都过一次。适合刚开始用 SQLite 的人也适合已经写了一段时间但没仔细看过插入逻辑的开发者。1. 从一条 INSERT 看 SQLite 最容易被忽略的执行流程1.1 一条标准 Insert 背后发生了什么先跑一条最标准的插入语句。假设你有一个test.db文件在命令行里执行sqlite3 test.db EOF CREATE TABLE user ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, score REAL DEFAULT 0 ); INSERT INTO user(id, name, score) VALUES(1, 张三, 95.5); SELECT * FROM user; EOF这里INSERT INTO user(id, name, score) VALUES(1, 张三, 95.5);是教科书里最常见的形式。但我的经验是如果你只知道这个写法后面遇到性能问题、约束冲突、类型变更时就会懵。原因是 SQLite 执行一条 INSERT 时并不是简单往磁盘文件里追加一行。它先要经过语法解析、生成 AST、再由代码生成器翻译成内部虚拟机的指令然后打开一个写事务把新数据写入 B 树对应的页。如果数据库运行在默认的 journal 模式写操作还要维护回滚日志防止系统崩溃或事务中途失败后留下半个数据。最后提交事务时要把事务日志同步到磁盘这个fsync操作非常耗时而且无法靠优化 SQL 彻底绕过去。所以你会发现一个现象单条 INSERT 无论怎么写都快两条三条也没问题但一旦累计到几万、十万条性能差异就出现了。真正慢的不是 INSERT 这个动词而是每一条语句都在独立执行“开事务、写日志、提交、落盘”的完整链路。这是后面第四部分要展开的重点。1.2 列的缺省、NULL 与 INTEGER PRIMARY KEY 的“自增”逻辑再来看一个容易混淆的点插入时哪些列可以不写。看这个例子INSERT INTO user(name, score) VALUES(李四, NULL); INSERT INTO user DEFAULT VALUES;第一条代码把 id 列留空了SQLite 会自动分配一个 rowid因为id INTEGER PRIMARY KEY本身就有这个能力。注意不需要额外声明AUTOINCREMENT才自动生成 id。只要主键列是INTEGER PRIMARY KEY插入时如果不指定值SQLite 就会取当前最大 rowid 加一作为新 id。那AUTOINCREMENT到底起了什么作用它的作用是禁止复用已被删除的最大 rowid。举个例子你插入 id1、2、3删掉 id3再插入新行。没有AUTOINCREMENT时新行很可能复用 id3有了AUTOINCREMENT后新行 id 会继续从 4 开始。普通业务场景下如果你没把 id 暴露给外部复用也没关系但如果是订单号、流水号这类不允许重复使用的标识就需要考虑AUTOINCREMENT。第二条INSERT INTO user DEFAULT VALUES也很容易忽略它表示“所有列都用默认值插入”。如果表里有不可为 NULL、又没有默认值的列这条语句会报 NOT NULL 约束错误。平时用到的场景不多但测试表结构时很好用。理解了这个你就明白了 SQLite 插入时的三种“缺值”状态不给列值、给 NULL、给显式 DEFAULT它们的结果可能完全不同。2. 多行写入和 SELECT 来源两种可能改变你习惯的写法2.1 VALUES 多值批量插入的格式与行数上限SQLite 从 3.7.11 版本开始支持一条语句插入多行INSERT INTO user(name, score) VALUES (王五, 88.0), (赵六, NULL), (钱七, 91.0);这种写法比循环单条 INSERT 要清晰得多也节省了多次网络往返或多次语句解析的开销。需要注意SQLite 会把这一整条语句当作一个原子操作。如果在第二行违反了 NOT NULL 约束默认冲突策略是ABORT它会回滚当前语句里已经插入的第一行所以不是“前面插进去、后面失败”的状态。这一点和很多人的直觉不一样我之前见过有人用多行插入后发现报错但部分数据进去了以为 SQLite 行为有问题其实是事务隔离和冲突策略没理解透。但多行 VALUES 也有两个限制。第一个是复合 SELECT 的行数限制默认上限是 500 行你要是一条语句塞 1000 行SQLite 会直接报too many terms in compound SELECT。第二个是参数绑定数量限制这取决于 SQLITE_MAX_VARIABLE_NUMBER常见版本里默认是 999新版本可能更高。也就是说不论是直接写值还是用?占位符单条语句能承载的变量数是有限制的。所以碰到大批量插入我会优先建议用事务包裹的循环或者参数绑定批量执行而不是一味把几百行塞进一条 VALUES 语句。如果非要用多值插入最好把数据按 200 到 300 行一批切分避免撞上限。2.2 INSERT INTO ... SELECT从表间搬数据避免 SELECT循环另一种被低估的写法是INSERT INTO ... SELECT。它把查询结果直接作为插入来源比如做数据归档或表重建时特别方便CREATE TABLE high_score ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, score REAL ); INSERT INTO high_score(id, name, score) SELECT id, name, score FROM user WHERE score 90;很多人做数据迁移时习惯先SELECT出来再用程序循环插入这其实没必要。SQLite 本身就能高效地完成“读取源表、写入目标表”这个操作中间不经过应用层语句少、速度快、逻辑也集中。这里我想提醒一个细节尽量显式列出列名不要直接写INSERT INTO high_score SELECT * FROM user。原因在于SELECT *是按位置匹配的一旦源表或目标表的列顺序不同数据就会串位。而且一旦有一方加了新列这条语句的语义就可能改变排错成本高。写全列名看起来啰嗦但很值得。SQLite 3.35 之后还支持RETURNING子句这让 INSERT 有了类似“回执”的能力INSERT INTO user(name, score) VALUES(周八, 87.0) RETURNING id, name;执行后会立即返回这一行生成的 id 和 name。以前想在插入后拿回主键得再查一次last_insert_rowid()或者再执行一条 SELECT现在直接在插入语句里拿结果。这个特性在后端接口里尤其好用减少了一次往返查询也避免了并发场景下拿错连接的问题。3. 修改字段类型之后 Insert 会怎样SQLite 的“类型宽松”不是免死金牌3.1 动态类型与 Type Affinity 怎么决定 Insert 的实际值SQLite 在数据库圈子里有个标签叫“类型宽松”很多人理解成列类型随便写、随便插这其实只说对了一半。SQLite 确实不会像 MySQL 那样严格拒绝“类型不对”的值但它在插入时有一套类型亲和性Type Affinity规则会自动尝试做类型转换。声明类型会先被归成几类亲和性INTEGER、TEXT、REAL、NUMERIC 和 BLOB。比如INT、BIGINT、INTEGER都会映射到 INTEGER 亲和性VARCHAR、CHAR、CLOB会映射到 TEXTDOUBLE、FLOAT、REAL映射到 REAL。我列一个实际表现表方便你对照声明类型例子亲和性插入值示例实际存储结果INT / INTEGER / BIGINTINTEGER123整数 123文本可转换TEXT / VARCHAR / CHARTEXT123文本123数值转文本REAL / FLOAT / DOUBLEREAL1.5实数 1.5NUMERIC / DECIMALNUMERICabc文本abc无法转换就保留BLOBBLOB任意值原样保存所以如果你把一列声明成 INTEGER然后插入字符串abcSQLite 并不会拒绝因为它转不成整数最终存进去的还是文本。但如果插入字符串123它就可能存成整数 123。用typeof(列名)函数可以查看某列当前存储的实际类型调试时非常有用。理解了亲和性你就能明白为什么“修改字段类型”这件事在 SQLite 里很微妙。单纯改CREATE TABLE里的声明类型不会自动去转换已存在的数据真正决定值的是插入那一刻亲和性规则和现有数据本身。3.2 修改列类型必须重建表的完整流程以及迁移时的 Insert 写法SQLite 不支持ALTER TABLE ... ALTER COLUMN ... SET DATA TYPE这一点和 MySQL、PostgreSQL 差异很大。很多初次接触 SQLite 的开发者会在 DB Browser 或命令行里想直接改列类型结果发现不支持或者改了之后实际行为没变。标准做法是重建表。比如我要把user.score从 REAL 改成 TEXTsqlite3 test.db EOF PRAGMA foreign_keysOFF; BEGIN; CREATE TABLE user_new ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, score TEXT ); INSERT INTO user_new(id, name, score) SELECT id, name, CAST(score AS TEXT) FROM user; DROP TABLE user; ALTER TABLE user_new RENAME TO user; COMMIT; PRAGMA foreign_keysON; EOF这段脚本里最关键的就是第二步INSERT INTO ... SELECT。旧数据必须在建好新表后立刻搬过去顺序一定不能乱。如果表很大建议在事务里执行搬数据过程中有任何问题可以整体回滚不会留下一个修到一半的数据库。关于外键要特别小心。如果user表被其他表引用直接DROP TABLE user再改名可能破坏外键关系。虽然新版 SQLite 在RENAME TO时会自动更新其他表里的引用但生产环境我还是建议先在测试库里完整演练一遍再用同样的脚本上线。另外PRAGMA foreign_keys不能在事务内随意切换这也是一个容易踩的点。4. 十万条数据写入的真实对比裸循环、事务、批量语句差了多少4.1 autocommit 是十万条数据变慢的首要原因先看最常让人崩溃的写法程序里开一个循环逐条执行INSERT每条都不主动提交事务。在 SQLite 里这相当于十万次独立的“开事务、写日志、提交、落盘”性能自然灾难。我自己用机械硬盘环境测过十万行数据用裸循环写能跑到 40 到 70 秒。改成一个显式事务包裹时间立刻降到一秒左右。差距在哪事务保证了同一批写入只做一次同步提交磁盘 IO 次数从十万次降到一次级别。Python 下比较稳妥的写法是先把连接设为手动事务模式再显式BEGIN和COMMITimport sqlite3 conn sqlite3.connect(bench.db, isolation_levelNone) conn.execute(CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, a TEXT)) conn.execute(BEGIN) for i in range(100000): conn.execute(INSERT INTO t(a) VALUES (?), (fv-{i},)) conn.execute(COMMIT)这里isolation_levelNone的作用是关闭 Python 自带的隐式事务管理让所有事务都明确掌握在你自己手里。很多 Python 开发者遇到过“明明插入了连接关闭后数据没了”的问题多半就是没提交事务连接关闭时回滚了。4.2 参数绑定和 prepared statement 的真实收益除了事务第二个容易被忽视的是参数绑定。下面这种写法不推荐for i in range(100000): conn.execute(fINSERT INTO t(a) VALUES({i}))字符串拼接看起来没问题但它让 SQLite 在每一轮循环里都要重新解析整条 SQL。十万次解析每次都要做语法分析、优化、再编译一次CPU 浪费极其明显。而且拼接方式还有 SQL 注入风险一旦插入内容里出现引号或特殊字符程序就会出错。正确做法是用绑定参数conn sqlite3.connect(bench.db, isolation_levelNone) conn.execute(PRAGMA journal_modeWAL) conn.execute(PRAGMA synchronousNORMAL) conn.execute(BEGIN) rows [(fv-{i},) for i in range(100000)] conn.executemany(INSERT INTO t(a) VALUES (?), rows) conn.execute(COMMIT)executemany会把同一条预编译语句反复执行每个参数都通过绑定传递不经过字符串拼接也不重复解析。这里我还加了PRAGMA journal_modeWAL和PRAGMA synchronousNORMAL。WAL 模式可以减少写日志时的互相阻塞synchronousNORMAL在高频写入下能进一步降低等待时间但代价是系统崩溃时可能丢失最近一小部分提交数据。对临时构造大数据量、测试数据这类场景可以接受生产数据得自己权衡。4.3 实测结果和“插入快不等于查询快”不同写法在十万行数据上的差距有多大我整理了一份参考数据机械硬盘环境下比较典型写入方式十万行耗时参考裸循环自动提交40~70 秒显式事务 execute 循环0.6~1.2 秒显式事务 executemany 参数绑定0.2~0.5 秒每 500 行一条多值 INSERT0.3~0.6 秒数据会因磁盘类型、文件系统、编译参数不同而波动但相对差距基本稳定。SSD 上裸循环可能不会到几十秒那么夸张但依然比事务版本慢很多。插入性能本身不是孤立指标。如果你给表加了五六个索引那么每插入一行SQLite 都要同步维护这些索引十万行插完可能多花好几倍时间。索引多查询变快写入变慢这是永恒矛盾。“十万条数据查询需要多久”是另一个话题没有索引的全表扫描即使只有十万行多个 join 之后也可能明显卡顿有合适索引时通常毫秒级就能完成。所以别只盯着 Insert 语句表结构和索引设计同样影响整体体验。5. 从命令行到 DB Browser两种最常见的 Insert 实操姿势5.1 Linux 下安装 sqlite3 并快速建库插入很多人在 Linux 服务器上拿到一个.db文件第一反应是“这东西怎么打开”。其实命令行自带的 sqlite3 就够用。如果系统里没装安装非常简单# Debian / Ubuntu sudo apt-get update sudo apt-get install sqlite3 # RHEL / CentOS sudo yum install sqlite # 或新版系统 sudo dnf install sqlite装完先确认版本sqlite3 --version然后就可以建库、建表、插入数据一气呵成sqlite3 test.db EOF DROP TABLE IF EXISTS t; CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT); INSERT INTO t(name) VALUES(first),(second); SELECT * FROM t; EOF非交互场景下也可以直接执行单条语句sqlite3 test.db INSERT INTO t(name) VALUES(third);这种操作在写脚本自动生成数据文件时非常方便。另一个实用点是.schema t可以查看这张表的定义.dump可以把整个数据库导出成 SQL 文本。遇到写操作没有预期效果时先用这两条命令确认表结构和当前数据往往比瞎猜快得多。5.2 DB Browser for SQLite 的下载与执行 Insert 的操作细节如果不想记命令或者需要直观地看着数据操作推荐 DB Browser for SQLite。它是一款跨平台开源工具官网是 sqlitebrowser.orgWindows、macOS、Linux 都有对应的安装包。装好后直接打开.db文件不需要额外配置。在“执行 SQL”页签里输入 INSERT 语句按 CtrlEnter 执行工具底部会显示受影响的行数。这个反馈在日常调试里很有用。你也可以在“数据库结构”页签右键表名选“修改表”图形化调整列类型、默认值、约束操作完成后它会在后台生成和重建表等价的 SQL我们可以直观看到 SQLite 到底做了什么。想立刻确认插入结果切到“浏览数据”页签就能看到最新数据。DB Browser 还有一个隐蔽的好处是支持一次执行多段 SQL。你可以把一批 INSERT 语句粘贴到执行窗口一次跑完。不过要注意如果其中某条语句报错默认不会把前面已经成功执行的语句自动回滚除非你手动在窗口里写了BEGIN和COMMIT。所以在里面做大批量导入时我建议多利用事务语句。5.3 构造安全可控的示例 .db 文件导出 Schema 与数据很多时候我们想找一个“sqlite 数据库 .db 示例文件”来练手但网上随便下载的数据库文件可能结构不清、数据混乱。更好的做法是自己构造一个可控的示例文件。上一步已经能建出test.db再用两条命令就能把它变成可复现的备份sqlite3 sample.db .schema user schema.sql sqlite3 sample.db .dump backup.sql.dump生成的文件是一条完整的 SQL 序列里面包含了 CREATE TABLE 和所有 INSERT 语句。恢复时直接执行sqlite3 new.db backup.sql这个流程本质上是在告诉你INSERT 不只是业务代码里的数据写入手段它同时也是数据库备份、迁移、测试数据构造的基础。别小看这种“SQL 文本复制”的方式它比直接拷贝.db文件更安全。因为.db文件在数据库运行中可能处于 WAL 状态直接复制可能得到不一致的数据而.dump是从数据库内部按一致性快照导出的。6. 踩坑排查约束冲突、错误回调与 Insert 的边界认知6.1 五种冲突策略的取舍别一股脑 OR REPLACE插入最常见的报错是主键或唯一键冲突。默认情况下一条 INSERT 遇到冲突会直接报错退出。SQLite 从语法层面给了INSERT OR系列的冲突策略很多人的第一反应是“冲突就替换”于是无脑写INSERT OR REPLACE。但 REPLACE 并不是万能钥匙。下面的表格整理了五种策略的行为差异策略冲突时发生了什么典型场景OR ROLLBACK回滚整个事务当前事务里所有写入全部撤销数据一致性要求极高OR ABORT默认策略回滚当前语句同一个事务里其他语句不受影响大多数业务默认行为OR FAIL当前语句失败已执行部分的写入不撤销想在语句内保留部分修改OR IGNORE跳过冲突行不报错继续处理后续行批量导入时跳过已存在OR REPLACE删除冲突旧行再插入新行整行无脑覆盖REPLACE的坑在于它本质上是“删除加插入”。如果表上有外键级联删除新旧数据又存在关联它可能把不该删的关联数据一起删掉。如果只是某一列需要更新我更推荐 UPSERT 语法也就是ON CONFLICTINSERT INTO user(id, name, score) VALUES(9, 吴十, 78.0) ON CONFLICT(id) DO UPDATE SET name excluded.name, score excluded.score;这里的excluded表示“本次原本想插入的那一行”。用这种写法冲突发生时只会更新指定的列而不是删除整行再插入副作用小很多。它从 SQLite 3.24 版本开始支持目前主流环境基本都能用。6.2 UNIQUE / NOT NULL / FOREIGN KEY 三个高频报错与排查思路实际开发里Insert 报错主要集中在这三种。第一个是NOT NULL constraint failed: user.name。这说明插入时name列没有被赋值或者显式赋了 NULL而该列不允许为空。排查时先看DEFAULT和NOT NULL定义再检查插入语句里是否漏掉了列。这类错误通常在代码改结构后出现比如新加了一个非空列但旧插入语句没有同步更新。第二个是UNIQUE constraint failed: user.id。原因很直白主键或唯一索引已经存在相同值。处理方式按业务需求选 IGNORE、REPLACE 或 UPSERT。如果不想覆盖数据就用INSERT OR IGNORE它会在冲突时静默跳过很适合从外部导入“只加不重”的数据。第三个是FOREIGN KEY constraint failed。插入的关联 id 在父表里不存在时触发。这里有个关键点SQLite 的PRAGMA foreign_keys默认是关闭的。也就是说同一个 SQLite 数据库文件在 A 工具里外键约束生效在 B 工具里可能完全不检查因为它是按连接设置的不是数据库文件的全局属性。如果你怀疑外键没生效第一件事就是执行PRAGMA foreign_keys;看返回值只返回 0 就说明当前连接没开启。6.3 为什么 INSERT 不会触发查询 callback一个容易搞混的 API 误解最后聊一个在 C/C 接口里特别常见的迷思SQLite 的 callback 会不会在 INSERT 后触发。很多人会拿下面的写法和 SELECT 类比sqlite3_exec(db, INSERT INTO t(a) VALUES(1);, callback, NULL, errmsg);他们期望callback返回插入后的结果但实际上一整条 INSERT 执行下来callback 可能一次都不会被调用。原因很简单SQLite 的 callback 机制是为了处理有结果集的查询而设计的像 SELECT 每返回一行SQLite 就会调用一次 callback。INSERT 没有结果集没有行可供回调所以 callback 不会触发第三个参数argv也是 NULL。判断插入成功与否要看sqlite3_exec的返回值是不是 SQLITE_OK以及sqlite3_changes()返回的受影响行数。要拿到新插入行的主键用sqlite3_last_insert_rowid()或者直接趁RETURNING特性把结果取回来。在 Python 里cursor.lastrowid也是同样的思路。我自己第一次用 C API 做批量插入的时候也犯过同样的错误在 callback 里数插入行数结果函数从早到晚没被调用。后来翻文档才明白SQLite 把查询和写操作的结果通道设计得很明确写操作本该通过 last_insert_rowid 和 changes 来观察。这个认知帮我省下了很多 debug 时间。如果你也在用 INSERT建议先把这几个 API 的行为边界记住它会让你少走很多弯路。
返回列表