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

基础 16 · 数据库怎么存东西

速查版:一屏看懂你项目里那27 张表是怎么组织的、主键外键索引到底是什么、SQL 四种操作对应前端哪个按钮、状态机为什么不能用魔法数字,以及那个把整库写崩的 JSON 事故。

项目锚点

你盘上有两套数据库,都是 SQLite 家族:
① C:\Users\Administrator\WorkBuddy\delimao-system\packages\db——德莉玛产供销一体化系统的数据层,用 drizzle-orm 0.38 + drizzle-kit + @libsql/client,表结构写在 src/schema.ts 里,一共 27 张表。
②德莉玛门店订购单系统 v2.5.0——Express + better-sqlite3,开了 SQLite WAL 模式,用 pm2 挂进程。
③ 另外两处历史项目:2026-08-09-task-100(淋浴房 P3)用 better-sqlite3 13,2026-07-01-task-63/kb-api 用 better-sqlite3 11。

先说清一件事,免得你学了一堆用不上:你全盘没有任何一个 MySQL 或 PostgreSQL 在跑。所有真实落地的库都是 SQLite 家族(一个文件就是一个库)。所以这一页讲 SQLite 是对的——不要因为某个 skill 文档里提过「MySQL schema」就去讲 MySQL,你代码里根本没落地那份东西。
01

一句话:数据库是什么

数据库就是一张巨大的、按行按列规矩排好的表。

「正经的表格」是给人看的:歪着写、加个颜色、画个框。数据库的表格是给机器用的:每列的类型固定、每行的宽度固定、谁在第几行不能乱。规矩换来的是「找得快、不认错、并发不乱」。

为什么不能直接用 Excel / JSON 文件存?因为它们只解决「存得下」,没解决三件事:①并发(两个人同时改,谁的会被覆盖)、②崩溃恢复(写到一半断电,数据还完整吗)、③事务(第 1 步成功第 2 步失败,能不能整体撤回)。这三件事就是数据库存在的理由。
你项目里数据库的物理形态:delimao-system 是一个 .db 文件(本地开发库),德莉玛门店系统也是一个 .db 文件。整个库就是一个文件——备份就是拷这个文件,迁移服务器就是拷这个文件。这是 SQLite 最大的好处。
02

六个核心词,一句话各一个

数据库的词汇就这几个。先用一句话说清,再看你项目里长什么样:

词一句话你项目里长什么样(真实)
表 table一类东西的清单,比如「所有订单」order(订单主表)、sku(标准件库)、supplier(供应商)——一共 27 张
行 row清单里的具体一条order 表里的一行 = 一张订单单(订单号 + 经销商 + 状态 + 总金额区间)
列 column清单上的一个项目名order 表的列:order_no / dealer_id / status / total_min / total_max
主键 primary key唯一标识一行,全表不许重复你 27 张表里绝大多数主键都是 id,integer 类型 + primaryKey({ autoIncrement: true })——数字自增。只有两张例外:param.key 和 std_parts.part_key 用 text 主键(因为它们天然有唯一编码)
字段类型这一列只准放这一类东西SQLite 只有四种:TEXT 文本 / INTEGER 整数 / REAL 小数 / BLOB 二进制。布尔没有真布尔类型,你项目里用 integer({ mode: 'boolean' }),实际存 0 和 1(比如 inventory_movement 之外的 bom_line.allocated、production_model_map.confirmed)
外键 foreign key指向另一张表的主键,意思是「这一行的那个东西必须真的存在」德莉玛 order_line.order_id 指向 order.id——没有订单就不可能有订单明细
索引 index书的目录页:它不存任何数据,只为了让你查得快你项目里真实存在的索引:product_model_code_idx(code 唯一)、series_sku_default_idx(series+bom_cat 组合唯一)、inventory_sku_wh_idx(sku+warehouse_id 组合唯一)。另外所有 .notNull().unique() 的列(如 sku.sku、order.order_no)SQLite 会自动给它建唯一索引
索引这条最容易误解,所以强调三遍:索引里没有你的业务数据,它只是给数据库指路的便签。删掉索引,表里的数据一条不少,但查询会变慢。而 unique() 是一种「顺便」的索引——它不只是加速,还顺手保证了「不许重复」这条规则。

主键 vs 唯一键:差在哪

