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

深入 · 数据库怎么存东西

深入版:速查版讲「有哪些表、SQL 怎么写」,这一篇讲「数据到底怎么落在磁盘上、为什么索引快、JSON blob 为什么会拖垮整库、出错时怎么救」。4KB 页、B+ 树、聚簇索引、丢更新、索引失效、JOIN 的两种语义——全部讲透。

项目锚点

两套真实数据库,都是 SQLite 家族:
① C:\Users\Administrator\WorkBuddy\delimao-system\packages\db——drizzle-orm 0.38 + drizzle-kit + @libsql/client,src/schema.ts 声明 27 张表,配 migrate.ts + seed.ts。
② 德莉玛门店订购单系统 v2.5.0——Express + better-sqlite3 + SQLite WAL 模式 + pm2。collections 表曾用单行 JSON blob,出过严重事故。

历史项目:2026-08-09-task-100(淋浴房 P3)better-sqlite3 13,2026-07-01-task-63/kb-api better-sqlite3 11。

再强调一次:全盘没有 MySQL / PostgreSQL 在跑。schema.ts 末尾确实有一段注释列了「迁到 PostgreSQL 时字段类型怎么映射」(integer→serial、real→doublePrecision…),但那是「将来要迁」的预案,不是现状。讲这一页时不要把它当成已有资产。
01

数据是怎么真的存到磁盘上的

先破一个幻觉:你以为数据库存的是「一个个像 Excel 行的东西」。不是。数据库文件里是一堆二进制块,你用文本编辑器打开只会看到乱码:

你项目里的产物:
  packages/db/xxx.db          ← 整个库就是一个文件
  门店系统 prod.db            ← 加上 prod.db-wal、prod.db-shm

$ head -c 120 xxx.db | xxd
00000000: 5351 4c69 7465 2066 6f72 6d61 7420 3300  SQLite format 3.← 文件头魔法字符串
00000010: 1000 0501 0400 4000 2000 2010 002d c62d  ← 页大小/ 各种头部参数
00000020: 0001 0000 0020 0020 0000 0005 0000 0000
00000030: 0000 0000 0000 0000 0000 0000 0000 0000
开头那16 个字节就是身份证明:SQLite format 3\0。你可以用任何文本编辑器打开 .db,只要看第一行是不是这串东西,就知道它是不是 SQLite 库——这是最快的类型识别法。

页 page:数据库的最小读写单位

三个词先解释
  • 页 page:数据库把磁盘切成一块块固定大小,SQLite 默认 4KB 一页。读写都以页为单位——哪怕你只要 10 个字节,数据库也得把整页 4KB 读进内存。改一个字段,得把这一页读出来、改、再整页写回。
  • 行 row:页里的一行数据。一行可以跨页(字段特别长时)。
  • 页头 page header:每页开头 8 个字节,记录这一页存了什么类型的叶子节点或内部节点。

这个 4KB 的设计解释了后面所有性能现象:

  • 数据多了,同一个查询会变慢——因为要读的页变多了,磁盘要转的圈变多了。
  • 「把整坨东西塞进一个字段」特别贵——那个字段一改,包含它的所有页都要重写。这就是 JSON blob 崩库的第一层原因。
  • 索引建在页的层面上才有效——因为索引本身就是一棵以页为单位组织的树。

为什么不能用「文件夹 + 文本文件」当数据库

需求文件夹 + 文本文件为什么不行
并发两个人同时改同一个文件,后写的直接覆盖先写的。没有「读到一份一致的中间状态」这个概念
崩溃恢复写到一半断电,文件处于「写了一半」的中间状态。要么全丢要么全留,你无法控制
事务「扣款成功但入账失败」这种一半一半的操作,在文件方案里没有机制能撤回
查询要按某个字段找,得把所有文件全读一遍,进内存后再筛。文件一多就崩
数据库的答案叫「页 + 日志 + 锁 + 索引」四件套:页让读写有固定单位;日志(-wal)保证崩溃能恢复;锁保证并发不打架;索引保证查得快。缺任何一样都不叫数据库。
02

B+ 树索引原理:为什么索引快

先打比方:查字典有两种方式。第一种是从头翻到尾,一页一页找——这就是全表扫描,10 万个字就得翻 10 万次。第二种是用目录页:先翻目录知道「G 在第 300 页」,再翻到第 300 页里找。翻几次就够了。

数据库用的就是第二种,而且比字典更严格:它要求翻的次数固定。这叫 B+ 树(一种多路平衡查找树):

