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,这是一个有十几年历史、用户活跃的技术社区的全部数据。

SQLite 官方 Logo

几乎同一时间,开发者 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 精简,缺少 regexpifstddev 这些 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_keysOFFON是否强制外键约束。SQLite 历史上默认关闭,这是最常见的「迁移后外键静默失效」根因
journal_modeDELETEWAL日志模式。WAL 允许读写并发,是 SQLite 上服务端的前提
synchronousFULLNORMALfsync 频率。NORMAL 在 WAL 下几乎不丢数据,但写入吞吐显著提升
busy_timeout05000写锁冲突时的等待毫秒数。0 表示立即报 SQLITE_BUSY,生产环境必须设
temp_storeDEFAULTMEMORY临时表和中间结果存内存还是磁盘
mmap_size0128 MB内存映射 I/O 上限,大数据库读性能提升明显
journal_size_limit64 MBWAL 文件大小上限,避免无限增长
cache_size2000 页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 复制架构:监控 SQLite 的 WAL 文件,持续流式复制到远端存储

上图是 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 命令在测试中表现稳定。对于生产备份,保守的做法是用经过长期验证的 .backupVACUUM 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。