关于我们

质量为本、客户为根、勇于拼搏、务实创新

< 返回新闻公共列表

SQLite 撑不住的不是数据量而是写并发:什么时候该换库,什么时候不用换

发布时间:2026-10-12

一个几百 GB 的 SQLite 库可以几年不重启、不报错,安静地跑在一台边缘服务器或内部工具的机器上;另一个只有几百万行的库,却在业务高峰每隔几分钟抛一次 database is locked,把整个接口的可用时间拖下去。这两个现象放在一起看,最容易得出的结论是「SQLite 撑不住了,换 MySQL 吧」,而这个结论通常是错的。真正决定一个 SQLite 库上限的,不是行数、不是文件体积,而是同一时刻有几个写事务在排队、每个写事务持续多久、每次提交要等几次 fsync。把「数据量」和「写并发」这两件事分开之后,你会发现相当一部分团队换库之后性能并没有变好,因为原来的瓶颈根本不在数据库引擎的选型上,而在事务怎么写、磁盘延迟有多高、重试策略怎么设。

几百 GB 跑得稳,几百万行却天天锁死:最初的判断就错了

「慢」和「锁」是两种完全不同的故障,但运维和开发在告警里看到的往往是同一个词:超时。接口响应从 50 毫秒涨到 3 秒,日志里出现 SQLITE_BUSY,第一反应是数据库扛不住了。可是把这两个现象拆开看,成因和处理方式相差很远。

「慢」通常是一条 SQL 本身耗时长:没有走索引的全表扫描、一次事务里塞了几千条 INSERT、或者查询返回了几十万行再在应用层做过滤。这类问题换到 MySQL 或 PostgreSQL 上大概率还是会慢,只是慢的表现形式变成慢查询日志里的 rows examined 特别大。「锁」则是另一个信号:写事务拿不到库级写锁,等待超过 busy_timeout 之后返回 SQLITE_BUSY。它的根因是同一时刻有多个写者在抢同一个写锁,或者某个写事务长时间不释放锁,把后面所有写请求全堵住。

第二个常见误判是把数据量当瓶颈。SQLite 官方给出的单库上限是 281 TB,前提是磁盘和文件系统支持这么大的单文件;这个数字放在真实业务里基本等于「没有上限」。所以当一个 800 GB 的库跑得好好的时候,问题从来不是「它太大了」,而是它的写入是不是稀疏的、是不是只有一个写者、事务是不是都很短。反过来,一个 2 GB 的库如果每秒有几百个独立写事务争抢写锁,且每个事务都要等一次 fsync 返回,那它天天报锁超时一点都不奇怪。

区分这两件事有个很朴素的办法:看故障是不是随写入量线性恶化,还是随并发连接数线性恶化。写入总量翻倍但并发不变,如果延迟没变,说明瓶颈在磁盘吞吐;并发连接翻倍但写入总量不变,如果 SQLITE_BUSY 立刻飙升,那就是写并发到了边界。这两种情况的应对方向完全不同,前者加盘、改批量提交,后者要么收口写者,要么换引擎。

库级写锁决定了 SQLite 的并发天花板

SQLite 的并发模型只有一句话:任意时刻,一个数据库文件最多允许一个写者,读者数量不限。这不是配置问题,是它的设计前提——SQLite 不是一个「服务器」,它是一个链接进你进程的库,没有常驻的协调进程去管理行级锁、死锁检测和事务隔离级别。它只能靠操作系统提供的文件锁来保证同一时间只有一个进程在改这个文件。

这意味着写事务是严格串行的。第二个写者到来时,它要么拿到锁,要么在 busy_timeout 指定的时间里等待,超时后返回 SQLITE_BUSY(在部分路径上表现为 database is locked)。官方对这件事的表述其实比很多人的想象乐观:多数写事务只持续毫秒级,多个写者排队轮流写就够了,SQLite 能承受的写并发比多数人以为的要高。这句话的关键限定词是「写事务只持续毫秒级」——一旦某个写事务被拉长到几百毫秒甚至几秒,整个写队列就会被它一个人堵死。

哪些操作会把写事务拉长

最常见的三类:第一类是把外部 IO 放进事务里,比如在一个写事务中间去调一个 HTTP 接口、读一个远程文件、或者等一个消息队列的响应,锁就在这段时间里一直被握着;第二类是一次事务写太多数据,比如单条事务塞 50 万行 INSERT,日志刷盘和页写入的时间都被算进持锁时间;第三类是事务里包含耗时的查询或应用层计算,开发写起来顺手,实际上锁的持有时间被这些非数据库操作放大了好几倍。

为什么不能靠「多开几个进程」解决

