FDE·技术基础
基础目录
数据库深层 并发与稳定性 PG 迁移预案

复盘 32 · PostgreSQL 迁移预案:从注释到可执行清单

这一篇只讲一件事:「迁 PostgreSQL」我落地了——落的不是数据库迁移,是一份能跑的诊断脚本 + 一份写清顺序和回滚点的清单。库没迁走,也暂时不该迁。

项目锚点

被扫对象(只读,一个字节没改):db/schema.ts、db/relations.ts、db/seed.ts、drizzle.config.ts、api/queries/connection.ts、package.json;工具 kb/tools/scan_pg_ready.py——只读文本,不连库、不写文件。

3张表
18处待改 P0 11 / P1 7
0条迁移

方法论出处:16-database-deep 的「JSON blob 拖垮整库」,第 07 节靠它下判断;17-concurrency-deep 讲为什么该迁,本篇补真要迁时按什么顺序、哪几步会翻车。对照库:dlmware.cn。

01

先说结论:我落的是脚本和清单,不是迁移

一句话:现在迁是给没疼的地方开刀。但我不是写个「暂缓」就交差——我把「真要迁时怎么办」写成了能跑的诊断 + 写清顺序和回滚点的清单 + 一条停手判断,三样都能被复核。

三条硬事实(脚本跑出来的)

#事实出处
1db/migrations 真实迁移文件数 = 0,目录里只有一个 0 字节 .gitkeep脚本实测 + ls 复核
2合计 18 处:P0 11 P1 7脚本最后一行
3dialect = sqlite;mysql2 ^3.14.1 装了但全树没有一处 import脚本输出 + package.json L63
迁移能力是零:没有一条 SQL 版本历史,drizzle-kit generate 的产物从未入库。这状态谈「迁 PG」不是技术难度问题,是地基问题。
口径:数字只来自脚本输出和我读文件核对。没测到的写「还没测」——三张表各多少行脚本不连库,本篇一个行数都没有。
02

注释不是预案:从一个 405 字节的文件说起

「迁 PostgreSQL」在这个项目里的全部沉淀,就在 db/seed.ts(405 字节)里。

// db/seed.ts
import { getDb } from "../api/queries/connection";
// TODO: import tables from "./schema"
async function seed() {
  const db = getDb();
  // TODO: insert seed data, e.g.
  console.log("Done.");
  process.exit(0); // close MySQL connection pool
}
seed();
位置内容说明什么
L1getDb() → connection.ts真实连接是 better-sqlite3
L2 / L8// TODO: import tables、// TODO: insert seed dataschema 一行没用上;示例写 schema.posts 而 schema.ts 里没有 posts 表
L14process.exit(0); // close MySQL connection pool注释写 MySQL,这里是 SQLite 且直接 exit,没有连接池可关
它同时暴露三层空洞:① 目标写错(注释 MySQL / 代码 SQLite);② 内容没定(示例字段 posts 不存在);③ 连「迁过没有」都答不出(migrations 零条)。
只在注释里的方案,写完那一刻就是旧的——不参与构建、不进版本历史、跑不出结果、没人能复核。
反过来说,这正是我要补的:把「注释」换成能跑的脚本 + 有行号的清单 + 一条停手判断,同一个念头就从自言自语变成一次 6 文件 / 10 规则 / 18 命中的扫描。
03

现状实测:18 处待改,三张表逐字段列清

脚本原始输出(没删没改):

