FDE·技术基础
基础目录
速查版 · 一屏看懂
①请求旅程 ②读懂monorepo ③数据库存东西 ④并发锁事务 ⑤前端为什么能跑 ⑥读懂AI代码
深入版 · 原理到能排障
①请求旅程 ②monorepo ③数据库 ④并发事务 ⑤前端渲染 ⑥读懂AI代码

深入 · 并发、锁与事务

深入版:速查版讲「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」指的是未来升级路线的判断,不是现状。

01

SQLite 为什么单线程还经常没事

先说清模型:SQLite 是多读单写。大白话就是——读的人可以随便来(多个读同时进行,互不排斥);写的人只能排队(同一时刻一个写)。

为什么这样设计
因为它是「一个文件就是一个库」
没有服务器进程、没有网络协议、没有独立内存进程——整个数据库引擎就嵌在你的 Node 进程里。所以它必须假设「最坏情况就是自己这个进程」,没法假设「有个专门的数据库服务器帮我管锁」。
换来的是巨大的部署优势:不用起服务、不用配账号密码、不用连网络,备份就是拷文件。你的门店系统选它,省下的就是这一整套运维。
所以什么时候没事
写操作不密集的时候
关键判断标准是「写入频率」,不是「有多少人用」。
你的业务是什么量级:4 家经销商门店,日订单几十单,写操作集中在白天的工作时间,频率是「个位数每秒」。这个量级下 SQLite 的单写者绑绑有余——两次写之间的间隔足够长,排队的概率极低。所以「为什么它经常没事」的答案是:你的写压力离天花板还很远。
你的读多写少特点非常适合 SQLite:订单列表页、BOM 查询、库存看板全是读,WAL 模式下读完全不阻塞、也不排队。写只有那几条(提交订单、审批、扣库存)。所以你选 SQLite 是判断正确的,不是侥幸。
⚠️ 但这三条红线一旦碰到,就该考虑换 PostgreSQL / MySQL 了:
信号为什么是死结
① 写入量到上万/秒级别单写者是串行的,每秒一万次写意味着每次写只能占 0.1 毫秒。物理上做不到,排队会无限堆积,最后所有请求超时
② 多进程 / 多机器高频写每个进程各自开连接,彼此不知道对方在写。这不是配置能解决的——单写者模型是架构层面的限制。你用 pm2 cluster 起 4 个进程,写压力就分摊成 4 份去抢一把锁
③ 需要复杂的并发控制比如「悲观锁 + 行级锁 + 间隙锁 + 可串行化隔离」这一套。SQLite 连行级锁都没有(它锁整库),更没有间隙锁、没有 SELECT ... FOR UPDATE。你只能靠「缩短事务」和「应用层排队」来绕

还有一个更硬的限制:WAL 模式下数据库文件不能放在网络文件系统上(NFS、SMB、云盘挂载都不行),因为 WAL 依赖本机的文件锁(-shm 文件)来协调读写,而网络盘的文件锁不可靠,会真的把库写坏。所以 「多台应用服务器共享一个数据库文件」这个方案在 SQLite 上根本不成立——这是架构级的不可能,不是性能问题。

对比一下 MySQL / PostgreSQL 的处境:它们是独立运行的数据库服务,应用通过网络连过去。多应用、多机器连同一个库是它们的设计常态,而且它们有行级锁(我改第 5 行,不影响你改第 6 行)、MVCC(读不阻塞写、写不阻塞读)、真正的可串行化隔离级别。代价是必须运维一个服务:安装、配置、备份、监控、升级、故障恢复,全都要人管。
你的选择逻辑很清晰:业务量小 + 单机部署 → SQLite;多机部署 / 高频写 / 复杂并发 → PostgreSQL。
02

锁的类型与粒度

两个词先解释
  • 共享锁(读锁,SHARED):我只想看,不改。共享锁之间互不排斥——多少人同时看都行。
  • 排他锁(写锁,EXCLUSIVE):我要改。排他锁和任何其他锁都排斥,包括别人的排他锁。
  • 粒度 granularity:锁的范围有多大——锁一行、锁一张表,还是锁整个数据库。范围越小,并发能力越强(因为锁的范围小,别人不受影响)。
