这一篇只讲一件事:「迁 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——只读文本,不连库、不写文件。
方法论出处:16-database-deep 的「JSON blob 拖垮整库」,第 07 节靠它下判断;17-concurrency-deep 讲为什么该迁,本篇补真要迁时按什么顺序、哪几步会翻车。对照库:dlmware.cn。
一句话:现在迁是给没疼的地方开刀。但我不是写个「暂缓」就交差——我把「真要迁时怎么办」写成了能跑的诊断 + 写清顺序和回滚点的清单 + 一条停手判断,三样都能被复核。
| # | 事实 | 出处 |
|---|---|---|
| 1 | db/migrations 真实迁移文件数 = 0,目录里只有一个 0 字节 .gitkeep | 脚本实测 + ls 复核 |
| 2 | 合计 18 处:P0 11 P1 7 | 脚本最后一行 |
| 3 | dialect = sqlite;mysql2 ^3.14.1 装了但全树没有一处 import | 脚本输出 + package.json L63 |
drizzle-kit generate 的产物从未入库。这状态谈「迁 PG」不是技术难度问题,是地基问题。「迁 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();
| 位置 | 内容 | 说明什么 |
|---|---|---|
| L1 | getDb() → connection.ts | 真实连接是 better-sqlite3 |
| L2 / L8 | // TODO: import tables、// TODO: insert seed data | schema 一行没用上;示例写 schema.posts 而 schema.ts 里没有 posts 表 |
| L14 | process.exit(0); // close MySQL connection pool | 注释写 MySQL,这里是 SQLite 且直接 exit,没有连接池可关 |
posts 不存在);③ 连「迁过没有」都答不出(migrations 零条)。脚本原始输出(没删没改):
· 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
.default("{") 那一种写法命中 settings;手看是 2 列——workshops.settings(L12)和 step_data.data(L37,notNull)。再补一条:step_data.participant_id(L35)没有 references,而 participants.id 存在;脚本 10 条规则里没有「外键缺失」,扫不到。| 表 · 列(行号) | 现在 | 迁 PG 要成什么样 |
|---|---|---|
| workshops.id · L5 | text 主键,例 "j2uyjunzkua3" | varchar(32) + 索引 P1 |
| created_at / started_at / ended_at · L8–L10 · joined_at / last_active_at · L24–L25 · submitted_at · L38 | 6 处 integer mode:"timestamp" | timestamptz P0 |
| is_live · L11 | integer mode:"boolean" notNull default(false) | boolean + 显式默认值 P0P1 |
| settings · L12 / data · L37 | 两列 text 存 JSON 字符串 | jsonb 或拆表 P0 |
| name / facilitator_name · L6–L7 | text notNull | 脚本没报 = 不用动 |
| participants.id · L20 / workshop_id · L21 | text 主键 / text + cascade | varchar+索引 / PG 外键,cascade 行为复核 P1 |
| role · L23 / joined_at 见上 | text enum 三值 | PG enum 或 varchar+CHECK(手看) |
| step_data.id · L33 / workshop_id · L34 | integer 自增主键 / text + cascade | bigserial / identity / PG 外键 P1 |
| participant_id · L35 / step_id · L36 | text notNull 无 references / integer(注释 1-7) | 建议补外键(手看);step_id 还没定,先按 integer 搬 P1 |
| # | 理由 | 证据 |
|---|---|---|
| ① | 数据体量没到需要换并发模型,三张表都是应用级小表,行数还没测 | schema.ts 全文 43 行 |
| ② | 单机部署,连接是单例,没有多进程抢写 | connection.ts L12–L19 if (!instance) |
| ③ | WAL 已经在用,并发写问题已被缓解 | connection.ts L15 PRAGMA journal_mode = WAL; |
| 触发条件 | 不满足时 | 判定 |
|---|---|---|
| 多机 / 多进程写并发 | connection.ts 单例假设破了,两个进程各开连接、互不知情 | 触发 |
需要行级锁 / FOR UPDATE / 可串行化 | SQLite 锁整库,这一套一个都没有,只能缩短事务 + 应用层排队 | 触发 |
| 需要 jsonb / 时间线 / 分区 | settings / data 只能当字符串,没法 WHERE、没法索引 | 触发 |
| 需要 DBaaS 托管 + 只读副本 | 单机单文件,备份就是拷文件 | 触发 |
| 上面四条都没触发 | — | 别动 |
顺序本身就是防护——多数迁移事故不是某一步做错,是两步之间的顺序反了。
做了什么:在当前 schema 上跑第一次 db:generate,让 migrations 出现第一条 0000_*.sql——先给现在拍照,再动 schema。
怎么验证:迁移文件数 > 0;git diff 看得出第一条 SQL 建了哪几张表;db:migrate 在干净库重放通过。
哪步能停:整条链路唯一零风险停点——没碰业务代码、没改 schema、没动数据。
做了什么:sqliteTable→pgTable;6 处 timestamp→timestamptz;is_live→boolean 并显式给默认值;两列 JSON 先按 jsonb 建模。
怎么验证:重跑扫描脚本 P0 从 11 降到 0;diff 里能看见 timestamptz / boolean / jsonb;tsc -b 通过。
哪步能停:能停,但只落了个空壳——它的价值在能反复横跳,不在能交付。
做了什么:旧 SQLite 与新 PG 同时挂着,把现有查询(含 db/relations.ts 三条 relations 的 join 路径)两边各跑一遍。目标不是「PG 能跑」,是「两边返回一致」。
怎么验证:同份查询两边相同行数、相同值、相同顺序;时间戳列看取值 / 排序 / 区间筛选三种读法。
哪步能停:能停,但仍是零数据,迁移事实上没开始。
做了什么:旧→新只写一个方向,新库绝不接受来自应用的写。
怎么验证:插得进读得回;自增列 step_data.id 要显式给值,否则 serial 会把已有 id 冲掉——搬完才发现、回滚很疼。
哪步能停:能停,此时新库是一个完整历史副本,怎么停都不丢东西。
三个检查,缺一项不许进下一步。行数逐表对;抽样按边界抽(最早一行、最晚一行、is_live 为真的、JSON 最长的);外键验 participants.workshop_id、step_data.workshop_id、补完要验的 step_data.participant_id。
怎么验证:三个数全对上才发通行证;任一对不上回第 ④ 步重搬(旧库没动,重搬代价最小)。
哪步能停:最舒服的停点——已对过账的完整副本,生产流量一行没切。
做了什么:读走 PG、写落 SQLite,两条路都在跑,对账要双向做。
怎么验证:同一时间窗两边行数一致、时间戳显示一致;没有时区偏移报警。
哪步能停:能停,整条链里最该被拉长的一格——第 ⑥ 步停留多久,就是 rollback 信心的来源。
做了什么:写切 PG,旧库转只读存档;搬运反向一次(新→旧)收敛;旧库只读权限显式关掉,不是「约定它别写」。
怎么验证:切流后三检重做;回滚演练至少真跑一次——真把读切回旧库一次走通了,才算切成了。
哪步能停:到这步才真的停不下来。正因如此,前六步每步都要留停点。
| SQLite 现状 | PG 侧 | 坑 |
|---|---|---|
text 主键(workshops / participants.id,如 "j2uyjunzkua3") | varchar(n) + 索引 | 长度上限先定,别事后改 |
| integer + timestamp(6 列) | timestamptz | 时区语义先定 |
| integer + boolean(is_live) | boolean | 0/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 就只能猜,猜回滚等于没回滚 |
| 三 | 复制方向单向:④–⑥ 只旧→新,⑦ 前先新→旧收敛一次 | 双向同步是最难回滚的状态,两边同时可写时你分不清哪边的脏数据是对的 |
| # | 坑 | 症状 | 前置防线 |
|---|---|---|---|
| 1 | schema 改完没迁数据 | 两边 schema 都变了,旧库还是老形状:新列全是默认值、老数据一行没动,接口 200、返回空。典型:is_live 旧库还是 0/1,按 boolean 读出来永远是 false,于是「没有一场 workshop 在进行中」——没人会报警 | 对账必须先于切读 |
| 2 | 时间戳时区 | integer 存秒数,drizzle 在本地时区解析;迁到 timestamptz 后同一个秒数可能显示成另一个时间点,等第一次跨时区的人看同一条记录时才爆 | 先定语义(UTC 还是本地时间),写进脚本常量,不靠口头约定 |
| 3 | JSON blob 照搬成 text | 把 jsonb 能力扔了,还把 16 那节整套坑带过去:重写整行、丢更新、没法 WHERE 和索引、类型保护失效 | 照 16 篇那句:需不需要出现在 WHERE / JOIN / ORDER BY? |
integer(timestamp)),扫不出「这批秒数当初按哪个时区写进去的」——那要连旧库读历史行跟业务确认。只能标「还没测」。但正因没测,它必须进第 ② 步的验证项,不能等搬完。迁移的难点不在「迁」,在「迁之前欠的东西」。欠着不还就迁,等于把旧债换个库再欠一遍。
现在 db/migrations 是 0 条真实迁移文件——这个项目从没走过版本化变更,schema 大概是「改一下 db:push 推上去」。这个状态谈迁移,等于没地基就盖第二层:你连「现在长什么样」都没有可 diff 的记录。所以第 ① 步不是「准备工作第一步」,它是迁移能不能成立的前提。
| 顺序 | 搬运 | 结果 |
|---|---|---|
| 先迁后拆 | 两次(先当 text 搬,再拆一次) | 多一趟全表重写,而这趟纯粹为了给自己腾地方 |
| 先拆后迁(我选的) | 一次 | 省一趟;拆完再迁 PG 干净落表,不用回头写拆表迁移 |
workshops.settings:若只整体读出来渲染 → 可留 JSON(PG 里用 jsonb 还能按路径查);step_data.data:step 提交记录,将来大概率要按 step_id 汇总、按里面分数排序 → 该拆。drizzle-kit generate 第一次 → 迁移文件数 > 0 → 干净库 db:migrate 重放通过 → 打 tag pre-migration-baselinejson-broken-upstep_data.participant_id 补外键(脚本扫不到)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 无关的独立工作」:挂到「迁移」这个大任务下面,它就会被无限期推后。
| # | 没做的事 | 真实现状 | 影响 |
|---|---|---|---|
| 1 | 没连过真 PG 跑这套七步 | 第 ② 改 schema、第 ④ 搬运脚本、第 ⑤ 对账 SQL,三个验证方法都还没实测 | 「怎么验证」按待办读,不能按已跑通读 |
| 2 | 没做过灰度切流 | 切读切写没有真跑记录,回滚演练也没跑过 | 回滚点三条目前是设计,不是验证过的机制 |
| 3 | 没改动任何生产代码 | 脚本只读文本、不连库不写文件;五个源文件一个字节没动 | 刻意:不附「我没改任何东西」的诊断不可复现 |
| 4 / 5 | 「数据体量小」没有数字;时区语义没定没测 | 脚本不连库,三张表各多少行我一个都不知道;时区只能标「还没测」 | 理由 ① 是定性判断;它是第 ② 步的验证项不是结论 |
| 6 / 7 | JSON blob 拆不拆还没问业务;这是预案不是事故报告 | 「data 将来怎么查」要业务回答;没因 SQLite 出过事故,18 处是扫描器输出不是踩坑计数 | 第 07 节给了方向没拍板;别读成「我迁过、我踩过」 |
process.exit(0); // close MySQL connection pool,而实际跑 better-sqlite3step_data.participant_id(L35)没有 references——扫描器给的是下界,跑完必须手看源码先说结论别铺垫:我做过的是迁移预案,不是迁移实施。现在还在 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 处已定位到行号、七步顺序和三个停点已排好、结论是先还两笔债」——这是两件事,前者是一句话,后者是一个清单。