· db/schema.ts: sqliteTable 表数 = 3,JSON blob 列 = 1
· db/seed.ts 提到 MySQL: True(实际跑的是 better-sqlite3)
· drizzle.config.ts dialect = sqlite
· package.json 含依赖 mysql2 / better-sqlite3 / drizzle-orm
· db/migrations 真实迁移文件数 = 0
--------------------------------------------------------------------------
[P0] L4 / L19 / L32 -> pgTable(...)
[P0] L8 / L9 / L10 / L24 / L25 / L38 -> integer(timestamp) → timestamptz
[P0] L11 -> integer(boolean) → boolean
[P0] L12 -> text 存 JSON 字符串 → 拆表或改 PG jsonb
[P1] L1 -> 换 drizzle-orm/postgres-js
[P1] L11 -> PG 侧默认值要显式给
[P1] L5 / L20 -> text 主键 → varchar(n) + 索引
[P1] L21 / L34 -> ON DELETE CASCADE
[P1] L33 -> serial / bigserial 或 identity
合计 18 处:P0 11 / P1 7
先纠我自己脚本的一个偏差:脚本报「JSON blob 列 = 1」,只因为 .default("{") 那一种写法命中 settings;手看是 2 列——workshops.settings(L12)和 step_data.data(L37,notNull)。再补一条:step_data.participant_id(L35)没有 references,而 participants.id 存在;脚本 10 条规则里没有「外键缺失」,扫不到。
这条纪律很重要:扫描器给的是下界不是全貌,跑完脚本必须手看源码。

三张表逐字段

表 · 列(行号)现在迁 PG 要成什么样
workshops.id · L5text 主键,例 "j2uyjunzkua3"varchar(32) + 索引 P1
created_at / started_at / ended_at · L8–L10 · joined_at / last_active_at · L24–L25 · submitted_at · L386 处 integer mode:"timestamp"timestamptz P0
is_live · L11integer mode:"boolean" notNull default(false)boolean + 显式默认值 P0P1
settings · L12 / data · L37两列 text 存 JSON 字符串jsonb 或拆表 P0
name / facilitator_name · L6–L7text notNull脚本没报 = 不用动
participants.id · L20 / workshop_id · L21text 主键 / text + cascadevarchar+索引 / PG 外键,cascade 行为复核 P1
role · L23 / joined_at 见上text enum 三值PG enum 或 varchar+CHECK(手看)
step_data.id · L33 / workshop_id · L34integer 自增主键 / text + cascadebigserial / identity / PG 外键 P1
participant_id · L35 / step_id · L36text notNull 无 references / integer(注释 1-7)建议补外键(手看);step_id 还没定,先按 integer 搬 P1
最该被注意的不是改的那些行,是「脚本没报 = 不用动」那一行。清单最容易犯的错是把所有列都重画一遍——18 处里真正要改类型的只有 timestamp / boolean / JSON / 主键这几类。清单的价值一半在列「改什么」,一半在列「不动什么」。
04

现在不迁的三个理由,和「什么时候必须迁」

#理由证据
①数据体量没到需要换并发模型,三张表都是应用级小表,行数还没测schema.ts 全文 43 行
②单机部署,连接是单例,没有多进程抢写connection.ts L12–L19 if (!instance)
③WAL 已经在用,并发写问题已被缓解connection.ts L15 PRAGMA journal_mode = WAL;
mysql2 是个错觉:装了但没被 import 过,它不是「迁移进度 30%」,是化石——会骗到下一个读代码的人。

什么时候必须迁(没触发就别动)

触发条件不满足时判定
多机 / 多进程写并发connection.ts 单例假设破了,两个进程各开连接、互不知情触发
需要行级锁 / FOR UPDATE / 可串行化SQLite 锁整库,这一套一个都没有,只能缩短事务 + 应用层排队触发
需要 jsonb / 时间线 / 分区settings / data 只能当字符串,没法 WHERE、没法索引触发
需要 DBaaS 托管 + 只读副本单机单文件,备份就是拷文件触发
上面四条都没触发—别动
口径照 28 的分母纪律:这张表不讲「多少万单必须迁」的阈值——那要实测吞吐量,我没测,所以不写,只讲一旦满足就是架构级判断的信号。本篇补的是「破了之后怎么破」,不是「破没破」。
05

真要迁的七步:每步写清「做了什么 / 怎么验证 / 哪步能停」

顺序本身就是防护——多数迁移事故不是某一步做错,是两步之间的顺序反了。

1

先立迁移基线

做了什么:在当前 schema 上跑第一次 db:generate,让 migrations 出现第一条 0000_*.sql——先给现在拍照,再动 schema。
怎么验证:迁移文件数 > 0;git diff 看得出第一条 SQL 建了哪几张表;db:migrate 在干净库重放通过。
哪步能停:整条链路唯一零风险停点——没碰业务代码、没改 schema、没动数据。

2