组合结果大白话
读+ 读✅ 允许两个人同时看一份文档,各看各的,不冲突
读 + 写❌互斥你要改的东西正在被人看。要么等他看完,要么你不许看——取决于数据库的模式和隔离级别。WAL 模式下读和写是互不阻塞的(读看旧快照),这是它最值钱的一点
写 + 写❌互斥两个人同时改同一份东西,必须排队。这是 SQLITE_BUSY 的直接来源

三种粒度对比

粒度锁住多少并发能力谁用
页级 page-level4KB 一页(SQLite 的页)中等SQLite 内部用。WAL 模式下写是先写日志,读完全不加锁(读的是不可变的快照)
行级 row-level只有被改的那一行高MySQL InnoDB / PostgreSQL。两个请求改不同行,完全不用互相等——这是它们并发能力强的根本原因
表级 / 库级 table/database-level整张表 / 整个库低SQLite 就是这一级。所以「两个请求改不同的 SKU」在 SQLite 下照样要排队——因为它们抢的是整库的写锁
这条对你影响很大,务必记住:SQLite 的写锁是「整库级」的,不是行级。所以哪怕 A 在改 SKU-001 的库存、B 在改 SKU-002 的库存,这两个毫不相干的操作也会互相阻塞。
推论:任何大事务(哪怕它只改一行)都会把整库堵住。这就是「大事务锁整库」的机制来源——不是它改得多,是它的锁范围大。
WAL 模式下读不加锁,这是它和默认模式的本质区别。默认模式(DELETE /TRUNCATE journal)下,读要加共享锁,会被写阻塞;WAL 模式下读完全不加锁——因为读的是「某一时刻的快照」,那个快照是不可变的,不会被人改。所以你可以一边写一边读,两边都不用等对方。
03

四种隔离级别:同一个转账例子,四种坏法

三个术语的大白话(先认这三个词)
  • 脏读 dirty read:读到了别人还没提交的数据。读的人以为是真的,其实对方随后回滚了——于是你手里拿着一个「从来没存在过的数据」
  • 不可重复读 non-repeatable read:同一个人读同一行,两次结果不一样。因为中间有人改了并提交了。注意:脏读是「读到不该存在的」,不可重复读是「读到两个都真实存在但不同版本的值」
  • 幻读 phantom read:同一个查询跑两次,第二次多出了几行。因为中间有人插入了新行。注意它跟不可重复读的区别:不可重复读是「一行的内容变了」,幻读是「行的数量变了」

贯穿下面的例子:你账户 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 在哪一级?它的行为接近「可串行化」,但是用另一种方式实现的:WAL 模式下所有读事务看到的是同一个时间点的快照(所以不会不可重复读),而且写是整库串行的(所以不会有交错)。结果是「数据正确性接近可串行化,但读可以并发」——这正是 WAL 被广泛使用的原因:它把「隔离」和「并发读」这两件事分开了。
⚠️ 但这里有个容易误解的点:SQLite 的「数据正确」和「你的业务代码正确」是两件事。事务层面的隔离能保证「两个事务不互相破坏」,但保证不了「你的事务内部逻辑对」。你的丢更新(lost update)就发生在一个事务内部——两个请求各自开事务、各自读、各自写,数据库层面每个事务都是合法的、隔离也是到位的,是你的业务逻辑「读-改-写」这个模式本身有问题。所以调隔离级别解决不了丢更新,只能靠「原子操作」或「加锁」。
04

SQLITE_BUSY 到底怎么回事

这是WAL 模式下依然会报的错。因为 SQLITE_BUSY 不是「有冲突」,而是「我等了你给定的超时时间,还是没拿到锁」。

