SQLite 在技术圈的存在感一直很矛盾:服务器后端讨论 MySQL、PostgreSQL 的时候,它经常被拿来当“那个轻量玩具”;可真到移动 App、桌面软件、IoT 网关、嵌入式设备里盘点数据存储方案,SQLite 又几乎是绕不开的默认答案。作为一款嵌入式关系数据库引擎,它不需要单独的数据库服务进程,直接以库的形式嵌进你的程序里,数据落在一个普通文件上,却能支撑出完整的 SQL 查询、事务、索引、触发器。这篇文章不打算重复官方文档,而是从引擎本身的结构出发,把“SQLite 凭什么能做到单文件存全库”“并发为什么用锁而不是 MVCC”“JSON 怎么塞进去”“存在就更新、不存在就新增到底怎么写”这些问题一次说透。
1. 为什么业界默认把 SQLite 当作嵌入式数据库的第一选择
1.1 从库定位看“嵌入式”三个字的分量
很多人第一次接触 SQLite 是在 Android 开发或者 iOS 开发里,误以为它只是移动端的一个小配件。但 SQLite 的定位从来不是“某个系统内置的数据库”,而是一个可嵌入的关系型数据库引擎。所谓嵌入式,最关键的含义是:它不像 MySQL 那样需要你先启动一个服务端进程、监听端口、创建账号密码,而是把数据库引擎作为一个 C 语言库直接链接进你的应用。应用进程调用它的 API,它读写你的磁盘文件,事务、索引、SQL 解析都在进程内完成。
这个设计带来的第一层好处是零配置。不需要 DBA 去调整 my.cnf,不需要维护数据库实例的生命周期,更不需要在部署环境里额外装一套依赖。只要你的程序能读写文件,SQLite 就能跑。第二层好处是隔离性。它没有网络监听端口,不存在被外部直接扫描连接的风险,本地数据类型直接由调用方掌控。第三层好处是跨平台。官方支持 Windows、Linux、macOS、Android、iOS,以及各种嵌入式 RTOS,同一个数据库文件格式在这些平台之间可以互相拷贝使用。
我见过不少团队在早期做项目时,把用户数据、配置数据、埋点数据全部塞进 JSON 文件或者自定义的二进制文件里,等到要查询维度复杂一点,就开始在代码里写一堆 filter、reduce、字符串切割,维护成本远超想象。后来换成 SQLite,结构约束、索引、SQL 查询全都回来了,而且不需要引一个庞大的数据库服务。这种“进程内服务”的定位,恰恰是 SQLite 能在移动端、桌面端、边缘计算和一部分服务端场景里成为事实标准的原因。
1.2 SQLite 的“不可替代区”到底在哪
如果一个项目并发写量上万、数据量几十 TB,需要用多个节点横向扩展,那 SQLite 确实不合适,硬上只会把自己坑了。但现实中的大量项目并不在这个区间,它们的数据量通常在几百 MB 到几个 GB,并发写主要是单机单进程内的几十路线程,这种场景 SQLite 的性能和稳定性足够,而且部署是碾压式的简单。
对比一下常见数据库的取舍:
| 场景 | MySQL/PostgreSQL | SQLite |
|---|---|---|
| 部署形态 | 独立服务端进程,需要管理实例 | 库文件嵌入应用进程,随应用启停 |
| 数据存储 | 通常多文件、多目录,依赖数据目录 | 单文件(WAL 模式下额外生成 -wal、-shm 文件) |
| 适用并发规模 | 高并发、网络多客户端 | 低并发写、单进程多线程读多写少 |
| 调优门槛 | 参数多,需要经验 | 参数少,但底层概念容易被忽视 |
| 典型场景 | Web 服务、后台管理、微服务 | 移动应用、桌面软件、IoT 设备、本地缓存 |
简而言之,只要能接受“数据库文件就在这台机器上”,SQLite 就是最省心的选择。很多服务端项目其实也符合这个条件,只是团队习惯性认为“生产必须上 MySQL”,导致 SQLite 的价值被忽略了。还好近些年越来越多的工具软件、单机应用、边缘节点开始重新拥抱 SQLite,因为它起码不会让你半夜因为一个 MySQL 实例挂掉而被叫起来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SQLite 的内部骨架:从 SQL 语句到磁盘页面的调用链
2.1 从 sqlite3_prepare 到 opcode 的一条流水线
SQLite 的内部结构比大多数人想象中要精密得多。最早它只有简单的接口,后来参考了数据库教科书的经典三个层设计。你发出一条 SQL,真正到达磁盘之前要经过编译器和虚拟机两个大阶段。
首先,应用层通过 sqlite3_prepare_v2() 把 SQL 文本交给 SQLite。词法分析器把字符串切成 token,语法分析器根据语法规则生成语法树,然后进入语义分析和优化阶段。在这一步,SQLite 会尝试把 WHERE 条件里的列对应到索引上,会做常量折叠、子查询扁平化、连接重排序。最终的结果不是一段 C 代码,而是一段虚拟机的字节码。这个字节码就是 SQLite 内部的“编译产物”,你可以用 EXPLAIN 看到它。
举个例子,SELECT * FROM users WHERE id = 10 这条 SQL 在 SQLite 的虚拟机里大致对应这几个操作:打开 users 表的读游标,根据主键或者 rowid 定位到 B 树里的第 10 条记录,把记录内容按列解析出来,返回给调用者。这些动作全部封装成 opcode。所以 SQLite 可以说是一部非常小的“数据库虚拟机”,每一条 SQL 文本都要先变成 opcode 序列,再交给内部的 VM 执行。
这种设计与 MySQL 的 executor 有相似之处,也有本质区别。相似之处在于都强调“先编译后执行”。区别在于,SQLite 的应用层 C API 把 prepare 这一步完全暴露出来。如果你的程序会反复执行同一条 SQL,正确的姿势是只在启动时 prepare 一次,然后循环 bind 参数、step、reset,而不是每执行一次就把 SQL 字符串重新解析一遍。很多人写代码偷懒,把 prepare 和 finalize 放在每一条数据循环里,结果跑了很久才发现 CPU 浪费在了语法解析上。
2.2 虚拟机里的“字节码”和它背后的可移植性
SQLite 的虚拟机指令集被称为 VDBE,也就是 Virtual Database Engine。B 树的读写、记录的解析、事务的开始与提交,都表现为 VDBE 指令。这样做有两个非常实际的好处。
第一,因为数据库逻辑和具体的操作系统文件读写被隔离开,移植到新平台时,只需要把底层 VFS(Virtual File System)实现好,其他 SQL 功能可以原样复用。SQLite 能在嵌入式 Linux、iOS、Android、Windows 上保持行为一致,靠的就是这个抽象层。第二,SQL 的执行计划可以缓存并复用。sqlite3_prepare 之后拿到的语句对象把整条执行计划保存在内存中,reset 只是把游标和临时状态清掉,计划本身不重建,这正是循环执行时可以大幅省时间的原因。
我在做服务端开发时经常遇到一个奇怪的现象:SQLite 的读写明明很快,但程序吞吐就是提不上去。最后一看代码,有人把一条 INSERT 放在 for 循环里,每次迭代都重新 sqlite3_open 数据库连接,或者不断 prepare/finalize,导致大部分时间都耗在了准备阶段。这个问题的解法很简单,连接可以复用,语句可以复用,事务可以批量。SQLite 从来不是“不支持高性能”,而是它把很多性能责任交给了调用方,调用方如果不懂引擎的工作方式,就会把简单的东西用得很慢。
3. 单文件数据库的秘密:B 树与页面如何组织数据
3.1 数据在磁盘上是“页”而不是“表”
打开
