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

基础 17 · 并发、锁与事务

速查版:一屏看懂事务、锁、隔离级别、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 整行重写(把冲突面积放大到整行)——三者叠加,就是你踩过的那个并发坑。

01

一句话:并发是什么

并发就是「多件事同时发生」。事务和锁,是保证它们不打架的手段。

「同时」这个词在服务器上有两层意思,你要分清:①真同时(多个 CPU 核同时在跑,几纳秒级交错);②假同时(单个 CPU 在多个任务间快速切换,每个任务以为自己从头跑到尾、中间没人打断)。

为什么这个区分很重要:你代码里的「读-改-写」三步,在真并发下会交错,在假并发下也会交错——因为只要第一步和第三步之间有一个任务切换,中间状态就被别人看到了。所以单核 CPU 一样有并发 bug。「我是单线程的所以没事」是错的。
你项目里的并发具体长什么样:①4 家经销商门店同时下单(不同的人在不同时间点提交订单,时间上会撞);②远程双签(两个角色审同一张单子);③生产工单派单(派单员和领料员几乎同时操作同一张工单);④pm2 多进程同时写同一个 .db 文件。这些全是并发。
02

四句大白话

词一句话你项目里的对应物
事务 transaction一组操作,要么全成功,要么全回滚。不是「一起做」,是「同生共死」「一张订单的创建:写order 表 + 写 order_line 表 + 扣 inventory 表的locked——这三步要么全成,要么一条都不留」。中间失败如果只留了两条,库存就被白占了
锁 lock有人改的时候,别人先等。门上一把锁,进去的人先锁门,后面的人排队你项目的 inventory(qty 在库 / locked 已锁)就是业务层的锁:先锁住库存再下单,而不是卖出去才发现不够
隔离级别 isolation允许多大程度上「同时」。越高越安全,但越慢SQLite 的隔离是「读之间完全不互斥,写之间完全互斥」——相当于可串行化的读 + 单写者
WAL写日志再落盘。写进日志就算完成,读的还是旧快照——所以读的人不卡德莉玛门店系统 v2.5.0 开的那个模式。它把你的系统从「一个人写全员等」变成「读随便读,写排队」
再补两个你一定会遇到的词:死锁 deadlock——A 等 B 的锁,B 等 A 的锁,永久卡住;SQLITE_BUSY——另一个进程正在写,你要的锁拿不到。这两个怎么防、怎么排,见 深入版 17。
03

你的 WAL 到底解决了什么

这是本页最重要的一段。先讲没有 WAL 是什么样,再讲有了 WAL 是什么样。

没有 WAL
读写互相阻塞:一个人写,全员等着
SQLite 默认模式(叫 rollback journal /回滚日志)下,写的时候会给整库加一把锁。所以只要有一个请求在写,所有想读的请求都得排队等它写完。反过来,一个长查询(比如忘了加 LIMIT 的全表扫)也会把写堵住。
大白话:整个数据库像一间只有一个门的仓库。有人进去搬货(写),门口就得排队,连进去看一眼的人(读)都得等。
有了 WAL
写进日志就算完成,读的还是旧快照
WAL = Write-Ahead Log,预写日志。机制大白话:改数据的时候不直接改原文件,而是先在旁边的日志文件(xxx.db-wal)里追加一条「把第N 页改成什么」。追加完就算这次写成功了——数据库能马上开始处理下一个读请求,而这个读请求看到的还是「修改之前」的旧数据(叫快照),完全一致,不会看到改了一半的样子。
所以读和写可以同时进行:写的在旁边记日志,读的从原文件读。两个不打架。
WAL 给你换来的三件事:①读不阻塞写(查询报表不会把下单卡住);②写不阻塞读(有人在提交订单时,别人的列表页照样刷得出来);③写入更快(顺序追加日志比随机改原文件对磁盘更友好,因为不需要频繁 fsync刷整个库)。
但 WAL 也有代价,三条必须知道:
① 同一时刻只能有一个写者。WAL 解决的是「读写互不阻塞」,它没有解决「两个写同时来」。两个写还是要排队,后来的那个会拿到 SQLITE_BUSY。所以「开了 WAL 就没有并发问题了」是错的。
② 会产生 -wal 和 -shm 两个文件,而且不会自动消失。WAL 模式下最新数据可能还在 -wal 里没合并回主文件。所以备份必须把三个文件一起拷,只拷 .db 会丢最近的数据。
③ 依赖同一台机器的本地磁盘,不能放在网络文件系统上。WAL 靠本机文件锁工作,NFS / SMB 这类网络盘上文件锁不可靠,会真的把库搞坏。所以 WAL 模式下不能做「数据库文件放共享盘、多台机器连同一个库」。
// 开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');      // 外键约束默认是关的,必须手动开
04

