SQLite 当生产数据库:Lobste.rs 迁移与 Julia Evans 的运维实录
7 月 11 日,技术社区 Lobste.rs 的系统管理员 pushcx 在一条题为《Lobste.rs is now running on SQLite》的帖子里,平静地宣布了这个运行了十几年的网站完成了数据库迁移:从 MariaDB 切换到 SQLite。这条帖子几天内收到了 438 个赞和上百条评论,同期 Hacker News 上相关讨论冲到了首页。
迁移的成果是这样的:CPU 占用下降,内存占用下降,站点响应更快, VPS 成本直接砍掉一半——因为那个专门跑 MariaDB 的虚拟机可以关了。Lobste.rs 的数据库文件最终大小约 3.8 GB,这是一个有十几年历史、用户活跃的技术社区的全部数据。

几乎同一时间,开发者 Julia Evans(Hacker News 上常被称为 jvns)发了一篇博客《Learning a few things about running SQLite》,记录她用 SQLite 跑一个 Django 网站时踩到的坑。两条消息放在一起,把一个长期被低估的问题重新推到了台前:SQLite 到底能不能上生产?如果能,需要付出什么代价?
这篇博文整理这两个真实案例,拆解 SQLite 在服务端运行时的关键机制、踩坑路径和它真正适合的负载画像。
Lobste.rs 的迁移:两次部署,一次失败
Lobste.rs 是一个邀请制的技术链接分享社区,定位类似 Hacker News,流量集中在工作日的美东上午。数据库迁移项目由社区成员 thomas0 主导,从 2025 年 6 月正式立项到 2026 年 7 月完成,跨了一年多。
迁移的真正难点不在写 SQL,而在于:没有生产数据库的访问权限。thomas0 在帖子里直接写道,这是他第一次在没有生产数据访问权限的情况下做底层迁移,而这次经历让他意识到,真实规模的数据集是性能测试的前提——本地造数脚本跑了一周才生成 Lobste.rs 一半规模的数据。
第一次部署发生在 2026 年 2 月 21 日。pushcx 和 thomas0 上线通话,过了一遍回滚 checklist,合并了 PR。切到只读模式后,只读流量就把 CPU 直接拉到 100%。他们查不出原因,决定回滚。
两天后 thomas0 开了第三个 PR,定位到三个性能问题。其中两个的根因是 SQLite 在最大的表上做了全表扫描(full table scan),另一个是经典的 N+1 查询。他在第二次部署当天早上还加了一个慢查询日志(slow query log),作为兜底。
第二次部署在 7 月 11 日。这次一切顺利,CPU 和内存使用率都低于 MariaDB 时代。他们守着 IRC 频道观察用户反馈,零星几个问题被快速修复。等到周一流量峰值到来,站点依然稳定。这次迁移被正式判定为成功。
thomas0 总结的 SQLite 踩坑清单
帖子里的「SQLite lessons」段落是这次迁移最有信息量的部分,逐条列出:
用户定义函数(UDF)填补方言差距。SQLite 的 SQL 方言比 MariaDB 精简,缺少 regexp、if、stddev 这些 MariaDB 内置函数。Lobste.rs 的 Ruby sqlite3 gem 支持 UDF,团队就用 UDF 重新实现了这些缺失函数,避免在 SQL 迁移层做大量 workaround。
不支持无符号大整数(unsigned bigint)。MariaDB 里用 unsigned bigint 存的部分 ID 字段,在 SQLite 里只能退回普通 bigint。这是一个容易被忽略的类型差异——如果你的迁移脚本没处理 unsigned,数据导入会直接报错。
排序规则(collation)是 SQLite 的弱项。Lobste.rs 在 MariaDB 里用的是 utf8mb4_general_ci,迁移到 SQLite 后只能用 NOCASE。但 NOCASE 只支持 ASCII 字符的大小写折叠,不覆盖完整的 Unicode case folding。对于英文为主的社区问题不大,对国际化内容就是隐患。
FTS5 全文搜索要用 Contentless-Delete Tables。这是最反直觉的一条。SQLite 的 FTS5 提供了多种表结构变体,默认选项并不是生产环境推荐的那个。Contentless-Delete Tables 不是默认值,但在需要删除索引行的场景里更稳定。thomas0 在帖子里直接写:「I'm constantly surprised by the default choices of SQLite」——SQLite 的默认配置经常不符合生产环境的最佳实践。
SQLite 的默认配置问题,是这次讨论的最大共识
thomas0 那句对默认值的吐槽,在评论里引发了最长的讨论分支。一位 ID 为 mort 的用户给出了被反复引用的「make SQLite sane config」配置块,这是社区里相对公认的「生产可用」PRAGMA 组合:
PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 5000; PRAGMA temp_store = MEMORY; PRAGMA mmap_size = 134217728; PRAGMA journal_size_limit = 67108864; PRAGMA cache_size = 2000;
逐项拆解这八行的含义,就能看清 SQLite 在生产环境里到底缺什么:
| PRAGMA | 默认值 | 生产推荐值 | 作用 |
|---|---|---|---|
foreign_keys | OFF | ON | 是否强制外键约束。SQLite 历史上默认关闭,这是最常见的「迁移后外键静默失效」根因 |
journal_mode | DELETE | WAL | 日志模式。WAL 允许读写并发,是 SQLite 上服务端的前提 |
synchronous | FULL | NORMAL | fsync 频率。NORMAL 在 WAL 下几乎不丢数据,但写入吞吐显著提升 |
busy_timeout | 0 | 5000 | 写锁冲突时的等待毫秒数。0 表示立即报 SQLITE_BUSY,生产环境必须设 |
temp_store | DEFAULT | MEMORY | 临时表和中间结果存内存还是磁盘 |
mmap_size | 0 | 128 MB | 内存映射 I/O 上限,大数据库读性能提升明显 |
journal_size_limit | 无 | 64 MB | WAL 文件大小上限,避免无限增长 |
cache_size | 2000 页 | 2000 页 | 内存中的数据库页缓存,Rails 默认已是合理值 |
这张表暴露了 SQLite 的核心设计取舍:默认配置是为了「嵌入式、单进程、低风险」场景优化的,而不是为了「高并发服务端」。PostgreSQL 默认就是服务端模式,这些开关出厂就调好了;SQLite 把这些选择权留给了开发者,代价是每个新项目都要重新踩一遍。
mort 在讨论里提出了一个被广泛赞同的提案:与其让开发者记 100 个 PRAGMA,不如引入一个 PRAGMA edition = 2026,一键切换到「现代服务端推荐默认值」。这个建议至今还停留在讨论阶段,但它精准地戳中了 SQLite 作为服务端数据库的入门门槛。
WAL 模式:SQLite 并发模型的全部秘密
理解 SQLite 在生产环境的行为,绕不开 WAL(Write-Ahead Logging)。Lobste.rs 的运维 pushcx 在评论里给出了最简洁的概括:
For SQLite, it's just a local set of files. We use the rails default journal mode set to WAL which allows concurrently many readers and a single writer to exist at a time.
这一句话包含了 SQLite 并发模型的全部要点:
- 多读单写。任意数量的读事务可以并发,但同一时刻只允许一个写事务。
- 读不阻塞写,写不阻塞读。这是 WAL 相比旧版 rollback journal 模式的关键改进。
- 数据写入
-wal文件,不直接改主库。修改先追加到 WAL,再由 checkpoint 进程异步合并回主库文件。
这套模型的代价是:写入并发被硬性限制为 1。如果你的应用有大量并发写(比如高频率的计数器更新、消息队列式写入),SQLite 会在写锁上排队,超过 busy_timeout 就抛 SQLITE_BUSY。这正是 Julia Evans 在博客里描述的崩溃路径。
Evans 写道,她在清理 Django 后台累积的已完成任务行时,DELETE 语句因为行数太多跑了 5 秒以上。同时另一个 worker 尝试写入数据库,5 秒的 busy_timeout 到期,worker 直接崩溃,虚拟机关闭。她的临时解决方案是「分批小量删除,确保没有单条语句超过 5 秒」,并承认这让她第一次理解了为什么有人坚持用 Postgres——「Postgres 可以同时有多个 writer」。