主键 primary key唯一键 unique
作用唯一标识一行,一行必须有且只有一个保证某列的值不重复,一行可以一个都不设
能不能为空不能普通 unique 列可以有空值(dealer.code 就是 .unique() 但允许 null)
数量一张表只能一个可以有很多个
你项目里的例子order.id、sku.id、dealer.id(27 张表里绝大多数)sku.sku、order.order_no、supplier.code、series.code
为什么订单不直接用订单号当主键?你项目选的是 id 自增做主键、order_no 另设唯一。原因:主键会进所有索引(外键要引它),用整数最省空间最快;订单号是业务规则生成的字符串,万一将来要改格式(比如从 DL20261002001 改成别的),改主键会连带所有引用它的外键一起改,而改唯一列只是一次 UPDATE。
03

你项目里的 27 张表,按业务分九层

表名全部来自 packages/db/src/schema.ts,一个不多一个不少。文件名里已经写了分组注释:

层表干什么
① 产品库series(系列)、product_model(型号)、product_option(选配)、production_model_map(生产型号映射)淋浴房有哪些系列(平开门/推拉门)、每个系列下有哪些型号(如 H4-T52)、可选件、生产型号 PV102 属于哪个系列
② 引擎库param(参数)、series_sku_default(系列默认 SKU)、std_parts(9 类标准件)、extra_product(附加产品)、bom_rule(BOM 规则)算 BOM 和报价要用的规则和参数。bom_rule.rule 是 jsonb(存规则结构),std_parts 只有两列:part_key + data(整个标准件塞在一个字段里——这个字段是合法的,因为 9 个标准件的字段结构本来就统一且不需要单独查)
③ SKU 库sku、sku_price标准件主数据(如 HINGE-H4002,含类别/规格/材质/单位/玻璃范围/载荷/孔位/颜色/价格区间/供货路线),sku_price 是多供应商报价(同一 SKU 不同供应商不同价,还带 effective_from 生效时间)
④ 供应商库supplier、material_route玻璃厂/型材厂/五金厂/胶条厂,material_route 记谁供什么料、lead_time_days 备货几天
⑤ 库存warehouse、inventory、inventory_movementinventory 是当前存量(qty 在库 / locked 已锁 / safety_stock 安全库存);inventory_movement 是流水(type 取 in/out/lock/unlock/count,带 ref_type+ref_id 指回来源单据)
⑥ 用户 / 经销商user、dealeruser.role 三个值 admin/factory/dealer;dealer 就是 4 家经销商门店,订单挂在它下面
⑦ 订单 / 拆单order、order_line、bom_line三层:订单主表→ 订单明细 → 拆单出的 BOM 行
⑧ 采购po、po_line、receiving采购单/ 采购明细 / 收货记录。po_line.bom_line_id 指回 bom_line,闭环回到订单
⑨ 工单 / 领料work_order、work_order_item、material_requisition生产工单 / 工单明细 / 领料单
怎么一眼看出分层:文件名里那几行注释就是分界线——// ================= ① 产品库 =================,一直数到 ⑨ 工单 / 领料。9 个分区,27 张表。这就是「按业务分层」的具体含义,不是抽象原则。
04

真实的外键连线:一条订单怎么串起来

外键是「指针」。下面每一条都是 schema.ts 里真的写了 .references(() => xxx.id) 的:

连线 1
dealer.id ← order.dealer_id
订单属于某家经销商门店。没有门店,这条订单挂不上去。所以删门店前得先看有没有订单挂着。
连线 2
order.id ← order_line.order_id
订单主表 → 订单明细。一张订单有多行商品(model 型号 + spec 规格 + thick 厚度 + process 玻璃工艺 + qty 数量 + area 面积)。order_line 的外键是 .notNull().references(),强制不为空。
连线 3
order_line.id ← bom_line.order_line_id
订单明细 → 拆单出的 BOM 行。bom_line 有 bom_cat(玻璃/五金/密封/型材)、sku、qty、route(玻璃厂/型材厂/五金厂/胶条厂)、supplier、line_cost、allocated(有没有被采购占用)。这就是拆单结果的落库位置。
连线 4
bom_line.id ← po_line.bom_line_id
采购明细 ← BOM 行。闭环:订单 → 拆单 → 采购 → 收货入库。你可以从一张订单出发,一路JOIN 走到收货记录,这就是整条业务链在数据库里的样子。
连线 5(跨类型)
sku.sku ← inventory.sku / sku_price.sku
⚠️ 值得单独看:这两条外键指向的不是主键 id,而是唯一键 sku 这个字符串(HINGE-H4002 那种)。SQLite 允许指向唯一列。好处:库存和价格直接用业务编码连,查数据一眼看得懂,不用先查 sku.id 再做转换。