有些团队的应对方式是把写入拆到多个进程并行跑,结果锁冲突反而更严重。原因很直接:SQLite 的写锁是库级的,进程数增加只会让锁队列变长,不会提高写吞吐。真正的并行写入在 SQLite 里是不存在的,能做的是让每个写事务尽量短、尽量少,让队列快速消化。想靠加进程提高写入能力,方向从一开始就反了。

WAL 让读不再被写挡住,但写依然只有一个

很多团队开了 WAL(Write-Ahead Logging)之后以为并发问题解决了,这是个典型的半懂。WAL 改变的是读写之间的关系,不是写写之间的关系。

在默认的 rollback journal 模式下,写事务开始时会拿到排他锁,此时所有读都被挡住;写事务提交前,读者不能读。WAL 模式下,写者把修改追加到一个 -wal 文件里,数据库主文件保持不变,读者继续读主文件加自己可见的那部分 WAL 内容,于是读不阻塞写、写也不阻塞读。这对读多写少的应用是巨大改善——一个后台批量任务在写,前台查询照样毫秒返回。

但写者依然只有一个。WAL 文件本身也是一个需要串行追加的日志,同一时刻只能有一个写事务往里追加。所以 WAL 把「读写互斥」这个更频繁出现的矛盾解决了,把「写写串行」这个硬边界原封不动地留了下来。如果你的问题是读写互相阻塞,开 WAL 基本能解决;如果你的问题是写不够快,开 WAL 帮助有限。

WAL 文件为什么会越涨越大

WAL 模式引入了一个新运维项:-wal 文件。只要有写,它就增长;只有当检查点(checkpoint)执行时,WAL 里的内容才会被写回主数据库文件,之后 WAL 才能被复用或截断。SQLite 默认在 WAL 达到 1000 页时自动触发检查点(由 wal_autocheckpoint 控制),但如果此时有长读事务正在跑,检查点就无法完成——因为回写会覆盖那些读者还需要的旧版本页面。于是 WAL 文件会一直涨,磁盘占用失控,读性能也会因为每次查询都要扫描更长的 WAL 索引而下降。

实践里最常遇到的组合是:一个定时任务每分钟跑一次全表统计,持续十几秒;同时后台持续写入;WAL 因为长读事务迟迟无法 checkpoint,几天涨到几十 GB。这不是 SQLite 的 bug,是长读事务和自动检查点的相互作用。处理方式是把长查询挪走(比如放到只读副本上)、把 wal_autocheckpoint 调小让检查点更频繁、或者在业务低峰主动执行一次 TRUNCATE 类型的检查点。另外,如果要把 wal_autocheckpoint 设为 0 关闭自动检查点,就必须自己负责检查点调度,否则 WAL 会无限增长。

synchronous 与 fsync 的关系

另一个必须理解的参数是 synchronous。它决定 SQLite 在关键节点调用 fsync(或等价系统调用)的频率:FULL 模式下每次事务提交都 fsync,崩溃后不会丢已提交事务;NORMAL 模式下 WAL 的写入不每次 fsync,崩溃可能丢失最近几个事务但性能更好;OFF 则完全不 fsync,断电后数据库可能损坏。WAL + synchronous=NORMAL 是很多单机应用的常见组合,因为 WAL 本身降低了损坏风险,代价是极端情况下丢少量已提交事务。

这里的关键认识是:fsync 的次数乘上单次 fsync 延迟,就是写入延迟的下限。如果一块盘的 fsync 延迟是 1 毫秒(公开渠道参考值,实际需结合磁盘型号与机房实测确认),那么单条事务逐个提交的场景下,每秒写入事务数上限就在千级;如果用的是网络存储或者 fsync 延迟到几毫秒甚至十几毫秒的介质,上限会掉到几百甚至几十。这就是为什么「把 1000 条 INSERT 合成一个事务」往往能带来数量级的提升——不是 SQLite 变快了,是 fsync 次数从 1000 次变成了 1 次。

事务长度、fsync 延迟、busy_timeout:真正决定写入速度的三组变量

第一组:事务长度与批量提交

把 N 条写入放进一个事务,和分 N 个事务提交,差距主要来自 fsync 次数与日志写入量。批量提交几乎是 SQLite 上投入产出比最高的优化,代价是原子性范围变大:一个事务里的 1000 条要么全成功要么全失败。对多数日志、采集、指标类数据,这个代价可以接受;对订单、账务类数据,通常要按业务边界折中,比如按批次 100–1000 条提交,既控制 fsync 次数,也限制回滚范围。