根节点第 1 层只有几个分支指针:「小于 1000 往左,1000~2000 往中,大于 2000 往右」
内部节点第 2 层「1000~2000」再分成三段,每段记着一段范围的页号
内部节点第 3 层继续二分,每层范围缩小约一半
叶子节点第 4 层最终定位到具体那一行(叶子节点之间用链表串起来,所以范围查询很快)
关键性质叫「树高固定」。1000 行、10 万行、1 亿行,查询都只需要 3~4 层——因为每个节点能挂很多子节点(扇出大),树长得又矮又胖。
数学上这叫 O(log n):n 涨 10 倍,查询次数只多一两步。反过来,没有索引就是 O(n)——数据涨 10 倍,查询时间也涨 10 倍。这就是「越用越慢」的数学原因。

聚簇索引:数据本身按主键顺序放

术语解释
  • 聚簇索引:索引和数据是同一份东西——叶子节点里直接存着完整那一行数据,不是「指向别处的地址」。
  • 在 SQLite / MySQL InnoDB 里,主键就是聚簇索引:整张表的数据物理上按主键顺序排列存放。
按什么查要跳几次为什么
按主键查(如 WHERE order.id = 42)1 次定位数据就在索引叶子节点上,直接读到行
按非主键查(如 WHERE order.order_no = 'DL2026...')2 次定位先在唯一索引里找到主键值 → 再拿这个主键去聚簇索引里找一次。这一步叫「回表」
无索引字段查全表扫描没有索引可走,只能一页一页扫,扫出 10 万行再一行行判断
这就是主键必须是 integer 自增的第二个理由(第一个是省空间)。自增主键是顺序写入的,新行永远追加到最末尾 → 索引页永远只在尾部追加,不会「插到中间把页撑爆再重排」。而随机 UUID 会到处插入,导致索引页反复分裂和重排,写性能掉一个数量级。「按主键取最新 20 条」也只在自增主键下才能靠「顺序读最后」实现——随机主键做不到。
03

为什么 JSON blob 会拖垮整库(最硬的一段)

这是你项目里真实出过的事故:德莉玛门店系统的 collections 表曾经把一整个对象存成一个字段(单行 JSON blob)。动机很好理解——「对象里字段那么多,一行存完多简单」。下面把机制逐层讲透。

1

改一个字段要重写整行 → 一行坏了,全库跟着遭殃

字段是 REAL 或 TEXT 时,改它只需要在原位置写新的字节,同页其他内容不动。但字段是 JSON blob 时,「改一个字段」意味着重新 JSON.stringify 出一个全新的、更长或更短的字符串——原来那一整段内容全部作废,要写一遍新的。而一个对象序列化后可能好几 KB,横跨好几页。
后果链条:字符串长度变了 → 后面的字节要整体前移/后移 → 涉及的所有页都要重写。这个过程中只要任何一页写失败(断电、进程被 kill、磁盘满),这行 JSON 就被截断在中间。
最致命的一点:截断的 JSON 是非法 JSON,JSON.parse 直接抛异常。而表里这一行读不出来,整个查询接口就报错了——连带同一查询里其他完全正常的数据也一起取不到。这就是「写坏整库就崩」的字面含义:不是「数据丢了」,是「一行坏 → 整个功能挂」。

2

并发写同一行 → 互相覆盖(丢更新 lost update)

blob 字段有个隐蔽的致命问题:「读出来 → 改一个字段 → 整个写回去」这三步不是一体的。两个请求同时来:

请求A 时刻 t1: 读到整行 JSON = { qty: 10, name: "H4-T52" }
请求 B 时刻 t1: 也读到同一份        = { qty: 10, name: "H4-T52" }   ← B 读到的是旧快照

请求 A 时刻 t2: 把 qty 改成 9,把整个对象写回 → { qty: 9, name: "H4-T52" }
请求 B 时刻 t3: 只改了 name,然后把整个对象写回→ { qty: 10, name: "H4-T60 改过" }
                  ↑ B 手里的 qty 还是 10 !它把 A 的改动覆盖掉了

结果是 A 的 qty 从 10 变成 9 这个修改凭空消失了——多卖了一件,或者少记了一笔。这个现象叫丢更新(lost update),是「读-改-写」模式的通病,跟你用不用 JSON 无关,但整行重写把它的后果放大了:因为覆盖的不只是一个字段,而是整行的所有字段。

关键对比:如果 qty 是独立的一列,UPDATE ... SET name='...' WHERE id=1 只会写 name 那一列,qty 那列的字节根本不动,所以覆盖不了。

3

没法索引、没法 WHERE → 只能全表扫 + 应用层解 JSON

这是最影响日常开发体验的一条。SQLite 的索引只能建在「列」上,不能建在「JSON 字符串里的某个键」上(它连 JSON 内部结构都当字节看)。所以:

-- 想要的功能:找 name 是 H4-T52 的那条
SELECT * FROM collections WHERE ??? = 'H4-T52';   ← 写不出来。name 在 JSON 里面,不是列

-- 你只能这么写(真实会变成的样子):
SELECT * FROM collections;              -- 全表扫,把所有行的 JSON 拉出来
-- 然后在 Node 里:
rows.map(r => JSON.parse(r.payload)).filter(o => o.name === 'H4-T52');
                                       ↑ 全在应用层过滤,数据库帮不上任何忙

后果有三条,全是硬伤:①性能随数据量线性恶化;②分页失效(LIMIT 10 OFFSET 100 先截的是没过滤的原始行,分页结果会错乱);③排序和统计做不了(按数量排序、按型号分组统计,都得把全量拉进内存用 JS 算,数据量大直接内存爆掉)。

4

扩展性归零:加字段不用改表,但也没人知道有哪些字段

blob唯一的好处是「加字段不用改表结构」。但代价是这个好处基本没用:字段没定义在 schema 里,数据库就不知道有哪些字段、类型是什么、该不该有索引。查询只能靠字符串硬拼,拼错了数据库不报错,返回空结果让你在应用层debug。
而 drizzle 那一层的类型保护也失效了——你的项目用 drizzle 0.38 换来「写错字段名编译期报错」,但 blob 字段传进来的是 jsonb<unknown>,检查不到里面的东西。

规范拆表 vs JSON blob:五个维度对比

维度规范拆表(每字段一列)JSON blob(整对象一行)
查询✅ WHERE 直接用列名,能走索引、O(log n)❌ 只能全表扫 + 应用层 JSON.parse 过滤,O(n) 且数据全进内存
更新✅ 只重写被改的那几列,同页其他字节不动❌ 整行重新序列化 + 重写,跨页、字段越多越慢,越容易半途损坏
并发✅ 行级锁 + 只改一列,丢更新范围被限制在一个字段❌ 整行互斥,并发直接串行;读-改-写覆盖整行所有字段
完整性✅ 类型、外键、notNull、unique 全部由数据库强制❌ 全靠应用自觉,坏 JSON 只有 parse 时才发现,一坏就整功能挂
扩展性⚠️ 加字段要改 schema(但有迁移流程兜着)✅ 加字段不用改表
❌ 但代价是上面四项全丢,且没人知道字段有哪些
这一页的结论句,记住它:数据库是用来存「可独立查询的最小单元」的,不是用来存「一坨结构」的。
判断标准只有一条:这个字段需不需要出现在 WHERE、JOIN、ORDER BY 里?需要 → 独立成列。不需要(只整体读出来给人看)→ 可以留JSON。
你项目里的对照:order_line.bom_data(拆单结果缓存)、bom_rule.rule(规则结构)、std_parts.data(9 类标准件,字段结构完全统一)留在 JSON 里是对的——这些数据只整体读出来算一次,从不单独按某个字段查。反过来,series.profile_color(颜色数组)也是 blob,但那是因为颜色天然是「一组值」而不是「一个可查的条件」。而 sku.category、sku.route 就必须是独立列——你会按它们筛选。
04

索引的代价与失效场景

先说代价:索引不是越多越好。
代价机制什么时候会痛
拖慢写入每插一行,每个索引都得同步更新一遍批量导入 / 大量 inventory_movement 流水写入时。索引越多,插入越慢
占空间索引是独立的数据结构,每份都要存一遍列值索引列很宽(长字符串)时,索引可能比表本身还大
拖慢删除删一行要把它从所有索引里摘掉高频增删的表(流水表)索引要少
优化器可能选错数据分布不均时,优化器可能选了索引反而比全表扫慢某个条件命中 95% 的行时,走索引要跳很多次不如直接扫。SQLite 有时会自动忽略索引
实践原则:先按业务查询建索引,别预防性地到处建。你项目里的索引数量很克制,这是对的——只有 product_model_code_idx、series_sku_default_idx、inventory_sku_wh_idx 这三个手写索引,加上 unique() 自动带的。而 order_line.order_id 这种外键列虽然没写索引,但 SQLite 会自动为外键列建索引(外键约束需要反向查子表),所以order_line 按 order_id 查明细是快的。

索引失效的四种场景(最实用的排查知识)

索引建了却没走,通常是下面四种之一。共同特征:优化器判断「走索引不划算」或者「根本没法用索引定位」,于是退回全表扫。