为什么订单必须拆成主表 + 明细表

因为一条订单天然有多行商品。如果硬塞进一张表,只有两个办法,两个都错:

做法后果
为每个型号都开一列(model_a model_b model_c…)订单有 5 个型号就多5 列,有 6 个就崩。而且加一个字段就要改表结构。列名也没地方放型号数量、单价、面积这些信息
把多个型号拼成一个字段('H4-T52 x2, H4-T60 x1')这就是你踩过的那个坑的另一种形态。查询「H4-T52 卖了多少」只能全表扫 + 在应用层拆字符串;改一个数量要把整串重写;一行坏了整行读不出来
正确解法叫「一对多」:主表只放整张单共有的信息(订单号、客户、状态、总额、备注),明细表放每一行独有的信息(型号、规格、数量、单价)。共有的放一起,各自的另起一行。加第五个型号只是多插一行明细,不改表结构。你项目 27 张表里 order/order_line、po/po_line、work_order/work_order_item 三对都是这个模式。
05

SQL 四种操作(超简版)

所有 SQL 就四个动作,动词开头:

动作关键词大白话对应前端哪个动作
查SELECT拿数据出来看,不改页面加载时显示列表、点「查询」按钮出结果
增INSERT加一行新的点「新增订单」「提交报价」
改UPDATE把已有的某几列换掉点「审核通过」「改数量」
删DELETE把整行拿走点「删除」「作废」

下面三条是你项目里真实会跑的查询。左边是纯 SQL,右边是 drizzle 写法(packages/db 用的就是右边这种,但它只是帮你拼字符串,底下还是左边这条 SQL):

-- ①查:某一家经销商门店的全部订单(前端:门店订单列表页)
SELECT o.id, o.order_no, o.status, o.total_min, o.total_max, o.created_at
FROM order o
JOIN dealer d ON d.id = o.dealer_id
WHERE d.code = 'DL01'
ORDER BY o.created_at DESC;

-- ② 查:某个型号的 BOM(前端:点型号看用料清单)
--注意 order_line.model 是业务型号,直接可读
SELECT l.bom_cat, l.item, l.sku, l.spec, l.material, l.qty, l.unit, l.route, l.supplier, l.line_cost
FROM bom_line l
JOIN order_line ol ON ol.id = l.order_line_id
WHERE ol.model = 'H4-T52'
ORDER BY l.bom_cat;

-- ③ 查:某SKU 在各仓库的存量(前端:库存看板)
SELECT i.sku, w.name AS warehouse, i.qty, i.locked, i.safety_stock, i.updated_at
FROM inventory i
JOIN warehouse w ON w.id = i.warehouse_id
WHERE i.sku = 'HINGE-H4002';
// drizzle 写法(等价于上面第 ① 条),你项目 packages/db 里的真调用形态
const rows = await db
  .select({ id: order.id, no: order.orderNo, status: order.status })
  .from(order)
  .innerJoin(dealer, eq(dealer.id, order.dealerId))
  .where(eq(dealer.code, 'DL01'))
  .orderBy(desc(order.createdAt));
drizzle 帮你干了什么:①把表名字符串换成 order.id 这样的属性,写错字段名编译期就报错;②自动拼 SQL 并参数化(值用 ?占位 传进去,不做字符串拼接,所以不会被 SQL 注入);③结果自动带类型。它没帮你干的事:复杂查询还是得你会写 SQL——遇到「三张表关联 + 聚合 + 分组」的时候,你必须能自己写出那条 SQL 才对得上debug。
UPDATE 和 DELETE 的红线:这两句后面必须有 WHERE。漏掉 WHERE 的 UPDATE order SET status='done' 会把整张表改成同一个状态;漏掉 WHERE 的 DELETE FROM order 会清空订单表。这是所有生产事故里最常见的一类手滑。正确做法是:先 SELECT 同样的条件,看看要改的几行对不对,再 UPDATE。
06

状态机怎么存:你项目的真实做法

你项目里所有状态都用TS 常量数组声明,这是最省心的做法。源码长这样(schema.ts 开头部分,真实代码):