反过来,事务越长,持锁时间越长,就越容易把其他写者堵死。所以这里有个真实的取舍:批量提交提升吞吐,但会拉长单次持锁时间,在多个写者并存时可能抬高 SQLITE_BUSY 概率。合理的做法是把写入收口到单一写者(比如一个专门的写队列),由它来做批量提交,这样批量与串行两者兼得。

第二组:fsync 与磁盘延迟

SQLite 的写入延迟下限由磁盘决定,这一点在换库讨论里最容易被忽略。因为很多人拿 MySQL 和 SQLite 在同一台机器上对比,发现 MySQL 快,就以为是引擎的差距,实际上 MySQL 默认可能把多次提交合并刷盘、或者用了不同的刷盘策略,比的不是同一件事。真正的变量是单次 fsync 延迟:本地 NVMe 通常在百微秒到毫秒量级,企业级 SATA SSD 略高,机械盘和网络存储(NFS、部分云盘)可能到毫秒到十几毫秒(均为公开渠道参考值,实际需结合磁盘型号与机房实测确认)。

验证方法很简单也很土:在目标机器上用 fio 之类的工具,以同步写 + 每次 fsync 的模式测小块随机写,看 IOPS 和延迟分布。这个数字乘上业务的事务模型,基本就能算出这台机器能撑多少写 TPS。如果发现 fsync 延迟是瓶颈,换数据库引擎一点用都没有,换盘或者改提交策略才有用。

第三组:busy_timeout 与重试策略

busy_timeout 设定的是拿不到写锁时的等待时长,默认在很多驱动里是 0 或很短。设为 0 的后果是:一旦有并发写者,立刻返回 SQLITE_BUSY,业务看到的就是 sporadic 的锁错误。把它设成几千毫秒,多数短事务冲突会在等待中自动消解,错误率断崖式下降。

但 busy_timeout 不是万能药,它只解决「短冲突」,不解决「持续的写锁饥饿」。如果持锁的那个事务本身要跑 5 秒,那么后面所有写者等 5 秒还是拿不到锁。所以正确的组合是:设一个合理的 busy_timeout(比如 2000–5000 毫秒做起点,按业务容忍度调),同时在应用层做带指数退避的重试,并且监控重试率。重试率如果持续超过个位数百分比,说明已经到了真实的并发边界,改配置只是延后问题。

TB 级数据量本身不是问题,索引膨胀和外置大字段才是

把数据量单独拿出来看,SQLite 的承受能力比多数人以为的高得多,真正的麻烦来自另外三件事。

第一件是索引膨胀。每个索引都是一棵独立的 B 树,写放大是真实的:一行 INSERT 会连带更新所有相关索引。一个表上建了七八个索引,写入成本可能是无索引表的数倍,而且这些成本全部计入持锁时间。很多「写入变慢」的案例,最后查出是半年前为了一个临时报表加的索引没删。定期审视索引用不用得上,比调任何参数都有效。

第二件是大字段。如果把图片、PDF、JSON 大 blob 直接存进表里,单个数据库文件会迅速膨胀,页缓存被大对象挤占,VACUUM 变得极慢,备份时间也跟着拉长。更合理的做法是大字段外置:数据库里只存路径或对象存储的 key,文件本身放文件系统或对象存储。这条规则对任何数据库都成立,但在 SQLite 上收益特别明显,因为它直接影响单文件大小和备份窗口。

VACUUM、碎片与页大小

频繁删除和更新会产生碎片,空闲页散落在文件里。VACUUM 能重建整个文件、回收空间,但它需要在库上拿排他锁并重写全部数据,对一个几百 GB 的库来说是一次长时间的停机操作——这也是「数据量大」带来的真实成本之一,不是查询慢,而是维护窗口长。相比之下,开启 auto_vacuum 可以让空间自动回收,代价是额外的页移动开销,写入性能会有一定损失。

页大小(page_size)在建库初期就要定好,之后不能随便改。较大的页对大范围扫描和 blob 更友好,较小的页对随机点查更友好;一旦选错,只能靠 VACUUM 重建。所以有明确预期的大型库,应该在建库时就把页大小、是否 auto_vacuum、编码这些不可变参数定下来,而不是等出问题再补。

备份与复制:换库之外的另一条路

「单机数据库不可靠」是换库的另一个常见理由,但这个理由正在变弱。以 Litestream 为代表的做法是持续读取 WAL 并把增量上传到对象存储,实现接近实时的异地备份与时间点恢复,业务侧几乎无感,也不需要长时间锁库。相比传统的「定时拷文件」(拷贝期间要处理一致性问题)或「定期 VACUUM INTO」(生成一份一致的快照副本),这类方案把备份从运维动作变成了后台常驻行为。

