文章探讨了将SQLite用于生产环境的可行性,指出默认配置下SQLite在高并发场景下存在多个问题,如读写互斥、锁等待超时、文件锁失效和数据损坏等。作者分享了应对这些问题的配置和技巧,包括切换WAL模式、设置忙等待超时、避免使用网络文件系统以及管理长事务等,以确保SQLite在个人项目和中小型独立工具中的稳定运行。
做个人项目或者中小型独立工具,很多人第一反应就是找台轻量云服务器,先把 MySQL 或者 PostgreSQL 装上。
之前我也是这个习惯。直到有一回手头一台 1 核 2G 的小机器跑了两个 Node.js 进程和一个 Python 后台,再塞进一个 MySQL 实例,内存直接飙到了 85% 以上。稍微来点爬虫扫一扫,内存打满,Linux 的 oom-killer 抬手就把数据库给杀了。
后来我把一个日请求量十几万的独立 SaaS 项目数据库换成了 SQLite。一个单文件搞定,内存开销几乎可以忽略不计,查询延迟在微秒级别。
很多人以为 SQLite 只能当本地测试玩具,其实它扛大部分单机项目绰绰有余。只是如果拿着默认配置直接扔上生产,只要并发稍微一高,各种问题就会接踵而至。
我把当初踩过的五个关键坑点和现在的应对配置理了一遍。
默认日志模式下,读写互斥报 database is locked
刚上线那天,只要后台跑数据同步任务,前端接口就隔三差五报 sqlite3.OperationalError: database is locked。
排查之后发现,SQLite 默认用的是传统的回滚日志模式(DELETE)。在这种模式下,只要有一个写事务在执行,整张表甚至整个数据库文件都会被加上排他锁,期间任何读请求都会被硬生生挡在门外。
解决办法是在数据库初始化连接时,显式切到 WAL(Write-Ahead Logging,预写日志)模式:
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
开了 WAL 之后,数据库会多出一个 .db-wal 文件和一个 .db-shm 共享内存文件。读操作只读旧版本快照,写操作直接追加到 WAL,读写不再互相阻塞。配合 synchronous = NORMAL,写操作性能大幅提升,系统崩溃时也能保证数据库完整性。
驱动默认 busy_timeout 为 0,毫秒级抖动直接报错
哪怕开了 WAL,SQLite 的写操作依然是全局互斥的,同一瞬间只允许一个事务写入。
如果你用 Python、Go 或者 Node.js,很多驱动库在建立连接时,默认的 busy_timeout 是 0 毫秒。这意味着当两个写请求恰好在同一微秒发起,后来的那个由于拿不到锁,不会做任何等待,直接把异常抛给调用方。
很多框架日志里冒出 database is locked,并不是数据库真的挂了,而是并发碰撞时没人愿意等几十毫秒。
只要在连接池建立时加上等待超时:
PRAGMA busy_timeout = 5000;
这行指令让拿不到锁的连接在原地休眠重试,最长等待 5 秒。对绝大多数个人项目来说,写事务通常几毫秒就执行完了,哪怕偶尔来个几十 QPS 的突发写入,排个队就能过去,上层接口再也不会无缘无故报错。
把数据文件丢在 NFS 或网盘上,文件锁失效损坏数据
我之前为了省事,把容器挂载卷指到了内网 NAS 共享目录(用的 NFS 协议),想着备份和迁移方便。
跑了不到两周,系统莫名其妙崩了一次,启动后直接报 database disk image is malformed,B-tree 索引坏了。
SQLite 协调并发读写完全靠操作系统底层的 POSIX 文件锁(flock 或 fcntl)。而 NFS、SMB 或者云厂商的通用共享存储,对这些文件锁的实现往往存在延迟甚至根本不支持。两个进程同时以为自己拿到了写锁,一起往同一个数据页里写,文件直接损坏。
生产环境的 SQLite 文件必须老老实实放在机器本地的 NVMe 或者 SSD 磁盘上,不要放在任何网络文件系统上。如果要容器化部署,只用宿主机的本地目录映射(bind mount)。
长事务挂起,WAL 文件无限膨胀吞噬磁盘
项目跑了一个多月,某天巡检发现只有 20MB 的主数据库旁边,那个 .db-wal 文件暴涨到了 4GB。
WAL 模式的机制是:每次修改都写进 WAL 文件,等攒到一定大小或者特定时机,SQLite 会执行 checkpoint 操作,把 WAL 里的改动合并回主 .db 文件并重置 WAL。
但如果某个连接开了一个事务(比如执行了 BEGIN TRANSACTION),由于业务逻辑异常卡住没有提交也没有回滚,SQLite 为了保证这个长事务能读到一致的历史视图,就绝对不敢清理 WAL 文件。后面的所有写入继续往 WAL 里堆,文件就像滚雪球一样越滚越大。
应对办法分两步:
一是业务代码里所有写操作必须加超时和强制回滚逻辑,绝不把数据库连接借给耗时长的外部网络调用。
二是配置自动合并阈值,并在低峰期通过定时脚本触发截断:
PRAGMA wal_autocheckpoint = 1000;
PRAGMA wal_checkpoint(TRUNCATE);
用 TRUNCATE 会把 WAL 内容合并完成后直接将文件大小截断回 0,避免虚占磁盘空间。
不能直接 cp 做热备,如何做到秒级防丢?
很多人的备份脚本写得很粗暴:
cp /data/app.db /backup/app_$(date +%Y%m%d).db
在生产环境这么做迟早出事。第一,写入过程中直接复制文件可能读到断页导致备份文件损坏;第二,WAL 模式下大量最新数据还在 .db-wal 缓存里,单纯复制主文件会丢掉最近的改动。
轻量级备份可以用 SQLite 自带的在线备份命令:
sqlite3 /data/app.db ".backup /backup/app_backup.db"
这个命令会在内部协调锁和 WAL,生成一个绝对完整一致的独立数据库文件。
如果希望更进一步,做到类似云数据库的实时增量容灾,可以搭配 Litestream 工具。Litestream 是个专门给 SQLite 写的后台守护进程,原理是监控 WAL 文件的字节级变动,几乎实时(默认 1 秒内)把变更压缩推送到自建的 MinIO 或者 S3 存储桶里。就算哪天整台云服务器硬盘报废,一条命令就能把数据库还原到事故发生前一秒的状态。
现在的综合连接模板
经过这几轮踩坑,我现在启动个人项目的 SQLite 连接,都会默认带上这套初始化参数:
import sqlite3
def get_connection(db_path: str):
conn = sqlite3.connect(db_path, timeout=5.0)
conn.execute("PRAGMA journal_mode = WAL;")
conn.execute("PRAGMA busy_timeout = 5000;")
conn.execute("PRAGMA synchronous = NORMAL;")
conn.execute("PRAGMA cache_size = -64000;") # 约 64MB 内存缓存
conn.execute("PRAGMA foreign_keys = ON;")
return conn
单机 SQLite 远比想象中能扛。只要避开并发锁死和备份的几个坑,配上本地 SSD 和 WAL 模式,支撑起一个中小规模的日常项目毫无压力,省心也省资源。
文章标题:拿 SQLite 直接上生产靠谱吗?我扛住个人项目并发后踩过的几个坑说清楚
文章链接:https://llbbs.cn/jishujaocheng/194.html
本站文章均为原创,未经授权请勿用于任何商业用途
评论一下?