改 schema 到 pg-core(11 处 P0)

做了什么:sqliteTable→pgTable;6 处 timestamp→timestamptz;is_live→boolean 并显式给默认值;两列 JSON 先按 jsonb 建模。
怎么验证:重跑扫描脚本 P0 从 11 降到 0;diff 里能看见 timestamptz / boolean / jsonb;tsc -b 通过。
哪步能停:能停,但只落了个空壳——它的价值在能反复横跳,不在能交付。

3

双环境跑通同一批查询

做了什么:旧 SQLite 与新 PG 同时挂着,把现有查询(含 db/relations.ts 三条 relations 的 join 路径)两边各跑一遍。目标不是「PG 能跑」,是「两边返回一致」。
怎么验证:同份查询两边相同行数、相同值、相同顺序;时间戳列看取值 / 排序 / 区间筛选三种读法。
哪步能停:能停,但仍是零数据,迁移事实上没开始。

4

数据搬运(方向单向)

做了什么:旧→新只写一个方向,新库绝不接受来自应用的写。
怎么验证:插得进读得回;自增列 step_data.id 要显式给值,否则 serial 会把已有 id 冲掉——搬完才发现、回滚很疼。
哪步能停:能停,此时新库是一个完整历史副本,怎么停都不丢东西。

5

对账:行数 + 抽样对值 + 外键完整性

三个检查,缺一项不许进下一步。行数逐表对;抽样按边界抽(最早一行、最晚一行、is_live 为真的、JSON 最长的);外键验 participants.workshop_id、step_data.workshop_id、补完要验的 step_data.participant_id。
怎么验证:三个数全对上才发通行证;任一对不上回第 ④ 步重搬(旧库没动,重搬代价最小)。
哪步能停:最舒服的停点——已对过账的完整副本,生产流量一行没切。

6

切读:PG 只接读,写还在旧库

做了什么:读走 PG、写落 SQLite,两条路都在跑,对账要双向做。
怎么验证:同一时间窗两边行数一致、时间戳显示一致;没有时区偏移报警。
哪步能停:能停,整条链里最该被拉长的一格——第 ⑥ 步停留多久,就是 rollback 信心的来源。

7

切写 + 立回滚点

做了什么:写切 PG,旧库转只读存档;搬运反向一次(新→旧)收敛;旧库只读权限显式关掉,不是「约定它别写」。
怎么验证:切流后三检重做;回滚演练至少真跑一次——真把读切回旧库一次走通了,才算切成了。
哪步能停:到这步才真的停不下来。正因如此,前六步每步都要留停点。

第 ④ 步的结构映射表(照类搬,不照列搬)

SQLite 现状PG 侧坑
text 主键(workshops / participants.id,如 "j2uyjunzkua3")varchar(n) + 索引长度上限先定,别事后改
integer + timestamp(6 列)timestamptz时区语义先定
integer + boolean(is_live)boolean0/1 映射写进搬运脚本,别靠 PG 隐式转
text 存 JSON(settings / data)jsonb别照搬成 text(见第 06 节)
自增主键(step_data.id)bigserial / identity搬历史数据显式给 id

「回滚点」具体指什么(缺一条都不算)

#回滚点为什么写死
一旧库保留只读,不删、不压缩、不上线回滚的前提是还有地方可回;最狼狈的迁移是回滚前先把旧库优化了——那不叫回滚,叫重来
二每一步打一个 tag(pre-gen / pg-schema / migrated / read-cut / write-cut)出事时唯一要答的是「我们在哪一步」;没 tag 就只能猜,猜回滚等于没回滚
三复制方向单向:④–⑥ 只旧→新,⑦ 前先新→旧收敛一次双向同步是最难回滚的状态,两边同时可写时你分不清哪边的脏数据是对的
06

三个最容易翻车的点(都不报错,发现时已经晚了)