场景写错的 SQL为什么失效
① 对索引字段做函数运算WHERE LOWER(model) = 'h4-t52'
WHERE YEAR(created_at) = 2026
索引里存的是字段的原值('H4-T52')。数据库要先算函数才知道等于什么,算不出索引里的东西。正解:改成对列加函数或用函数索引,比如给 created_at 建一个存年月分量的索引列
② 隐式类型转换WHERE sku = 123(而 sku 是 TEXT列)SQLite 要做类型转换才能比较,而转换方向和索引里的存储类型对不上,索引用不了。而且这种错很隐蔽——它能查出结果,只是慢,所以你不会立刻发现。正解:参数类型和列类型保持一致,字符串就传字符串
③ 前导模糊匹配WHERE model LIKE '%T52'这是最常见的失效原因。% 代表「任意多个字符」,意味着可能是任何值('H4-T52'、'X-T52'、'ABC-T52'…),数据库没法用索引定位起点,只能全扫。正解:改成后缀匹配 LIKE 'H4-%'(% 在末尾,数据库能从前缀开始定位,索引可用)
④ OR 的某一边没索引WHERE model = 'H4-T52' OR note = '急'OR 连接的每个条件都要能用索引才能整体用上。只要有一边用不了,整体就退化成全表扫。正解:拆成两条 SQL(UNION)分别走索引,或者给 note 也建索引
-- 怎么自己验证「索引到底走没走」
EXPLAIN QUERY PLAN
SELECT * FROM order_line WHERE model = 'H4-T52';
-- 输出里看这两个关键字:
--   SEARCH  ... USING INDEXxxx   → 走索引了(好)
--   SCAN xxx                    → 全表扫描(该建索引)
一句话记忆:索引只认「列的原值」和「确定的前缀」。只要你的条件需要先算一下才能比较,索引就废了一半。
05

事务与 ACID(速查级,详见 17 系列)

事务 transaction:一组数据库操作,要么全部成功,要么全部撤回,一个字都不留。不是「一起执行」,而是「同生共死」。

ACID 四个字母的大白话
  • A 原子性 Atomicity:要么全做,要么全不做。不会出现「做了三步,第四步失败」这种半吊子状态
  • C 一致性 Consistency:做完后数据还得是合法的。比如库存不能变成负数、订单状态不能从 done 跳回 draft
  • I 隔离性 Isolation:多个事务同时改数据,互相看不见中间状态。A 改的东西,B 在 A 提交前看不到
  • D 持久性 Durability:一旦提交成功,就真的写下去了。断电、重启、崩溃,都不会丢

用同一个「转账」例子讲清四件事:你账户 100 元,给朋友转50 元。这需要两步:①你的余额 100→50、②朋友余额 0→50。

字母如果做不到会怎样
A 原子性第① 步做完,进程崩了,第 ② 步没做 → 你的钱凭空蒸发了。这就是「扣款成功但入账失败」,它破坏的就是原子性
C 一致性转账转完,总额应该还是一样(你少了多少朋友就多了多少)。如果中途有第三笔操作插进来导致总额变了,钱凭空多出来
I 隔离性两个人同时转你,你读到的是「转出之前」的余额,于是两笔都成功,各自都基于旧余额扣 → 超支
D 持久性你看到「转账成功」的提示,结果断电之后钱回滚了 → 用户投诉、数据对不上
⚠️ 这里只是速查级。隔离级别怎么分、脏读/不可重复读/幻读是什么、SQLITE_BUSY 怎么处理、事务边界该划在哪、多进程访问 SQLite 会不会打架——这些都在并发专题:速查版 17 · 并发、锁与事务 和 深入版 17 · 并发、锁与事务。
SQLite 的特殊之处:它默认把整个事务当成一个大事务,而且事务类型只有两种:DEFERRED(默认,延迟到第一次读才真正开始拿锁)和 IMMEDIATE(一开始就拿写锁)。你的门店系统写了WAL 模式,而 WAL 有个关键特性:读和写可以同时进行——详细机制在 17 深入版。
06

JOIN 怎么看:INNER vs LEFT

JOIN 的作用:把两张表「按某个共同字段拼在一起」再查。你项目里 4 家经销商门店的订单要带上每一行商品明细,就要 JOIN order 和 order_line。

-- 真实场景:查某经销商门店的订单,每行带上商品明细
SELECT
  o.order_no,          -- 订单号(来自 order)
  o.status,             -- 订单状态(来自 order)
  d.name  AS dealer_name,-- 门店名(来自 dealer)
  ol.model,             -- 型号(来自 order_line)
  ol.spec,              -- 规格
  ol.qty,               -- 数量
  ol.line_max           -- 行金额上限
