速查版:一屏看懂事务、锁、隔离级别、WAL 四句大白话;WAL 到底解决了什么、代价是什么;「并发串行」在你项目里是怎么发生的(丢更新),以及三种解法怎么选。
①德莉玛门店订购单系统 v2.5.0:Express + better-sqlite3,数据库开了 SQLite WAL(预写日志)模式,用 pm2 挂进程。WAL 就是这个系统并发能力的地基。
② collections 表曾把整个对象存成单行 JSON blob,直接后果之一就是并发串行——同一行只能一个人写,别人全排队。
③ LumenX 用 pm2 起 3 个进程;你另一套 2026-08-09-task-100(淋浴房 P3)用 better-sqlite3 13,2026-07-01-task-63/kb-api 用 11。
⚠️ 三条事实连起来看才有意义:pm2 多进程(会同时写) + SQLite 单写者(WAL 也只能一个写者) + JSON blob 整行重写(把冲突面积放大到整行)——三者叠加,就是你踩过的那个并发坑。
并发就是「多件事同时发生」。事务和锁,是保证它们不打架的手段。
「同时」这个词在服务器上有两层意思,你要分清:①真同时(多个 CPU 核同时在跑,几纳秒级交错);②假同时(单个 CPU 在多个任务间快速切换,每个任务以为自己从头跑到尾、中间没人打断)。
.db 文件。这些全是并发。| 词 | 一句话 | 你项目里的对应物 |
|---|---|---|
| 事务 transaction | 一组操作,要么全成功,要么全回滚。不是「一起做」,是「同生共死」 | 「一张订单的创建:写order 表 + 写 order_line 表 + 扣 inventory 表的locked——这三步要么全成,要么一条都不留」。中间失败如果只留了两条,库存就被白占了 |
| 锁 lock | 有人改的时候,别人先等。门上一把锁,进去的人先锁门,后面的人排队 | 你项目的 inventory(qty 在库 / locked 已锁)就是业务层的锁:先锁住库存再下单,而不是卖出去才发现不够 |
| 隔离级别 isolation | 允许多大程度上「同时」。越高越安全,但越慢 | SQLite 的隔离是「读之间完全不互斥,写之间完全互斥」——相当于可串行化的读 + 单写者 |
| WAL | 写日志再落盘。写进日志就算完成,读的还是旧快照——所以读的人不卡 | 德莉玛门店系统 v2.5.0 开的那个模式。它把你的系统从「一个人写全员等」变成「读随便读,写排队」 |
这是本页最重要的一段。先讲没有 WAL 是什么样,再讲有了 WAL 是什么样。
LIMIT 的全表扫)也会把写堵住。xxx.db-wal)里追加一条「把第N 页改成什么」。追加完就算这次写成功了——数据库能马上开始处理下一个读请求,而这个读请求看到的还是「修改之前」的旧数据(叫快照),完全一致,不会看到改了一半的样子。SQLITE_BUSY。所以「开了 WAL 就没有并发问题了」是错的。-wal 和 -shm 两个文件,而且不会自动消失。WAL 模式下最新数据可能还在 -wal 里没合并回主文件。所以备份必须把三个文件一起拷,只拷 .db 会丢最近的数据。// 开WAL 只需要一行,门店系统 v2.5.0 里就是这么写的
db.pragma('journal_mode = WAL');
// 配套的三个建议参数
db.pragma('busy_timeout = 5000'); // 拿不到锁时最多等 5 秒,别立刻报 BUSY
db.pragma('synchronous = NORMAL'); // 配合 WAL:断电可能丢最后几笔,换来的写入提速
db.pragma('foreign_keys = ON'); // 外键约束默认是关的,必须手动开
成因一句话:AI 生成的代码把「读-改-写」这三步当成了一个原子操作,但这三步之间是可以被别人插进来的。
collections 表 JSON blob 事故并发面的根因:blob 需要整行重写,而整行重写意味着「只要两个请求碰同一行,所有字段都会被后写的那个覆盖」——冲突面积从1 个字段扩大到整行。库存 10 件,两个经销商门店同时各要 1 件。正确结果应该是 8。
// ✗ 有 bug 的写法(AI 常生成这样):读-改-写三步各自独立
app.post('/api/orders', async (req, res) => {
const row = db.prepare('SELECT qty FROM inventory WHERE sku = ?').get(sku);
if (row.qty >= 1) {
db.prepare('UPDATE inventory SET qty = ? WHERE sku = ?')
.run(row.qty - 1, sku); // ← 危险:这里的值是「读的时候」的旧值
}
res.json({ ok: true });
});
// 时间线(两个请求交错执行)
// t1请求A:SELECT → 读到 qty = 10
// t2 请求B:SELECT → 也读到 qty = 10 ← B 读到的是旧快照
// t3 请求A:UPDATE SET qty = 9 → 库里现在是 9
// t4 请求B:UPDATE SET qty = 9 → 库里还是 9!B 手里的旧值是 10,减1还是 9
// 结果:卖出去 2 件,库存只减了 1 → 少卖了一件
UPDATE,日志里两条都在,但第二条把第一条的效果吃掉了。它比报错可怕得多——不报错、不告警、数据悄悄少一件,等月底盘点才发现。读之前先拿锁,拿不到就等。要么用应用层的锁(Node 里一个 Map 按 sku 排队),要么用数据库的事务把三步包起来(BEGIN → 读 → 写 → COMMIT),要么开 IMMEDIATE 事务一开始就抢写锁。
// 解法 1a:数据库事务(最标准)
const wrap = db.transaction(() => {
const row = db.prepare('SELECT qty FROM inventory WHERE sku = ?').get(sku);
if (row.qty < 1) throw new Error('库存不足');
db.prepare('UPDATE inventory SET qty = ? WHERE sku = ?').run(row.qty - 1, sku);
});
wrap(); // better-sqlite3 的 transaction() 自动 BEGIN/COMMIT,出错自动 ROLLBACK
代价:事务持有锁期间别人全部排队。事务越长,队排越长。所以事务里绝对不要做「发 HTTP 请求」「等用户确认」这种事。
让数据库自己算,不要把值读出来再算完写回去。因为数据库执行单条 UPDATE 是原子的(不会被打断):
-- ✓ 正确:让数据库自己减,且加条件防超卖
UPDATE inventory
SET qty = qty - 1,
updated_at = ?,
WHERE sku = ? AND qty >= 1;
-- 影响行数 = 1 → 成功扣减
-- 影响行数 = 0 → 库存不足,直接拒绝(连超卖的漏洞都没有)
为什么这个方案最好:整个操作只有一条语句,不可能被插进来。数据库保证「读到最新的值 → 减1 → 写回」是一口气完成的,中间不会有第二个请求插进来。代码更短,性能更好,还顺手防了超卖。记住这条原则:能用一条 SQL 表达的并发控制,就不要用三步 JS。
不改数据,而是在读的时候把版本号记下来,写的时候检查版本号变了没有。变了说明有人抢先改过,这次操作失败,让用户重新看一次。
// 读的时候把 version 一起返回给前端 SELECT qty, version FROM inventory WHERE sku = ?; -- 假设 version = 7 // 写的时候带上「我看到的版本是 7」 UPDATE inventory SET qty = 9, version = version + 1 WHERE sku = ? AND version = 7; -- 影响行数 = 0 → 版本已经不是 7 了 → 有人改过 → 提示「库存已变动,请刷新」
代价:冲突多的时候用户体验差(老是要让人重来)。所以适合「冲突少」的场合,比如订单详情页的备注编辑。库存扣减这种高频冲突的场合,用解法 2。
inventory 有 qty(在库)和 locked(已锁)两个字段,这就是「两级库存 + 业务锁」的设计——下单时先把 locked 加 1(占住),生产领料时再从 qty 里真正扣。而 inventory_movement(流水表)记 type 为 lock/unlock,就是这套机制的账本。业务层的锁 + 数据库层的原子操作,两层一起用,这套设计在并发下是站得住的。| 场景 | 你项目里的具体例子 | 后果 |
|---|---|---|
| 多用户同时点 | 4 家经销商门店同时下单——A 门店在提交订单,B 门店在同一秒也提交 | 如果同时扣同一个 SKU 的库存,丢更新 |
| 双人同时审批 | 远程双签:区域经理和工厂两个人同时审同一张单 | 两个人都通过了(该驳回的被放行);或者两个请求都写 reviewed 状态,后写的覆盖先写的审批意见 |
| 批量脚本 + 页面同时操作 | 你跑数据导入 / seed / 迁移脚本的同时,门店的人还在页面上操作 | 导入脚本拿长事务锁住表,页面所有写操作报 SQLITE_BUSY。这是你做数据迁移时最常踩的 |
| 定时任务 + 页面 | 定时结算 / 定时同步物料,和店员手动下单撞上 | 同上也报 BUSY,或者数据被互相覆盖 |
| 多进程同时写(最容易忽略) | pm2 起多个进程,进程之间没有任何「我正在写」的默契 | 这是 SQLite 的硬限制:不管你几个进程,同一时刻只有一个能写,其他全拿不到锁。LumenX 是 3 个 pm2 进程 |
SQLITE_BUSY。这是 SQLite 的天花板,不是你代码写错了。原则只有一句:一次业务操作 = 一个事务。
「业务操作」是从用户角度看是一件事的那个粒度。「创建一张订单」是一件事:写订单主表 + 写明细行 + 锁库存。这三步必须在同一个事务里,因为它们要么一起成功要么一起不算。但「导入 5000 张订单」不是一件事,它是 5000 件事。
| ✗ 错误:大事务当保护伞 | ✓ 正确:每条一个事务 | |
|---|---|---|
| 做法 | BEGIN → 循环插入 5000 条 → COMMIT | 循环里:每条订单自己 BEGIN → 写主表 + 写明细 → COMMIT;某条失败就回滚这一条、记进失败日志、继续下一条 |
| 出问题时的样子 | 第 4999 条失败 → 整个事务回滚 → 前 4998 条全部白干,而且数据库被锁了几分钟,期间所有页面操作全卡 | 第 4999 条失败 → 只丢这一条,前 4998 条已入库(可以随时继续跑),锁每次只持有几毫秒,页面基本无感 |
| 锁的时间 | 几分钟 | 几毫秒 |
| 能重跑吗 | 不能,得从头来 | 能——只补失败的那些就行 |
// 逐条事务 + 失败收集(这是正确的样子)
const failed = [];
for (const row of rows) {
const runOne = db.transaction(() => {
const id = insertOrder(row);
for (const line of row.lines) insertOrderLine(id, line); // 同一事务内
});
try {
runOne();
} catch (e) {
failed.push({ row, error: String(e) }); // 记下来,不中断整批
}
}
// 导出 failed 成 CSV,人工/脚本重试
sleep 一下,让页面有机会插进来。WAL 只解决「读和写互相阻塞」,不解决「两个写同时来」。WAL 模式下同一时刻仍然只有一个写者,第二个写依然会撞 SQLITE_BUSY。把 WAL 当成并发问题的终点,是最常见的误解。
见第 04 节。这是你的真实事故:AI 生成的代码把 SELECT → 判断 → UPDATE 当成一步,实际三步之间会被插入。症状是数据悄悄少一点,不报错。防范动作:写代码时凡是「先查出来再算再写回去」,都要停下来想一句「这两步之间别人插进来会怎样」。能改成一条 SQL 就改。
「套个大事务不就安全了」——安全是真的,但代价是整库被锁住。WAL 下写锁是库级的,大事务一跑,页面上所有人的写操作全排队,表现为「系统卡了」。正确做法是「小事务 + 失败单独处理 + 可重跑」。
两个错叠加就出大事故:①「SQLite 是单线程的」——单个连接内部确实是串行的,但你的进程不止一个连接(请求之间各自开连接),而且 pm2 多进程各开各的;②「我只有一个用户」——你只有 4 家经销商门店,但并发不是按人数算的,是按「同一时刻有几个请求在处理中」算的。结果是 WAL 下多进程写会直接报 SQLITE_BUSY。
-wal 文件WAL 模式下最新数据可能还在 xxx.db-wal 里没合并回 xxx.db。只拷主文件会丢掉最近的数据。要么三个文件一起拷,要么用 SQLite 自带的 .backup 命令(它会正确处理 WAL)。只拷 .db 还要小心:拷的过程中如果有写入发生,拷出来的主文件本身可能是不一致的。
SQLite 为了兼容老应用,外键约束默认不开启。不开启的话,「订单明细指向一个不存在的订单」这种脏数据可以被正常写进去——数据库不拦,脏数据一路流到业务层才炸。必须显式开:db.pragma('foreign_keys = ON')。
-wal、备份必须连它一起拷UPDATE ... SET qty = qty - 1(推荐) / 乐观锁版本号collections 的 JSON blob 会让并发串行(整行重写 → 冲突面积从1 个字段变整行)qty/locked 双字段 + inventory_movement 流水 = 业务层锁 + 账本pragma('foreign_keys = ON'),因为 SQLite 外键默认关先说背景,我的并发场景是明确的:门店订购单系统 v2.5.0,Express + better-sqlite3 + SQLite WAL 模式 + pm2 挂进程。并发来源有四个:4 家经销商门店同时下单;远程双签两个角色审同一张单;我跑批量导入/迁移脚本时店员还在页面上操作;以及 pm2 多进程各自开连接同时写——最后这条是最容易被忽略的,因为直觉上「SQLite 是单线程的」会觉得没事,但单进程内也并发,单机也并发。
第一层:我开了 WAL 模式,这解决的是「读和写互相阻塞」。机制是——写的时候不直接改原文件,而是先在 -wal 日志里追加,追加完就算成功;读请求从原文件读,看到的是修改前的旧快照,完全一致但不会被卡住。所以报表查询不再把下单卡住,下单也不再把列表页卡住。顺带还快了,因为顺序追加日志对磁盘更友好,不用频繁把整个库刷盘。
但WAL 不是万能的,这我要说清:它只解决读写互不阻塞,不解决两个写同时来。WAL 模式下同一时刻仍然只有一个写者,第二个写照样撞 SQLITE_BUSY。WAL 还有三个代价:会产生 -wal 和 -shm 文件;备份必须把这三个文件一起拷,只拷主文件会丢最近数据;以及它依赖本机文件锁,所以不能把库放 NFS 上,也不能多台机器共享一个库文件。
第二层:我踩过一个真实的坑,就是丢更新 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 是原子的,中间不可能被插进来,而且用 WHERE qty >= 1 顺手把超卖也防了,影响行数是 1 就是成功、是 0 就是库存不足。这一步比加锁更短、更快、更可靠。第三层才是加锁,而且我知道加锁的代价:事务持有锁期间别人全排队,所以事务里绝对不能发 HTTP 请求或等用户确认。另外我保留了业务层的锁——inventory 是 qty(在库)+ locked(已锁)两个字段,下单时先加 locked 占住、领料时才真正扣 qty,配套 inventory_movement 用 lock/unlock 类型记账,这套两级设计在并发下是站得住的。
第三层是事务边界,我的原则是「一次业务操作 = 一个事务」。这里我吃过亏:最早我以为「套个大事务最安全」,结果做数据迁移的时候一个事务插 5000 条,第 4999 条失败全回滚,前 4998 条白干,而且大事务把整库锁了几分钟,期间页面上所有人都点不动——WAL 的写锁是库级的,一大事务进来所有写全排队。现在我改成每条订单一个事务,失败的那条单独回滚并记进失败清单,整批可以随时重跑,锁每次只持有几毫秒。做迁移还会拆批,一批 500 条,批与批之间 sleep 一下,让页面有机会插进来。
最后是 WAL 模式下的两个必须记住的运维细节:①SQLITE_BUSY 一定会遇到,处理方式是配 busy_timeout 让它等而不是立刻失败、缩短事务、失败重试、合并写操作;②备份必须连 -wal 一起拷,或者用 SQLite 自带的 .backup 命令,只拷 .db 会丢最近的数据。还有一条容易忘的:SQLite 的外键约束默认是关的,必须显式 pragma('foreign_keys = ON'),不然脏数据能被写进去。
如果要说升级路线:SQLite 的天花板很明确——同一时刻只有一个写者。我的业务量(4 家经销商门店、日订单几十单)它绑绑有余。但如果将来要多进程高频写、或者写入量到万级每秒,就得换PostgreSQL,因为那时候 SQLITE_BUSY 会变成常态而不是偶发事件。