需要说清楚边界:这类复制解决的是备份与灾后恢复,不是读扩展,也不是多机写入。它不会让你获得第二个写者,只是让你在机器挂掉之后能在分钟级把库恢复到另一台机器上继续单写。如果你的需求是「两台机器同时写同一个库并且强一致」,那确实超出了 SQLite 的能力范围,只能换架构。

瓶颈判断与应对:症状、原因、验证与无效边界

症状 最可能的原因 怎么验证 应对措施 什么情况下这招无效
间歇性 database is locked / SQLITE_BUSY,高并发时段集中出现 多个写者争抢库级写锁,且 busy_timeout 过短或未设 统计 SQLITE_BUSY 次数与写连接数的关系:并发翻倍错误率是否同步飙升 设 busy_timeout 到秒级,写请求收口到单一写队列,应用层加退避重试 存在长事务(持锁数百毫秒以上)时,调 timeout 只能延后报错,堵点仍在
单条 INSERT 很慢,批量插入反而很快 每条独立提交都触发一次 fsync,磁盘延迟被逐条放大 fio 同步随机写测单次 fsync 延迟,与单事务提交延迟对照 合并为批量事务提交;WAL 模式下将 synchronous 调为 NORMAL(接受极端情况丢少量事务) 介质 fsync 本身在毫秒到十几毫秒量级且无法换盘时,吞吐上限受物理限制
-wal 文件持续增长,磁盘占用失控 长读事务阻碍 checkpoint,WAL 无法回写复用 观察 -wal 文件大小曲线,并找出运行时间最长的只读查询 长查询挪到只读副本;调小 wal_autocheckpoint;低峰主动执行 TRUNCATE 检查点 业务本身存在持续数分钟的分析型长查询且无法拆分时,只能靠副本隔离
库不大,但查询明显变慢,重启后也没有改善 索引缺失或索引过多导致写放大;统计信息未更新 用 EXPLAIN QUERY PLAN 看是否走索引,清点长期未使用的索引 按查询模式补删索引,定期执行 ANALYZE 更新统计信息 查询本身需扫描全表且业务无法改造成增量计算时,索引手段无解
备份耗时长、备份期间业务抖动 直接拷贝大文件无法保证一致性;VACUUM 需要排他锁并重写全库 记录一次全量备份耗时与该窗口内的错误率 改用 VACUUM INTO 生成一致快照,或用 Litestream 类方案持续同步 WAL 增量 需要多机同时写、或要求跨副本强一致时,备份手段解决不了架构问题
写入吞吐长期上不去,CPU 与内存都很空闲 写事务串行 + fsync 延迟构成硬上限,资源并未饱和 看 CPU 利用率与 iowait 分布,确认瓶颈落在设备等待 缩短事务、批量提交、换更低 fsync 延迟的本地 NVMe,或把写收口后异步化 业务确实需要多个独立进程同瞬间写入且不能排队时,只能换客户端/服务器数据库

这几种情况下,SQLite 该换了

换库的判断应该是条件式的,而不是情绪式的。下面几种情况属于「该换」,且换成客户端/服务器架构之后问题会真的消失。

多机同时写同一个库。这是最硬的一条。SQLite 的写锁依赖操作系统文件锁,一旦数据库文件放在网络文件系统上,延迟会显著上升,而且很多网络文件系统的文件锁实现存在缺陷,锁不生效时两个客户端可能同时修改同一部分数据导致损坏——SQLite 官方明确说这是底层文件系统的 bug,它自己无能为力。所以「多台机器通过网络直接访问同一个 SQLite 文件」是被官方点名要避免的用法。如果确实要多机写,正确做法是在前面放一个应用服务器,由它独占这个库,其他机器通过接口访问;如果这个应用服务器本身就扛不住或者需要多活,那就该换库了。

需要行级锁与高并发写。客户端/服务器数据库有常驻进程协调访问,能做行级锁、死锁检测、细粒度隔离级别,写并发能力通常远超 SQLite。如果你的业务特征是大量用户同时提交订单、库存扣减、消息投递,且这些写不能排队等待,那么 SQLite 的串行写会成为永久瓶颈,调任何参数都只是把它往后推。

需要在线 DDL、复杂权限与审计。SQLite 的表结构变更能力有限,权限模型基本等于没有(它不做用户和授权,靠文件系统权限),也没有内建的审计、角色、行级安全。内部工具可以接受,一旦涉及多团队协作、合规审计、按角色授权,就要用有完整权限体系的数据库。

需要强一致的多副本。如果你的需求是「任一副本写入后,其他副本立即可见且保证一致」,那属于分布式共识问题,SQLite 这类单文件库不提供这个能力。可以做的是备份、只读副本、故障后恢复,但做不了多点写入的强一致。