FROM order o
JOIN dealer  d  ON d.id = o.dealer_id      -- ① 门店
JOIN order_line ol ON ol.id = o.order_line_id  -- ② 明细(写错了!这行会报错)
WHERE d.code = 'DL01'
ORDER BY o.created_at DESC, ol.id;
⚠️ 上面那段 SQL 第② 个 JOIN 是我故意写错的——order_line 连的是 order.id,不是 order_line.id(order_line.id 正是 order_line 自己的主键)。真实写法是 JOIN order_line ol ON ol.order_id = o.id。
顺带说一个真实存在的字段:你 schema 里 order_line 有 area(面积)、unit_min/unit_max(单价区间)、line_min/line_max(行金额区间),之所以不用一个 price 字段,正是因为价格是「区间」——同一个型号不同面积单价不同,所以用下限/上限两个 REAL 列,而不是把区间塞成一个字段。这是「该拆的拆」的正面例子。
JOIN 类型大白话什么时候用(你的场景)
INNER JOIN
(JOIN 默认就是)
两边都有才给我。左表有一行右表配不上,这行直接消失查「有明细的订单」——我就是要看有商品的订单,没商品的(刚提交还没拆单)这次不关心
LEFT JOIN左表全给我,右表配不上就填 NULL(「没有也要显示」)查「所有订单,包括还没填明细的」。刚提交的订单 order_line 里还没有对应行,用 INNER JOIN 的话这张订单会凭空消失——而这恰恰是「订单列表页漏单」的常见原因
-- LEFT JOIN 版本:订单一张都不漏,缺明细的显示为 NULL
SELECT o.order_no, o.status, ol.model, ol.qty
FROM order o
JOIN dealer d ON d.id = o.dealer_id
LEFT JOIN order_line ol ON ol.order_id = o.id   -- ← 关键是这里 LEFT
WHERE d.code = 'DL01'
ORDER BY o.created_at DESC;
记住这个区分:INNER JOIN = 筛掉不完整的;LEFT JOIN = 保留左边的全部,右边缺就是 NULL。
选哪个看你的业务问题:要「完整清单」用 LEFT JOIN,要「有对应项的数据」用 INNER JOIN。订单列表页通常用 LEFT JOIN(每张订单都要看到),而统计「哪些订单已经拆过单」用 INNER JOIN。
LEFT JOIN 的一个坑:在 WHERE 里给右表加条件,会让它退化成 INNER JOIN。比如 WHERE ol.model = 'H4-T52' ——对没有明细的行,ol.model 是 NULL,NULL = 'H4-T52' 不成立,那些行还是被过滤掉了。正确做法是把右表的条件写进 ON 子句:LEFT JOIN order_line ol ON ol.order_id = o.id AND ol.model = 'H4-T52'。
07

drizzle 到底帮你干什么,什么时候该手写 SQL

ORM 这个词的大白话

ORM = Object-Relational Mapping,中文叫「对象关系映射」。它的作用是让你用「操作对象」的方式间接操作数据库,而不用手写字符串SQL。注意:它生成的仍然是 SQL,你只是换了个写法去描述它。