上图是 Litestream(下面会讲)的工作原理示意。它正是利用了 WAL 文件作为「真实数据存储」这一特性:数据库的权威状态 = 主库文件 + WAL 文件。
ANALYZE:被低估的 5 秒变 0.05 秒
Julia Evans 的博客里有一个被广泛引用的细节:一条在 4000 行表上的 FTS5 全文搜索查询,从 5 秒降到了 0.05 秒,方法只是运行了一次 ANALYZE。
ANALYZE 做的事是扫描数据库,把每个表、每个索引的行数、值分布等统计信息写进 sqlite_stat1 表。SQLite 的查询规划器(query planner)依赖这些统计信息决定走索引还是全表扫描。没有统计信息时,规划器只能基于最保守的假设做决策,而最保守的假设经常导向全表扫描。
Evans 自己也没完全搞清那条查询原本为什么慢,她的推测是「某种意外二次复杂度(accidentally quadratic)」。但结论很明确:SQLite 的查询规划器在没有统计信息时,决策质量会显著下降,而且没有任何警告。这是从 Postgres 迁过来的开发者最容易忽略的一点——Postgres 的 ANALYZE 由 autovacuum 自动执行,SQLite 没有这个自动化机制,需要手动维护。
Lobste.rs 的 thomas0 在第一次部署失败后定位到的「全表扫描」问题,属于同一类根因:查询规划器在没有足够统计信息或索引选择性判断失误时,选择了最差的执行路径。他在「Wishes」段落里写下的第一条愿望,就是希望测试框架能自动 fail 遇到全表扫描的查询——「这本来可以抓住第一次部署时的性能问题」。
评论里 itamarst 指出,这个功能其实可以实现:SQLite 在执行查询时会统计字节码操作数(bytecode operations),Axiom 这个基于 SQLite 的 ORM 就用它来在测试里检测全表扫描。但有一个前提:测试数据集必须足够大、足够多样,否则规划器即使有索引也不会用——几百行的小表,全表扫描本来就比走索引快。
备份:Lobste.rs 用 restic,Evans 用 Litestream,两种范式
SQLite 的备份是这次讨论里分歧最大的话题。Lobste.rs 和 Julia Evans 选择了完全不同的方案,折射出两种生产范式。
Lobste.rs 的方案:每晚一次全量 + restic。thomas0 在评论里确认,他们用 restic 每晚做一次 .backup 命令生成的完整数据库副本,推到对象存储。这意味着最坏情况下(主机在第 23 小时崩溃),当天所有评论和帖子都会丢失。pushcx 承认这是真实的取舍,「这在 MariaDB 时代也是一样的」,而且 Litestream 和更频繁的增量备份被列为后续改进项。
Julia Evans 的方案:restic + Litestream 双轨。她在博客里写了两种备份方式。方式一是 restic,和 Lobste.rs 类似,用 VACUUM INTO 生成压缩副本再 gzip 上传 S3,但她抱怨这个流程经常被 OOM killer 干掉。方式二是 Litestream,增量流式复制 WAL 到对象存储,配置 retention: 400h 保留约 16 天的历史版本。
Litestream 的工作原理是接管 SQLite 的 checkpoint 进程:它打开一个长期读事务阻止 SQLite 自动 checkpoint,自己监控 WAL 文件的新页(page),复制到一个叫 shadow WAL 的暂存目录,再异步推到 S3/Azure Blob/SFTP 等远端。恢复时,从一个基线快照开始,按顺序重放所有 WAL 文件,就能还原到任意时间点。官方文档给出的恢复时间,对于几个 GB 的数据库通常在 30 秒以内。
这两种方案的取舍点落在 RPO(恢复点目标) 上:restic 每晚一次,RPO 是 24 小时;Litestream 近实时,RPO 是亚秒级。对于一个每天产生几千条评论的社区,丢一天是可以接受的;对于一个写入频繁的 SaaS,亚秒级 RPO 是硬需求。
讨论里还出现了一个需要警惕的技术警告:有用户反馈 sqlite3_rsync(SQLite 3.40+ 引入的增量同步工具)在早期版本里会损坏目标数据库,他反复实验都中招。thomas0 回应说他没用过 sqlite3_rsync,但默认的 .backup 命令在测试中表现稳定。对于生产备份,保守的做法是用经过长期验证的 .backup 或 VACUUM INTO,别盲目追新工具。
什么时候该用 SQLite,什么时候不该
把 Lobste.rs 的迁移叙事、Julia Evans 的踩坑记录、以及社区讨论里反复出现的共识叠在一起,可以梳理出一份相对清晰的选型清单。
SQLite 适合的场景
- 读多写少的中小流量网站。Lobste.rs 是典型案例:工作日峰值流量,数据库 3.8 GB,CPU/内存反而比 MariaDB 时代更低。
- 单机部署、不需要水平扩展。SQLite 的数据就是一个文件,
scp一下就能迁移整机。Lobste.rs 明确跑在单个 VM 上。 - 内部工具、管理后台、仪表盘。这类应用并发写极少,SQLite 的单写限制几乎感知不到。
- 需要「整个数据库发给别人」的场景。有用户在评论里分享了一个优雅的架构:一个 meta DB + 每个客户一个 SQLite 文件,客户要数据就
.backup一次发过去,客户离场就删文件。 - 作为 Postgres 的对照基准或回退方案。sqlite.org 自己的官网就是用 SQLite 跑的,每天处理 40-50 万 HTTP 请求,其中 15-20% 是动态请求,运行在一台与其他 23 个 VM 共享物理机的虚拟机上。
SQLite 不适合的场景
- 高并发写入。消息队列、实时计数器、高频订单系统——WAL 的单写限制会成为硬瓶颈。
- 需要多机水平扩展。SQLite 文件不能被多台机器同时挂载读写(NFS 因文件锁定问题不可用)。多机场景需要 LiteFS 或 Turso 的 libSQL 这类额外组件。
- 依赖 Postgres 专有特性。JSONB、高级窗口函数、丰富的扩展生态(pgvector、PostGIS)、精细的用户权限模型——这些 SQLite 都没有。
- 团队不熟悉数据库运维。Julia Evans 的总结很诚实:「SQLite is still a database, databases are complicated, and I do not know a lot about operating databases」。换数据库不能省掉运维学习成本。
pushcx 在评论里对「为什么不是 Postgres」的回答,是这次讨论里最有水平的选型表态:
我不想用比我们实际需求更大、更复杂的方案。Postgres 是我新项目的默认选择,但它确实带来了一个独立服务的额外复杂度——要运行、调优、维护。这次选 SQLite,一个务实原因是愿意做这个工作的人(thomas0)用了 SQLite;另一个是 Lobste.rs 的规模根本不需要 Postgres 的全部能力。
这句话点破了一个经常被忽视的事实:数据库选型的第一性原则是匹配实际负载和团队能力,强弱对比反而是次要维度。Lobste.rs 用十年时间证明了一个技术社区的全部数据可以装进 3.8 GB 的 SQLite 文件,运维成本比跑一个独立的 MariaDB 实例更低。
两条路径,一个共识
Lobste.rs 的迁移和 Julia Evans 的踩坑日记,从两个方向抵达了同一个结论:SQLite 可以是生产数据库,但它不是一个「零配置」的生产数据库。
要把 SQLite 跑稳,至少需要做四件事:开 WAL 模式、设 busy_timeout、定期 ANALYZE、设计一套匹配 RPO 需求的备份方案。每一件都不难,但每一件都有坑。Lobste.rs 的 thomas0 花了一年多、两次部署、一份踩坑清单才把这套配置跑通;Julia Evans 用了四个 SQLite 项目才第一次知道 ANALYZE 的存在。
SQLite 的定位其实很朴素——很多项目根本用不到 Postgres 的分布式特性,却还得为它专门养一台数据库服务器。Lobste.rs 关掉那台 MariaDB VPS 的瞬间账单砍掉一半,SQLite 在生产里到底解决了什么问题,这笔账就是答案。
至于 SQLite 那套让 thomas0「constantly surprised」的默认配置会不会改进,mort 提出的 PRAGMA edition = 2026 是一个优雅的方向,但目前还停留在社区讨论。在那之前,每位把 SQLite 搬上生产的开发者,都得自己背熟那八行 PRAGMA。