export const USER_ROLES  = ['admin', 'factory', 'dealer'] as const;
export const ORDER_STATUS = ['draft', 'submitted', 'reviewed', 'approved',
                             'rejected', 'in_production', 'done', 'cancelled'] as const;
export const PO_STATUS   = ['draft', 'submitted', 'partial', 'received', 'cancelled'] as const;
export const WO_STATUS   = ['open', 'in_progress', 'completed', 'cancelled'] as const;
export const REQ_STATUS  = ['pending', 'issued', 'cancelled'] as const;
export const MOVE_TYPES  = ['in', 'out', 'lock', 'unlock', 'count'] as const;

as const 是关键:它让这个数组的类型变成「只读的字面量联合类型」,所以 status 列的类型推导出来是 'draft' | 'submitted' | ...。你写一个 status: 'shipped',TypeScript 当场报错——因为拼写错了或者状态压根不存在。

为什么绝不能用魔法数字

反面写法 vs 正确写法
// ✗ 魔法数字:三个月后你自己都忘了3 是啥
await db.update(order).set({ status: 3 });

// ✓ 常量:一眼看懂
await db.update(order).set({ status: ORDER_STATUS[3] });   // 或者直接写常量名
// 更常见的写法是给状态配一个对象/映射
await db.update(order).set({ status: 'in_production' });

为什么第2 行也不算错但仍然别这么写:靠数组下标取值,等于把状态和数字焊死了。你哪天在数组中间插一个状态,所有下标全部错位,而且编译器不会报错——这是最危险的一类 bug。

写法半年后你还能看懂吗插一个状态会崩吗
status: 3不能。得回去翻代码找注释—
status: ORDER_STATUS[3]能,但要多想一步会。全部错位,且不报错
status: 'in_production' + 类型来自常量数组能不会。写错拼写当场编译失败
所以你项目的正确姿势是两件事一起做:①schema.ts 里用常量数组导出所有合法状态;②表定义里写成 text('status', { enum: ORDER_STATUS }).notNull().default('submitted')。数组管「有哪些合法值」,enum 管「这一列只准用这些值」,两层一叠,状态就再也不会乱了。
这一页最该记住的踩坑:数据库里存的是字符串('in_production'),代码里用的是常量名。这两边必须对得上。对不上就是状态乱——典型症状是:数据库里存了 'in_production',代码里判断写的是 'inproduction'(少个下划线),于是所有行都显示成「未知状态」,而代码也不会报错。
唯一的防线是:只允许通过常量数组拼状态字符串,永远不要在业务代码里手打字符串。要手打,就用 ORDER_STATUS 里的成员。
07

迁移 migrate 是什么

先说痛点。你改了 schema.ts,以为数据库变了——其实没有。

问题现场
schema 改了,数据库里的老数据还是老结构
上周你在 sku 表加了一个字段 load_kg,代码里已经读得到。但服务器上那个 .db 文件里根本没有这一列,一查就报 no such column: load_kg。
为什么:schema.ts 只是一份说明书(图纸),它不会自动改造已有的库。库是之前某次执行时按旧图纸盖的,现在你改了图纸,总得有人去按新图纸改造现场。

迁移(migrate)就是「按新图纸改造现场」这个动作。你项目有两条命令,一前一后,顺序不能换:

1

npm run db:generate —— 生成迁移文件(不动数据库)

实际执行 drizzle-kit generate:对比当前 schema.ts 和数据库的现状,算出「需要改什么」,写成一个带时间戳的迁移文件(里面是真正的 ALTER TABLE 语句)。这一步只生成文件,一个字节都没写进数据库。所以它可以随时跑、跑多少次都没副作用。

2

npm run db:migrate —— 执行迁移(真正改数据库)

实际执行 tsx src/migrate.ts:读迁移目录里还没跑过的文件,按顺序对数据库逐条执行,并记录「这条已经跑过了」。这一步才真的改库。