drizzle 帮你干的具体是什么
拼 SQL你写 db.select().from(order).where(eq(order.status, 'done')),它给你拼成 SELECT * FROM "order" WHERE "status" = ?
类型提示order.orderNo 而不是字符串 'order_no'。写错字段名 TypeScript 当场报错,不用等数据库报错
防SQL 注入这是 ORM 最值钱的安全价值。你写 eq(order.status, userInput),它生成 WHERE status = ? 并把值单独传。而你手写 `WHERE status = '${userInput}'` 就是把用户输入直接拼进 SQL——别人传 ' OR '1'='1 就能把整张表拖出来
结果带类型查出来的行,字段类型从数据库定义直接推导,不用自己声明接口
它的代价具体是什么
复杂查询反而更难写三张表关联 + 分组 + 聚合 + 窗口函数,drizzle 的链式调用能写出来,但你自己都读不下去。这时候一条 db.all(sql\`...\`) 里的原生 SQL 反而更清楚
排障必须懂 SQL线上报no such column、查询慢了 10 倍,你怎么定位?你必须能读懂它生成的那条 SQL,才能判断是索引问题还是写法问题。不懂 SQL 的人用 ORM,出问题时完全瞎的
抽象会遮蔽细节新人不小心就能写出 N+1 次查询(循环里查一次数据库),ORM 不阻止你,只是让你更容易写出来
什么时候该手写 SQL(用 drizzle 的 sql 模板,它一样帮你参数化):①三张表以上关联 + 聚合/分组(报表类需求);②需要索引提示或EXPLAIN 调优;③需要 SQLite 特有能力(INSERT OR REPLACE、PRAGMA、UPSERT);④递归查询(组织架构这类层级数据);⑤批量导入(拼一条多值 INSERT 比循环快几十倍)。
你项目已经有这个混合模式:schema.ts 里的 jsonb 是 customType(自定义类型),std_parts 这种「整对象一列」的场景靠它实现——这说明你已经在用 ORM 的扩展位,而不是被 ORM 的抽象绑住。这是健康的使用方式。
08

迁移的正确姿势:五步,顺序不能乱

改 schema.ts 之后到线上生效,中间有五步。顺序错了的代价是「恢复不了原来的数据」。

1

备份 + 校验(永远第一步,绝不能省)

迁移会改数据结构,改坏了你得有东西能退回去。你项目的验证方式是 cp 拷贝 + md5 校验——拷贝完必须比一下md5,因为拷贝过程本身也可能断、也可能写了一半,不校验等于没备份:

# ① 停服务(必须先停,不然 WAL 里的数据没落盘)
pm2 stop all

# ② 拷贝(WAL 模式下必须连 -wal 一起拷,详见 17 深入版)
cp xxx.db   backup/xxx.db.$(date +%Y%m%d_%H%M%S)
cp xxx.db-wal backup/xxx.db-wal.$(date +%Y%m%d_%H%M%S)

# ③ 校验:两个 md5 必须一致
md5sum xxx.db   backup/xxx.db.20261002_143000
# xxx.db= a1b2c3d4e5f6...
# backup/xxx.db.20261002_143000 = a1b2c3d4e5f6...   ← 一致才算备份成功

⚠️ 只拷 .db 不拷 -wal 是最常见的备份错误——WAL 模式下最新数据可能还在 -wal 里没合并回主文件,只拷主文件会丢掉最近的数据。详见 17 深入版。

2

npm run db:generate —— 生成迁移文件

drizzle-kit generate对比 schema 和数据库现状,算出差异,生成带时间戳的迁移文件。这一步不碰数据库,可以随便跑。

3

看一眼迁移文件(这一步最容易被跳过,也最不该跳)

打开生成的文件,里面就是真正的 ALTER TABLE。你必须亲眼确认它要做什么:有没有意外 DROP 掉一整列?有没有把有数据的列改成 NOT NULL(而老数据是NULL)导致失败?
看到 DROP COLUMN 或 DROP TABLE 必须停下来——那意味着删数据,而迁移文件一旦执行没有撤销(只能靠第 1 步的备份)。

4

npm run db:migrate —— 执行迁移

tsx src/migrate.ts 按顺序执行没跑过的迁移文件,并记录已执行。这一步才真正改库。

5

对账 —— 验证数据没丢

迁移后不是看一眼「没报错」就完事,要实际查:

-- 关键表的行数应该和迁移前一致(除了本来就要变的那张)
SELECT COUNT(*) FROM order;SELECT COUNT(*) FROM order_line;
SELECT COUNT(*) FROM sku;

-- spot-check:随手抽几个关键字段看内容对不对
SELECT order_no, status, total_min FROM order ORDER BY id DESC LIMIT 5;

对不上的话,立刻从第 1 步的备份恢复,别试图「补救」——补出来的数据更脏。

生产库迁移的红线:①迁移前必须备份,而且必须校验 md5,不校验等于没备;②必须先看迁移文件内容再执行;③生产迁移前先在开发库跑一遍同样的迁移,确认能跑通;④删字段前先把数据导出成 CSV 留档——「删字段不留数据」是你 schema 演进里最容易后悔的操作,因为 SQLite 没有 ALTER COLUMN,删列通常伴随重建表。
09

常见坑 / 红线

坑 1 · 把整个对象塞进一个字段

见第 03 节全篇。一句话:改一个字段重写整行 → 坏一行整个功能挂;没法 WHERE → 只能全表扫;整行互斥 → 并发串行。

坑 2 · 外键列没有索引

按外键查是最常见的查询动作(「查这张订单的所有明细」就是 WHERE order_id = ?)。没索引就是全表扫。好消息:SQLite 外键约束会自动给外键列建索引,所以你项目里的外键列基本都有索引。但如果你只用 drizzle 的类型声明而没写 .references(),数据库层面就没有外键,也就不会有自动索引——「看起来是外键」和「数据库里真的是外键」是两件事。

坑 3 · 主键用随机字符串

见第 02 节。两个后果:①数据物理顺序乱,「按主键取最新 N 条」实现不了;②索引页反复分裂重排,写性能掉一个数量级。你27 张表里 25 张用自增 integer,只留两个 text 主键(param.key、std_parts.part_key,因为它们天然有唯一编码),这个判断是对的。

坑 4 · 迁移前不备份

迁移脚本写错一个字段名、或者生成的文件里有意外的 DROP,执行完就回不去了。你项目验证过 cp + md5 的方式,但一定要每次都做——「上次做过所以这次不用」是事故的标准剧本。

坑 5 · 删字段不留数据

SQLite 没有 ALTER COLUMN,也没有 DROP COLUMN 的完整支持——要改列类型或删列,SQLite 的做法是「建一张新表 → 拷数据 → 删旧表 → 改名」。这个过程一旦中途失败,表就停在「新旧都在」的状态。所以删字段前的CSV 导出留档不是保守,是必需。

坑 6 · 时区没统一

你项目的门店在中山、深圳,都在同一时区(UTC+8),所以跨时区这个坑你暂时踩不到。但有两件事仍要注意:①统一存储约定——你 schema 里时间戳统一用 integer({ mode: 'timestamp' }),这是存秒级Unix 时间戳(UTC 基准),本身就没有时区问题,这是对的;②别在数据库里存本地时间字符串(比如 '2026-10-02 15:30:00'),因为这种字符串含义依赖「谁写的」,跨机器或改时区就乱。存timestamp、显示时才转本地时间。

✓

自查清单

  • ✓我知道 .db 文件开头16 字节是 SQLite format 3\0,用文本编辑器打开是乱码
  • ✓我知道 SQLite 默认页大小 4KB,读写都以页为单位
  • ✓我能说清为什么「文件夹 + 文本文件」不能当数据库(并发/崩溃恢复/事务/查询四件事)
  • ✓我知道 B+ 树树高固定,百万行也只要 3~4 层,这叫 O(log n)
  • ✓我知道聚簇索引 = 索引和数据同一份,SQLite 主键就是聚簇索引
  • ✓我知道按非主键查要回表(两次定位),比按主键查慢一次
  • ✓我能把 JSON blob 崩库的四个机制讲全:重写整行 / 丢更新 / 无法索引与WHERE / 扩展性归零
  • ✓我知道索引不是越多越好:拖慢写入、占空间、拖慢删除、优化器可能选错
  • ✓我能说出索引失效的四种场景:函数运算 / 隐式类型转换 / LIKE '%x' 前导模糊 / OR 有一边没索引
  • ✓我会用 EXPLAIN QUERY PLAN 看是SEARCH ... USING INDEX(好)还是 SCAN(该建索引)
  • ✓我能用「转账」例子讲全 ACID 四条,尤其「扣款成功但入账失败」破坏的是原子性
  • ✓我知道 LEFT JOIN = 没有也要显示,且知道在 WHERE 里给右表加条件会让它退化成 INNER JOIN
  • ✓我知道 drizzle 的价值在类型提示 + 参数化防注入,代价是复杂查询难写、排障必须懂 SQL
  • ✓我知道什么时候该手写 SQL:多表关联+聚合、索引调优、PRAGMA、递归、批量导入
  • ✓我能背出迁移五步:备份+md5 → generate → 看迁移文件 → migrate → 对账
  • ✓我知道备份必须连 -wal 一起拷,而且不校验 md5 等于没备份
  • ✓我知道 SQLite 没有 ALTER COLUMN,改列/删列靠「建新表拷数据」,所以删字段前必须导出留档
面试怎么答

题目:数据库层面你踩过什么坑?怎么改的?

我先说清我的技术选型,免得后面讲的东西显得奇怪:我两套库都是 SQLite 家族——产供销一体化的数据层用 drizzle-orm 0.38 + libsql client,表结构集中在 packages/db/src/schema.ts,27 张表;门店订购单系统用 better-sqlite3 + WAL 模式。选 SQLite 是因为整个库就是一个文件,备份是拷文件、迁移服务器是拷文件。我的部署是 pm2 + 单机,不需要 MySQL/PostgreSQL 那一套。(注:schema 末尾有一段迁PostgreSQL 的字段映射注释,那是预案不是现状,我没把它当成已有资产讲。)

最严重的坑:门店系统的 collections 表把一整个对象存成单行 JSON blob。动机是「字段那么多,一行存完多简单」。结果出了四个层级的后果,我从底层机制说。

第一,改一个字段要重写整行。普通字段改的时候只写那几个字节,同页其他内容不动。但 blob字段是重新序列化出一个全新的字符串,原来那段全部作废。字符串长度一变,后面的字节要整体移位,涉及的所有页都要重写。这个过程中只要有一页写失败——断电、进程被 kill、磁盘满——这行 JSON 就截断在半路。而截断的 JSON 是非法 JSON,JSON.parse 直接抛异常,于是这个查询接口整个报错,连带同一次查询里其他完全正常的数据也一起取不到。这就是「写坏整库就崩」的字面含义:不是数据丢了,是一行坏导致整个功能挂。

第二,并发写会丢更新。因为 blob 的读写是「读出来 → 改字段 → 整个写回」三步,不是原子的。两个请求同时读到 {qty:10},A 改成 9 写回去,B 只改了 name 但手里还是qty=10,写回时把 A 的修改整个覆盖掉了。这个叫丢更新。如果 qty 是独立一列,UPDATE ... SET name=... 只写 name 那几字节,根本覆盖不了 qty——所以拆列不只让查询快,它把丢更新的影响范围从整行缩到一个字段。

第三,没法索引、没法 WHERE,只能全表扫 + 应用层过滤。SQLite 的索引只能建在列上,不能建在 JSON 内部。所以「找 name 是 H4-T52 的那条」SQL 写不出来,只能把全表拉出来在 Node 里 JSON.parse 再filter。连带分页失效、排序和分组统计做不了、数据量大直接内存爆。

第四,完整性归零 + 类型保护失效。字段不在 schema 里,数据库不知道有哪些字段、类型是什么、该不该有索引。drizzle 那层「写错字段名编译期报错」的保护也失效——因为 blob 字段的类型就是 jsonb<unknown>,里面检查不到任何东西。

我的改造思路是一条判断标准:这个字段需不需要出现在 WHERE / JOIN / ORDER BY 里?需要就必须独立成列。按这条标准,order_line.bom_data(拆单结果缓存)、bom_rule.rule(规则结构)、std_parts.data(9 类标准件,字段结构本来就统一)留在 JSON 里是对的——这些数据只整体读出来算一次,从不按单个字段查。而 sku.category、sku.route 就必须是独立列,因为会按它们筛选。改完之后查询走索引、写只改一列不再重写整行、并发也只锁被改的那几行。

索引方面我踩过「越用越慢」:开发时表小,WHERE sku = 'HINGE-H4002' 手到就来;数据涨到几万行,同一页面要几秒。回头看那个字段既没 unique 也没索引,数据库只能全表扫。我的判断纪律是任何出现在 WHERE 右边的列都要问一句「它有索引吗」。另外要记住索引不是越多越好——每插一行每个索引都要更新一遍,索引还会拖慢删除、占空间(索引列宽的时候索引可能比表还大)。而且索引有四种失效场景:对索引字段做函数运算、隐式类型转换(TEXT列传数字)、前导模糊 LIKE '%T52'、OR 有一边没索引。验证办法是 EXPLAIN QUERY PLAN:看到 SEARCH ... USING INDEX 是走索引了,看到 SCAN 就是没走。

迁移上我守一条纪律:改 schema.ts 之后必须 db:generate → db:migrate,顺序不能换。因为 schema.ts 只是图纸,不会自动改造已有的库。这两步之前还有一步不能省的:备份 + md5 校验——cp 完必须比 md5,不校验等于没备,因为拷贝过程本身也可能写一半。而且WAL 模式下必须连 -wal 一起拷,最新数据可能还在 WAL 里没合并回主文件。然后 generate 之后要亲眼看一眼迁移文件,看到 DROP TABLE / DROP COLUMN 必须停下来。最后 migrate 完还要对账:SELECT COUNT(*) 关键表行数没变、抽样看几行内容对不对。对不上立刻从备份恢复,不要试图补救,补出来的数据更脏。

另外两个我主动防的:①主键一律用 integer 自增,不用随机 UUID。因为自增是顺序追加,索引页只在尾部更新;随机 UUID 会到处插入导致索引页反复分裂重排,写性能掉一个数量级,而且「按主键取最新 N 条」也实现不了。所以 27 张表里 25 张自增,只留 param.key 和 std_parts.part_key 这种天生有唯一编码的用 text 主键。②删字段前先导出 CSV 留档,因为 SQLite 没有 ALTER COLUMN,删列改列的底层动作是「建新表→拷数据→删旧表→改名」,中途失败就会停在「新旧表都在」的状态。

最后说清我对ORM 的立场:drizzle 给我的三样东西是类型提示(写错字段名当场编译报错)、参数化查询(防注入)、结果带类型。它的代价是复杂查询反而更难写,而且排障必须真的懂 SQL——线上 no such column 或者查询慢了 10 倍,我得能读懂它生成的那条 SQL 才能定位。所以我的做法是混合:CRUD 走 drizzle,多表关联加聚合、需要 EXPLAIN 调优、批量导入的时候用它的 sql 模板手写原生SQL。顺带说,我们用 customType 自己实现了 SQLite 的 jsonb,说明我是把 ORM 当工具用,而不是被它的抽象绑住。

🔗 反向链接

想看一句话版本、六个核心词的速查表、27 张表的业务分层和「为什么订单要拆主表+明细表」,回速查版 16 · 数据库怎么存东西。