数据量进入 TB 级且仍在快速增长。官方的建议是:内容量看起来要进入 TB 范围时,就该考虑集中式数据库,因为它能把内容分布到多个文件甚至多个卷上。注意这里的表述是「考虑」,不是「必须」——一个 2 TB 但几乎只追加、只有单个写者的归档库,留在 SQLite 上照样合理。

这几种情况下,换库是白花钱

也有大量场景,换库带来的收益接近零,成本却是实打实的:要运维一个数据库进程、要考虑高可用、要处理连接池、要做备份与升级,还得有人半夜接告警。

单机部署、单一写者。内部工具、工控上位机、单机交付的软件、边缘设备本地库,这些场景里本来就只有一个进程在写,写锁根本没有竞争,换库唯一的作用是给自己增加一个需要维护的服务。

读多写少。WAL 模式下读写几乎不互相阻塞,读的扩展可以通过本机多进程读、或者复制到多个只读副本来做。一个配置中心、一个字典表服务、一个报表只读库,写入可能一天只有几次,读 QPS 却有几百,这种形态下 SQLite 的表现往往比一个配置不当的 MySQL 更稳。

读副本可以水平扩展。如果瓶颈在读,复制几份库文件(用 VACUUM INTO 生成一致快照,或持续同步 WAL 增量)放到多台机器上各自读,成本远低于搭一套主从。前提是能接受副本的秒级到分钟级延迟,且这些副本不接受写。

边缘离线场景。边缘节点断网是常态而不是异常:工厂车间、车载设备、门店 POS、船载采集。这类场景的核心诉求是「断网时本地能写、联网后能同步」,而客户端/服务器数据库在断网时写不了。本地用 SQLite 承接写入、网络恢复后向上游汇总,是这里唯一合理的架构,换库反而会让离线能力消失。

数据量虽大但写入稀疏。设备历史数据、日志归档、时序冷数据,一天写几次、每次写一批,总数据量可能有几百 GB。这种负载下 SQLite 的写入压力可以忽略,查询靠索引和分区表(按时间分多个库文件)就能解决,维护成本远低于一个同样规模的数据库集群。

换库之前,先把这五件事做完

这五件事的顺序大致按投入产出比排列,做完之后再评估要不要换,通常会有相当一部分团队发现不用换了。

1. 批量提交。把逐条提交的写入合并成事务批次,是 SQLite 上收益最大的一步。日志、采集、指标类数据可以直接按批;交易类按业务边界分批,并在应用层处理批次失败的回滚与补偿。

2. 缩短事务。检查所有写事务里有没有非数据库操作:HTTP 调用、文件读写、消息发送、复杂计算。把这些挪到事务之外,只在事务里保留必要的 SQL。这一条往往能把持锁时间从秒级压到毫秒级,直接消除大部分锁冲突。

3. WAL 与检查点调优。打开 WAL 模式,设置合理的 busy_timeout,按写入强度调整 wal_autocheckpoint,并确保没有长读事务长期阻碍检查点。同时监控 -wal 文件大小,把它纳入常规告警。

4. 读写分离到本地副本。把分析型长查询、报表任务挪到只读副本上跑,主库只承担在线写入和短查询。这一步同时解决了长读事务阻碍检查点的问题,属于一次改动解决两个症状。

5. 大字段移出库外。把 blob、图片、大 JSON 改成存路径或对象存储 key,数据库里只留元数据。库文件变小之后,页缓存命中率、备份窗口、VACUUM 时间都会一起改善。

做完这五步,如果 SQLITE_BUSY 消失、P99 写入延迟回到毫秒级,那说明原来的瓶颈是用法而不是引擎。如果做完之后写入吞吐仍然卡在业务需要的水平之下,且监控显示是串行写与 fsync 的硬上限,那才是真的到了换库的时候。

写敏感的单机库,服务器该怎么选盘和配内存

这类负载的机器配置和常见的 Web 服务器配置思路不太一样,优先级要重排。

排在第一位的是磁盘的 fsync 延迟,而不是容量和顺序带宽。一块顺序读写 3 GB/s 但 fsync 延迟高的盘,对 SQLite 写入的帮助远小于一块顺序带宽一般但 fsync 稳定在百微秒级的本地 NVMe。选盘时要关注的是掉电保护、写缓存策略、以及是否是企业级稳态性能(很多消费级盘在缓存写满之后性能会断崖下跌)。带电池或电容保护写缓存的 RAID 卡/企业级 SSD,能让 fsync 在断电时依然安全,这也是为什么「有掉电保护的写缓存」在这类场景里比「更大的缓存」更有价值。至于具体延迟数字,公开渠道的参考值差异很大,实际仍需结合磁盘型号与具体机房实测确认。

