在桌面应用、嵌入式设备或者小型Web服务里,SQLite是最常见的本地存储方案。不过不少人在第一次用它做批量数据导入时会发现一个奇怪的现象:往表里插入一万条数据,居然要等上几十秒甚至几分钟,而换用MySQL或者PostgreSQL反而更快。问题多半不在SQLite本身,而在于没有使用事务。SQLite默认每条写语句都是一个独立事务,每条都要刷盘一次,磁盘I/O成了瓶颈。把一万条插入放进一个事务里批量提交,速度提升几十倍是常有的事。

逐条插入到底慢在哪里:理解自动提交模式
SQLite默认运行在自动提交模式下,也就是每一条INSERT、UPDATE或者DELETE语句执行完毕后,都会立即开启一个事务、提交这个事务。表面上看这只是省事的默认行为,但背后隐藏着一次完整的持久化流程。
SQLite为了保证事务的原子性和持久性,采用了预写式日志(WAL)或者回滚日志机制。以默认的回滚日志模式为例,每次提交需要经历这些步骤:先创建或写入日志文件,把即将被修改的原始数据页保存下来;接着执行数据修改;然后调用fsync将日志刷到磁盘;再修改数据库文件本身;最后再次fsync数据库文件,并删除日志文件。也就是说,一次提交至少伴随两次强制磁盘同步操作。
机械硬盘上一次fsync大约需要10毫秒左右,即便是固态硬盘,频繁的小规模同步也是相当昂贵的系统调用。假设插入一万条记录,每条独立提交,仅磁盘同步就要消耗上万次,光这一项开销就足以解释为什么逐条插入会慢到令人发指。而在SSD上这个问题依然存在,只是程度轻一些。
批量提交的核心原理:合并磁盘同步次数
显式开启事务后,一万条插入语句中的前9999条只是把修改写到内存中的页缓存,只有最后执行COMMIT时,SQLite才会把所有脏页一次性写回磁盘,做一次日志同步和一次数据库文件同步。原本两万次磁盘同步被压缩成两次,这就是性能飞升的根本原因。
打个比方,自动提交模式就像每买一件东西就跑一趟银行转账,而批量事务相当于把所有账目记在本子上,最后一次性结清。两者的逻辑结果完全一致,但后者的I/O成本几乎可以忽略不计。实测数据也能说明问题:逐条插入一万条记录可能需要30秒以上,而包在一个事务里通常1秒以内就能完成,差距可达百倍。
需要注意,批量提交改变的是性能,而不是语义。事务仍然保证要么全部成功、要么全部回滚,这在批量导入场景下反而是更合理的行为——导入中途出错时,不会留下半截脏数据。
三种常见的批量提交实现方式
第一种是直接用BEGIN和COMMIT包裹语句块。以Python为例,标准库sqlite3在执行commit之前默认不会真正提交,写法非常自然:
import sqlite3
conn = sqlite3.connect('data.db')
cursor = conn.cursor()
cursor.execute('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)')
# 显式开启一个事务,批量插入
cursor.execute('BEGIN')
data = [('user%d' % i,) for i in range(10000)]
cursor.executemany('INSERT INTO users (name) VALUES (?)', data)
conn.commit() # 一次性提交,磁盘同步只发生在这里
conn.close()Python中的executemany本身就很高效,它复用了同一条预处理语句,避免了对每条数据都重新解析SQL。配合事务使用,是标准做法。
第二种是在C或者C++接口中使用预处理语句加显式事务。C API层面同样要遵循BEGIN、循环sqlite3_step、COMMIT的套路:
sqlite3 *db;
sqlite3_open("data.db", &db);
sqlite3_exec(db, "BEGIN", 0, 0, 0); // 开启事务
sqlite3_stmt *stmt;
sqlite3_prepare_v2(db, "INSERT INTO users(name) VALUES(?)", -1, &stmt, 0);
for (int i = 0; i < 10000; i++) {
char name[32];
snprintf(name, sizeof(name), "user%d", i);
sqlite3_bind_text(stmt, 1, name, -1, SQLITE_STATIC);
sqlite3_step(stmt); // 执行插入
sqlite3_reset(stmt); // 重置语句以便复用
}
sqlite3_finalize(stmt);
sqlite3_exec(db, "COMMIT", 0, 0, 0); // 统一提交
sqlite3_close(db);预处理语句只解析一次SQL,重复绑定参数并执行,避免了每次都走完整的语法分析流程,这也是批量写入性能的另一个重要来源。
第三种是用多值插入语句,也就是一条INSERT后面跟多个VALUES组:
BEGIN;
INSERT INTO users (name) VALUES
('user0'),
('user1'),
('user2'),
('user3');
COMMIT;这种写法在SQL脚本和某些不支持预处理语句的场景下很好用,但要注意SQLite对单条语句的变量数量有上限(默认999,编译期可调整到32766),所以大批量数据需要分批拼接。综合来看,预处理语句加事务是最通用、最推荐的方案。
批量提交的注意事项与进阶技巧
事务也不是包得越大越好。一个巨型事务会把所有待写的脏页堆在内存和临时文件里,内存占用随数据量增长,一旦中途失败,回滚的成本也很高。工程上通常按批次拆分,比如每5000到50000条提交一次,在性能和风险之间取得平衡。对于超大数据迁移,还可以结合PRAGMA journal_mode=OFF临时关闭日志来进一步提速,但这样做会失去崩溃保护,只适合导入失败后可以重来的场景。
另外几个值得调整的PRAGMA参数:PRAGMA synchronous默认值为FULL,可以设为NORMAL,在WAL模式下兼顾安全与速度;PRAGMA cache_size决定了内存中能缓存多少数据库页,大批量写入时适当调大能减少中间换页。要注意这些PRAGMA设置必须在事务开始之前执行,事务进行中修改它们可能不会生效或者引发错误。
还有一个容易踩的坑是并发。如果一个连接持有写事务太久,其他连接的写入会直接返回SQLITE_BUSY。虽然可以设置busy_timeout让SQLite自动等待重试,但更稳妥的做法是控制事务粒度,避免长时间独占写锁。读写混合的场景建议开启WAL模式,它允许读操作和写操作并发进行,能显著缓解锁冲突。
最后提醒一点:某些框架的ORM默认开启了自动提交,或者每保存一条记录就自动commit一次,这时候即使你写了循环插入,性能照样上不去。需要在框架层面关闭自动提交,或者找到批量保存的API,才能真正享受事务合并带来的性能红利。