深入版:速查版讲「有哪些表、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。
schema.ts 末尾确实有一段注释列了「迁到 PostgreSQL 时字段类型怎么映射」(integer→serial、real→doublePrecision…),但那是「将来要迁」的预案,不是现状。讲这一页时不要把它当成已有资产。先破一个幻觉:你以为数据库存的是「一个个像 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
SQLite format 3\0。你可以用任何文本编辑器打开 .db,只要看第一行是不是这串东西,就知道它是不是 SQLite 库——这是最快的类型识别法。这个 4KB 的设计解释了后面所有性能现象:
| 需求 | 文件夹 + 文本文件为什么不行 |
|---|---|
| 并发 | 两个人同时改同一个文件,后写的直接覆盖先写的。没有「读到一份一致的中间状态」这个概念 |
| 崩溃恢复 | 写到一半断电,文件处于「写了一半」的中间状态。要么全丢要么全留,你无法控制 |
| 事务 | 「扣款成功但入账失败」这种一半一半的操作,在文件方案里没有机制能撤回 |
| 查询 | 要按某个字段找,得把所有文件全读一遍,进内存后再筛。文件一多就崩 |
-wal)保证崩溃能恢复;锁保证并发不打架;索引保证查得快。缺任何一样都不叫数据库。先打比方:查字典有两种方式。第一种是从头翻到尾,一页一页找——这就是全表扫描,10 万个字就得翻 10 万次。第二种是用目录页:先翻目录知道「G 在第 300 页」,再翻到第 300 页里找。翻几次就够了。
数据库用的就是第二种,而且比字典更严格:它要求翻的次数固定。这叫 B+ 树(一种多路平衡查找树):
| 按什么查 | 要跳几次 | 为什么 |
|---|---|---|
按主键查(如 WHERE order.id = 42) | 1 次定位 | 数据就在索引叶子节点上,直接读到行 |
按非主键查(如 WHERE order.order_no = 'DL2026...') | 2 次定位 | 先在唯一索引里找到主键值 → 再拿这个主键去聚簇索引里找一次。这一步叫「回表」 |
| 无索引字段查 | 全表扫描 | 没有索引可走,只能一页一页扫,扫出 10 万行再一行行判断 |
integer 自增的第二个理由(第一个是省空间)。自增主键是顺序写入的,新行永远追加到最末尾 → 索引页永远只在尾部追加,不会「插到中间把页撑爆再重排」。而随机 UUID 会到处插入,导致索引页反复分裂和重排,写性能掉一个数量级。「按主键取最新 20 条」也只在自增主键下才能靠「顺序读最后」实现——随机主键做不到。这是你项目里真实出过的事故:德莉玛门店系统的 collections 表曾经把一整个对象存成一个字段(单行 JSON blob)。动机很好理解——「对象里字段那么多,一行存完多简单」。下面把机制逐层讲透。
字段是 REAL 或 TEXT 时,改它只需要在原位置写新的字节,同页其他内容不动。但字段是 JSON blob 时,「改一个字段」意味着重新 JSON.stringify 出一个全新的、更长或更短的字符串——原来那一整段内容全部作废,要写一遍新的。而一个对象序列化后可能好几 KB,横跨好几页。
后果链条:字符串长度变了 → 后面的字节要整体前移/后移 → 涉及的所有页都要重写。这个过程中只要任何一页写失败(断电、进程被 kill、磁盘满),这行 JSON 就被截断在中间。
最致命的一点:截断的 JSON 是非法 JSON,JSON.parse 直接抛异常。而表里这一行读不出来,整个查询接口就报错了——连带同一查询里其他完全正常的数据也一起取不到。这就是「写坏整库就崩」的字面含义:不是「数据丢了」,是「一行坏 → 整个功能挂」。
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 那列的字节根本不动,所以覆盖不了。
这是最影响日常开发体验的一条。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 算,数据量大直接内存爆掉)。
blob唯一的好处是「加字段不用改表结构」。但代价是这个好处基本没用:字段没定义在 schema 里,数据库就不知道有哪些字段、类型是什么、该不该有索引。查询只能靠字符串硬拼,拼错了数据库不报错,返回空结果让你在应用层debug。
而 drizzle 那一层的类型保护也失效了——你的项目用 drizzle 0.38 换来「写错字段名编译期报错」,但 blob 字段传进来的是 jsonb<unknown>,检查不到里面的东西。
| 维度 | 规范拆表(每字段一列) | 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 就必须是独立列——你会按它们筛选。| 代价 | 机制 | 什么时候会痛 |
|---|---|---|
| 拖慢写入 | 每插一行,每个索引都得同步更新一遍 | 批量导入 / 大量 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 → 全表扫描(该建索引)
事务 transaction:一组数据库操作,要么全部成功,要么全部撤回,一个字都不留。不是「一起执行」,而是「同生共死」。
done 跳回 draft用同一个「转账」例子讲清四件事:你账户 100 元,给朋友转50 元。这需要两步:①你的余额 100→50、②朋友余额 0→50。
| 字母 | 如果做不到会怎样 |
|---|---|
| A 原子性 | 第① 步做完,进程崩了,第 ② 步没做 → 你的钱凭空蒸发了。这就是「扣款成功但入账失败」,它破坏的就是原子性 |
| C 一致性 | 转账转完,总额应该还是一样(你少了多少朋友就多了多少)。如果中途有第三笔操作插进来导致总额变了,钱凭空多出来 |
| I 隔离性 | 两个人同时转你,你读到的是「转出之前」的余额,于是两笔都成功,各自都基于旧余额扣 → 超支 |
| D 持久性 | 你看到「转账成功」的提示,结果断电之后钱回滚了 → 用户投诉、数据对不上 |
SQLITE_BUSY 怎么处理、事务边界该划在哪、多进程访问 SQLite 会不会打架——这些都在并发专题:速查版 17 · 并发、锁与事务 和 深入版 17 · 并发、锁与事务。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;
order_line 连的是 order.id,不是 order_line.id(order_line.id 正是 order_line 自己的主键)。真实写法是 JOIN order_line ol ON ol.order_id = o.id。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;
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'。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 模板,它一样帮你参数化):①三张表以上关联 + 聚合/分组(报表类需求);②需要索引提示或EXPLAIN 调优;③需要 SQLite 特有能力(INSERT OR REPLACE、PRAGMA、UPSERT);④递归查询(组织架构这类层级数据);⑤批量导入(拼一条多值 INSERT 比循环快几十倍)。schema.ts 里的 jsonb 是 customType(自定义类型),std_parts 这种「整对象一列」的场景靠它实现——这说明你已经在用 ORM 的扩展位,而不是被 ORM 的抽象绑住。这是健康的使用方式。改 schema.ts 之后到线上生效,中间有五步。顺序错了的代价是「恢复不了原来的数据」。
迁移会改数据结构,改坏了你得有东西能退回去。你项目的验证方式是 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 深入版。
npm run db:generate —— 生成迁移文件drizzle-kit generate对比 schema 和数据库现状,算出差异,生成带时间戳的迁移文件。这一步不碰数据库,可以随便跑。
打开生成的文件,里面就是真正的 ALTER TABLE。你必须亲眼确认它要做什么:有没有意外 DROP 掉一整列?有没有把有数据的列改成 NOT NULL(而老数据是NULL)导致失败?
看到 DROP COLUMN 或 DROP TABLE 必须停下来——那意味着删数据,而迁移文件一旦执行没有撤销(只能靠第 1 步的备份)。
npm run db:migrate —— 执行迁移tsx src/migrate.ts 按顺序执行没跑过的迁移文件,并记录已执行。这一步才真正改库。
迁移后不是看一眼「没报错」就完事,要实际查:
-- 关键表的行数应该和迁移前一致(除了本来就要变的那张) 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 步的备份恢复,别试图「补救」——补出来的数据更脏。
见第 03 节全篇。一句话:改一个字段重写整行 → 坏一行整个功能挂;没法 WHERE → 只能全表扫;整行互斥 → 并发串行。
按外键查是最常见的查询动作(「查这张订单的所有明细」就是 WHERE order_id = ?)。没索引就是全表扫。好消息:SQLite 外键约束会自动给外键列建索引,所以你项目里的外键列基本都有索引。但如果你只用 drizzle 的类型声明而没写 .references(),数据库层面就没有外键,也就不会有自动索引——「看起来是外键」和「数据库里真的是外键」是两件事。
见第 02 节。两个后果:①数据物理顺序乱,「按主键取最新 N 条」实现不了;②索引页反复分裂重排,写性能掉一个数量级。你27 张表里 25 张用自增 integer,只留两个 text 主键(param.key、std_parts.part_key,因为它们天然有唯一编码),这个判断是对的。
迁移脚本写错一个字段名、或者生成的文件里有意外的 DROP,执行完就回不去了。你项目验证过 cp + md5 的方式,但一定要每次都做——「上次做过所以这次不用」是事故的标准剧本。
SQLite 没有 ALTER COLUMN,也没有 DROP COLUMN 的完整支持——要改列类型或删列,SQLite 的做法是「建一张新表 → 拷数据 → 删旧表 → 改名」。这个过程一旦中途失败,表就停在「新旧都在」的状态。所以删字段前的CSV 导出留档不是保守,是必需。
你项目的门店在中山、深圳,都在同一时区(UTC+8),所以跨时区这个坑你暂时踩不到。但有两件事仍要注意:①统一存储约定——你 schema 里时间戳统一用 integer({ mode: 'timestamp' }),这是存秒级Unix 时间戳(UTC 基准),本身就没有时区问题,这是对的;②别在数据库里存本地时间字符串(比如 '2026-10-02 15:30:00'),因为这种字符串含义依赖「谁写的」,跨机器或改时区就乱。存timestamp、显示时才转本地时间。
.db 文件开头16 字节是 SQLite format 3\0,用文本编辑器打开是乱码LIKE '%x' 前导模糊 / OR 有一边没索引EXPLAIN QUERY PLAN 看是SEARCH ... USING INDEX(好)还是 SCAN(该建索引)-wal 一起拷,而且不校验 md5 等于没备份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 · 数据库怎么存东西。