Node 后端实战 · 老系统数据迁移怎么不出乱子?V1→V2 重构实战与 3 个生产坑
各位看官,这一篇不聊某个 API 怎么写,聊一件更让人手心出汗的事------把一套运行了多年的老系统数据,迁到我新做的多租户 SaaS 里。
为什么说手心出汗?因为数据迁移有两个铁律:丢了没法补,错了对不起客户。老系统里跑着一万多条主数据、上万个关联轨迹,任何一个归属错配、任何一条孤儿记录,上线后都是实打实的客诉。
这篇复盘全部来自我自己一次真实的生产迁移。最终结论先摆这儿:主数据 1.6 万余条,关联记录 3 万余条,迁移后孤儿记录 0 条、丢失 0 条。下面把怎么做到的、以及踩的 3 个生产坑,掰开讲。
说明:文中业务对象、字段名沿用真实代码结构(leads / call_records 等),但具体客户名、人员姓名、域名、项目名等均已泛化脱敏,不涉及任何具体业务系统类型。
一、这不是"挪数据",是"重构数据模型"
很多人以为迁移就是 SELECT * 出来再 INSERT 进去。这次不是。V1 老系统是一张大宽表撑全场 :一个 customers 表里塞了客户信息、状态、归属、地址、批次,外加一张 call_logs 流水和一张 assignment_logs 分配日志。V2 新系统按规范化的思路,把它拆成了好几张职责单一的表。
最终落地的目标表:
| 目标表 | 作用 | 数据来源 |
|---|---|---|
leads |
主数据(每条业务对象一行) | V1 customers |
call_records |
沟通记录(每次联系一行) | V1 call_logs |
lead_followups |
跟进备注(有备注才生成) | V1 call_logs 中带备注的行 |
lead_assignments |
分配/流转审计(全量保留) | V1 assignment_logs |
lead_imports |
导入批次(保留来源与成本) | V1 batches |
注意 call_logs → call_records + lead_followups 这一步:一张流水表拆成两张,有备注的才额外落一条跟进,没备注的只留沟通记录。这就是迁移里最考验细心的"一对多拆分"。
二、字段映射的两个关键决策
大宽表拆小表,最难的不是搬,是语义对齐。举两个我当时拍板的映射:
1. 状态不是列,是"类型 + 归属"组合出来的
V1 的 customers.type(-1/0/1/2 表示意向等级)到了 V2,并不能直接等于"状态"。V2 的 status 是工作流状态(废/待分配/已分配/跟进中/已转化),而意向等级在 V2 是另一个独立的 categoryId 轴。所以映射规则是:
| V1 type | 有无归属 | V2 status |
|---|---|---|
| -1 废对象 | 任意 | invalid |
| 0 普通 | 有归属 | assigned |
| 0 普通 | 无归属(公海) | pending |
| 1 意向 | 任意 | following |
| 2 高意向 | 任意 | converted |
这条规则的核心教训:别把两个不同维度的东西硬塞进一个字段。V2 把"工作流状态"和"分类标签"拆成两个轴,迁移时就得照这个新模型重新推导,而不是机械改名。

2. 一个被误命名的列
V1 的 company 字段,名字叫"公司",实际存的却不是公司名,而是某类结构化地址串 (比如"片区-A 栋-某单元"这种层级地址)。V2 有专门的 address 字段,所以这一列直接落到 address,V2 的 company 反而是空的。
这类"列名和实际内容对不上"的情况,在老系统里特别常见。迁移时不能看名字翻译,得看真实数据内容定落点,否则新库里一堆张冠李戴。
三、3 个真实生产坑(重点)
模型想清楚了,真跑起来还是被现实教育了三回。
坑 1:唯一约束被"空字符串"撞穿
新库在 (tenant_id, project_id, phone) 上建了唯一索引。迁移脚本对 phone 做了标准化(去 +86、空格、横杠,统一成纯数字)。问题来了:V1 里有 2 条记录的 phone 是空值 ,标准化后都变成 ''(空字符串)。空字符串和空字符串在唯一索引上是同一条 ,直接 UNIQUE constraint failed。
修复很简单,但得想清楚:SQLite 的唯一索引允许多个 NULL 共存,但不允许多个空串共存 。所以把这两行的 phone 置为真正的 NULL,唯一约束就不冲突了;它们对应的沟通记录用原始值兜底(满足那个表 phone NOT NULL 的约束)。
教训:标准化之后一定要单独处理"空"和"NULL"两个概念。空串是值,NULL 是缺失,在唯一索引下待遇天差地别。
坑 2:大文件上传中途断网,差点造出孤儿
最大的那张表导出的 SQL 有 5.8MB 。上传到边缘数据库的中途网络一抖,fetch failed,事务回滚,这张表归零。但坑在于------我之前的执行顺序是先写后面几张小表、再写这张大表 。大表回滚归零后,前面几张小表已经写进去了,于是出现"有沟通记录、却没有对应主数据"的孤儿记录。
改法很朴素但很关键:每次重试之前,先把这 5 张表彻底清空,单独把大表重试成功(校验条数对得上)之后,再依次导入后面几张。这样无论重试几次,都不会出现"半截数据"。
教训:批量导入的顺序设计要能承受"任意一步失败重来"。把有依赖关系的表放在同一个可重置的批次里,失败就整批清空重来,远比"分步提交、靠人工对账"稳。
坑 3:时间戳凭空偏了 8 小时
V1 的时间字段是 YYYY-MM-DD HH:MM:SS 这种不带时区标记 的字符串。Node 里 new Date('2026-07-29 10:00:00') 会按运行机器的本地时区去解析。脚本在本地(UTC+8)跑,于是所有时间被当成"本地时间"再存的瞬间,就整体偏了 +8 小时。
修法是强制按 UTC 解读:把空格换成 T 再补 Z,即 new Date(str.replace(' ','T') + 'Z')。这样无论脚本在哪台机器跑,时间戳都一致。
教训:老系统凡是"无时区标记的时间字符串",迁移时必须显式指定源时区,绝不能依赖运行环境的本地时区。这是个一旦错了就很难察觉、察觉了也很难全量回滚的坑。
修复就是一行,但想明白之前会让你怀疑人生:
js
// V1 时间串 '2026-07-29 10:00:00'(无时区),强制按 UTC 解读
const ts = Math.floor(new Date(str.replace(' ', 'T') + 'Z').getTime() / 1000);
四、边缘数据库(D1)的两个硬约束
这次目标库是 Cloudflare D1(边缘 SQLite),它和本地 MySQL 不一样,有两个约束直接影响了迁移脚本写法:
1. 不支持 BEGIN/COMMIT 事务包裹
wrangler d1 execute --file 走的是 Durable Objects 事务模型,你写在 SQL 文件里的 BEGIN/COMMIT 它不认。所以迁移脚本不能靠数据库事务保证原子性 ,只能靠"先清空、再写、靠守卫保证可重跑"的应用层幂等来兜底。这也正是坑 2 里"整批清空重来"策略能成立的前提。
2. 单条语句 100 个绑定参数的硬上限
D1 一条 INSERT 最多 90 个命名参数。宽表 leads 有 27 列,算下来一条多值 INSERT 只能塞 3 行 。所以分块不是按"行数"算,是按"列数 × 行数 ≤ 90"算的。脚本里是 rowsPerStmt = Math.max(1, floor(MAX_PARAMS / 列数))。
教训:边缘数据库的"小"是结构性的,不是调个配置就能放宽的。批量写入前先算清楚参数上限,否则大表导入会莫名截断。