# 完整流程,你项目根目录敲
npm run db:generate# 1. 改了 schema.ts 之后先跑这个 → 生成迁移文件
npm run db:migrate    # 2. 再跑这个 → 迁移文件真正应用到 .db
npm run db:seed        # 3.(可选)灌初始主数据:SKU 主数据、系列价格、真实型号
最高频的错:改了 schema.ts 就去重启服务,忘了跑这两步。结果就是「代码里写得好好的,一查就报 no such column」,然后开始怀疑是不是缓存、是不是驱动版本不对、要不要重启 pm2——方向全错。数据库不会因为你觉得「我改了」就跟着改,迁移文件才是改的凭据。
所以养成习惯:schema.ts 一动,先 generate 再 migrate,然后再重启服务。
还有一个方向相反的坑:直接手工去改 .db 文件(比如拿个工具加列)。这会导致「数据库实际结构」和「迁移记录」对不上,下一次 migrate 会尝试执行一个「把这列删掉」或「这列已存在」的语句,直接失败。数据库结构的变更只走 generate + migrate 这一条路,手工改是给自己埋雷。
08

常见坑 / 红线

坑 1 · 把整个对象塞进一个字段(你的真实事故)

德莉玛门店系统的 collections 表曾经把一整个对象存成一个字段(单行 JSON blob)。后果不是「不好看」,是三件事同时崩:①写坏整行就崩(改一个字段要重写整行,JSON 一旦截断就整行读不出来,连正常数据也取不到);②没法查询(想找「某个型号的那条」只能全表扫,扫出来还得在应用层 JSON.parse 再筛);③并发直接串行(同一行只能一个人写,别人全等)。这就是「存成一坨」和「拆成可独立查询的最小单元」的真实差距。

坑 2 · 拿没索引的字段当查询条件

刚开发时表小,怎么查都飞快,于是「用这个字段查一下」就顺手写进代码了。数据涨到几万行后同一个页面要几秒。回过头看,那个字段既没 unique() 也没建索引。数据库只能一行一行扫(叫全表扫描),扫完才算得出结果。判断方法:任何出现在 WHERE 右边的列,都要问一句「它有索引吗」。

坑 3 · 主键用随机字符串

比如用 uuid 或时间戳字符串当主键。两个坏处:①顺序乱——UUID 是随机的,插到表里到处乱放,「按主键取前 20 条最新」这种需求就没法用「顺序」实现;②索引失效变慢——主键会进所有索引,随机字符串让索引页不断分裂、频繁重排,比自增整数慢好几倍。你项目的27 张表里 25 张用 integer 自增做主键,就是这个原因。

坑 4 · 状态存魔法数字

见第 06 节。status: 3 这种写法三个月后没人知道是啥,改的时候还得先猜对下标。用常量数组 + enum,让编译器帮你盯着。

坑 5 · 改了 schema 不跑迁移

见第 07 节。no such column 的头号原因。改了 schema.ts 之后必须 generate + migrate。

坑 6 · 别把「当前值」和「流水」混在一张表

这个坑你没踩,但你项目做对了,值得认一下:inventory(当前存量:qty/locked)和 inventory_movement(流水:谁在什么时候加减了多少、ref_type 指回来源单据)是两张表。如果只留一张、只存当前值,那「上周三这批货为什么少了 20」就永远答不上来——因为历史被覆盖了。

✓

自查清单

  • ✓我知道你全盘只有 SQLite 家族(delimao-system 的 libsql / 门店系统的 better-sqlite3),没有 MySQL 和 PostgreSQL
  • ✓我能说出 schema.ts 里27 张表的名字,能按①~⑨ 九个业务层归类
  • ✓我讲得出主键 vs 唯一键的区别,并知道主键全表只能一个、不能为空
  • ✓我知道为什么你27 张表里 25 张用 integer 自增主键,只有 param.key 和 std_parts.part_key 用 text 主键
  • ✓我讲得出 5 条真实外键连线,尤其是 sku_price.sku 和 inventory.sku 指向的是唯一键 sku 字符串不是主键
  • ✓我知道索引不存数据(书目录页类比),也知道 unique() 是「顺便」的索引
  • ✓我能解释为什么订单必须拆主表 + 明细表(一条订单有多行商品,共有的放一起,各自的另起一行)
  • ✓我能背出 SELECT/INSERT/UPDATE/DELETE,并知道 UPDATE/DELETE 漏WHERE 会清全表
  • ✓我能说出 6 个状态常量数组的全部值,尤其 ORDER_STATUS 的 8 个态
  • ✓我知道 as const 的作用:写错状态拼写当场编译报错
  • ✓我能分清 db:generate(生成迁移文件,不碰库)和 db:migrate(真正改库),且知道顺序不能换
  • ✓我能讲清 collections 表JSON blob 事故的三个后果:写坏整行就崩、没法查询、并发串行