#坑症状前置防线
1schema 改完没迁数据两边 schema 都变了,旧库还是老形状:新列全是默认值、老数据一行没动,接口 200、返回空。典型:is_live 旧库还是 0/1,按 boolean 读出来永远是 false,于是「没有一场 workshop 在进行中」——没人会报警对账必须先于切读
2时间戳时区integer 存秒数,drizzle 在本地时区解析;迁到 timestamptz 后同一个秒数可能显示成另一个时间点,等第一次跨时区的人看同一条记录时才爆先定语义(UTC 还是本地时间),写进脚本常量,不靠口头约定
3JSON blob 照搬成 text把 jsonb 能力扔了,还把 16 那节整套坑带过去:重写整行、丢更新、没法 WHERE 和索引、类型保护失效照 16 篇那句:需不需要出现在 WHERE / JOIN / ORDER BY?
第 2 条我还没测,得诚实说:脚本扫得出类型(6 处 integer(timestamp)),扫不出「这批秒数当初按哪个时区写进去的」——那要连旧库读历史行跟业务确认。只能标「还没测」。但正因没测,它必须进第 ② 步的验证项,不能等搬完。
三个坑的共同形状:全都不会在跑迁移脚本那天报错。① 跑完才看出没搬;② 搬完三个月后才发现差 8 小时;③ 上线半年后想按 data 某字段筛选发现查不了。
所以「迁移成功」不能被「脚本 exit 0」定义,只能被三件事定义:数据搬过去了、值对得上、将来还能按你想要的方式查。
07

迁之前得先还这两笔债(本篇我最想留下的判断)

迁移的难点不在「迁」,在「迁之前欠的东西」。欠着不还就迁,等于把旧债换个库再欠一遍。

债 ① 先给 migrations 立基线

现在 db/migrations 是 0 条真实迁移文件——这个项目从没走过版本化变更,schema 大概是「改一下 db:push 推上去」。这个状态谈迁移,等于没地基就盖第二层:你连「现在长什么样」都没有可 diff 的记录。所以第 ① 步不是「准备工作第一步」,它是迁移能不能成立的前提。

债 ② 先拆掉两列 JSON blob

顺序搬运结果
先迁后拆两次(先当 text 搬,再拆一次)多一趟全表重写,而这趟纯粹为了给自己腾地方
先拆后迁(我选的)一次省一趟;拆完再迁 PG 干净落表,不用回头写拆表迁移
拆不拆的判据照 16-database-deep 那句,不另造一套:需不需要出现在 WHERE / JOIN / ORDER BY?
- workshops.settings:若只整体读出来渲染 → 可留 JSON(PG 里用 jsonb 还能按路径查);
- step_data.data:step 提交记录,将来大概率要按 step_id 汇总、按里面分数排序 → 该拆。
但这要先跟业务对一次「这两列将来怎么查」——我还没做,不替业务猜字段名。
两笔债 · 操作模板(迁之前抄一遍)
  1. 债 ① 立基线:drizzle-kit generate 第一次 → 迁移文件数 > 0 → 干净库 db:migrate 重放通过 → 打 tag pre-migration-baseline
  2. 债 ② 拆 JSON:settings / data → 先问业务这两列将来怎么查 → 需 WHERE/JOIN/ORDER BY 的拆独立列,其余保留但改 jsonb → 迁移文件入库,打 tag json-broken-up
  3. 顺手修:step_data.participant_id 补外键(脚本扫不到)
  4. 清一个化石:mysql2 留就写明用途、不留就删,别继续当「迁了一半」的假信号

现成案例:不迁 PG,但债已经在了

dlmware.cn 德莉玛门店订购系统(服务器上 /opt/delimao/delimao.db):15 张表,product_order 33,178 单、customer 387 家,但 quote 只有 2 条、bom_line 只有 27 行;身上两列 JSON blob,客户归属外键空值 100%。
它说明:不迁 PG 不等于不用还债。还在 SQLite 上,可那两列已经把「没法 WHERE、没法索引、改一个字段要重写整行」的债欠下了——跟类型、跟部署形态都没关系,跟值怎么组织有关系。
所以我把债 ② 划成「和迁不迁 PG 无关的独立工作」:挂到「迁移」这个大任务下面,它就会被无限期推后。

08

我明确不做的 / 还没做的(诚实短板)

