我自己的短链服务跑在 3001 端口,用 better-sqlite3 存数据。前阵子翻 pm2 日志,里头刷出一行:
Error: SQLITE_BUSY: database is locked
量不多,但短链跳转是公开接口、不做鉴权,每跳一次要先 UPDATE 一次 clicks,再 INSERT 一条 click_logs,两次写都落在同一个库文件上。
第一反应是磁盘满了或者进程被 kill,排查了一圈都不是。后来写了个最小脚本复现,才发现问题出在 journal_mode 上。
先看当前的 journal_mode
SQLite 的日志模式主要有两种,delete(也叫 rollback journal)和 wal。
$ sqlite3 /opt/shortlink/data/links.db "PRAGMA journal_mode;"
delete
同一台服务器上 openclaw 那个任务用的却是 wal:
$ python3 -c "import sqlite3; print(sqlite3.connect('/home/ubuntu/.openclaw/tasks/runs.sqlite').execute('PRAGMA journal_mode').fetchone()[0])"
wal
一开始我没觉得这两个词有什么区别,直到撞上锁。
复现:同样的代码,两种结果
写两个连接,一个读、一个写,用最简模型重现:
import sqlite3, os, tempfile
d = tempfile.mkdtemp()
db = os.path.join(d, 't.db')
w = sqlite3.connect(db, isolation_level=None)
w.execute('PRAGMA journal_mode=delete')
w.execute('CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT)')
w.execute("INSERT INTO t VALUES (1,'x')")
# 读者开一个事务,选完不提交
r = sqlite3.connect(db, isolation_level=None)
r.execute('PRAGMA busy_timeout=0')
r.execute('BEGIN')
r.execute('SELECT * FROM t').fetchall()
# 写入方试着提交
w.execute('PRAGMA busy_timeout=0')
try:
w.execute("INSERT INTO t VALUES (2,'y')")
print('写入 OK')
except Exception as e:
print('写入 ERR:', type(e).__name__, e)
跑出来:
写入 ERR: OperationalError database is locked
只把 journal_mode 换成 wal,其它一个字不动:
写入 OK (0.0009s)
为什么 delete 模式会被读事务卡死
这是 rollback journal 的设计:写事务在 COMMIT 那一刻要把 EXCLUSIVE 锁拿满,才能回放 journal 把改动写回主库文件。而读者的 SHARED 锁跟它互斥。所以只要有个读事务开着不放手,写方就卡在 commit 这一步,直接抛 locked。
WAL 的路子不同:写入不原地改主库文件,而是往 -wal 追加新页。读方从 WAL 里读快照,读写两拨人互不打架,写方 commit 时只要自己的写锁就够。
有个细节值得说清楚:我那句 SELECT 在两种模式下都能读到已提交的数据,差别根本不在可见性,而在 commit 能不能过。搞混这两件事,很容易往 MVCC 方向白找半天。
busy_timeout 治不了根
上面脚本里我故意加 PRAGMA busy_timeout=0,让它立刻报错而不是干等。生产环境 better-sqlite3 默认给 5000ms:
$ cd /opt/shortlink && node -e "
const D=require('better-sqlite3');
const db=new D('data/links.db');
console.log(db.pragma('busy_timeout'));
"
[ { timeout: 5000 } ]
构造函数第二个参数就是 timeout,能改:
const db = new Database(DB_PATH, { timeout: 10000 });
但 timeout 只是让写入多等几秒,读者一直不放手的话等再久也是超时。而且等待期间请求线程是堵着的,并发一上来堆的是 Node 的 event loop。
生产库怎么切
先备份,别直接改正在跑的库:
$ cp /opt/shortlink/data/links.db /opt/shortlink/data/links.db.bak
(这一步是 delete 模式下才成立的,切到 WAL 之后备份方式得换,后面会说。)
用 sqlite3 CLI 改最省事,它自己会处理锁:
$ sqlite3 /opt/shortlink/data/links.db "PRAGMA journal_mode=WAL;"
wal
切换是持久的,写进库文件头部,下次打开仍然是 WAL。我拿生产库拷了个副本试,耗时 0.005 秒:
转换前 journal_mode = delete
转换后 journal_mode = wal (耗时 0.005s)
行数 links = 15
行数 click_logs = 140
数据没丢。代码里也能切,better-sqlite3 有 pragma 方法:
db.pragma('journal_mode = WAL');
代价:-wal 文件会涨
WAL 不是白拿的。切过去目录里会多两个文件:
$ ls -la /opt/shortlink/data/
links.db
links.db-shm
links.db-wal
-wal 存的是还没 checkpoint 回主库的增量。默认 wal_autocheckpoint 是 1000 页,页大小默认 4096 字节,也就是 4MB 触发一次自动 checkpoint。
我模拟长事务把自动 checkpoint 关掉,灌 3000 行 4KB 的 blob:
wal 文件大小 = 38633272 bytes
--- 手动 checkpoint ---
(0, 0, 0)
checkpoint 后 wal 大小 = 0 bytes
38MB 的 WAL 手动 checkpoint 一下直接归零。返回值是个三元组 (busy, 日志页数, 已 checkpoint 页数),全 0 说明干净完成、没卡住谁。手动触发:
$ sqlite3 /opt/shortlink/data/links.db "PRAGMA wal_checkpoint(TRUNCATE);"
用 TRUNCATE 而不是默认的 PASSIVE,是为了让 wal 文件直接截断到 0,而不是留个预分配的空壳。要是发现 wal 莫名其妙地涨,先看这条。
切换之后,备份方式也得跟着改
这是我在切 WAL 的时候差点踩进去的坑。
切换前 cp links.db backup.db 一直好使,切到 WAL 之后就不对了。因为最新数据可能还躺在 -wal 里没 checkpoint 回主库文件,你 cp 过去的主库是个缺斤少两的旧快照。
实测一下。先 checkpoint 落一条盘,再插一条只在 -wal 里的:
主库大小 = 8192 / -wal 大小 = 4152
只 cp 主库备份的行数 = 1 (真实应为 2)
备份文件里少了一条,而且它不会报错,你以为备份成功了,真要回滚的时候才发现数据没了。
更极端的情况,表本身是建在 WAL 里的,主库文件还是空壳:
Error: sqlite3.OperationalError: no such table: t
连表都读不出来。
正确做法是用 sqlite3 的 .backup 命令,它会走 SQLite 自己的备份 API,自动处理 WAL 合并:
$ sqlite3 /opt/shortlink/data/links.db ".backup /opt/shortlink/data/links-$(date +%F).db"
或者用 Python 的在线备份接口:
import sqlite3
src = sqlite3.connect('/opt/shortlink/data/links.db')
dst = sqlite3.connect('/opt/shortlink/data/links-backup.db')
src.backup(dst) # 一致性快照,不阻塞写入
dst.close()
src.close()
src.backup(dst) 是 SQLite 3.6.11 之后提供的在线备份 API,备份期间不锁库,写服务照常跑。
如果确实只能用 cp,那得连 -wal 和 -shm 一起拷,而且必须在停写之后拷——这基本等于停机,不如用 .backup。
顺手压一下
4 个写线程各写 200 次 UPDATE,对比两种模式:
[delete] 4 写线程 x 200 次: 耗时 1.22s, 最终 clicks=800, 错误 0
[wal] 4 写线程 x 200 次: 耗时 0.81s, 最终 clicks=800, 错误 0
WAL 快了三成多。delete 模式每次 commit 要写 journal 再回放,WAL 只需要顺序追加,磁盘 IO 模式对友好很多。
顺带一提,better-sqlite3 是同步的,.run() 是阻塞调用。它不占 event loop,但单次查询慢照样卡住整个进程。短链这种写多读少的场景,先把 WAL 打开、索引建对,比上 MySQL 划算。
我这套库现在还挂在 delete 上,等观察几天点击量再动生产库。到时候大概率还得回来补一句 -wal 到底要多久 checkpoint 一次。