「并发串行」到底怎么回事(你的真实踩坑)

成因一句话: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 → 少卖了一件
这个现象叫丢更新(lost update)。数据库明明执行了两次 UPDATE,日志里两条都在,但第二条把第一条的效果吃掉了。它比报错可怕得多——不报错、不告警、数据悄悄少一件,等月底盘点才发现。

三种解法

1

加锁(最通用,但最麻烦)

读之前先拿锁,拿不到就等。要么用应用层的锁(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 请求」「等用户确认」这种事。

2

用数据库的原子操作(最推荐,最短最可靠)

让数据库自己算,不要把值读出来再算完写回去。因为数据库执行单条 UPDATE 是原子的(不会被打断):

-- ✓ 正确:让数据库自己减,且加条件防超卖
UPDATE inventory
SET qty = qty - 1,
    updated_at = ?,
WHERE sku = ? AND qty >= 1;
-- 影响行数 = 1 → 成功扣减
-- 影响行数 = 0 → 库存不足,直接拒绝(连超卖的漏洞都没有)

为什么这个方案最好:整个操作只有一条语句,不可能被插进来。数据库保证「读到最新的值 → 减1 → 写回」是一口气完成的,中间不会有第二个请求插进来。代码更短,性能更好,还顺手防了超卖。记住这条原则:能用一条 SQL 表达的并发控制,就不要用三步 JS。

3

乐观锁(适合冲突少、且要告诉用户「数据变了」)

不改数据,而是在读的时候把版本号记下来,写的时候检查版本号变了没有。变了说明有人抢先改过,这次操作失败,让用户重新看一次。

// 读的时候把 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。

你的 schema 里本来就有解法 3 的材料:inventory 有 qty(在库)和 locked(已锁)两个字段,这就是「两级库存 + 业务锁」的设计——下单时先把 locked 加 1(占住),生产领料时再从 qty 里真正扣。而 inventory_movement(流水表)记 type 为 lock/unlock,就是这套机制的账本。业务层的锁 + 数据库层的原子操作,两层一起用,这套设计在并发下是站得住的。
05

什么时候会并发

场景你项目里的具体例子后果
多用户同时点4 家经销商门店同时下单——A 门店在提交订单,B 门店在同一秒也提交如果同时扣同一个 SKU 的库存,丢更新
双人同时审批远程双签:区域经理和工厂两个人同时审同一张单两个人都通过了(该驳回的被放行);或者两个请求都写 reviewed 状态,后写的覆盖先写的审批意见
批量脚本 + 页面同时操作你跑数据导入 / seed / 迁移脚本的同时,门店的人还在页面上操作导入脚本拿长事务锁住表,页面所有写操作报 SQLITE_BUSY。这是你做数据迁移时最常踩的
定时任务 + 页面定时结算 / 定时同步物料,和店员手动下单撞上同上也报 BUSY,或者数据被互相覆盖
多进程同时写(最容易忽略)pm2 起多个进程,进程之间没有任何「我正在写」的默契这是 SQLite 的硬限制:不管你几个进程,同一时刻只有一个能写,其他全拿不到锁。LumenX 是 3 个 pm2 进程
最容易漏的是最后一条:你以为「单机单进程」就没有并发,但pm2 cluster / pm2 -i 3 就是多进程。每个进程各自连自己的 SQLite 连接,彼此完全不知道对方正在写。而 SQLite 只允许一个写者,于是必然有进程撞上 SQLITE_BUSY。这是 SQLite 的天花板,不是你代码写错了。
06

事务边界怎么划

原则只有一句:一次业务操作 = 一个事务。

「业务操作」是从用户角度看是一件事的那个粒度。「创建一张订单」是一件事:写订单主表 + 写明细行 + 锁库存。这三步必须在同一个事务里,因为它们要么一起成功要么一起不算。但「导入 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,人工/脚本重试
大事务会锁表——这是你做数据迁移必须记住的一条。WAL 模式下写锁是库级的(不是行级),所以只要有一个大事务在跑,整个库的写操作全排队。你上次做迁移时如果出现过「跑脚本的时候页面上所有人都点不动」,就是这条在起作用。所以迁移要拆批:一批 500条,每批一个事务,批与批之间 sleep 一下,让页面有机会插进来。
另一个反面:事务里绝不能放这些——①发 HTTP 请求(网络可能等 3 秒,锁就持有3 秒);②等用户确认(可能等几分钟,锁就一直拿着,别人全卡死);③写文件 / 读大文件;④复杂循环里的每一轮都提交(那就等于没事务)。事务里只该有「读写数据库」这几十毫秒的事。
07

常见坑 / 红线

坑 1 · 以为有 WAL 就没并发问题

WAL 只解决「读和写互相阻塞」,不解决「两个写同时来」。WAL 模式下同一时刻仍然只有一个写者,第二个写依然会撞 SQLITE_BUSY。把 WAL 当成并发问题的终点,是最常见的误解。

坑 2 · 读-改-写不自知

见第 04 节。这是你的真实事故:AI 生成的代码把 SELECT → 判断 → UPDATE 当成一步,实际三步之间会被插入。症状是数据悄悄少一点,不报错。防范动作:写代码时凡是「先查出来再算再写回去」,都要停下来想一句「这两步之间别人插进来会怎样」。能改成一条 SQL 就改。

坑 3 · 把大事务当保护伞

「套个大事务不就安全了」——安全是真的,但代价是整库被锁住。WAL 下写锁是库级的,大事务一跑,页面上所有人的写操作全排队,表现为「系统卡了」。正确做法是「小事务 + 失败单独处理 + 可重跑」。

坑 4 · 以为单线程 SQLite 就没锁问题

两个错叠加就出大事故:①「SQLite 是单线程的」——单个连接内部确实是串行的,但你的进程不止一个连接(请求之间各自开连接),而且 pm2 多进程各开各的;②「我只有一个用户」——你只有 4 家经销商门店,但并发不是按人数算的,是按「同一时刻有几个请求在处理中」算的。结果是 WAL 下多进程写会直接报 SQLITE_BUSY。

坑 5 · 备份不连 -wal 文件

WAL 模式下最新数据可能还在 xxx.db-wal 里没合并回 xxx.db。只拷主文件会丢掉最近的数据。要么三个文件一起拷,要么用 SQLite 自带的 .backup 命令(它会正确处理 WAL)。只拷 .db 还要小心:拷的过程中如果有写入发生,拷出来的主文件本身可能是不一致的。

坑 6 · 外键约束默认是关的

SQLite 为了兼容老应用,外键约束默认不开启。不开启的话,「订单明细指向一个不存在的订单」这种脏数据可以被正常写进去——数据库不拦,脏数据一路流到业务层才炸。必须显式开:db.pragma('foreign_keys = ON')。

✓

自查清单

  • ✓我知道并发不等于多核,单核一样有并发 bug(任务切换就会插进来)
  • ✓我能用一句话说清事务、锁、隔离级别、WAL 各是什么
  • ✓我知道没有 WAL 时读写互相阻塞,有 WAL 时读的是旧快照但不卡
  • ✓我知道 WAL 的代价:只能一个写者、会生成 -wal、备份必须连它一起拷
  • ✓我知道 WAL 模式下不能把库放网络共享盘(依赖本机文件锁)
  • ✓我能讲出丢更新的完整时间线(两个请求都读到 10,各自减1,都写回 9)
  • ✓我知道三种解法:加锁 / UPDATE ... SET qty = qty - 1(推荐) / 乐观锁版本号
  • ✓我知道为什么 collections 的 JSON blob 会让并发串行(整行重写 → 冲突面积从1 个字段变整行)
  • ✓我知道你项目里 qty/locked 双字段 + inventory_movement 流水 = 业务层锁 + 账本
  • ✓我能说出四种并发场景,特别是批量脚本 + 页面同时操作
  • ✓我知道pm2 多进程也是并发,LumenX 是 3 个进程
  • ✓我知道事务边界 = 一次业务操作,并且能给出「大事务 vs 每条一事务」的对比
  • ✓我知道大事务会锁整库(WAL 的写锁是库级),迁移要拆批 + sleep
  • ✓我知道事务里不能放 HTTP 请求、等用户确认、大文件操作
  • ✓知道要显式 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 会变成常态而不是偶发事件。