分块公式长这样,按"列数"而不是"行数"算:
js
const MAX_PARAMS = 90; // D1 单语句绑定参数硬上限
const LEAD_COLS = 27; // leads 表列数(宽表)
const rowsPerStmt = Math.max(1, Math.floor(MAX_PARAMS / LEAD_COLS)); // => 3 行/INSERT
五、迁移后的回填:连接键与幂等
迁移时为了赶上线,有两类维度(分类、项目归属)先留了空。等上线稳定后,我又做了一次回填,补这两列。这里有两个值得记的技术点:
连接键 = 标准化后的 phone。 因为迁移脚本生成的是随机 UUID,没持久化"旧 id → 新 uuid"映射,回填时就用 phone 作 1:1 连接键。前提是核验过老数据的 phone 标准化后全唯一(上万条里只有 2 条空号,用"phone 为空 + 姓名 + 创建时间"复合匹配)。
只填空、不覆盖。 回填 SQL 一律带 WHERE category_id IS NULL / project_id = '未分类',绝不去动用户后来在后台手动改过的值。这样的 SQL 可反复重跑,跑十次和跑一次结果一样------这是离线数据修复脚本的底线。
sql
-- 按连接键回填分类,但只动"还没分类"的行(已手动改过的不动)
UPDATE leads
SET category_id = :catId
WHERE phone = :normalizedPhone
AND category_id IS NULL;
六、完整性校验怎么做
数据进去了不能拍胸脯说没问题,得验。我当时做了几组核对:
| 校验项 | 结果 |
|---|---|
| 各表条数与 V1 源一致 | ✅ |
| 孤儿沟通记录(无对应主数据) | 0 |
| 孤儿跟进记录 | 0 |
| 沟通结果分布与 V1 完全一致 | ✅ |
| 归属分布(按角色) | 销售甲 4127 / 销售乙 4085 / 销售丙 4041 / 销售丁 3900 / 公海 223 |
| 分类/项目空值 | 0(回填后) |
归属分布那行,是我最在意的------一万多条主数据,谁的归谁,一条都不能错配。验证方法是拿 V1 的归属口径重新算一遍,和导入后的结果逐一对账,完全一致才敢上线。
七、如果再来一次,我的迁移 checklist
| 环节 | 要点 |
|---|---|
| 模型设计 | 先对齐语义,别看列名翻译;多维度别硬塞一列 |
| 唯一约束 | 标准化后单独处理"空串 vs NULL" |
| 执行顺序 | 有依赖的表放同批次,失败整批清空重来 |
| 时区 | 无标记时间串必须显式指定源时区 |
| 边缘库约束 | 不支持 BEGIN/COMMIT → 靠应用层幂等;算清参数上限再分块 |
| 回填 | 用稳定连接键;只填空不覆盖;保证可重跑 |
| 上线前 | 条数、孤儿数、分布三核对,逐一对账 |
数据迁移没有"差不多",只有"对得上"和"对不上"。把上面这七条钉死,1.6 万条迁移也能做到零丢失、零孤儿。
相关阅读:
- Node 后端实战 · 列表查询到底怎么写?一个通用 DSL 封装,过滤分页排序一次搞定
- Node 后端实战 · 边缘 Cron 定时任务怎么写?Cloudflare 三个实战任务与踩坑
- Node 后端实战 · 后端敏感数据怎么防泄露?PII 自动脱敏与审计日志实战
- Node 后端实战 · 多租户 SaaS 的数据隔离
- Node 后端实战 · 边缘 Serverless 下的大批量异步导出
本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!