成因
另一个进程/连接正持有写锁
场景还原:pm2 起了 3 个进程,同时向同一个 .db 发起写请求。WAL 的机制决定同一时刻只能有一个写者,所以数据库把写锁只给其中一个进程,另外两个拿不到。
拿不到之后 SQLite 会做什么:它会重试,但只等到 busy_timeout 设的时长(默认是 0,也就是立刻失败)。于是你看到的就是一个 SQLITE_BUSY 错误。
所以 SQLITE_BUSY 通常不是「出了诡异 bug」,而是「并发写超过了单写者能力,或者你的重试策略太笨」。它是正常的背压信号,不是异常。

四个真实有效的处理办法

1

配 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 秒的超时远远大于它——所以绝大多数情况下,等一下就能拿到锁,拿到就成功了,业务层根本感知不到。

2

缩短事务(治本)

持锁时间越短,别人等得越少,撞上的概率就越低。正确的做法:事务里只放读写数据库的动作(目标几十毫秒)。把这些东西挪出事务: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。

3

失败重试(带抖动的退避)

即使配了超时,高负载下仍会失败。这时候要在应用层重试。关键:重试间隔要「随机抖动」(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 又一起冲上去」,永远撞在一起。

4

合并写(减少写次数)

单写者模型下,写次数本身就是资源。能批量就批量:循环里插 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),
  ...;   -- 一条语句、一个事务、一次写锁
05

WAL 的完整机制

先认三个文件名
  • xxx.db:主数据库文件
  • xxx.db-wal:预写日志。WAL 模式下新数据先追加到这里
  • xxx.db-shm:共享内存索引。用来让各进程快速知道日志里有哪些数据、当前该读到哪个位置。它是纯临时的,可以随时重建——删了它下次启动会自动建(前提是没有其他连接在用)

一次写入的完整过程

1

写到日志(追加)

要改数据时,不直接改 .db,而是在 -wal 末尾追加一条:「第 5000 号页的第 3 段,改成 X」。这个追加是顺序写——对磁盘最友好,因为不需要来回移动磁头。追加完成,这个事务就算成功了(写到这就已经可以告诉客户端「成了」)。

2

同时给读者一份旧快照

关键点:在日志里写之前,SQLite 会把当前的 .db 标记为「这是快照版本N」(信息记在 -shm 里)。所以哪怕 .db 里的页正在被覆盖,读事务拿到的也是完整的旧版本——因为旧数据在写之前已经被「钉住」了。这就是「读不阻塞写」的原理。

3

checkpoint:把日志合并回主文件

日志不能无限增长。当 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 一起拷
1

因为最新数据可能还「只在日志里」

checkpoint 不是实时发生的。它要等日志积累到 1000 页,或者最后一个连接关闭。如果你的服务一直在跑,那最近写入的数据可能就一直在 -wal 里,还没合并回 .db。
后果:你只拷了 prod.db,恢复到新机器后,最近几笔订单/审批全都不见了——而且你完全看不出来(数据库能打开、能查,就是少了几天的数据)。这是最难发现的一种数据丢失。

2

直接拷 -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 到一半的文件不一致
WAL 模式下崩溃恢复靠什么?靠日志本身。WAL 是 「先落日志再改数据」,所以崩溃时:①已经写进日志但还没 checkpoint 的 → 重启后从日志恢复,数据在;②还没写进日志的 → 视为没发生,数据不在。所以 WAL 的持久性等级可以用 synchronous 调:
synchronous掉电时丢什么速度怎么用
FULL基本不丢慢(每次都刷盘)金融级要求时
NORMAL可能丢最后几笔事务(但数据库文件不会坏)明显快WAL 模式的推荐值
OFF可能损坏数据库最快不要用

你的门店系统大概率是 NORMAL。要讲清这个取舍:「掉电时最多丢最后几笔操作,但数据库结构完好、可以继续用」——这在业务上通常是可接受的(用户会重试),但如果丢的是「用户刚提交的订单」,就是投诉。所以关键业务(订单提交、远程双签)可以单独用 synchronous=FULL 的连接来写,其他操作用 NORMAL——两条连接,重要的事走慢路。

06

进程模型:pm2 多进程 + 并发

这一节讲你项目里真实存在的那个「必然会撞」的问题。