面试怎么答

题目:你项目的数据库怎么设计的?遇到过大问题吗?

先说规模和技术选型。我这边有两套,都是 SQLite 家族:产供销一体化的数据层用 drizzle-orm 0.38 + libsql client,表结构集中写在 packages/db/src/schema.ts,一共 27 张表;门店订购单系统用 better-sqlite3,开了 WAL 模式,pm2 挂进程。为什么是 SQLite:整个库就是一个文件,备份是拷文件、迁移服务器是拷文件,我的部署方式是 pm2 + 单机,不需要 MySQL/PostgreSQL 那一套。

再说设计:我按业务分成 9 层——产品库(series / product_model / product_option / production_model_map)、引擎库(param / series_sku_default / std_parts / extra_product / bom_rule)、SKU 库(sku / sku_price)、供应商库(supplier / material_route)、库存(warehouse / inventory / inventory_movement)、用户经销商(user / dealer)、订单拆单(order / order_line / bom_line)、采购(po / po_line / receiving)、工单领料(work_order / work_order_item / material_requisition)。

层与层之间靠外键串起来,主链路是:dealer → order → order_line → bom_line → po_line → receiving。从一张订单出发能一路 JOIN 走到收货记录,这就是整条业务链在库里的样子。订单拆主表 + 明细表是因为一条订单天然有多行商品——共有的信息(订单号、客户、状态、总额)放主表,各自的(型号、规格、数量、单价)放明细,加第五个型号只需多插一行,不改表结构。

状态机我用 TS 常量数组声明,不写魔法数字。比如订单 8 个态:draft / submitted / reviewed / approved / rejected / in_production / done / cancelled,导出成 ORDER_STATUS,然后表定义写成 text('status', { enum: ORDER_STATUS })。关键在 as const——它把数组变成字面量联合类型,业务代码里写一个不存在的状态,TypeScript 当场报错。反例是 status: 3,三个月后没人知道3 是什么。

最大的坑是我自己踩过一次,性质很严重:门店系统的 collections 表曾经把一整个对象存成单行 JSON blob。开始只是图省事——「反正对象里有这些字段,一行存完多简单」。结果出了三件事:第一,写坏整行就崩。改一个字段要重写整行,JSON 一旦写截断或者并发写互相覆盖,整行读不出来,连带正常数据也一起取不到,最后是整个功能挂掉;第二,没法查询。想找「某个型号的那条」,SQL 完全帮不上忙,只能全表扫出来在应用层 JSON.parse 再筛,数据量一大就卡死;第三,并发直接串行。同一行只能一个人写,别人全排队等着。

改造的思路是「把可独立查询的最小单元拆出来」:一个对象里凡是「会被单独查、会被单独改、会被单独引用」的字段,都该自己占一列;剩下的整体结构(比如 BOM 规则这种内部就要整体读的)才留在 JSON 字段里。我判断标准是:这个字段需不需要出现在 WHERE 里?需要,就必须独立成列。改完之后查询能走索引、写入只改一列不再重写整行、并发也只锁被改的那几行。

另外两个我主动防的坑:①任何出现在 WHERE 里的列必须有索引,没索引就是全表扫描,数据涨到几万行直接卡住;②主键一律用 integer 自增,不用随机字符串——随机 UUID 会让插入顺序乱掉,「取最新 20 条」和索引效率同时受影响,所以 27 张表里 25 张都用自增,例外是 param.key 和 std_parts.part_key 这种天生有唯一编码的。

最后说迁移纪律:改 schema.ts 之后必须 db:generate(drizzle-kit 生成迁移文件,不碰库)→ db:migrate(执行文件,真正改库),两步顺序不能换,也不能只跑第一步就以为生效了。数据库结构变更只走这一条路,手工改 .db 文件会让实际结构和迁移记录对不上,下一次 migrate 直接失败。

🔗 想看原理细节

上面是速查版,只讲「是什么、怎么用」。4KB 页与 B+ 树索引原理、为什么JSON blob 必然拖垮整库、聚簇索引、索引失效的四种场景、JOIN 的 INNER vs LEFT、drizzle 的代价与什么时候该手写 SQL、生产库迁移前必须备份 + md5 校验都在深入版:

深入版 16 · 数据库深入 →