#没做的事真实现状影响
1没连过真 PG 跑这套七步第 ② 改 schema、第 ④ 搬运脚本、第 ⑤ 对账 SQL,三个验证方法都还没实测「怎么验证」按待办读,不能按已跑通读
2没做过灰度切流切读切写没有真跑记录,回滚演练也没跑过回滚点三条目前是设计,不是验证过的机制
3没改动任何生产代码脚本只读文本、不连库不写文件;五个源文件一个字节没动刻意:不附「我没改任何东西」的诊断不可复现
4 / 5「数据体量小」没有数字;时区语义没定没测脚本不连库,三张表各多少行我一个都不知道;时区只能标「还没测」理由 ① 是定性判断;它是第 ② 步的验证项不是结论
6 / 7JSON blob 拆不拆还没问业务;这是预案不是事故报告「data 将来怎么查」要业务回答;没因 SQLite 出过事故,18 处是扫描器输出不是踩坑计数第 07 节给了方向没拍板;别读成「我迁过、我踩过」
最该被当成短板的是第 1 条。从没连过真 PG 的人写的迁移顺序,价值不在「它验证过」,在「它把顺序想清楚了」——尤其「哪一步能停」这个别人最不爱写、出事时最要命的细节。
这页标成预案,不标事故报告:预案靠「顺序 + 证据」取信,事故报告靠「踩过」取信,拿后者的方式讲前者就是吹。
✓

自查清单

  • ✓三条硬事实:migrations 真实迁移 0 条、18 处(P0 11 / P1 7)、dialect = sqlite 且 mysql2 装了没用
  • ✓「注释不是预案」的实物证据:db/seed.ts 第 14 行 process.exit(0); // close MySQL connection pool,而实际跑 better-sqlite3
  • ✓11 处 P0 / 7 处 P1 分别是什么(3 处 pgTable、6 处 timestamp、1 处 boolean、1 处 JSON;换 import、两个主键索引、默认值、两处 cascade、自增)
  • ✓脚本报「JSON blob 列 = 1」实际 2 列;step_data.participant_id(L35)没有 references——扫描器给的是下界,跑完必须手看源码
  • ✓「不动什么」:纯 text 列在 PG 里不用动;不迁的三个理由(每条指到行号):数据体量未到、单机部署、WAL 已在用
  • ✓四条触发条件:多机/多进程写并发、行级锁与 FOR UPDATE、jsonb/时间线/分区、DBaaS 托管与只读副本;没触发就别动
  • ✓七步 + 每步验证 + 每步停点,尤其第 ① 步是唯一零风险停点、第 ⑤ 步(对账)是切读前的必经闸门;回滚点三条:旧库保留只读不删、每步打一个 tag、复制方向单向
  • ✓三个翻车点:schema 改完没迁数据、时间戳时区(还没测)、JSON blob 照搬成 text;拆 JSON 的判据照搬 16 篇:需不需要出现在 WHERE / JOIN / ORDER BY
  • ✓dlmware.cn 案例:15 张表 / 33,178 单 / 387 家 / quote 2 条 / bom_line 27 行——不迁 PG 不等于不用还债
  • ✓短板:没连过真 PG、没做过灰度切流、没改任何生产代码、没有行数、这是预案不是事故报告
面试怎么答

题目:你做过数据库迁移吗?这个项目为什么还在用 SQLite?

先说结论别铺垫:我做过的是迁移预案,不是迁移实施。现在还在 SQLite,我认为现在迁是给没疼的地方开刀——但我不只写个暂缓就走人,我把「真要迁时怎么办」写成了能跑、能复核、能按步执行的清单。

先把「只有注释」讲清楚,它是我动手的直接原因。「迁 PG」此前就一行注释:db/seed.ts 第 14 行 process.exit(0); // close MySQL connection pool。它同时错三处——注释说 MySQL 代码跑 better-sqlite3;TODO 示例里的 posts 表根本不存在;db/migrations 一条迁移都没有。只在注释里的方案,写完那一刻就是旧的:不参与构建、不进版本历史、跑不出结果、没人能复核。