现状
LumenX 是 pm2 起 3 个进程(124.222.108.196)
3 个 Node 进程,各自持有一个 SQLite 连接,全部指向同一个 .db 文件。它们彼此完全不知道对方在做什么——没有共享内存、没有协调机制、唯一的信息来源是数据库自己的锁文件。
会发生什么
写操作变成三路抢一把锁
三个进程同时收到写请求 → 数据库只能给其中一个 → 另外两个进入等待。等的时间由 busy_timeout 决定:配了 5 秒 → 大多数时候等一下就成功,配了 0(默认)→ 直接两个报 BUSY。
更麻烦的是「读」不受影响,但「写」会被拖慢:因为所有写操作在抢同一把整库级的锁。
还有一个容易忽略的
连接数也有限制
SQLite 不是服务,它没有「连接池服务进程」那套东西——每个连接就是一个文件句柄,直接参与文件锁竞争。3 个进程 × 每个进程 1 个连接 = 3 个竞争者,不算多;但如果你在进程里为了性能开了一堆连接(比如每个请求开一个、或者 better-sqlite3 用多个实例),并发持有锁的句柄就更多,冲突就更容易。
// 正确做法:整个进程共用一个连接(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');   // ← 每个请求一个句柄
  ...
});
为什么很多生产部署会改用 PostgreSQL?不是因为 SQLite 慢,是因为多进程/多机场景下它的模型不对。三个具体痛点:①写锁是整库级的,而 PostgreSQL 是行级——两个进程改不同的行,在 PostgreSQL 里完全不冲突,在 SQLite 里要排队;②没有真正的并发控制工具,没有行级锁、没有间隙锁、没有 SELECT ... FOR UPDATE,复杂业务只能靠「缩短事务」和「应用层排队」硬扛;③文件不能放网络盘,所以「多机共享一个库」这个架构根本不成立——而这恰恰是横向扩容的前提。
你的现状判断:3 个 pm2 进程 + SQLite,在当前业务量(日订单几十单)下是可行的——但前提是 busy_timeout 配好了、事务都短、连接全进程复用。这三个前提做到位,BUSY 就是偶发事件而不是常态。如果哪天这三个前提破了(事务变长、连接被复制、订单量涨上去),第一反应应该是「换 PostgreSQL」而不是「继续优化 SQLite」。
07

幂等性:防重复提交的正确做法

先解释这个词:幂等 idempotent是指同一个操作执行 1 次和执行 10 次,结果一样。
为什么突然讲这个:因为它本质上也是并发问题——但不是「两个人同时做」,是「同一个人/同一个请求做了两次」。而触发它的场景非常真实:

触发场景你项目里的例子为什么
用户手抖点两下远程双签页面,点了「通过」按钮两下两个请求几乎同时发出
网络超时后重试提交报价请求超时了,前端自动重试⚠️ 最隐蔽的一种:第一个请求其实成功了,只是响应没回来。客户端以为是失败,就再发一次
网关/客户端重试反向代理超时后重发请求服务器端无法知道「客户端已经放弃」
定时任务重叠上次跑批还没结束,下次又启动了两个批处理同时处理同一批数据
⚠️ 网络重试是重点:「客户端超时 → 重试」这个看似正确的容错动作,会造成重复下单、重复审批、重复扣库存。因为服务端已经执行成功了,只是确认信息没送达。HTTP 是「至少一次」语义,不是「恰好一次」——所以幂等必须由服务端自己保证。

幂等键怎么设计

两种思路,选一种并且写进规范
  • 思路 A · 业务唯一键:用业务本身已有的唯一性做幂等键。这是最可靠的,因为它是数据本身的性质,不依赖任何外部机制。
    你的项目天然有:order.order_no(.notNull().unique())、po.po_no、work_order.wo_no、material_requisition.req_no——这几个单号本来就是唯一的,直接拿它当幂等键。
  • 思路 B · 请求 ID:客户端在每次「用户意图」开始时生成一个唯一 ID,重试时沿用同一个 ID,服务端把「这个 ID 处理过没有」记下来。
    适用于没有天然业务单号的场景,比如「点了三次,每次都是同一批数据要处理」。

