![]()
一、传了十几年的那句话,官文档自己不认
有人在技术群里问:一个内容站,日请求十万上下,读写比大概二十比一,还有必要单独养一台 MySQL 吗?底下最高赞的回复是「换 SQLite 试试」。点赞的人里一半在调侃,另一半是认真的。
认真的那部分人有底气。SQLite 官方那篇《Appropriate Uses For SQLite》写得明明白白:一般来说,日请求量在 10 万次以下的站点用 SQLite 都能跑得很好;10 万次是保守估计,不是硬上限,SQLite 已经被证明能扛住十倍于此的流量。同一份文档还附了一个样本:sqlite.org 官网自己就是用 SQLite 跑的,2015 年时每天处理 40 万到 50 万次 HTTP 请求,其中 15% 到 20% 是会碰数据库的动态页面,每个动态页面大约 200 条 SQL,整套东西跑在一台与另外 23 台虚拟机共享物理机的 VM 上,多数时候负载低于 0.1。
这里必须把口径说清楚:官方给的是「日请求 10 万次」,是 hits/day,不是日活 10 万人。这两个数字差着一个量级,日活 10 万的站点日请求往往是几百万次。标题里那个 10 万,出处就在上面这一段,拿它当容量规划可以,别当成日活去套自己的业务。
真正让 SQLite 从「本地小工具」变成「能上生产」的,不是硬件变快了,也不是谁胆子大,是一条 PRAGMA:journal_mode=WAL。
二、Tailscale 把主库换成 SQLite,后来踩了什么坑
### 一次有据可查的迁移
要说清楚 SQLite 上生产这件事,绕不开 Tailscale。这家做组网的公司把控制平面的主库放在 SQLite 上,而且把全过程写成了公开博客。
时间是 2022 年 4 月,作者是 David Crawshaw,文章标题就叫《A database for 2022》。按他的说法,他们最早的「数据库」是写进磁盘的一个 JSON 大对象,峰值 150MB;后来换成 etcd;最后换成 SQLite。不选 MySQL 和 PostgreSQL 的理由很实在:团队里几个人都运维过这类客户端/服务器数据库,不想再折腾一遍主从复制;而且他们有一条硬性要求——整个测试套件要能在本地跑起来,不用虚拟机也不用容器,数据库一旦变成独立进程,这一条就守不住了。
更值得抄的是他们做的取舍判断:控制平面短时不可用,后果是「新节点登录不上,但已经连通的网络照常工作」。这句话是整件事的前提。换成「挂一分钟就丢订单」的业务,结论会完全相反。
架构上,控制平面被拆成多个分片,每个分片一个 SQLite 库,由单个 Go 进程独占访问。他们在后来的事故复盘里写了一句原话:这种单写者设计,正是 SQLite 期望的使用方式。迁移本身是三段式:先让封装层同时写 etcd 和 SQLite,再把读取的真相源切到 SQLite,最后关掉 etcd。这套「双写—切读—下线」的节奏,任何一次数据库迁移都能照搬。
生态这一层也交代一下:SQLite 本身是公有领域代码,商用无需授权;把 WAL 持续复制到对象存储、让 SQLite 拥有准实时备份的 Litestream 是 Apache-2.0 协议,GitHub 上一万四千多 star;Expensify 那套把 SQLite 包成分布式事务层的 Bedrock 是 LGPL-3.0,一千二百多 star。地址分别是 github.com/benbjohnson/litestream 和 github.com/Expensify/Bedrock。
### WAL 到底改了什么
SQLite 默认用的是回滚日志模式。写事务提交时要把改动写回主库文件,这个动作需要拿到整库排他锁,在这段时间里读写是互斥的。
WAL 把这个顺序倒过来:写不碰主库,只往一个 -wal 文件尾部追加;读的人照旧读主库,顺带看一眼 -wal 里有没有更新的页。于是读者不必等写者,写者也不必等读者。这句话的边界要盯准:WAL 让读写不再互斥,但它没有给你并行的写——任何时刻仍然只有一个写事务存在。
上生产要配的几件事,代码就这几行:
import sqlite3conn = sqlite3.connect("app.db", timeout=5.0) # timeout 即 busy_timeout,单位秒conn.execute("PRAGMA journal_mode=WAL") # 返回 wal 才算生效,网络盘上会退回 deleteconn.execute("PRAGMA synchronous=NORMAL") # WAL 下常见选择,FULL 更稳但更慢conn.execute("PRAGMA busy_timeout=5000") # 拿不到锁最多等 5 秒,别用默认的 0conn.execute("PRAGMA foreign_keys=ON") # 外键默认是关的还有一条容易被忽略:写事务一律用 BEGIN IMMEDIATE。Tailscale 就被这个咬过——他们 ATTACH 了两个库,用默认的 BEGIN DEFERRED 时,加锁顺序取决于先写哪张表,两笔事务顺序不同,就会和唯一的写者撞成死锁。改成 BEGIN IMMEDIATE,在事务开始时就拿写锁,问题消失。
三、实测 80 倍差距,以及 WAL 没给你的那部分
### 同一台机器上的对照实验
讲道理不如跑一遍。下面两个实验在同一台机器上完成:Apple M2 Pro、10 核、16GB 内存、本地 SSD,SQLite 3.50.4,Python 3.13。表里有 2 万行预置数据,模拟一个读多写少的内容站。
第一个实验很直观:开一个读事务持有 3 秒,模拟后台导出或统计这类慢查询,同时另一个进程尝试写入并提交。
import sqlite3import timedef long_reader(path, hold_seconds, started): """开一个读事务并持有若干秒,模拟导出/统计类慢查询。""" conn = sqlite3.connect(path, timeout=10.0) conn.execute("BEGIN") conn.execute("SELECT count(*) FROM events").fetchone() started.set() time.sleep(hold_seconds) conn.execute("SELECT count(*) FROM events").fetchone() conn.commit()def try_write(path, busy_timeout): """独立进程写入一行并提交,返回耗时与异常。""" time.sleep(0.5) conn = sqlite3.connect(path, timeout=busy_timeout) t0 = time.perf_counter() try: conn.execute("INSERT INTO events (ts, payload) VALUES (?, 'new')", (int(time.time()),)) conn.commit() except sqlite3.OperationalError as e: return time.perf_counter() - t0, str(e) return time.perf_counter() - t0, None结果相当干脆:
journal_mode=delete busy_timeout=5.0s | 长读事务持有 3.0s 期间的写入提交:耗时 2.424s,成功提交journal_mode=wal busy_timeout=5.0s | 长读事务持有 3.0s 期间的写入提交:耗时 0.001s,成功提交journal_mode=delete busy_timeout=0.0s | 长读事务持有 3.0s 期间的写入提交:耗时 0.000s,异常「database is locked」journal_mode=wal busy_timeout=0.0s | 长读事务持有 3.0s 期间的写入提交:耗时 0.005s,成功提交默认模式下,一次后台慢查询就能让写入干等 2.4 秒;把 busy_timeout 设成 0,等都不等,直接甩一句 database is locked。这两行,就是很多人对 SQLite 全部坏印象的来源。
第二个实验是 4 个读进程加 1 个写进程同时压 8 秒,每种配置跑 3 次取中位数,每笔写事务批量插 20 行,两边都设 synchronous=FULL:
配置
读吞吐
写吞吐
写事务延迟 p50 / p95
delete,读连接等锁 5 秒
3,201 次/秒
48,988 行/秒
0.31ms / 0.61ms
WAL,读连接等锁 5 秒
264,448 次/秒
203,600 行/秒
0.08ms / 0.17ms
delete,读连接不等锁
74,136 次/秒,8 秒内锁冲突 383 万次
1,535 行/秒
4.22ms / 63.06ms
WAL,读连接不等锁
261,493 次/秒,锁冲突 0 次
176,125 行/秒
0.08ms / 0.21ms
读吞吐差了 80 多倍,写吞吐差 4 倍,写延迟 p95 从 63 毫秒掉到 0.2 毫秒。第三行那个 383 万次锁冲突最说明问题:默认模式下,读进程几乎每两次尝试就撞上一次锁。
这就是迁移前后的真实差距,不是换了个数据库,是换了个锁模型。
### 三个必须先想清楚的边界
先泼第一盆冷水:WAL 不解决写写并发。WAL 模式下依然只允许一个写事务存在,第二个写者要么等 busy_timeout,要么拿到 SQLITE_BUSY。网上流传的「WAL 开启并发写」是彻底的误传,官方文档的原话是「读者不阻塞写者、写者不阻塞读者」,从来没说过两个写者能同时写。Turso 在 2025 年做过 MVCC 并发写的预览,SQLite 官方也有实验性的 begin concurrent 分支,但都没进主线。
第二盆冷水:那个著名的「SQLite 单机跑出 400 万 QPS」,来自 Expensify 2018 年的博客。成色要看清楚——那是一台 192 核、1TB 内存、3TB NVMe 的裸金属服务器,跑的是 447GB 数据集全内存缓存的只读随机求和操作,而且用的是他们改过的 SQLite 分支,连 POSIX advisory lock 都关掉了。这个数字证明的是 SQLite 的上限,不是你那台 4 核云主机的容量。
第三盆冷水最该记住。2026 年 8 月,Tailscale 发了一篇事故复盘:半年内 19 次数据库损坏,根因是 SQLite 源码里 checkpoint 与写事务之间的一场竞态,SQLite 开发者把它命名为 WAL-Reset bug,估计已经存在至少 16 年,修复随 SQLite 3.51.3 发布。他们自己总结的教训值得抄在本子上:之所以会撞上这个罕见 bug,是因为他们手动接管了 checkpoint 流程、而且跑得非常激进——把一项无聊技术用成了非标准姿势,风险就自己回来了。
所以该问的不是「SQLite 扛不扛得住」,而是「你的负载形状对不对得上」。对得上的是这四条:读远多于写、单机部署、数据量可控、短时不可用不致命。对不上的是:写密集且并发高、必须多机或跨机房高可用、数据量会持续膨胀到单机装不下、数据库文件放在网络文件系统上——WAL 依赖共享内存原语,NFS 上不该用。
四、真要迁的话,把这三件事做扎实
第一件,锁参数配齐再上线:WAL 模式、合理的 busy_timeout、写事务统一 BEGIN IMMEDIATE。三件事做完,上一节里那 383 万次锁冲突基本就消失了。
第二件,备份走 WAL 流式复制,别只拷主库文件。WAL 模式下最新数据可能还躺在 -wal 里,只拷 .db 文件拿到的是旧数据甚至坏数据。Litestream 这类工具做的就是这件事;用不上工具,至少让定时任务跑一遍 PRAGMA integrity_check,别等出事才发现。
第三件,迁移按「双写—切读—下线」三段走,一次只动一步。Tailscale 从 etcd 切到 SQLite 用的就是这个节奏,任何数据库迁移都能复用:先让新库跟着写,再把读切过去观察一段时间,最后才关旧库。一步到位的切换,出问题时没有回头路。
至于 checkpoint,除非你非常清楚自己在做什么,否则交给 SQLite 自己管。Tailscale 那 19 次损坏,就是手动激进 checkpoint 换来的代价。
五、你的库,真的需要一台单独的数据库服务器吗
SQLite 换掉的不是 MySQL,它换掉的是「网络往返 + 独立进程 + 一整套运维」这一层。当数据量单机装得下、读写比又是压倒性的读多写少时,这一层就是纯成本,还顺带把本地跑测试的快乐还给了你。
真想问一句:你现在手上跑着的那个项目,数据库是不是也就是一台 2 核 4G 的小机器?如果把它换成进程内的 SQLite,你是真的会出问题,还是只是不敢?评论区报一下你的读写比和日请求量,看看谁的库其实早就可以下班了。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.