聊到 SQLite,很多人第一反应是“轻量”“嵌入式”“零配置”,然后反手一句:“这玩意儿还需要调优?初始化参数在哪?”。说实话,我第一次接触 SQLite 的时候也是这个想法。后来在移动端、桌面工具、甚至服务端边缘计算场景里把它往死里压过一段时间,才慢慢意识到——SQLite 并不是“没有参数”,而是它的参数体系跟 MySQL、PostgreSQL 那套完全不一样。它把很多配置藏在 PRAGMA 里,用不用得起、调不调得好,直接决定你的应用并发高了之后是丝般顺滑,还是整天 “database is locked”。
这篇是“SQLite 五脏俱全”系列的第一篇,我先从初始化参数和调优这个最常被误解的入口讲起。适合谁看?主要三类人:在 Android/iOS 或 uni-app 里用 SQLite 做本地存储、用 C++/Qt 把数据落地的桌面开发,以及把 SQLite 当生产数据库单机来用的后端开发。你会在这篇里搞清楚 SQLite 到底有哪些真正值得调的参数,怎么验证调整效果,以及我在实际项目中踩过哪些坑。
1. 先搞明白:SQLite 的“初始化参数”到底藏在哪里
1.1 没有配置文件,不代表没有参数
用过 MySQL 的朋友对 my.cnf 一定不陌生,改完重启就生效。SQLite 作为嵌入式库,走的是完全另一套思路:它没有一个集中式的服务进程,也没有全局配置文件。按理说,库都没了,参数往哪放?
答案是:参数分三个层面存在。
第一层是编译期参数。也就是你在编译 SQLite 源码时通过 C 预处理宏指定的,比如是否启用 WAL、默认 page size、是否开启 FTS5 全文检索、是否支持 JSON1 扩展函数。这类参数一旦编译成库,运行期无法修改。拿我常用的一个例子:默认的 SQLITE_DEFAULT_PAGE_SIZE 在旧版本里是 1024,在较新版本里已经跑到 4096。如果你的表行宽比较大,编译期没调,后期只能靠 PRAGMA page_size 在建库前设置,一旦建库完成再改就必须做 VACUUM,麻烦不少。
第二层是连接期 PRAGMA。这一层就是大家最有感知的“初始化参数”。只要你打开一个数据库连接,就能通过PRAGMA 名称 = 值这样的语句去设置。比如最出名的PRAGMA journal_mode = WAL;就是在这里执行。它有几个很有意思的特性:部分 PRAGMA 是持久化的,会写进数据库文件头,比如 journal_mode、page_size,改一次之后就算换一个连接也依旧生效;而像 cache_size、mmap_size、synchronous 这类属于连接级状态,默认只对当前连接有效,连接关闭就丢。
第三层是运行期查询。用PRAGMA不带参数地执行一遍,能读到当前连接各种状态的实时值,比如PRAGMA cache_size;、PRAGMA journal_mode;。这层对诊断问题特别重要,很多“我明明改了没生效”的困惑,都是因为没先查一遍当前值。
所以,如果有人问“SQLite 需要初始化参数吗”,我的标准回答是:不需要传统意义上的初始化,但如果你想拿到高性能,必须在打开连接之后立刻做一套 PRAGMA 调优。
1.2 为什么说“调 SQLite 就是调 PRAGMA”
SQLite 之所以把调优手段做成 PRAGMA,是因为它背后的设计哲学是“程序嵌入,而非服务部署”。在嵌入式场景里,没有 DBA 去维护配置,数据库和宿主程序共享同一份资源,所以参数必须足够简洁、足够局部、足够见缝插针。
比如最常见的并发场景。默认情况下,一个 SQLite 库同一时间只允许一个连接写数据,所有写操作串行排队。如果你的应用不需要高并发,那默认配置确实够用;但只要你的程序一开多线程,或者多个进程共享同一个库文件,默认参数就会瞬间变成瓶颈。这时候你第一个要调的,就是 journal_mode 和 busy_timeout。
再比如频繁的执行大量 INSERT。如果每次写一条数据都把事务提交一次,SQLite 会频繁刷日志和写主数据库文件,磁盘 I/O 直接拉满。但如果你把事务包成一个大事务,配合合适的 synchronous 级别,性能差距可能达到几十倍。这些优化不是靠数据库自动完成的,得你在代码里和 PRAGMA 里一起控制。
所以,把 SQLite 调优理解为“PRAGMA 组合拳”,一点不过分。
2. 核心参数逐个拆解:每个参数改的是什么东西
2.1 journal_mode:决定你的并发上限
journal_mode 是 SQLite 最重要的一个参数,没有之一。它的取值包括 DELETE、TRUNCATE、PERSIST、MEMORY、WAL、OFF,其中生产环境里最常用的就是默认的 DELETE(也叫 rollback journal 模式)和 WAL(Write-Ahead Logging,预写日志)。
DELETE 模式的工作方式可以这样理解:每次写事务开始前,SQLite 把要修改的原始页面内容拷贝到一个独立回滚日志文件里;事务提交后,回滚日志立刻删除。这个模式的缺陷在于,任何时刻数据库文件本身只能被一个写连接持有,读操作虽然可以并发,但如果读发生在写事务中间,也可能被阻塞或返回 SQLITE_BUSY。
WAL 模式解决的就是这个问题。它不再把改动直接写到主数据库文件,而是追加写到一个独立的-wal日志文件里。写连接只管顺序追加日志,读连接照常读主文件,读和写真正做到了互不阻塞。这里有个直观的数据:在 WAL 下,一个进程内的多线程读写的并发度远高于默认模式;把多个进程指向同一个库文件,也比 DELETE 模式稳得多。
我一般会在建库之后立刻执行:
PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;值得注意,journal_mode 是持久化参数,设置后产出的 WAL 状态会写进数据库头,后续连接默认就是 WAL,不需要每次重设。
如果选 MEMORY 或 OFF,对写入性能提升更夸张,但代价是数据库可能损坏或丢数据,我强烈不建议在生产环境碰。
2.2 synchronous:用安全换性能的关键开关
synchronous 控制的是 SQLite 何时把 WAL 内容或回滚日志 fsync 到磁盘。它有三个级别:FULL、NORMAL、OFF。
默认 FULL 是最安全的。在 DELETE 模式下,FULL 意味着每一步关键写入都要求数据落到磁盘,掉电不会丢事务。但代价是大量磁盘同步操作,写入性能会明显打折。
在 WAL 模式下,NORMAL 和 FULL 的行为又不一样。WAL + NORMAL 是一个非常有名的组合:它会保证 WAL 文件完整同步,但不会在每次事务提交时都对主数据库文件做 fsync。这样做的实际效果是:如果系统崩溃或断电,最多损失最近一小部分已经提交的事务(通常可以恢复),但数据库整体结构不太会损坏,读取永远能拿到一个一致快照。这也是我推荐生产环境用WAL + NORMAL的原因。
OFF 就是完全交给操作系统,极致性能但风险极高,一旦进程崩溃或掉电,数据库文件很可能直接损坏且无法恢复。自己临时写个小工具、存一些可再生的缓存数据,你可以考虑 OFF;正经业务数据,千万别用。
2.3 cache_size:让热数据留在内存里
cache_size 控制的是 SQLite 每个连接用来缓存数据库页面的内存上限,单位默认是页,不是字节。算一下很容易:如果 page_size 是 4096,PRAGMA cache_size = -64000;这种写法里的负数表示以 KiB 为单位,也就是设置 64MB 缓存。
那么问题来了:为什么默认缓存只有 2MB 左右?因为 SQLite 是嵌入式库,默认目标是不能在别人手机上反手吃掉几百 MB 内存。但如果你的设备内存足够,调大 cache_size 往往比加索引还立竿见影。
你可以在连接初始化时执行:
PRAGMA cache_size = -65536;注意,这里的负号是有意义的。正数表示“页数”,负数表示“KiB”。例如 -65536 就是 64MiB,写起来比PRAGMA cache_size = 16384;(页数,如果页大小 4KB 也是 64MiB)更直观,不容易受 page_size 影响。
cache_size 是连接级参数,不会持久化。所以如果期望每个连接都有大缓存,就得在每个连接打开时都执行一遍。这里有个常见的坑:在连接池场景下,如果你只在初始化第一个连接时设了 cache_size,其他连接可能还是默认值。
2.4 mmap_size:让读操作少拷贝一次
mmap_size 是很多人忽略的调优点。它允许 SQLite 把数据库文件映射到进程地址空间,读数据的时候直接访问内存映射区域,减少一次从内核态到用户态的拷贝,对大量只读查询很有帮助。
设置方式:
PRAGMA mmap_size = 268435456;这个值是字节数,上面这句设置约 256MB。并不是所有环境都适合开很大,因为 mmap 区域会占用进程虚拟内存空间。32 位进程要特别谨慎,映射太大可能撑爆地址空间;64 位环境基本可以放开一点。
有个小经验:如果数据库文件非常小,mmap 的好处并不明显;当单库文件超过 100MB,并且你的热点读很密集,mmap_size 带来的提升才比较容易感知到。
2.5 temp_store:临时表去哪儿
temp_store 控制临时表(比如 ORDER BY、DISTINCT、子查询中间结果)是放在内存还是磁盘。取值 FILE、MEMORY、DEFAULT。
瓶颈在于:用 FILE,临时数据写到磁盘,不占内存但慢;用 MEMORY,占用应用内存但快。如果你查询经常产生大量中间结果,又不想动 SQL 逻辑,可以先试PRAGMA temp_store = MEMORY;。但千万注意,过大的排序结果全塞内存,可能会直接把内存吃满,触发 OOM,尤其是移动端。
我自己在桌面工具里开 MEMORY,移动端保持默认,就是一种取舍:场景不同,不能一刀切。
2.6 busy_timeout 与 locking_mode:并发问题的两个侧面
SQLite 严格来说不擅长高并发写入,但通过 busy_timeout 可以极大改善多连接竞争时的体验。它表示一个连接在获取到数据库锁失败后,愿意等待的毫秒数。如果不设置,默认可能是 0,也就是说一遇到锁直接返回 SQLITE_BUSY,应用层不加处理就会报错。
设置方式很简单:
PRAGMA busy_timeout = 5000;这个参数是连接级的,建议在每个连接初始化的时候立刻设置。它不能真正解决并发写冲突,但能让锁竞争变得有“弹性”,而不是动不动就抛异常。
locking_mode 通常在涉及到跨进程共享同一个数据库文件时使用。默认 NORMAL 下,如果两个进程同时访问,会依赖操作系统文件锁;如果需要长期持有锁,可以试试PRAGMA locking_mode = EXCLUSIVE;。代价是别的进程基本读不到数据,所以一般只在专用场景下用。
2.7 其他值得知道的参数:page_size、wal_autocheckpoint、foreign_keys
page_size 决定数据库页大小,建库前设置一次,之后不能直接改。B+Tree 的节点大小就是 page_size,如果你的单行数据较大、索引较多,较大的页可以让一次磁盘 I/O 读到更多有效数据。默认 4096 其实已经适合大多数场景,除非你有大量宽表,可以建库前设成 8192。
wal_autocheckpoint 控制 WAL 文件自动 checkpoint 的阈值,默认 1000 页。也就是说 WAL 文件积累到约 4MB(1000 页 * 4KB)时,SQLite 自动把 WAL 内容合并回主库。checkpoint 本身也会占用 I/O,如果写非常密集,可以适当调大。例如在批量导数据的时候,我会设成较大的值甚至 0(关闭自动 checkpoint),导入完成再手动执行:
PRAGMA wal_checkpoint(TRUNCATE);foreign_keys 默认是关闭的,也就是说外键约束并不会自动生效。这不是性能参数,但属于“初始化参数”里非常重要的一个。如果你依赖外键级联删除或完整性校验,记得在每个连接上执行:
PRAGMA foreign_keys = ON;为什么是“每个连接”?因为 foreign_keys 也是连接级参数,不持久化。很多新手在 SQLite 里发现外键“没生效”,十有八九是忘了这一条。
下面把常用的参数整理成一个对照表,方便快速查阅:
| 参数名 | 核心作用 | 是否持久化 | 推荐值(一般场景) |
|---|---|---|---|
| journal_mode | 日志模式,决定读写并发 | 是 | WAL |
| synchronous | 同步级别,影响安全与性能 | 否 | WAL + NORMAL |
| cache_size | 页缓存大小 | 否 | -65536(64MB)左右 |
| mmap_size | 内存映射大小 | 否 | 268435456(256MB) |
| temp_store | 临时表存储位置 | 否 | MEMORY / FILE 按需 |
| busy_timeout | 锁等待毫秒数 | 否 | 5000 |
| page_size | 数据库页大小 | 是(建库前) | 4096 / 8192 |
| wal_autocheckpoint | WAL 自动合并阈值 | 否 | 1000,可调大 |
| foreign_keys | 外键约束是否启用 | 否 | ON |
3. 动手设置参数:初始化流程与验证方法
3.1 用 sqlite3 CLI 完成建库和参数设置
命令行是最直接的验证方式。假设我要新建一个名为 app.db 的数据库,并做全套初始化:
sqlite3 app.db进入交互环境后,先设 journal_mode。这里有个细节:如果你在库还没建表的时候设置 WAL,SQLite 会先创建一个空库,然后返回当前 journal_mode 值:
PRAGMA journal_mode = WAL; -- 期望输出:wal接着设置其他运行时参数:
PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 5000; PRAGMA cache_size = -65536; PRAGMA temp_store = MEMORY; PRAGMA foreign_keys = ON;然后创建表、写入测试数据、查询参数确认生效:
CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, score INTEGER DEFAULT 0 ); PRAGMA journal_mode; -- wal PRAGMA synchronous; -- 1(对应 NORMAL) PRAGMA cache_size; -- -65536这里你会注意到,PRAGMA synchronous 查询出来的是 1,而不是字符串 NORMAL。因为 SQLite 内部就是用 0、1、2 表示 OFF、NORMAL、FULL。同理,journal_mode 查询出来是小写的文本,因为它是持久化属性,需要以字符串存进文件头。
验证完,可以执行一个简单的插入测试,感受一下参数调整前后的差别。比如先建一张只有一列的表,然后循环插入 10000 条数据,分别测“不开事务、默认同步级别”和“开事务、WAL+NORMAL”的耗时。不用精确到毫秒级,差不多量级的区别就能让人印象深刻。
3.2 在应用代码里怎么“初始化参数”
CLI 验证结束,应用代码里就是一套“连接即初始化”的固定流程。我用一个 C++ 的例子说明,因为 C++ 是我们后端工具链里很常用的语言:
#include <sqlite3.h> sqlite3* db = nullptr; int rc = sqlite3_open("app.db", &db); if (rc != SQLITE_OK) { // 处理错误 } // 设置 busy_timeout,避免并发时立刻报错 sqlite3_busy_timeout(db, 5000); // 执行 PRAGMA 初始化 const char* pragmas[] = { "PRAGMA journal_mode = WAL;", "PRAGMA synchronous = NORMAL;", "PRAGMA cache_size = -65536;", "PRAGMA temp_store = MEMORY;", "PRAGMA foreign_keys = ON;" }; for (const char* sql : pragmas) { char* errMsg = nullptr; rc = sqlite3_exec(db, sql, nullptr, nullptr, &errMsg); if (rc != SQLITE_OK) { // 记录 errMsg,并释放 sqlite3_free(errMsg); } }这套写法的核心思路是:把 PRAGMA 初始化固定成一个函数,在每次打开数据库后调用。尤其是用了连接池的项目,每个连接都必须执行一遍,不能只初始化第一个连接。在 C++ 里也可以用 C++20 的 jthread 做连接级线程检查,但为了控制篇幅,这里不展开。
如果你在 Python 里用 sqlite3 模块,同样也是在拿到 connection 对象后立刻 execute 一串 PRAGMA:
import sqlite3 conn = sqlite3.connect("app.db", timeout=5) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") conn.execute("PRAGMA cache_size=-65536") conn.execute("PRAGMA foreign_keys=ON")其实几乎所有语言封装的 SQLite 驱动都保留了对原始 SQL 语句的透传能力,所以 PRAGMA 的设置逻辑可以完全复用。
3.3 用 DB Browser for SQLite 怎么可视化调参
很多朋友喜欢用 DB Browser for SQLite 或者 Navicat for SQLite 来操作库,不太习惯写命令行。这件事也能做。
DB Browser for SQLite 自带一个“Execute SQL”标签页,你可以在里面直接输入上面那些 PRAGMA 语句并执行,效果和命令行一致。尤其适合快速验证某个参数设置后,再去看“Database Structure”或“Browse Data”是否正常。比如你在界面上看到某个表的外键约束没有生效,直接执行一句PRAGMA foreign_keys = ON;就能当场验证。
Navicat for SQLite 相对更图形化一些,同样可以在查询窗口执行 PRAGMA。注意一点:这些图形化工具自带的连接不一定替你执行过初始化参数,所以你在工具里测试的性能数据,未必代表你应用代码连接后的真实表现。
4. 调优案例:从默认配置到百万行写入
4.1 场景描述与性能基线
我拿一个前阵子做的桌面端数据采集工具举例。程序每秒钟要接收若干条传感器数据,落库到本地 SQLite。单次采集的数据量不大,但一天累积下来可能就是上百万行。最初,程序用的是最原始的写法:每来一条数据,单独执行一个 INSERT,连接保持默认配置,事务全靠自动提交。
跑了一个小时后,采集端开始出现明显的延迟堆积,数据库文件对应的目录里经常能看到临时日志文件,并且偶尔弹出 “database table is locked” 错误。
我做的第一件事是查基线数据。用一个简单的测试脚本,模拟程序行为:循环执行 10000 条单条 INSERT,记录耗时。当时的结果是:在默认配置下,10000 条单条 INSERT 大约耗时 1.8 秒,这还没有算上应用层其他逻辑。如果设备持续跑一天,这个性能完全撑不住。
4.2 参数调整与实测对比
我先做了第一轮调整:开启 WAL,并把 synchronous 降到 NORMAL。
PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;同一个脚本,10000 条单条 INSERT 的耗时降到约 1.1 秒。有提升,但还不够明显。原因很简单:单条自动提交模式下,每次 INSERT 仍然要做一次事务提交,WAL 日志文件被反复写,fsync 虽然被 NORMAL 削弱了,但文件写入的开销还在。
第二轮,我在代码层面把 INSERT 包进显式事务:
conn.execute("BEGIN") for item in data_list: conn.execute("INSERT INTO sensor VALUES (?, ?)", item) conn.commit()同时把 cache_size 调大一点:
PRAGMA cache_size = -65536;结果非常夸张:10000 条插入的总耗时降到约 0.05 秒,也就是 50 毫秒左右,比第一轮快了一个数量级以上。这里面的关键不是 cache_size,而是“显式事务”把成千上万次磁盘同步压成了一两次。
第三轮,我又试着把 temp_store 设为 MEMORY,并稍微调整了 wal_autocheckpoint:
PRAGMA temp_store = MEMORY; PRAGMA wal_autocheckpoint = 2000;排序类查询的速度有一定提升,但写入这块基本没有变化,因为全表扫和单条插入很少用到临时表。这也能体现一个思路:调优不是把所有参数都拉满,而是针对瓶颈去调。
4.3 批量写入的通用调优套路
从上面这个案例可以提炼出一套非常通用的批量调优步骤:
第一,能用事务就用事务。这是所有 SQLite 写入优化的第一原则。单条提交意味着每次都要落盘一次,把 10000 条合并到一个事务里,性能提升是数量级的。
第二,把 journal_mode 设成 WAL,synchronous 设成 NORMAL。WAL 模式降低了读写锁的竞争,NORMAL 则减少了数据落盘的频率。这两个组合在一起,对绝大多数读写混合场景,都是安全又高效的平衡点。
第三,cache_size 按设备内存预算来。如果桌面端内存 16GB,给 SQLite 划分 64MB 缓存完全不影响;移动端如果内存紧张,默认值也不是不能用,只是大量范围查询会比较明显。
第四,如果数据是一次性灌入的,比如导旧数据、跑初始化脚本,甚至可以临时把 synchronous 设为 OFF,等导入完成后再改回 NORMAL。这一点非常适合那种“一次性把 1000 万行历史数据灌进去”的场景,性能差异可以达到两三倍。
4.4 只读场景的调优侧重
如果你主要是在做只读查询,比如把 SQLite 当作一个本地只读数据库分发给用户,调优思路又不一样。这时候写入优化几乎不重要,重点应该放在 mmap_size、cache_size 和合适的索引上。
只读场景下,我会建议把 mmap_size 调得比较大,比如 512MB 甚至更高,让热数据尽量走内存映射通道。cache_size 也可以放开,因为只读连接不会产生脏页,缓存的换页成本很低。再配合 WAL 模式,即使有一个独立的写进程在更新数据,只读查询也能顺畅读取。
另外一个容易被忽略的是PRAGMA query_only = ON;,这个参数设成 ON 之后,连接彻底拒绝写操作,能在架构上防止误写。它不算严格意义上的调优参数,但在只读分发场景里非常实用。
5. 常见问题与排查技巧实录
5.1 “PRAGMA journal_mode = WAL”返回了 delete,但没有报错
这个问题出现频率最高。原因是:你设置 WAL 的时候,数据库可能已经存在,并且有其他连接占用。SQLite 只有在没有任何连接持有共享锁或排他锁时,才能切换日志模式;如果当前会话或其他会话已经锁定了数据库,这次修改会静默失败,返回的还是原来的模式。
排查方法很简单,执行前先确认没有并发连接,或者直接在一个干净连接里执行。设置完成后,立刻用PRAGMA journal_mode;查返回值,必须是 wal 才算成功。很多坑都是因为“以为成功了,其实没有”造成的。
还有一种情况:数据库文件存放在文件系统不支持共享内存的目录里(比如某些网络磁盘、FUSE 挂载),WAL 也需要共享内存支持,失败时也会表现出类似问题。解决办法是把库文件挪到本地盘,或者干脆在初始化逻辑里探测式设置,如果失败就降级。
5.2 database is locked 到底是什么原因
“database is locked”是 SQLite 群里每天都会出现的问题。原因分类大致如下:
| 现象 | 可能原因 | 优先排查 |
|---|---|---|
| 高频写 | 事务没合并,提交太频繁 | 检查代码里是否每个写操作都自动提交 |
| 多进程并发 | 没有设置 busy_timeout | 连接初始化时加PRAGMA busy_timeout=5000; |
| 长事务 | 写事务长时间不提交,阻塞其他连接 | 查看代码里是否有忘记 commit 的分支 |
| 死锁 | AB-BA 模式跨连接加锁 | 规范事务顺序,尽量只用一个写连接 |
| 归档/备份 | 外部程序读取时持锁 | 避免直接用文件复制方式备份 WAL 数据库 |
如果用的是 uniapp 或移动端,还会遇到一个特殊场景:应用前后台切换时,之前的事务没有提交,系统把进程挂起,然后新的连接一写就读不到锁,报错,看起来就是“database is locked”。
5.3 synchronous 设成 NORMAL 之后,会不会丢数据
这也是被问得最多的问题之一。坦白说,在 WAL 模式下,NORMAL 并不等于危险。它保证每个事务提交时 WAL 文件被可靠写入,只是可能延迟把 WAL 合并回主库文件的时机。如果系统崩溃,最多出现的是“最近提交的一些事务丢失”,但这种丢失通常仅限于最后一次 checkpoint 之后的数据,而且数据库文件本身不会因此损坏。
如果你的业务要求严格不丢数据,那就保持 FULL;如果对“万一断电丢最近几毫秒数据”可以接受,那 NORMAL 换来的性能收益非常可观。这是典型的可用性和性能权衡,没有绝对标准,取决于业务。
5.4 为什么设置了 cache_size 但感觉没效果
可能原因有三种:一是你在连接 A 设置了 cache_size,实际跑查询用的是连接 B;二是你设置过PRAGMA cache_size = 20000;,以为是 20000KB,但正数单位其实是页,按 4KB 页大小算是 78MB,表现可能超过预期;三是你的查询是串行扫描级别的,缓存命中率本身就不高,缓存再大也救不了全表扫描。
所以更好的做法是先明确业务 SQL 属于“点查”“小范围查”还是“遍历型统计”。点查和小范围查对 cache 敏感,遍历型统计更吃 I/O 顺序读取速度,一般可以结合 mmap 或 SSD 本身的速度来缓解。
5.5 C++ 里把 JSON 保存进 SQLite 的坑
有些朋友会在 C++ 程序里把整个 JSON 对象作为字符串存进 SQLite 某个 TEXT 列。这个做法本身没问题,但有两个隐患。
第一,如果你的 JSON 很大,单个字段超过几 MB,SQLite 默认参数下执行性能会明显下降。建议执行一次:
PRAGMA max_page_count;同时关注是否有大量 UPDATE 重写整条大字段。更推荐的做法是:将 JSON 拆成多个结构化字段,或者只在 SQLite 里存 JSON 的索引信息,而把原始 JSON 文件直接放磁盘,SQLite 里存路径或哈希。
第二,如果 JSON 里包含特殊字符,拼 SQL 时千万不要用简单的字符串拼接,否则会出现转义问题和 SQL 注入风险。用 sqlite3_bind_text 绑定参数才是正确姿势。C++ 下可以配合 json 库先序列化成 std::string,再绑定到预编译语句。
这两个点其实不完全是 PRAGMA 调优,但和整库初始化配合起来,能让 SQLite 的整体表现上一大截。
6. 一些实操总结
6.1 我建议的“标配初始化模板”
不管是什么项目,我几乎每次打开 SQLite 数据库都会套下面这套模板,有特殊场景再改:
PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON; PRAGMA cache_size = -65536;如果程序跑在 64 位桌面/服务端,额外加一条:
PRAGMA mmap_size = 268435456;如果要对一个存量库做检查,也可以先执行PRAGMA integrity_check;确认库没坏,再执行后续 PRAGMA 优化。这个检查虽不是参数,但排错时能省下不少时间。
6.2 调优思路比参数更关键
很多参数都是“项目不同,最佳值不同”,所以只背参数没有意义。我个人的经验是,调优前先明确三件事:当前瓶颈是 I/O 还是 CPU;应用的读写比例大致多少;并发连接是单进程多线程还是多进程。
判断方式也很直接:如果 CPU 利用率不高但程序卡,基本是 I/O 问题,优先调 journal_mode、synchronous、cache_size;如果数据库文件不大但查询很慢,优先看索引和是否在全表扫描;如果并发一上来就报 locked,优先检查每个连接的初始化 PRAGMA。
SQLite 真正强大之处在于它把所有关键控制器都浓缩成了几条 PRAGMA,上手快、可验证、也能压榨出很高的性能。这一期先把“初始化参数”的坑填上,后续系列里我会继续拆解索引优化、事务设计、以及 WAL 模式在跨平台使用时的底层细节。希望这一篇能帮你少走一点弯路。