第二位是内存,但用法要清楚:SQLite 自己不做数据缓存管理,它依赖操作系统的页缓存。内存越大,热数据越可能留在页缓存里,读的随机 IO 越少。对一个几百 GB 的库,不可能全部放进内存,通常的做法是保证最热的那部分索引和近期数据能常驻,剩下的靠磁盘。判断依据是缓存命中率和缺页读入的 IO 量,而不是「内存占用率高不高」。

第三位才是 CPU 核数,而且通常不需要多。因为写是串行的,多核无法并行化一个库的写入;查询虽然可以并发,但多数内部系统的查询并发并不高。真正需要核数的场景是:单台机器上跑多个独立的库(每个库一个写者,天然并行)、或者应用层本身有较重的计算。盲目上高核数机器,把预算花在不会用到的核上,是这类场景最常见的采购浪费。

从采购角度看,这类写敏感的单机数据库配置,一万网络这类深耕 IDC 19 年(成立于 2007 年)的服务商在选型时通常把 NVMe 盘的 fsync 延迟与写缓存保护放在核数之前考虑,而不是反过来先挑 CPU。以一万网络的公开报价为例,轻量的一万云 ¥25 起可以承接内部工具与边缘采集这类写入稀疏的场景;需要独享物理机时,裸金属 E5-2620 ¥999 起、E5-2698v4×2 ¥3999 起(海外买 1 送 1)属于官网明示价区间,可按写入压力与单机多库的并行需求比选;其余海外节点与非标配置的月租需实时询价,具体以官网实时价为准。

部署 SQLite 时最容易踩的四个坑

坑一:把库文件放到网络存储上。为什么坑:NFS、部分共享文件系统的延迟明显高于本地盘,且文件锁实现存在缺陷,锁失效时有损坏风险,SQLite 层无法补救。怎么判断:看数据库文件是否位于网络挂载点,同时观察写入延迟是否远高于本地盘的 fsync 参考值。怎么避:数据库文件只放本地磁盘;确实需要共享访问时,在该机器上放一个独占访问这个库的应用服务,其他机器走接口。

坑二:事务里做外部 IO。为什么坑:持锁时间被 HTTP 调用、文件读写、消息发送放大几十倍,所有其他写者被一起堵住,表现为莫名其妙的锁超时。怎么判断:在写事务开始与提交处打点,统计持锁时间的分布,如果出现与业务逻辑耗时相当的长尾,基本就是它。怎么避:先把数据准备好或写入落盘队列,事务内只做 SQL,外部动作放到事务提交成功之后。

坑三:以为开了 WAL 就解决了并发。为什么坑:WAL 只消除读写互斥,写写串行没变,团队以为调完了参数,实际写入瓶颈原封不动,还额外背上 -wal 文件增长的运维负担。怎么判断:开 WAL 后观察写 TPS 与 SQLITE_BUSY 是否真的改善,同时监控 -wal 大小。怎么避:把 WAL 当作读写分离手段而非并发写入手段,并配套检查点监控。

坑四:数据量一大就急着换库。为什么坑:真实瓶颈常常是索引膨胀、大字段入表、或者事务写法,换成 MySQL 之后这些问题原样复制,还多了一套要运维的数据库服务,性能可能更差(因为多了网络往返和连接池开销)。怎么判断:先用 EXPLAIN QUERY PLAN 和持锁时间统计定位瓶颈,确认瓶颈确实是「多写者无法排队」再换。怎么避:按前面五件事走一遍,把换库当成最后一步而不是第一步。

关于写并发、WAL 与备份的八个高频疑问

database is locked 到底是怎么产生的?

它的直接来源是写事务拿不到库级写锁。SQLite 任一时刻只允许一个写者,第二个写者到来时会在 busy_timeout 设定范围内等待;等待期间前一个事务提交并释放锁,则后者正常拿到锁;如果超时仍未拿到,驱动就返回 SQLITE_BUSY,多数语言的错误信息呈现为 database is locked。所以这个错误说的是「有人在写,我等不及了」,而不是「数据坏了」。触发它的典型情形有三类:并发写者确实多且事务不短;某个写事务被外部 IO 或大批数据拉长;busy_timeout 设为 0 或过短,连毫秒级的正常排队都等不了。排查时先统计错误发生时刻的并发写连接数,再看持锁时间的长尾分布,通常一两个小时就能定位到具体是哪条写入路径在长时间握锁。

开了 WAL 就万事大吉了吗?

