深入版:速查版讲「WAL 有什么用、丢更新怎么解」,这一篇讲「锁到底锁在哪一层、四种隔离级别分别坏在哪、SQLITE_BUSY 怎么来怎么治、WAL 的 -wal 文件里到底是什么、pm2 多进程为什么会打架、重复提交怎么防——全部讲到能动手排障。
德莉玛门店订购单系统 v2.5.0:Express + better-sqlite3 + SQLite WAL 模式 + pm2。
collections 表 单行 JSON blob 的事故后果之一:并发串行(整行重写 → 两个请求碰同一行时争同一把锁)。
LumenX:pm2 起 3 个进程(124.222.108.196),多进程同时访问同一个 SQLite 文件。
版本:2026-08-09-task-100 better-sqlite3 13;2026-07-01-task-63/kb-api better-sqlite3 11。
全盘没有 MySQL / PostgreSQL 在跑。这一篇里提到「该换 PostgreSQL」指的是未来升级路线的判断,不是现状。
先说清模型:SQLite 是多读单写。大白话就是——读的人可以随便来(多个读同时进行,互不排斥);写的人只能排队(同一时刻一个写)。
| 信号 | 为什么是死结 |
|---|---|
| ① 写入量到上万/秒级别 | 单写者是串行的,每秒一万次写意味着每次写只能占 0.1 毫秒。物理上做不到,排队会无限堆积,最后所有请求超时 |
| ② 多进程 / 多机器高频写 | 每个进程各自开连接,彼此不知道对方在写。这不是配置能解决的——单写者模型是架构层面的限制。你用 pm2 cluster 起 4 个进程,写压力就分摊成 4 份去抢一把锁 |
| ③ 需要复杂的并发控制 | 比如「悲观锁 + 行级锁 + 间隙锁 + 可串行化隔离」这一套。SQLite 连行级锁都没有(它锁整库),更没有间隙锁、没有 SELECT ... FOR UPDATE。你只能靠「缩短事务」和「应用层排队」来绕 |
还有一个更硬的限制:WAL 模式下数据库文件不能放在网络文件系统上(NFS、SMB、云盘挂载都不行),因为 WAL 依赖本机的文件锁(-shm 文件)来协调读写,而网络盘的文件锁不可靠,会真的把库写坏。所以 「多台应用服务器共享一个数据库文件」这个方案在 SQLite 上根本不成立——这是架构级的不可能,不是性能问题。
| 组合 | 结果 | 大白话 |
|---|---|---|
| 读+ 读 | ✅ 允许 | 两个人同时看一份文档,各看各的,不冲突 |
| 读 + 写 | ❌互斥 | 你要改的东西正在被人看。要么等他看完,要么你不许看——取决于数据库的模式和隔离级别。WAL 模式下读和写是互不阻塞的(读看旧快照),这是它最值钱的一点 |
| 写 + 写 | ❌互斥 | 两个人同时改同一份东西,必须排队。这是 SQLITE_BUSY 的直接来源 |
| 粒度 | 锁住多少 | 并发能力 | 谁用 |
|---|---|---|---|
| 页级 page-level | 4KB 一页(SQLite 的页) | 中等 | SQLite 内部用。WAL 模式下写是先写日志,读完全不加锁(读的是不可变的快照) |
| 行级 row-level | 只有被改的那一行 | 高 | MySQL InnoDB / PostgreSQL。两个请求改不同行,完全不用互相等——这是它们并发能力强的根本原因 |
| 表级 / 库级 table/database-level | 整张表 / 整个库 | 低 | SQLite 就是这一级。所以「两个请求改不同的 SKU」在 SQLite 下照样要排队——因为它们抢的是整库的写锁 |
DELETE /TRUNCATE journal)下,读要加共享锁,会被写阻塞;WAL 模式下读完全不加锁——因为读的是「某一时刻的快照」,那个快照是不可变的,不会被人改。所以你可以一边写一边读,两边都不用等对方。贯穿下面的例子:你账户 100 元,给朋友转 50 元。需要两步:①你的余额 100→50(代码叫 扣款)、②朋友余额 0→50(代码叫 入账)。另一个操作是朋友同时取款 80 元(考验「能不能看到最新余额」)。
| 隔离级别 | 大白话 | 这个转账例子会出什么问题 |
|---|---|---|
① 读未提交READ UNCOMMITTED | 最松:别人没提交的我也看得见 | 朋友取款 80 提交了,但你读到的还是旧余额 0(因为你读到的是他没提交时的中间状态)。更糟的场景:你读到的「入账 50」随后被朋友回滚了(他发现账户开了就取消)——于是系统里凭空多出 50 元,凭空少掉50 元。这就是脏读,后果是数据永久不一致。SQLite 不支持这个级别 |
② 读已提交READ COMMITTED | 常见默认:我只能看到别人已提交的结果 | 解决了脏读(我看不到你没提交的了)。但仍有问题:①不可重复读——你读自己余额 100,此时朋友那边也给你转了 50 并提交,你再读一次变成 150 了;如果你的代码是「读余额 → 判断够不够 → 扣款」这三步,第二次读到 150 就可能多扣。②幻读——你查「这个 SKU 有多少在库」,第一次查到 10,中间别人插入了一条库存记录,第二次查变 11 了 |
③ 可重复读REPEATABLE READ | 同一个事务里读多少次都一样——别人改了我看不见 | 解决了不可重复读:转账过程中你的余额从头到尾都是 100,朋友给你的那 50 元这个事务里看不到。但幻读还在——「SELECT COUNT(*) 查库存」跑两次,如果中间有人插入了新行,第二次结果会不同(因为它锁的是「你读过的行」,管不到「后来新增的行」)。MySQL InnoDB 默认就是这一级 |
④ 可串行化SERIALIZABLE | 最严:所有事务排队一个个执行,效果等同于完全没并发 | 三个问题全解决:既不脏读、也不不可重复读、也不幻读。因为彻底排除了并发。代价是性能——并发度等于 1,越多事务同时来,越多事务在排队等。你的门店系统如果用这一级,吞吐会掉一个数量级 |
SQLITE_BUSY 到底怎么回事这是WAL 模式下依然会报的错。因为 SQLITE_BUSY 不是「有冲突」,而是「我等了你给定的超时时间,还是没拿到锁」。
.db 发起写请求。WAL 的机制决定同一时刻只能有一个写者,所以数据库把写锁只给其中一个进程,另外两个拿不到。busy_timeout 设的时长(默认是 0,也就是立刻失败)。于是你看到的就是一个 SQLITE_BUSY 错误。SQLITE_BUSY 通常不是「出了诡异 bug」,而是「并发写超过了单写者能力,或者你的重试策略太笨」。它是正常的背压信号,不是异常。busy_timeout(必做,一行解决 90% 的情况)让 SQLite 自己去等锁,而不是立刻失败。默认 0 意味着「不等,立刻报错」——这是绝大多数人踩 BUSY 的直接原因,因为没配这个参数:
const db = new Database('prod.db');
db.pragma('journal_mode = WAL'); // 写日志再落盘
db.pragma('busy_timeout = 5000'); // 拿不到锁就最多等 5 秒 ← 关键这一行
db.pragma('synchronous = NORMAL'); // 配合 WAL:断电可能丢最后几笔,换写入提速
db.pragma('foreign_keys = ON'); // 外键默认关,必须显式开
为什么这行这么重要:你的写操作本来就只在几十毫秒内(改几行然后提交)。5 秒的超时远远大于它——所以绝大多数情况下,等一下就能拿到锁,拿到就成功了,业务层根本感知不到。
持锁时间越短,别人等得越少,撞上的概率就越低。正确的做法:事务里只放读写数据库的动作(目标几十毫秒)。把这些东西挪出事务:HTTP 请求、日志文件写入、大对象序列化、await 一个慢的第三方调用、setTimeout 等待。
// ✗ 危险:事务里发了HTTP 请求(可能 3 秒超时)
const tx = db.transaction(() => {
insertOrder(order);
callShippingApi(order); // ← 持锁 3 秒!
});
// ✓ 正确:事务只写库,外部调用放外面
const tx = db.transaction(() => {
insertOrder(order);
return db.prepare('SELECT id FROM order WHERE order_no = ?').get(order.orderNo).id;
});
const orderId = tx();
await callShippingApi(order); // ← 事务已提交,锁已释放
还有一条:别用大事务。WAL 的写锁是整库级的,哪怕你的大事务只改一行,它也会把整个库的写操作全堵住。批量导入要拆批 + 批间 sleep。
即使配了超时,高负载下仍会失败。这时候要在应用层重试。关键:重试间隔要「随机抖动」(jitter),不能固定。
// 带抖动的指数退避:避免两个进程「一起等 10ms 又一起冲」的共振
function retry(fn, { retries = 3, base = 20 } = {}) {
for (let i = 0;; i++) {
try { return fn(); } catch (e) {
if (!/SQLITE_BUSY/.test(String(e)) || i >= retries) throw e;
const wait = base * 2 ** i + Math.random() * base; // 20, 40+~20, 80+~20...
console.warn(`BUSY,第 ${i + 1} 次重试,等 ${wait.toFixed(0)}ms`);
// 注意:重试的必须是「整个事务」,不是事务里的某一步
sleepSync(wait);
}
}
}
retry(() => db.transaction(() => { /* ... */ })());
⚠️ 两个细节:①重试的必须是「整个事务」,不能是事务里的某一步——否则你已经提交了一部分,再重试就是重复插入。②必须加抖动——固定间隔重试会让两个进程「一起等 50ms 又一起冲上去」,永远撞在一起。
单写者模型下,写次数本身就是资源。能批量就批量:循环里插 1000 条改成一条多值 INSERT;频繁更新同一个计数器的操作,改成内存里累加、最后写一次;纯读的一致性快照需求,用一个长读事务统一处理,别每个查询各开各的。
-- 怎么确认到底是不是 BUSY 在困扰你
PRAGMA busy_timeout; -- 看当前超时是多少(0 就是没配)
PRAGMA journal_mode; -- 确认是不是 WAL(wal 才支持读不阻塞写)
PRAGMA integrity_check; -- 确认库没被写坏
-- 批量写用一条多值INSERT(少 1000 次写锁竞争)
INSERT INTO inventory_movement (sku, warehouse_id, type, qty, created_at)VALUES
('HINGE-H4002', 1, 'in', 10, 1759400000),
('HINGE-H4002', 1, 'out', 3, 1759400100),
...; -- 一条语句、一个事务、一次写锁
xxx.db:主数据库文件xxx.db-wal:预写日志。WAL 模式下新数据先追加到这里xxx.db-shm:共享内存索引。用来让各进程快速知道日志里有哪些数据、当前该读到哪个位置。它是纯临时的,可以随时重建——删了它下次启动会自动建(前提是没有其他连接在用)要改数据时,不直接改 .db,而是在 -wal 末尾追加一条:「第 5000 号页的第 3 段,改成 X」。这个追加是顺序写——对磁盘最友好,因为不需要来回移动磁头。追加完成,这个事务就算成功了(写到这就已经可以告诉客户端「成了」)。
关键点:在日志里写之前,SQLite 会把当前的 .db 标记为「这是快照版本N」(信息记在 -shm 里)。所以哪怕 .db 里的页正在被覆盖,读事务拿到的也是完整的旧版本——因为旧数据在写之前已经被「钉住」了。这就是「读不阻塞写」的原理。
日志不能无限增长。当 WAL 超过 1000 个页(默认 wal_autocheckpoint = 1000),SQLite 会自动做一次 checkpoint:把 -wal 里的内容写回 .db,然后清空日志。
触发时机:①日志超过阈值自动触发;②最后一个连接关闭时触发;③手动执行 PRAGMA wal_checkpoint(TRUNCATE);。
-- 手动checkpoint + 顺便把 -wal 文件缩到 0(TRUNCATE 模式) PRAGMA wal_checkpoint(TRUNCATE); -- 三种模式的区别: -- PASSIVE :尽可能checkpoint,不阻塞其他连接(默认行为) -- FULL :等所有读者读完才checkpoint -- RESTART :同上,且强制等新读者读到快照之后 -- TRUNCATE :checkpoint 完把 -wal 文件长度缩成 0(磁盘上最干净)
-wal 一起拷checkpoint 不是实时发生的。它要等日志积累到 1000 页,或者最后一个连接关闭。如果你的服务一直在跑,那最近写入的数据可能就一直在 -wal 里,还没合并回 .db。
后果:你只拷了 prod.db,恢复到新机器后,最近几笔订单/审批全都不见了——而且你完全看不出来(数据库能打开、能查,就是少了几天的数据)。这是最难发现的一种数据丢失。
-wal 也不行更危险的一种做法:一边服务在跑,一边直接 cp prod.db。问题在于 .db 是被不断覆盖的(checkpoint 时),你拷贝的那一瞬间可能正好在覆盖过程中——拷出来的 .db 是不一致的(半新半旧)。这种损坏 平时看不出来,跑到那个特定的页才崩,极难排查。
-- ✓ 正确做法一:用 SQLite 自带的 .backup 命令(它会正确处理 WAL) sqlite3 prod.db ".backup 'backup/prod-20261002.db'" # 若需要一致性备份,加 .backup 后立刻 checkpoint,让日志落回主文件 sqlite3 prod.db ".backup 'backup/prod-20261002.db'" sqlite3 prod.db "PRAGMA wal_checkpoint(TRUNCATE);" -- ✓ 正确做法二:停服务 + 三个文件一起拷 + 校验 md5 pm2 stop all cp prod.dbbackup/ && md5sum ... # 主文件 cp prod.db-wal backup/ && md5sum ... # 日志 ← 绝对不能漏 cp prod.db-shm backup/ # 临时文件,拷不拷都行(会被重建) md5sum prod.db backup/prod.db # 两个 md5 必须一致 pm2 start all -- ✗ 错误做法:服务还在跑,只 cp prod.db #→ 最近的数据可能还在 -wal 里,备份缺数据;更糟的是 cp 到一半的文件不一致
synchronous 调:synchronous | 掉电时丢什么 | 速度 | 怎么用 |
|---|---|---|---|
FULL | 基本不丢 | 慢(每次都刷盘) | 金融级要求时 |
NORMAL | 可能丢最后几笔事务(但数据库文件不会坏) | 明显快 | WAL 模式的推荐值 |
OFF | 可能损坏数据库 | 最快 | 不要用 |
你的门店系统大概率是 NORMAL。要讲清这个取舍:「掉电时最多丢最后几笔操作,但数据库结构完好、可以继续用」——这在业务上通常是可接受的(用户会重试),但如果丢的是「用户刚提交的订单」,就是投诉。所以关键业务(订单提交、远程双签)可以单独用 synchronous=FULL 的连接来写,其他操作用 NORMAL——两条连接,重要的事走慢路。
这一节讲你项目里真实存在的那个「必然会撞」的问题。
.db 文件。它们彼此完全不知道对方在做什么——没有共享内存、没有协调机制、唯一的信息来源是数据库自己的锁文件。busy_timeout 决定:配了 5 秒 → 大多数时候等一下就成功,配了 0(默认)→ 直接两个报 BUSY。// 正确做法:整个进程共用一个连接(better-sqlite3 是同步 API,本来也不需要连接池)
const db = new Database('/data/prod.db');
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
// ✓ 把这个 db 实例挂到 app.locals,全进程复用
app.locals.db = db;
// ✌ 错误做法:每个请求新建一个连接(连接数不受控,文件锁竞争加剧)
app.get('/api/xxx', (req, res) => {
const conn = new Database('/data/prod.db'); // ← 每个请求一个句柄
...
});
SELECT ... FOR UPDATE,复杂业务只能靠「缩短事务」和「应用层排队」硬扛;③文件不能放网络盘,所以「多机共享一个库」这个架构根本不成立——而这恰恰是横向扩容的前提。busy_timeout 配好了、事务都短、连接全进程复用。这三个前提做到位,BUSY 就是偶发事件而不是常态。如果哪天这三个前提破了(事务变长、连接被复制、订单量涨上去),第一反应应该是「换 PostgreSQL」而不是「继续优化 SQLite」。先解释这个词:幂等 idempotent是指同一个操作执行 1 次和执行 10 次,结果一样。
为什么突然讲这个:因为它本质上也是并发问题——但不是「两个人同时做」,是「同一个人/同一个请求做了两次」。而触发它的场景非常真实:
| 触发场景 | 你项目里的例子 | 为什么 |
|---|---|---|
| 用户手抖点两下 | 远程双签页面,点了「通过」按钮两下 | 两个请求几乎同时发出 |
| 网络超时后重试 | 提交报价请求超时了,前端自动重试 | ⚠️ 最隐蔽的一种:第一个请求其实成功了,只是响应没回来。客户端以为是失败,就再发一次 |
| 网关/客户端重试 | 反向代理超时后重发请求 | 服务器端无法知道「客户端已经放弃」 |
| 定时任务重叠 | 上次跑批还没结束,下次又启动了 | 两个批处理同时处理同一批数据 |
order.order_no(.notNull().unique())、po.po_no、work_order.wo_no、material_requisition.req_no——这几个单号本来就是唯一的,直接拿它当幂等键。⚠️ 两种思路的关键区别:思路 A 判「这是不是同一个业务」,思路 B 判「这是不是同一次请求」。思路 A 天然更强——因为用户如果真的想下两张一样的订单(比如给两家门店各下一单),你得给他能下单;而如果用请求 ID,客户端每次都生成新 ID,那就两张都能下。所以「用户想重复执行」和「系统重试」要区分开——只有「同一次用户意图」才应该被合并。
-- ✓ 做法 A:靠唯一索引天然防重(推荐)
-- order_no 已经是 .notNull().unique(),所以第二次插入会直接失败
INSERT INTO "order" (order_no, dealer_id, status, created_at, updated_at)
VALUES (?, ?, 'submitted', ?, ?);
-- catch 到的错误就是「这单已经下过了」,然后去查出来返回给前端
-- ✓ 做法 B:专门的幂等键表
CREATE TABLE IF NOT EXISTS idempotency (
key TEXT PRIMARY KEY, -- 客户端传来的请求 ID
result_json TEXT, -- 第一次执行的结果,重试时直接返回它
created_at INTEGER NOT NULL
);
-- 完整实现思路(伪代码)
function handle(req, idemKey) {
const done = db.prepare('SELECT result_json FROM idempotency WHERE key = ?').get(idemKey);
if (done) return JSON.parse(done.result_json); // ①已经做过 → 直接返回上次的结果
const result = doRealBusiness(req); // ②真正执行(放在事务里)
db.transaction(() => {
db.prepare('INSERT INTO idempotency (key, result_json, created_at) VALUES (?, ?, ?)')
.run(idemKey, JSON.stringify(result), Date.now());
writeOrder(result); // ③幂等记录和业务写入同一个事务
})();
return result;
}
PRIMARY KEY)在并发下会保证「两个请求同时来,只有一个能插进去」,另一个撞唯一约束失败,然后它去读那个已经写好的结果返回。这一步在应用层的「先查后插」是防不住的——两个请求可能都查到「不存在」,然后都去插,但唯一索引会拦住第二个。这就是为什么这个方案必须依赖数据库的唯一约束,而不是靠代码里的 if 判断。| 症状 | 大概率是什么 | 往下查什么 |
|---|---|---|
日志里偶发 SQLITE_BUSY | 写竞争,或 busy_timeout 没配 | 先查 PRAGMA busy_timeout;再统计 BUSY 出现频率 |
| 数据「少了一点」,但没有任何报错 | 丢更新 lost update | 去代码里搜「SELECT 出来的值在后面被 UPDATE 写回去」这种模式(见下面的复现脚本) |
| 某个页面「越用越卡」 | 大事务持锁,或查询缺索引 | 看有没有跑批/迁移;用 EXPLAIN QUERY PLAN 看是不是 SCAN |
报 SQLITE_READONLY 或库「坏了」 | 文件锁不可靠(库放在网络盘上了) | 检查库文件路径 |
sqlite3 prod.db sqlite> PRAGMA journal_mode; -- 期望 wal sqlite> PRAGMA busy_timeout; -- 期望 > 0(不是0) sqlite> PRAGMA foreign_keys; -- 期望 1(SQLite 默认是0!) sqlite> PRAGMA integrity_check; -- 期望 ok;如果报一堆内容就是文件坏了 sqlite> PRAGMA wal_checkpoint(TRUNCATE); -- 顺便把 -wal 收干净,再对比前后库大小 sqlite> SELECT COUNT(*) FROM inventory;
// 并发压测脚本:20 个并发,各跑 50 次扣减,检验有没有丢更新
// 期望:最终库存 = 初始 - 成功次数;BUSY 次数应该很少(配了 busy_timeout 的话)
node -e "
const Database = require('better-sqlite3');
const db = new Database('test.db');
db.pragma('journal_mode = WAL'); db.pragma('busy_timeout = 5000');
db.exec('DROP TABLE IF EXISTS inv');
db.exec('CREATE TABLE inv (sku TEXT PRIMARY KEY, qty INTEGER)');
db.exec(\"INSERT INTO inv VALUES ('SKU1', 1000)\");
let busy = 0, lost = 0;
function worker(n) {
// ✓ 正确写法:原子操作
const atomic = db.transaction(() => {
db.prepare('UPDATE inv SET qty = qty - 1 WHERE sku = ? AND qty >= 1').run('SKU1');
});
for (let i = 0; i < 50; i++) {
try { atomic(); } catch (e) { if (/BUSY/.test(String(e))) busy++; }
}
}
Promise.all(Array.from({length: 20}, (_, i) => Promise.resolve().then(() => worker(i))))
.then(() => {
const left = db.prepare('SELECT qty FROM inv WHERE sku = ?').get('SKU1').qty;
console.log('剩余:', left, ' 期望:', 1000 - 20*50, ' BUSY 次数:', busy);
console.log(left === 1000 - 1000 ? '✓ PASS' : '✗ 有丢更新');
});
"
// 对照实验:换成有 bug 的「读-改-写」,看会不会丢
function workerBuggy() {
const bad = db.transaction(() => {
const row = db.prepare('SELECT qty FROM inv WHERE sku = ?').get('SKU1');
db.prepare('UPDATE inv SET qty = ? WHERE sku = ?').run(row.qty - 1, 'SKU1');
});
for (let i = 0; i < 50; i++) { try { bad(); } catch (e) { if (/BUSY/.test(String(e))) busy++; } }
}
// 期望:最终剩余 > 1000-1000(有东西多卖了)→ 复现成功
-- SQLite 没有「当前谁在持锁」的完整视图(这是它的短板),但可以这样看: -- ① 看库文件有没有在增长(WAL 模式) ls -la prod.db prod.db-wal -- ② 如果有另一个终端开着,可以用 .timeout 试抢锁 sqlite> .timeout 0 sqlite> BEGIN IMMEDIATE; -- 立刻返回 SQLITE_BUSY = 现在有人在写 -- 注意:这个探测会失败是正常的,目的只是「确认有人在写」 -- ③ 最实用的:看日志里的时间戳 -- 同一个时间戳密集出现一批BUSY → 基本就是某个大事务/跑批造成的
这条单独拎出来,因为它是新手最容易上当的一条。「套个大事务不就安全了?」——安全是真的,代价也是真的。
| ✗ 大事务当保护伞 | ✓ 小事务 + 失败单独处理 | |
|---|---|---|
| 好处 | 所有写入要么全成要么全滚,不会有半截数据 | 同样没有半截数据(每条自己是一个完整事务) |
| 锁的范围 | 整库(WAL 的写锁是库级) | 整库,但只持有几毫秒 |
| 别人等多久 | 几十分钟(跑5000 条的时候) | 几毫秒 |
| 出问题 | 第 4999 条失败 → 前 4998 条全白干;期间页面上所有人卡死 | 第 4999 条失败 → 只丢这一条,已入库的 4998 条有效,可以随时重跑 |
| 能重跑吗 | 不能,得从头来 | 能,只补失败的那些 |
WAL 只解决读写互不阻塞。同一时刻仍然只有一个写者,写竞争原样存在。把 WAL 当并发问题的终点,是最普遍也最贵的误解。
busy_timeout 没配(默认是 0)默认 0 意味着「拿不到锁立刻报错」。一行配置就能解决 90% 的 BUSY,不配就是白挨。而且报错抛到业务层通常没有重试逻辑,于是用户看到的就是「偶发操作失败」——最难查的一类问题。
HTTP 请求、等用户确认、写大文件、await 第三方调用——每一个都会把锁的时间从毫秒拉到秒。事务里只该有「读写数据库」。
SQLite 每个连接就是一个文件句柄,直接参与文件锁竞争。连接数不受控 → 竞争加剧 → BUSY 变多。正确做法是全进程共用一个连接实例(better-sqlite3 是同步 API,本来也不需要连接池)。
-walWAL 模式下最新数据可能还在日志里没合并回主文件。只拷 .db 会静默丢掉最近的数据——而且数据库能正常打开,你发现不了。正确做法:用 .backup 命令,或停服务后三个文件一起拷 + 校验 md5。
WAL 依赖本机文件锁(-shm)。NFS/SMB 上文件锁不可靠,会真的把库写坏。这也意味着「多台机器共享一个 SQLite 文件」在架构上就不成立——横向扩容必须换 PostgreSQL。
两个请求同时来,都查到「不存在」,然后都去插——应用层的 if 判断挡不住。真正的那道防线是数据库的唯一约束:第二个请求撞唯一约束失败,然后去读第一个写好的结果返回。所以幂等方案必须落在数据库上,不能只写在前端或内存里。
SQLite 外键默认关。不开的话「订单明细指向不存在的订单」这种脏数据能直接写进去,直到业务层才炸。必须显式 pragma('foreign_keys = ON')。
任何「用户点一下、有可能重试」的写操作(提交订单、通过审批、提交报价)都要有幂等键。你的 order_no / po_no / wo_no / req_no 都是天然幂等键,用上它们。
SQLITE_BUSY 的成因是另一个连接持有写锁,而且默认 busy_timeout=0 是最大原因busy_timeout / synchronous / foreign_keys,并知道 synchronous=OFF 会损坏数据库-wal 和 -shm 各自是什么,-shm 是纯临时的可重建-wal 一起拷,也知道服务运行时直接 cp 会拷出不一致的文件order_no/po_no/wo_no/req_no),并且知道真正防线是唯一索引不是先查后插先说我的技术选型和它的天花板在哪,这个我判断得很清楚。我的门店系统是 Express + better-sqlite3 + SQLite WAL 模式 + pm2。选 SQLite 的理由是它不需要独立的服务进程——备份就是拷文件、部署就是拷文件,对我这种单机部署是最省心的。但我也清楚它的天花板:多读单写,同一时刻只有一个写者,而且写锁是整库级不是行级。我现在的业务量是 4 家经销商门店、日订单几十单,写操作集中在白天、频率是个位数每秒,离天花板还很远,而且读写比是压倒性的读多写少——这个特征非常适合 SQLite。所以选型是判断,不是侥幸。
什么情况下我会换 PostgreSQL?三条明确的信号:写入量到万级每秒(单写者串行,物理上做不到);多进程或多机器高频写(每个进程各开连接、彼此不知道对方在写);需要复杂并发控制(行级锁、间隙锁、SELECT FOR UPDATE 这一套,SQLite 根本没有)。还有一条架构级的硬限制:WAL 依赖本机文件锁,所以数据库文件不能放网络盘,「多台应用服务器共享一个库文件」在 SQLite 上根本不成立——这直接堵死了横向扩容的路。真要扩,那就是换 PostgreSQL 的信号。
WAL 具体解决了什么?机制是写不直接改主文件,而是在 -wal 里追加,追加完就算成功;同时把主文件标记成一个快照版本,所以读请求从主文件读、看到的是完整的旧数据,不会看到改了一半的样子。于是读和写可以同时进行——报表查询不再把下单卡住,下单也不再把列表页卡住。附带好处是顺序追加对磁盘更友好,不用频繁刷整个库。但我要说清 WAL 没解决什么:它没解决两个写同时来,同一时刻仍然只有一个写者,第二个写照样撞 SQLITE_BUSY。所以「开了 WAL 就没有并发问题」是错的——这是我一开始的误解,后来靠压测才搞明白。
我踩过两个真实的坑。
坑一:丢更新 lost update。成因是AI 生成的代码把「读-改-写」三步当成了一个原子操作,但中间会被插入。具体是库存 10、两个请求各要 1 件:两个都读到 10,各自减 1 写回,结果是卖出去 2 件但库存只减了 1。这个现象比报错可怕得多——不报错、不告警、数据悄悄少一件,要等月底盘点才发现。我的 collections 表那次更严重:那个字段是 JSON blob,改一个字段要重新序列化整个对象、整行重写,所以两个请求碰同一行时不只是那个字段冲突,是整行所有字段都会被后写的覆盖,这就是「并发串行」的机制来源。
我的解法是分层的:第一层不让整对象进数据库,凡是要单独查单独改的字段独立成列,冲突面积就从整行缩到一个字段;第二层是能用一条 SQL 表达的绝不用三步 JS——用 UPDATE inventory SET qty = qty - 1 WHERE sku = ? AND qty >= 1,让数据库自己算。单条 UPDATE 是原子的,中间不可能被插进来,而且这个写法顺手把超卖也防了:影响行数是 1 就是成功、是 0 就是库存不足。第三层才是加锁,我也知道加锁的代价——事务持有锁期间别人全排队,所以事务里绝不能发 HTTP 请求或等用户确认。另外业务层我保留了自己的锁:inventory 是 qty 在库 + locked 已锁两个字段,下单先加 locked 占住、领料才真正扣 qty,配套 inventory_movement 用 lock/unlock 类型记账——两级库存 + 账本这套设计在并发下是站得住的。
坑二:大事务把整库锁住。我最早以为「套个大事务最安全」,结果做数据迁移时一个事务插 5000 条,第 4999 条失败全回滚、前 4998 条白干,而且因为 WAL 的写锁是库级的,那几分钟里页面上所有人都点不动。现在我改成每条订单一个事务,失败那条单独回滚并记进失败清单,整批可以随时重跑,锁每次只持有几毫秒;迁移还会拆批,一批 500 条,批间 sleep 一下,让页面有机会插进来。这件事让我记住一条红线:不要用「加大事务」当万能药,它不解决问题,只是把影响范围从一行放大到整库。
关于 SQLITE_BUSY,我的处理是四层:①配 busy_timeout——这是最大原因,默认值是 0 也就是「不等立刻报错」,配成 5000 之后,因为我的写操作本来就只占几十毫秒,绝大多数情况等一下就成功了,业务层根本感知不到;②缩短事务,把所有慢操作挪出事务;③失败重试,而且重试间隔必须加随机抖动,否则多个进程会「一起等 50ms 又一起冲」永远撞在一起,而且重试的必须是整个事务不是事务里某一步;④合并写,批量插入用一条多值 INSERT,把写次数降下来。
关于 WAL 的运维,有两条我踩过的:①备份必须连 -wal 一起拷,因为 checkpoint 不是实时发生的,最近的数据可能还在日志里没合并回主文件,只拷 .db 会静默丢掉最近几天的数据,而且数据库能正常打开你发现不了。更糟的是服务运行时直接 cp 可能拷出「半新半旧」的不一致文件,那种损坏平时看不出来、跑到特定页才崩。正确做法是用 SQLite 自带的 .backup 命令,或者停服务后三个文件一起拷并校验 md5。②WAL 模式下 synchronous 默认该设 NORMAL,掉电可能丢最后几笔但数据库不会坏;但订单提交、远程双签这类关键写,我会单独用 synchronous=FULL 的连接来写——两条连接,重要的事走慢路。
还有一个我主动做的是幂等性。因为HTTP 是「至少一次」语义不是「恰好一次」——网络超时后重试会造成重复下单、重复审批、重复扣库存,而且服务端无法知道客户端已经放弃了,因为请求确实执行成功了只是响应没回来。我用两种思路:优先用业务唯一键,因为 order_no、po_no、wo_no、req_no 本来就是唯一的,直接当幂等键;没有天然单号的场景才用客户端生成的请求 ID。关键细节是幂等记录和业务写入必须在同一个事务里,而且真正的防线是数据库的唯一索引——两个请求同时来时,应用层的「先查后插」挡不住(都查到不存在、都去插),但唯一索引会拦住第二个,它去读第一个写好的结果返回。
最后讲排障方法论,因为并发问题靠「想」是找不出来的。我的做法是:先按症状分类(偶发 BUSY 是写竞争或超时没配;数据少一点但不报错是丢更新;某页越用越卡是大事务或缺索引),然后三个 PRAGMA 体检(journal_mode / busy_timeout / foreign_keys + integrity_check),最后是主动压测复现——写一个脚本 20 个并发各跑 50 次扣减,然后做对照实验:原子操作版应该 PASS,读-改-写版会复现出丢更新。这样就能立刻区分「是锁竞争还是逻辑问题」:BUSY 次数多就是锁竞争,数据不对就是丢更新。因为并发问题只在特定时序下发生,你手动点两下永远复现不出来。
想看一句话版本、四句大白话、WAL 到底解决了什么、lost update 的三步时间线和事务边界正误对比,回速查版 17 · 并发、锁与事务。