⚠️ 两种思路的关键区别:思路 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;
}
关键细节 1:幂等记录和业务写入必须在同一个事务里。否则「业务写成功了但幂等记录没写进去」→ 下次重试会重复执行;反过来「幂等记录写了业务没写」→ 请求永久丢失。
关键细节 2:唯一索引才是真正的那道防线。幂等键表的主键约束(PRIMARY KEY)在并发下会保证「两个请求同时来,只有一个能插进去」,另一个撞唯一约束失败,然后它去读那个已经写好的结果返回。这一步在应用层的「先查后插」是防不住的——两个请求可能都查到「不存在」,然后都去插,但唯一索引会拦住第二个。这就是为什么这个方案必须依赖数据库的唯一约束,而不是靠代码里的 if 判断。
关键细节 3 · 前端也要配合:按钮提交后立刻置为 disabled 并显示 loading。这不解决根本问题(网络重试依然存在),但能消掉「手抖点两下」这类最常见的重复。真正的防线永远在后端。
08

排障方法论:怎么查、怎么测、怎么复现

第一步:先确认是哪一类问题

症状大概率是什么往下查什么
日志里偶发 SQLITE_BUSY写竞争,或 busy_timeout 没配先查 PRAGMA busy_timeout;再统计 BUSY 出现频率
数据「少了一点」,但没有任何报错丢更新 lost update去代码里搜「SELECT 出来的值在后面被 UPDATE 写回去」这种模式(见下面的复现脚本)
某个页面「越用越卡」大事务持锁,或查询缺索引看有没有跑批/迁移;用 EXPLAIN QUERY PLAN 看是不是 SCAN
报 SQLITE_READONLY 或库「坏了」文件锁不可靠(库放在网络盘上了)检查库文件路径

第二步:三个 PRAGMA 快速体检

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;

第三步:主动压并发,看 BUSY 出现几次

// 并发压测脚本: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 → 基本就是某个大事务/跑批造成的
整个方法论的核心就一句:并发问题不能靠「想」和「读代码」找出来,必须靠「主动压」和「看时间戳」。因为它只在特定时序下才发生——你手动点两下永远复现不出来,得让 20 个进程同时打。而一旦复现出来了,上面那个对照实验能立刻告诉你是「原子操作」的问题还是「锁竞争」的问题(前者表现为数据不对,后者表现为 BUSY 变多)。
09

常见坑 / 红线

红线 · 不要用「加大事务」当万能药

这条单独拎出来,因为它是新手最容易上当的一条。「套个大事务不就安全了?」——安全是真的,代价也是真的。

✗ 大事务当保护伞✓ 小事务 + 失败单独处理
好处所有写入要么全成要么全滚,不会有半截数据同样没有半截数据(每条自己是一个完整事务)
锁的范围整库(WAL 的写锁是库级)整库,但只持有几毫秒
别人等多久几十分钟(跑5000 条的时候)几毫秒
出问题第 4999 条失败 → 前 4998 条全白干;期间页面上所有人卡死第 4999 条失败 → 只丢这一条,已入库的 4998 条有效,可以随时重跑
能重跑吗不能,得从头来能,只补失败的那些
坑 1 · 以为 WAL 解决了并发问题

WAL 只解决读写互不阻塞。同一时刻仍然只有一个写者,写竞争原样存在。把 WAL 当并发问题的终点,是最普遍也最贵的误解。

坑 2 · busy_timeout 没配(默认是 0)

默认 0 意味着「拿不到锁立刻报错」。一行配置就能解决 90% 的 BUSY,不配就是白挨。而且报错抛到业务层通常没有重试逻辑,于是用户看到的就是「偶发操作失败」——最难查的一类问题。

坑 3 · 事务里放慢操作

HTTP 请求、等用户确认、写大文件、await 第三方调用——每一个都会把锁的时间从毫秒拉到秒。事务里只该有「读写数据库」。

坑 4 · 每个请求新建一个数据库连接

SQLite 每个连接就是一个文件句柄,直接参与文件锁竞争。连接数不受控 → 竞争加剧 → BUSY 变多。正确做法是全进程共用一个连接实例(better-sqlite3 是同步 API,本来也不需要连接池)。