不是。WAL 解决的是读写互相阻塞:写者把修改追加到 -wal 文件,读者继续读主文件,两边互不干扰,这对读多写少的系统改善非常明显。但写者仍然只有一个,写事务依然串行提交,写入吞吐的上限没有被抬高。另外 WAL 引入了新的运维项:-wal 文件需要在检查点时回写主库才能复用,如果有长读事务挡着,检查点无法完成,文件会持续增长,最终既吃磁盘又拖慢读。所以开 WAL 之后要配套做三件事:设好 wal_autocheckpoint、监控 -wal 文件大小、把长查询从主库挪走。只改一个 PRAGMA 就指望解决所有并发问题,是这类系统最常见的误操作之一。

一个 SQLite 库能有多大?几百 GB 会有问题吗?

从引擎本身看,官方给出的单库上限是 281 TB,前提是你的磁盘和文件系统支持这么大的单文件,所以几百 GB 完全在能力范围内,不会因为这个体积本身出问题。真正随体积上升的是维护成本:VACUUM 会重写整个文件并长期持排他锁,几百 GB 的库执行一次可能是小时级;全量备份的窗口会变长;页缓存装不下全部数据之后,随机读会更依赖磁盘性能。另外要注意,很多文件系统的单文件大小限制比引擎限制更严格,部署前要确认。所以几百 GB 的库只要写入稀疏、索引合理、大字段外置,是可以长期稳定运行的;出问题通常出在维护动作上,而不是日常读写上。

我该怎么判断是磁盘慢还是锁竞争?

两者的表象都是写入变慢,但区分方法很直接。看资源指标:如果是磁盘慢,CPU 会大量空闲在 iowait 上,单线程顺序写入也很慢,且减少并发连接数并不能改善;如果是锁竞争,磁盘利用率并不高,CPU 也不忙,但 SQLITE_BUSY 次数会随并发连接数线性上升,把并发降到 1 之后吞吐反而可能变好。再看延迟分布:磁盘慢表现为所有写入的延迟整体抬高;锁竞争表现为延迟呈现长尾,少数请求等了几百毫秒甚至几秒。最可靠的验证还是直接用 fio 以同步写加 fsync 的方式测一块盘,得到单次 fsync 延迟的参考值,再和业务的单事务提交延迟对照,两者接近说明瓶颈在盘,差距很大说明瓶颈在等待锁。

能不能多个进程同时写?

能「同时连接」,但不能「同时写入」。多个进程可以打开同一个库文件、可以并发读、也可以依次写,但任意瞬间的写者只有一个,其余写者要么等待要么返回 SQLITE_BUSY。多个进程写并不是被禁止的用法,只要每个事务足够短、busy_timeout 设置合理,排队轮流写在很多业务里完全够用,官方也指出 SQLite 能承受的写并发比多数人预期的高。需要注意的边界是网络文件系统:如果这些进程在不同的机器上,通过 NFS 之类的共享存储访问同一个库文件,那就不只是慢的问题,很多网络文件系统的文件锁实现有缺陷,锁失效时可能造成数据库损坏,这是官方明确建议避免的用法。同机多进程写是支持的,跨机直接写同一个文件是不推荐的。

备份怎么做才不会锁住业务?

最不建议的做法是直接 cp 数据库文件:拷贝过程中如果有写入,拿到的可能是不一致的快照。更稳妥的有三条路。第一条是用 VACUUM INTO 生成一份一致的副本文件,它在生成期间需要读库但不改变原库,业务影响比 VACUUM 小得多,适合每天一次全量。第二条是在低峰短暂取得一致性点之后拷贝(配合检查点或应用层暂停写入),适合小库。第三条是用 Litestream 这类持续读取 WAL 增量并上传到对象存储的方案,把全量备份变成后台常驻行为,业务几乎无感,还能做到时间点恢复。选择哪条取决于 RPO 容忍度:能接受一天一次全量的用第一条,要求分钟级恢复点的用第三条。无论哪条,都要定期做一次真实的恢复演练,备份只在恢复成功之后才算存在。

迁移到 MySQL/PostgreSQL 之后一定会更快吗?

不一定,而且相当比例的情况下会更慢。原因是换引擎只解决了「多写者无法排队」这一个问题,其他瓶颈会原样搬过去:没走索引的查询照样慢,事务里做外部 IO 照样长时间持锁,大字段入表照样拖慢备份,写入逐条提交照样被刷盘策略拖累。更现实的是,换到客户端/服务器架构之后每次查询多了一次网络往返和连接池开销,原来本地函数调用的微秒级开销变成毫秒级,短平快的点查反而可能变差。真正会因为换库而变快的场景是明确的:多个独立写者无法排队、需要行级锁与高并发写、需要多副本强一致、需要在线 DDL 与权限体系。如果这几条都不成立,先把事务写法、索引、批量提交、磁盘 fsync 这几件事做完,通常能达到同样甚至更好的效果,而且不用多养一个数据库服务。

