SQLite:一个文件,凭什么成为世界上用得最多的数据库
如果我告诉你,你口袋里的手机、你正在用的浏览器、电脑上的 Python 和 Node.js,甚至每一台 Mac 和绝大多数 Linux 系统里,都内置了一个完整的数据库,你可能会问:这是哪个数据库?
答案是 SQLite。
它没有独立的服务器进程,没有安装向导,没有账号密码,没有my.cnf配置文件。所谓"一个数据库",在 SQLite 的世界里就是一个普通的文件。备份它就是复制文件,删除它就是删除文件,迁移它就是把它拷到另一个目录。
这个 2000 年就诞生的项目,如今是世界上部署量最大的数据库——官方估计的安装量以百亿计,比 MySQL 和 PostgreSQL 加起来还多几个数量级。而它的源码放在公有域里,你可以随随便便把它嵌进任何商业产品,不用署名,不用付费。
这篇文章想聊清楚三件事:SQLite 到底是什么,它内部是怎么工作的,以及什么时候该用它、什么时候不该用。
数据库住进程序里
要理解 SQLite,最快的方式是先看看"正常"的数据库长什么样。
用 MySQL 的时候,你的程序和数据库是两个东西:数据库是一个独立运行的进程,守在网络的某个端口上,你的程序通过 TCP 连过去,把 SQL 发过去,再把结果读回来。中间隔着一次网络往返,隔着序列化和反序列化,隔着账号权限校验。你要先安装它,启动它,配置它,运维它。
SQLite 把这套东西全部砍掉了。它就是一个 C 语言写的库,编译进你的程序,和你的代码跑在同一个进程里。所谓"执行 SQL",就是一次普通的函数调用。数据存在哪?磁盘上一个.db文件而已。
MySQL: 应用程序 ──网络──> 数据库服务器进程 ──> 磁盘 SQLite: 应用程序 ──函数调用──> SQLite 引擎 ──> 一个 .db 文件 (同一个进程里)这个设计带来的好处是连锁的:
- 零配置。不用安装,不用启动服务,第一次调用就自动建库。
- 零运维。没有守护进程可以挂,没有端口可以被攻击,没有配置可以调错。
- 零网络。查询不走网络,不存在连接池耗尽、网络抖动这些问题。
- 备份极简。停写,拷文件,完事。
当然,代价也是同样的直接:数据库和应用绑死在一个进程里,你能用多强的机器,它就有多大能耐。这是后话。
一个 .db 文件里装了什么
SQLite 官方文档里有一句很有意思的话:理解 SQLite 的最好方式,是理解它的文件格式。因为这个文件里,装的不是一堆看不懂的二进制黑盒,而是一棵棵结构清晰的 B-tree。
打开一个数据库文件,它由固定大小的"页"(默认 4096 字节)串起来,就像一本书的一页页纸。文件开头 100 字节是头部:魔数、页大小、编码、版本号。剩下的页里住着两类东西:表和索引,它们全都是 B-tree。
对,你没看错——表就是一棵 B-tree。你建的每一张表是一棵树,每个索引是另一棵树。表那棵树的叶子节点上放着完整的行数据,索引那棵树的叶子节点上放着"索引列 + 行指针"。查询走索引,就是在索引那棵树上定位,拿到行指针,再回到表那棵树里把整行捞出来。和 MySQL 的 InnoDB 大同小异,只不过这一切都浓缩在一个文件里。
这里有个 SQLite 特有的细节,值得单独说。每张表其实都有一个隐藏的 64 位整数主键,叫rowid,哪怕你建表时没写主键它也存在。而如果你把主键声明成INTEGER PRIMARY KEY,它就是rowid的别名——这意味着主键查询等于直接在树上定位,一次到位,是 SQLite 里最快的查找路径。反过来,如果你用一个字符串或者 UUID 当主键,所有查找都要绕道二级索引。
所以 SQLite 社区有个约定俗成的建议:能用整数自增主键,就用INTEGER PRIMARY KEY。这不是老派,是这个文件格式决定的最优解。
写入为什么"慢",以及 WAL 的救场
SQLite 有一个名声:写入慢。
这个名声一半是真的,一半是误会。先说真的那一半。
数据库要保证"事务提交了就一定在"(持久性),就必须在改数据时确保落盘——也就是调用fsync,等磁盘真的把字节写进去。在默认的 journal 模式下,SQLite 的一次写入是这么干的:
- 先把要改的页的旧内容复制到日志文件,
fsync; - 改主文件,
fsync; - 删掉日志文件。
一次提交,至少两次fsync。机械硬盘上一次fsync是毫秒级,也就是说每行数据单独提交一个事务,每秒撑死几百次写入。这是性能悬崖,也是大多数人抱怨"SQLite 写入慢"的真实原因。
但误会在于:这根本不是 SQLite 的上限。你把一千行数据放进一个事务里提交,fsync还是那几次,吞吐量立刻能冲到每秒几万行。写入的粒度,比写入的数量重要得多。
再说 WAL 的救场。SQLite 有个叫 WAL(Write-Ahead Log,预写日志)的模式,一条命令开启:
PRAGMA journal_mode=WAL;开启之后,写入不再直接改主文件,而是先追加写进一个叫-wal的日志文件,后台再慢慢合并回主文件。读取的时候,读"主文件 + WAL 里还没合并的部分",得到一个一致的快照。
这带来两个质变:
第一,读写不再互相阻塞。旧模式下写的时候要独占文件,所有读都得等;WAL 模式下读者读快照,写者写日志,互不干扰。对读多写少的场景,这是决定性的提升。
第二,写者和写者之间也更从容。虽然同一时刻仍然只允许一个写事务(这点下面细说),但冲突的窗口小了很多。
代价是目录里多了两个文件:app.db-wal和app.db-shm。这里埋着两个经典坑:
- 备份时只拷主文件,会丢掉 WAL 里没合并的最新数据。正确姿势是先执行
VACUUM INTO 'backup.db'生成一个原子快照,或者先跑PRAGMA wal_checkpoint(TRUNCATE)把 WAL 合并清空。 - WAL 依赖共享内存(mmap),所以数据库文件必须放在本地磁盘。放 NFS、放 Windows 网络共享,轻则报错,重则直接损坏数据。云端容器部署时把这个路径挂到网络存储上,是真实发生过的事故。
一把库级锁,和"单写者"的智慧
如果说 WAL 解决了"读和写打架",那"写和写打架"的问题依然存在,而且是 SQLite 最根本的架构约束。
SQLite 的锁是库级别的:不管你的程序里开了多少个连接、多少个线程,同一时刻整个数据库只有一个写事务在跑。写和写之间,永远排队。
这跟 MySQL 的行级锁完全不同——在 MySQL 里更新两行不相干的数据可以并行,在 SQLite 里不行,先来后到。
于是并发写会撞上那个著名的错误:SQLITE_BUSY: database is locked。
解法有三层,一层比一层根本。
第一层,等。每个连接都设置PRAGMA busy_timeout = 5000,意思是拿不到锁就原地重试 5 秒,而不是立刻报错。光是这一条,就能消掉九成的 BUSY 错误。注意它是 per-connection 的,连接池里每个新连接都得带上。
第二层,聪明地等。事务别用默认的BEGIN(deferred,第一条语句才去抢锁,冲突暴露得晚、处理起来最狼狈),有写操作就用BEGIN IMMEDIATE,一开始就把写锁抢到手——冲突提前暴露,应用层捕获SQLITE_BUSY做指数退避重试即可。
第三层,从架构上消灭竞争:单写者模式。这是 SQLite 官方推荐的高并发写姿势——所有写操作投进一个队列,由单个连接串行消费;读操作走连接池随便并发。写锁永远不冲突,因为写者根本只有一个,吞吐量反而比一堆连接互相抢锁高得多。
读连接池(N 个)──→ ┌────────────┐ ←── 写队列 → 单写协程(串行) │ SQLite 文件 │ └────────────┘想明白这一点,SQLite 的并发问题就从"bug"变成了"设计约束下的正常工作方式"。它不是不能高并发,它是不能多写者并发——而多数应用的写入量,一个串行写者绰绰有余。
它其实没那么"简陋"
很多人对 SQLite 的印象停留在"存存配置的小数据库",这低估它了。它是一个通过了 SQL 标准大部分测试的完整关系数据库:事务、外键、视图、触发器、CTE、窗口函数,一样不缺。更惊喜的是那些"白送"的内置扩展。
全文检索。FTS5 虚拟表开箱即用,倒排索引、分词、词干还原、BM25 排序、关键词高亮,一套齐全。写个站内搜索、日志检索,CREATE VIRTUAL TABLE ... USING fts5(...)加一句MATCH就完事,不用引入 Elasticsearch。
JSON 处理。json_extract()能像查列一样查 JSON 字段,配合表达式索引,还能直接给 JSON 里的字段建索引:
CREATEINDEXidx_statusONorders(json_extract(data,'$.status'));某种意义上,这让 SQLite 变成了一个带索引的文档数据库。
空间索引。R*Tree 扩展让经纬度范围查询变成一次索引扫描,地图 POI 检索这种活它也接得住。
文件即工具。.dump导出文本、VACUUM INTO在线热备、dbstat虚拟表告诉你"是谁把库撑大了"、generate_series生成数字序列……这些内置能力加起来,SQLite 经常能在小场景里顶替掉一整套中间件。
动态类型:一个温柔的陷阱
从 MySQL 切到 SQLite,最容易被咬一口的是类型系统。
SQLite 的列类型是"建议"而非"规定",官方术语叫 type affinity(类型亲和性)。你写age INTEGER,它只是"倾向于把值转成整数",转不了也不会拒绝:
CREATETABLEt(aINTEGER);INSERTINTOtVALUES('123');-- 存成整数 123INSERTINTOtVALUES('abc');-- 也能存!变成文本 'abc'INSERTINTOtVALUES(3.5);-- 浮点也收听起来宽容,用起来要命:一列里混着整数和文本,ORDER BY的结果就可能诡异(SQLite 的类型排序是 NULL < 数字 < 文本),写进 MySQL 时才会发现数据早脏了。
应对方式很朴素:别指望列类型兜底。NOT NULL 该加就加,应用层做校验,需要的话用CHECK (typeof(a) = 'integer')显式约束。另外时间字段建议统一存 ISO8601 文本或 Unix 时间戳,别让格式自由发挥。
MySQL 和 SQLite 的语法差异还有不少——没有TRUNCATE、没有AUTO_INCREMENT、ON DUPLICATE KEY UPDATE要换成ON CONFLICT、字符串拼接是||不是CONCAT。写之前记住一个原则:SQLite 是方言,不是 MySQL 的子集,重要 SQL 让EXPLAIN QUERY PLAN验一遍,比背语法表管用。
什么时候该用它,什么时候别用
聊了这么多,回到最实际的问题:什么时候选 SQLite?
它的甜蜜点很清晰——单机、嵌入、读多写少、不想运维。
桌面应用和手机 App 的本地存储,是它的主场(iOS 的 Core Data、Android 的 Room 底层都可以是它);CLI 工具、脚本、爬虫的落地存储,它比"写个 CSV"省心,比"起个 MySQL"轻量;微服务的本地缓存和元数据,不值得为它开一个数据库实例的场合,用它正合适;还有原型和测试环境——很多项目开发时用 SQLite,几行配置就能切到生产用的 MySQL。
反过来,这些场景请直接绕开:
- 多个服务实例要同时写同一份数据。单写者是进程内的纪律,跨机器它管不着。要么上 LiteFS、rqlite 这类分布式 SQLite 方案,要么老老实实用 MySQL。
- 高频并发写。不是写不进去,是排队。写入量大到一个串行写者跟不上时,换引擎比优化更省时间。
- 需要账号权限、审计、行级安全。SQLite 没有用户概念,它的安全模型就是文件权限——能读文件的人能读全部数据。
- 数据库必须放在网络存储上。WAL 依赖本地共享内存,这条是红线。
有个粗略但好用的判断标准:如果你在纠结"要不要给这个数据库写运维脚本",说明它已经不是 SQLite 的量级了。反过来,如果你发现自己在写脚本启动数据库、检查端口、清理连接池——那这些活 SQLite 天生就不需要。
结语
SQLite 不是"玩具数据库",也不是 MySQL 的廉价替代品。它是一种截然不同的架构选择:把数据库从"数据中心"搬进"程序内部",用一个文件换掉了整个服务端。
它用 B-tree 和页组织数据,用 journal 和 WAL 守住 ACID,用一把库级写锁换来实现的极致简单与可靠——简单到它的测试代码比产品代码多几百倍,稳定到二十年前的数据库文件今天照样能打开。
下一次当你随手pip install某个包、打开一个 App、或者在代码里写下sql.Open("sqlite", "app.db")的时候,可以想起这件事:你没有启动任何服务器,但你确实打开了一个完整的数据库。
它就在那个文件里。
参考链接
- SQLite 官网:https://www.sqlite.org
- 官方文件格式文档:https://www.sqlite.org/fileformat.html
- 官方 Wiki:How To Corrupt An SQLite Database File(反着读,学保命):https://www.sqlite.org/howtocorrupt.html
- SQLite 在 WAL 下的并发:https://www.sqlite.org/wal.html