脚本扫 6 个文件 10 条规则,跑一遍得 18 处,P0 11 / P1 7。11 处 P0 是 3 处 pgTable、6 处 integer(timestamp)→timestamptz、1 处 integer(boolean)→boolean、1 处 text 存 JSON;P1 是换 import、两个 text 主键改 varchar+索引、默认值显式给、两处 ON DELETE CASCADE、自增改 identity。
两条脚本之外:脚本只数 .default("{") 那一种写法所以报 1 列,实际 2 列;step_data.participant_id 没有 references,脚本没有「外键缺失」这条规则扫不到。扫描器给的是下界不是全貌——拿扫描输出当全部,是自动化给人的最大错觉。

「现在不迁」三个理由每条指到行号。数据体量没到换并发模型的量——但我诚实:脚本不连库,三张表各多少行我一个都不知道,这条是定性不是量化;单机部署,getDb() 单例缓存没有多进程抢写;WAL 已在用(connection.ts L15),并发写已被缓解不是靠换库绕。
mysql2 装在 package.json 但全树没一处 import,它不是「迁移进度 30%」是化石,会骗到下一个读代码的人。
真要迁就七步,每步强制写三件事:做了什么、怎么验证、哪步能停。第 1 步先立基线——唯一零风险的停点;第 2 步改 11 处 P0,验证是重跑脚本 P0 归零、diff 里能看见 timestamptz/boolean/jsonb;第 3 步双环境跑同一批查询,验证标准不是「PG 能跑」是「两边返回一致」;第 4 步搬运方向单向、自增列显式给值;第 5 步对账,行数+抽样对值+外键,三个数全对上才发通行证;第 6 步切读,PG 只接读写还在旧库,这格最该拉长;第 7 步切写,先新→旧收敛再做一次真回滚演练。
映射按「类」不按「列」:text 主键、integer timestamp、integer boolean、text JSON、自增,五类够了——18 处里真正有坑的就 5 类。

回滚点三条:旧库保留只读不删不压缩(回滚的前提是还有地方可回,最狼狈的迁移是回滚前先把旧库优化了,那叫重来不叫回滚);每步打一个 tag(出事时唯一要答「我们在哪一步」,没 tag 只能猜,猜回滚等于没回滚);复制方向单向(双向同步是最难回滚的状态)。
三个翻车点全都不会在跑迁移脚本那天报错。schema 改完没迁数据——接口 200 返回空,具体说:is_live 旧库还是 0/1,新代码按 boolean 读出来永远是 false,于是「没有一场 workshop 在进行中」,而没人会报警;时间戳时区——脚本扫得出类型扫不出「秒数当初按哪个时区写的」,所以标还没测,但正因没测它必须进第 2 步的验证项;JSON blob 照搬成 text——把 jsonb 扔了还把整套坑带过去。

我最想留下的是:迁移的难点不在「迁」,在迁之前欠的东西,欠着不还就迁等于把旧债换个库再欠一遍。债 ① migrations 现在 0 条,从没走过版本化变更,这状态谈迁移等于没地基盖第二层。债 ② 先拆两列 JSON——先迁后拆要多搬一趟全表,那趟纯粹为给自己腾地方;先拆后迁搬的时候已经拆成列,PG 还干净落表。
更重要的观点:我把债 ② 划成和迁不迁 PG 无关的独立工作。dlmware.cn 那个库就是例子——15 张表、33,178 单、387 家客户,quote 只有 2 条、bom_line 只有 27 行,两列 JSON blob、客户归属外键空值 100%,它还在 SQLite 上可债已经欠下了,跟类型跟部署形态都没关系,跟值怎么组织有关系。一旦把「拆 JSON」挂到「迁移」下面,它会被无限期推后。

短板讲完这页才立得住:没连过真 PG 跑七步,第 2、4、5 步的验证还是待办;没做过灰度切流、回滚演练没跑过;这篇没改任何生产代码,因为不附「我没改任何东西」的诊断就不可复现;没有行数;时区没测。最重要的是这是预案不是事故报告,这项目没因 SQLite 出过生产事故,18 处是扫描器输出不是踩坑计数。
收尾挑一句:预案的价值不在写了什么,在它能不能被人跑一遍。「我们打算迁 PostgreSQL」和「我有 18 处待改、P0 11 处已定位到行号、七步顺序和三个停点已排好、结论是先还两笔债」——这是两件事,前者是一句话,后者是一个清单。