边缘节点上用它合适吗?断网重连怎么同步?

合适,而且在断网常态化的场景里几乎是唯一合理的选择。边缘节点的核心诉求是离线可写:车间断网、车辆进隧道、门店链路抖动时,本地必须能继续记录数据,客户端/服务器数据库在断网期间写不进去,这一条就否掉了。SQLite 作为进程内库没有这个问题,本地写入照常提交,恢复网络后再向上游汇总。同步思路通常是:本地库只做追加写入并带单调递增的序号或时间戳,上游提供幂等的批量接收接口,节点联网后按序号断点续传,冲突按「本地先记、中心裁决」处理。要注意的是这类架构里中心侧通常才是客户端/服务器数据库,SQLite 只承担边缘这一层的本地持久化,两者不是替代关系。涉及海外边缘节点的服务器选型与专用链路方案,月租与带宽费用需实时询价,以官网实时报价为准。

先分清是写并发还是数据量,再决定换不换库(SQLite 篇)

结论应该是条件式的:如果你的痛点是 database is locked 频繁、写 TPS 上不去、且监控显示瓶颈在写锁排队与 fsync 延迟,先别急着换库,把批量提交、缩短事务、WAL 与检查点调优、读副本隔离、大字段外置这五件事做完,再用数据判断。如果做完之后仍然是「多个独立写者必须在同一瞬间写入且不能排队」,那 SQLite 的库级串行写就是硬边界,换成客户端/服务器数据库是对的。反过来,如果你的痛点是数据量大但写入稀疏、读多写少、或者边缘离线场景,那么留在 SQLite 上通常更省心,换库只是给自己增加一套需要运维的服务。

判断顺序上有个简单的优先级:先看是不是锁(错误率随并发上升),再看是不是盘(fsync 延迟与 iowait),然后才是事务写法与索引,最后才轮到引擎选型。把这个顺序倒过来、一上来就换库,是这个话题上最贵的错误——它既没有解决真正的瓶颈,又把系统的运维复杂度永久性地提高了。

从预算比选的角度看,单机 SQLite 方案对硬件的要求集中在一块 fsync 延迟低、带掉电保护的本地 NVMe 和足够的页缓存内存上,核数反而不是重点,这决定了它的服务器选型往往可以走「中等 CPU + 好盘 + 大内存」的组合,而不是堆核数的高端机型。以一万网络的配置为例做预算参考:深圳南山自营机柜的机型可作为国内部署的比选项,免费系统盘快照(每日 3 份、30 秒回滚)适合给单机数据库留出低成本的回退点,硬件故障 10 分钟自动迁移与 7×24 中文工单平均 5 分钟响应,可以覆盖这类单点部署最担心的硬件故障场景。中国香港及海外节点的物理机与带宽月租需实时询价,具体以签约时与官网最新报价为准。

本文参考的公开资料与适用边界(SQLite 篇)

SQLite 官方文档《Appropriate Uses For SQLite》:关于 SQLite 与客户端/服务器数据库的适用边界、单库 281 TB 上限、任一时刻单一写者、以及不应在多台计算机通过网络直接访问同一数据库文件的说明。

SQLite 官方文档《Write-Ahead Logging》:关于 WAL 模式读写不互相阻塞、写者仍然唯一、检查点机制与 WAL 文件增长的原理说明。

SQLite 官方 PRAGMA 文档:关于 wal_autocheckpoint、synchronous、busy_timeout、page_size、auto_vacuum 等参数的作用与默认行为。

SQLite 官方备份与 VACUUM 相关文档:关于 VACUUM 需要排他锁并重写整个数据库文件、VACUUM INTO 生成一致快照的行为说明。

Litestream 公开项目文档:关于持续读取 WAL 并上传对象存储实现增量备份与时间点恢复的设计思路。

本文中涉及的 fsync 延迟量级、并发写 TPS、锁等待时长等数值,均为公开渠道参考值,实际需结合磁盘型号、文件系统与具体机房实测确认,不构成任何性能承诺。文中示例场景为典型部署假设,不代表任何真实客户案例。文中提及的价格均为官网明示价或需实时询价项,具体以签约时最新报价与合同为准。


上一篇:授权策略一上量就拖慢接口:OPA 这类策略引擎的开销到底出在哪

下一篇:WebAssembly 跑到服务端之后:沙箱边界、启动延迟和它能替掉什么