坑 5 · 备份不连 -wal

WAL 模式下最新数据可能还在日志里没合并回主文件。只拷 .db 会静默丢掉最近的数据——而且数据库能正常打开,你发现不了。正确做法:用 .backup 命令,或停服务后三个文件一起拷 + 校验 md5。

坑 6 · 把库放在网络盘上

WAL 依赖本机文件锁(-shm)。NFS/SMB 上文件锁不可靠,会真的把库写坏。这也意味着「多台机器共享一个 SQLite 文件」在架构上就不成立——横向扩容必须换 PostgreSQL。

坑 7 · 靠「先查后插」做幂等

两个请求同时来,都查到「不存在」,然后都去插——应用层的 if 判断挡不住。真正的那道防线是数据库的唯一约束:第二个请求撞唯一约束失败,然后去读第一个写好的结果返回。所以幂等方案必须落在数据库上,不能只写在前端或内存里。

坑 8 · 外键约束没开

SQLite 外键默认关。不开的话「订单明细指向不存在的订单」这种脏数据能直接写进去,直到业务层才炸。必须显式 pragma('foreign_keys = ON')。

坑 9 · 没有幂等就上远程双签

任何「用户点一下、有可能重试」的写操作(提交订单、通过审批、提交报价)都要有幂等键。你的 order_no / po_no / wo_no / req_no 都是天然幂等键,用上它们。

✓

自查清单

  • ✓我知道 SQLite 是多读单写模型,判断标准是写入频率不是用户数
  • ✓我能说出三条该换 PostgreSQL 的信号:万级/秒写、多进程高频写、需要复杂并发控制
  • ✓我知道WAL 模式下库不能放网络盘,所以「多机共享一个库」架构上不成立
  • ✓我能画出共享锁/排他锁的三种组合:读读✅、读写❌、写写❌
  • ✓我知道三种粒度,并且知道 SQLite 的写锁是整库级(不是行级)——所以大事务哪怕只改一行也堵全库
  • ✓我能用转账例子讲出四种隔离级别分别会坏在哪,并说出脏读/不可重复读/幻读各自的大白话
  • ✓我知道隔离级别越高越慢,也知道 SQLite 的行为接近可串行化但读能并发(WAL 的价值)
  • ✓我知道调隔离级别解决不了丢更新——丢更新是业务逻辑问题,不是隔离问题
  • ✓我知道 SQLITE_BUSY 的成因是另一个连接持有写锁,而且默认 busy_timeout=0 是最大原因
  • ✓我会配 busy_timeout / synchronous / foreign_keys,并知道 synchronous=OFF 会损坏数据库
  • ✓我知道 -wal 和 -shm 各自是什么,-shm 是纯临时的可重建
  • ✓我知道 checkpoint 是把日志合并回主文件,默认 1000 页自动触发
  • ✓我知道备份必须连 -wal 一起拷,也知道服务运行时直接 cp 会拷出不一致的文件
  • ✓我知道 pm2 多进程是真实并发(LumenX 是 3 个),且每个进程各开连接会加剧竞争
  • ✓我知道全进程共用一个数据库连接,不每个请求新建
  • ✓我知道网络重试会造成重复下单(HTTP 是「至少一次」不是「恰好一次」)
  • ✓我能设计幂等键:优先用业务唯一键(order_no/po_no/wo_no/req_no),并且知道真正防线是唯一索引不是先查后插
  • ✓我知道幂等记录和业务写入必须同一事务,否则要么重复执行要么永久丢请求
  • ✓我会用并发压测脚本 + 对照实验复现丢更新(BUSY 多 = 锁竞争;数据不对 = 丢更新)
  • ✓我记住那条红线:不要用「加大事务」当万能药——它解决不了问题,只是把范围放大到全库
面试怎么答

题目:讲讲你的并发模型,以及你踩过什么并发坑。

先说我的技术选型和它的天花板在哪,这个我判断得很清楚。我的门店系统是 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 · 并发、锁与事务。