FDE·交付知识库
目录
交付方法论
需求诊断 项目切片 灰度上线 验收交接 报价期望 集成治理
技术纵深
数据迁移 稳定性 定价引擎
基础
①托管 ②SEO ③验证 ④AI产线

模块 11 · 数据迁移实战

代码写完一周能上线,历史脏数据能拖三个月。迁移失败几乎都不是技术问题,是数据问题。

项目锚点 · collections 单行 JSON blob 写坏整库

真实事故:德莉玛 collections 表把整个对象存成单行 JSON blob,后果是写坏整库就崩、无法查询、并发串行。根因正是「数据库是用来存可独立查询的最小单元,不是存一坨结构」——改一个字段要重写整行,并发写互相覆盖,没法索引只能全表扫,一行坏了其他正常数据也取不到。

修法:拆成规范表结构 + 加索引(delimao-system 的 packages/db/src/schema.ts 就是 27 张表的规范结构,字段与索引都写实)。延伸:基础 16 · 数据库存东西 讲透B+ 树与拆表原理。

01

为什么数据迁移是项目最大的隐性风险

开发的风险是可见的:写不通就报错,跑不起来就崩溃。而数据迁移的风险是沉默的——它不报错,它只是安静地把错数据搬进新系统,然后你在三个月后才发现报价算错了。

对比项功能开发数据迁移
工期可估、可拆、可并行高度依赖数据现状,盘点前无法估准
失败方式立刻报错,当场修沉默写入,事后才发现
工作量写出来就固定了每发现一类脏数据就要多一套清洗规则
可否回退代码回滚即可数据回退要靠备份,没有备份就是单向门
最常见的错误排期:按功能开发估工期,把数据迁移当成「三天的事」。结果源数据一打开,脏数据形态超出预期,工期翻倍,而且没法用加人解决——因为脏数据规则要一条一条讨论业务口径,不是写代码能消化的。
一句话立论:迁移失败几乎都不是技术问题,是数据问题。所以我的习惯是——不盘点不开工,盘点不干净不报价。
02

德莉玛真实数据画像

这一节是整个知识库里最能说明问题的一段。它解释了为什么「直接上引擎」是一个会失败的决策。

2.1 全域真实资产

德莉玛这条线的全域真实数据是:33,176 笔订单 / 387 家经销商 / 2,799 个型号。规模不小,看起来数据基础应该不错。用这批数据跑一遍覆盖率校验,结果是:

校验项真实覆盖率结论
型号覆盖96.6%✅ 可用,型号字典能建起来
厚度覆盖95.2%✅ 可用,厚度基本齐
尺寸覆盖29%🔴 致命——没尺寸算不出价
客户匹配0%🔴 致命——不知道给谁报

2.2 抽样 400 单看到什么

我抽样跑了 400 单,结果是:97% 的订单 spec 为空,也就是根本没有尺寸。有尺寸的只有 3 单——而这 3 单还属于 K / Y 系列,不是主流的 H / S / M 系列。

这就是最扎人的一句事实:不是数据「有点脏」,是主数据根本不存在。主流系列 H/S/M 的订单里几乎没有人填尺寸。29% 的覆盖率看起来还能接受,是因为分母算的是全部订单;而真正要用来算价的那部分,覆盖率接近于零。

2.3 这页的结论

数据匮乏不是缺陷,而是必须先说出口的诊断结论。如果跳过这一步直接上报价引擎,结果是一个算不出价的系统——它会跑得很快、界面会很完整、演示会很漂亮,然后在真实数据上大面积返回空结果。这比不做更糟:不做客户只是没东西用,做了客户会认为是我做的不能用。
所以项目排期被改成了两段:第一段做数据治理(建型号↔尺寸数据源、建订单↔经销商 ID 对照表、补出厂价红线表),第二段报价引擎等数据达标再启动。第一段不产出「智能」,但不做它,第二段根本无法启动。这个判断改变了整个项目排期,也改变了报价的拆分方式。
03

迁移四步法

顺序不能乱。跳步的代价:跳过盘点直接清洗,你会按错误的假设写清洗规则;跳过清洗直接装载,脏数据进了新库;跳过对账,你根本不知道有没有丢。

1

盘点

搞清楚三件事:源数据有多少、字段是什么、脏在哪。产出物是一份字段清单和一份数据画像——每个字段的总行数、空值数、唯一值数、取值分布、最长长度。这一步不做,后面全是猜。

2

清洗

定规则、去重、补空。每一条清洗规则都要写下来并和业务方确认,因为「补空」涉及判断:空的尺寸是「这单确实没要」还是「录的时候漏了」,两种处理方式完全不同。这一步工作量最大,也最需要业务方参与。

3

转换

结构对齐、编码统一。把源系统的字段名、单位、编码映射到目标结构上。这里最隐蔽的坑是编码和口径:同一个「厚度」单位不同、字符编码不同、时区不同、型号大小写不同。见下面脏数据第3 种形态。

4

装载 + 对账

写入目标库,然后必须对账。对账不是看日志说成功——日志只证明你写了,不证明写对了、没写漏、没写重。这一步的具体做法见模块后面第 06 节。

红线:第 4 步的「对账」不能省,也不能只对行数。行数对得上但内容全错,是迁移项目最常见的假成功。
04

脏数据的六种典型形态与对策

这六种是我在真实项目里反复遇到的。分类的价值在于:每种形态有固定的对治套路,不用重新想。

#形态典型表现对策
1空值 / 缺字段德莉玛的 400 单里 97% spec 为空——不是空字符串,是根本没有这个字段的值区分三种情况分别处理:业务上确实不需要(留空并标注,不参与计算);录入了漏填(回溯源系统补,无法补的进待处理队列);源头就没有这个字段(改需求或改流程,别用默认值糊)。最忌讳把空值当 0 处理
2单字段存 JSON blob德莉玛真实踩过的坑:collections 表把整个对象存成单行 JSON,结果是写坏整库就崩、无法查询、并发串行拆成规范表结构 + 加索引。一对多的进子表,标量字段各占一列,需要经常查询的字段必须有独立列和索引,不能塞在 JSON 里靠全表扫描
3口径不一致同一个「厚度」,一处毫米一处厘米;型号大小写混用;时区一个是 UTC 一个是本地时间;中文里全角半角混用建一张口径映射表,所有换算集中在一处,禁止在业务代码里散落硬编码系数。转换前统一做大小写归一、空白裁剪、全半角转换、时区转换,并保留原始值字段以便追溯
4重复记录同一笔订单导入两次;同一家经销商两条档案;同一个型号两种写法先定去重键(一般用业务唯一键,比如订单号),再定保留策略(保留最新/保留最全/保留人工确认)。不定义去重键就开跑,一定会出现重复单据
5历史遗留状态已废弃的型号还在库里,还在参与计算;已停用的经销商还是有效状态;工单早就关闭了但状态还挂在进行中区分有效 / 停用 / 废弃三态并显式建模,不能靠「库里有没有这条」判断。废弃数据保留但隔离(加状态位、不进正常查询),不直接删——删了就没有历史可查
6越界值尺寸是负数或0;字段超长把列撑爆;金额是负数(退货场景可能是合法的,订单场景不是)入库前加校验与约束:非空、范围、检查约束,把非法数据拦在库外。同时产出「异常数据清单」给业务方确认,而不是静默丢弃——丢弃也要有清单和口径
第 2 种要单独强调,因为它的隐蔽性最强:JSON blob 写法在开发阶段看起来很省事——不用建表、加个字符串就完事。但代价是后期全部要还:无法建索引、无法用SQL 聚合、跨表关联只能全量读出来在应用层拼、性能随数据量断崖下跌、并发写入直接串行。只要这个字段将来需要查询或统计,就绝不能存成 JSON。
05

从 JSON 落到真实 SQLite 库的配方

下面是门店订购系统 v2.5.0 的建表配方。技术栈是 Express + better-sqlite3 + SQLite(开启 WAL):单文件库、零部署、同步API 适合单进程服务,WAL 模式下读写不互斥,门店端写单和技术端读单可以并发。

-- ① 订单主表:一单一行
CREATE TABLE orders (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  order_no      TEXTNOT NULL UNIQUE,      -- 业务唯一键:幂等与去重都靠它
  dealer_id     INTEGER NOT NULL,          -- 经销商,对照表外键
  status        TEXT    NOT NULL,          -- 状态机:dispatched / store_signed / tech_confirmed / voided
  signed_at     INTEGER,                    -- 门店签认时间戳(UTC,存整数)
  tech_confirmed_at INTEGER,
  total_qty     INTEGER NOT NULL DEFAULT 0,-- 冗余合计,便于对账
  created_at    INTEGER NOT NULL,
  FOREIGN KEY (dealer_id) REFERENCES dealers(id)
);
CREATE INDEX idx_orders_status  ON orders(status);
CREATE INDEX idx_orders_dealer  ON orders(dealer_id);
CREATE INDEX idx_orders_created  ON orders(created_at);

-- ② 订单明细:一单多行
--为什么拆开:规格是列表,必须能按型号/厚度查询与统计,
--    塞成 JSON 就只能全表扫描(德莉玛踩过的坑)
CREATE TABLE order_items (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  order_id     INTEGER NOT NULL,
  model_id     INTEGER NOT NULL,          -- 型号字典外键,不存型号字符串
  thickness_mm INTEGER NOT NULL,          -- 单位入库存:毫米。只存整数,不存 '8mm' 这类字符串
  spec_json    TEXT,-- 仅留不可查询的扩展属性,规格主体在上面三列
  qty          INTEGER NOT NULL DEFAULT 0,
  FOREIGN KEY (order_id)    REFERENCES orders(id),
  FOREIGN KEY (model_id)    REFERENCES models(id)
);
CREATE INDEX idx_items_order ON order_items(order_id);
CREATE INDEX idx_items_model ON order_items(model_id);
CREATE INDEX idx_items_thick ON order_items(thickness_mm);

-- ③ 型号字典:2,799 个型号,型号覆盖 96.6%
--    关键点:型号是「字典」不是「文本」;thickness 单列成整数列才能建索引
CREATE TABLE models (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  model_code   TEXT    NOT NULL UNIQUE,    -- 归一化后的大写编码
  series       TEXT    NOT NULL,           -- H / S / M / K / Y 系列
  thickness_mm INTEGER NOT NULL,
  is_active    INTEGER NOT NULL DEFAULT 1  -- 有效/停用/废弃三态靠这个位隔离
);
CREATE INDEX idx_models_series ON models(series, is_active);

-- ④ 经销商对照表:387 家,客户匹配原本是 0%,靠这张表补上
CREATE TABLE dealers (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  dealer_code TEXT    NOT NULL UNIQUE,     -- 订单里的历史编码,靠它做映射
  name       TEXT    NOT NULL,
  short_name TEXT,-- 门店口语简称,用于对账时的模糊匹配
  is_active  INTEGER NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX idx_dealers_code ON dealers(dealer_code);

-- ⑤ 迁移执行记录:对账的凭据
CREATE TABLE migration_batches (
  batch_id     TEXT PRIMARY KEY,           -- 批次号,也用作幂等键
  source_count INTEGER NOT NULL,
  loaded_count INTEGER NOT NULL,
  ok_count     INTEGER NOT NULL,
  failed_count INTEGER NOT NULL,
  checksum_md5 TEXT    NOT NULL,           -- 源文件 MD5,验证没动过源数据
  created_at   INTEGER NOT NULL
);

为什么这么拆:三条判断依据

决策依据
订单与明细拆两张表一对多关系,明细需要独立查询和统计。合表会导致每行重复订单头信息,且无法对明细单独建索引
型号指向字典表,不存字符串2,799 个型号用字符串存,等于放弃了外键约束、放弃了 join、放弃了按系列聚合。字典表让「型号覆盖 96.6%」这种统计变成一次 count distinct
厚度用整数毫米单列需要按厚度筛选和分组。存字符串就没法有效索引,存不同单位则算错。同时杜绝了「8mm」和「8 毫米」和「8」并存
一个真实的岗位教训:拆单员 → 技术确认岗。原来门店叫「拆单员」,负责把订单拆成生产工单。后来拆成两个岗:拆单按固定规则拆(型号、厚度、数量这些确定的字段),技术确认按真实技术条件确认(尺寸能不能做、要不要特殊工艺这些需要判断的字段)。这个拆分不是为了多设一个岗,而是因为「按固定规则拆」和「按真实技术条件拆」是两种完全不同的思维动作,混在一起的结果是拆单员为了赶进度自己猜技术条件。数据设计同理:把「确定的」和「需要判断的」在结构上分开,不要混在一张表的一堆列里。
06

数据对账怎么做

对账是唯一可信的验证方式。日志说成功只证明你写了,不证明写对了、没漏、没重。五层对账,从便宜到严格依次做:

层次做法能发现什么成本
行数对账源表总行数 vs 目标表装载行数 vs 成功 + 失败 = 总数整体丢失、批次中断最低,一条 SQL
求和对账数量、金额等可加字段分别求和比对单价或数量算错、累加丢失低,但最能抓计算错误
关键字段抽样比对按业务键抽样,逐字段对比源和目标字段错位、单位换算错、编码不一致中,样本量自己定
孤儿记录检查查明细里的 dealer_id / model_id 在字典表里找不到的记录映射表没建全、编码对不上低,但结果往往惊人
反向验证用接口跑真实数据反查:从新系统读出来,再和源系统原始数据比只有走完整链路才暴露的问题——序列化、精度丢失、单位在应用层又被处理了一次最高,但这层最不可省
孤儿记录检查是性价比最高的一层。客户匹配率是0%这件事,在对账里就是一句话查出来的:SELECT count(*) FROM orders o LEFT JOIN dealers d ON o.dealer_id = d.id WHERE d.id IS NULL。这一条 SQL 比任何人工核对都可靠。
纪律:上线后仍要每日对账,不是迁移完就结束。原因很直接:源系统还在被人用、还在继续录新数据、还可能有人回头改历史记录。迁移不是一次性事件,是一段需要持续盯的时期。每日对账的行数差、求和差、孤儿数,三个数字进监控看板,异常当天报警。
07

常见坑 / 红线

坑说明 / 对策
只验日志不看数据(最常见)「导入成功,写入 3,317 条」——然后收工。对策:日志一律不算验证。最低限度也要跑一遍行数对账 + 求和对账
抽样只抽成功的从成功清单里抽 100 条对比,全对,宣布迁移完成。失败的那些从来没被看过。对策:抽样必须包含全部失败记录和异常记录,而且要按规则抽(如随机 + 全量异常),不能只抽方便抽的
空值当 0 处理把空的spec 填成 0,引擎跑得很happy,算出来的价全是错的。对策:空值和 0 是两个不同的语义,必须分开;参与计算前显式判空,不给默认兜底
直接覆盖源数据没备份迁移脚本直接 update 源表,或者原地清洗。出错后没有回退点,整个项目停摆。德莉玛的做法是:先备份再动,并留 md5 校验——备份文件算 md5,源文件算 md5,一致才允许开始清洗
一次性全量导入不留回退点一条脚本全量跑完,没有分批、没有开关。对策:分批 + 可重入。按批次号切分,每批独立事务,失败只回滚那一批;重跑时靠幂等键跳过已成功的批次。schema 里那张migration_batches 表就是为这个存在的
清洗规则没落文档规则散在脚本注释和脑子里,三个月后业务方问「为什么这单型号被改了」,没人答得上来。对策:每条清洗规则一条记录:规则名、影响行数、示例、确认人
三条红线:① 没备份和 md5 校验,不许动源数据;② 迁移脚本必须分批 + 幂等可重入,不许一次性全量;③ 没有对账数据,不许宣布迁移完成——日志说成功不算数。
✓

自查清单

  • ✓我知道迁移失败几乎都不是技术问题,是数据问题;排期时按数据现状估,不按代码量估
  • ✓我背得出德莉玛的数据画像:33,176 单/ 387 经销商 / 2,799 型号,型号 96.6%、厚度 95.2%、尺寸 29%、客户 0%
  • ✓我知道 400 单抽样里 97% 的 spec 为空,仅 3 单有尺寸且属 K/Y 系列,不是主流 H/S/M 系列
  • ✓我能用这组数据解释「为什么不能直接上报价引擎」,以及它如何改变了整个项目排期
  • ✓我按盘点 → 清洗 → 转换 → 装载对账四步走,不跳步
  • ✓我能说出脏数据的六种形态,并知道每种的对治手段,特别是 JSON blob 必须拆表加索引
  • ✓我建表时会拆订单主表/明细/型号字典/经销商对照表,并说清每张表为什么这么拆
  • ✓厚度用整数毫米单列存,不存字符串、不存不同单位的混合值
  • ✓我会做五层对账:行数 / 求和 / 抽样 / 孤儿记录 / 反向验证
  • ✓我坚持上线后每日对账,并把三个数字进监控告警
  • ✓我动源数据前会先备份并留 md5 校验;迁移脚本分批 + 幂等可重入
  • ✓我知道「日志说成功」不等于「数据迁移对了」
面试怎么答

「数据迁移要注意什么?脏数据你怎么处理?」

我的观点是:迁移失败几乎都不是技术问题,是数据问题。开发的风险是可见的——写不通就报错;迁移的风险是沉默的,它不报错,它只是安静地把错数据搬进新系统,然后你三个月后才发现报价算错了。所以我的习惯是不盘点不开工,盘点不干净不报价。

第一步是盘点:每个字段的总行数、空值数、唯一值数、取值分布、最长长度。产出字段清单和数据画像。不做这步,后面全是猜。第二步清洗,每条规则都要和业务方确认,因为「补空」本身就含判断:空的尺寸是「这单确实没要」还是「录的时候漏了」,两种处理完全不同。第三步转换,结构对齐、编码统一。第四步装载 + 对账,这步不能省,也不能只对行数。

讲个我真实做过的例子。德莉玛的门店订购系统,全域真实数据是 33,176 笔订单、387 家经销商、2,799 个型号。规模不小,看着底子应该够。我抽样跑了 400 单,结果是 97% 的订单 spec 为空——根本没有尺寸。有尺寸的只有 3 单,而这3 单还属于 K/Y 系列,不是主流的 H/S/M 系列。全域跑覆盖率校验:型号 96.6%、厚度 95.2% 都能用,但尺寸只有 29%、客户匹配是 0%。

这件事对项目排期的影响是根本性的。如果跳过这一步直接上报价引擎,我会做出一个跑得很快、界面很完整、演示很漂亮,然后在真实数据上大面积返回空结果的系统——那比不做更糟,因为客户会认为是我做的不能用。所以我把「数据匮乏」当成一个必须先说出口的诊断结论,而不是一个缺陷,然后把项目拆成两段:第一段做数据治理,建型号↔尺寸数据源、订单↔经销商 ID 对照表、补出厂价红线表;这段不产出「智能」,但不做它第二段根本无法启动。第二段报价引擎等数据达标再启动,范围和价格单独谈。

脏数据我按六种形态分类处理,因为每种有固定套路:空值缺字段——区分「业务不需要」「录漏了」「源头没这字段」三种,空值和 0 是两个语义,绝不能当0 处理;单字段存 JSON blob——必须拆表加索引;口径不一致——单位、大小写、时区、全半角,建口径映射表集中换算,禁止散落硬编码;重复记录——先定去重键和保留策略,不定义就去跑一定会出重复单据;历史遗留状态——有效/停用/废弃三态显式建模,废弃数据保留但隔离,不直接删;越界值——加校验约束拦在库外,同时产出异常清单给业务方确认,静默丢弃也要有清单和口径。

其中我想特别讲 JSON blob 这个坑,因为德莉玛是真踩过的:collections 表把整个对象存成单行 JSON,结果是写坏整库就崩、无法查询、并发串行。它在开发阶段很省事——不用建表,加个字符串就完事。但代价是后期全部要还:建不了索引、用不了 SQL 聚合、跨表关联只能全量读出来在应用层拼、性能随数据量断崖下跌。我的原则是:只要这个字段将来需要查询或统计,就绝不能存成 JSON。

然后是落到真实库的配方。门店订购系统 v2.5.0 是 Express + better-sqlite3 + SQLite开WAL,我拆四张表:订单主表、订单明细、型号字典、经销商对照表,外加一张迁移批次记录表当对账凭据。三条拆分依据是:订单和明细是一对多,明细要独立查询和统计;型号指向字典表而不是存字符串——2,799 个型号用字符串存等于放弃外键、放弃 join、放弃按系列聚合;厚度用整数毫米单列存——存字符串就没法有效索引,存混合单位则必然算错,也顺手消灭了「8mm」「8 毫米」「8」并存的情况。

这里还有个岗位上的教训:原来门店叫拆单员,后来拆成拆单和技术确认两个岗。拆单按固定规则拆,技术确认按真实技术条件确认。这么拆不是多设一个岗,而是因为「按固定规则拆」和「按真实技术条件拆」是两种完全不同的思维动作,混在一起的结果是拆单员为了赶进度自己猜技术条件。数据设计同理:把确定的和需要判断的在结构上分开,不要混在一堆列里。

对账我分五层,从便宜到严格:行数对账抓整体丢失、数量金额求和对账抓计算错误、关键字段抽样比对抓字段错位和单位错、孤儿记录检查抓映射表没建全、反向验证——用接口跑真实数据反查,抓只有走完整链路才暴露的序列化、精度丢失问题。性价比最高的是孤儿记录检查:客户匹配率是0% 这件事,一条 SQL 就查出来了,比任何人工核对都可靠。

最后是纪律。迁移完不等于结束,上线后我仍然要每日对账——源系统还在被人用、还在继续录新数据、还可能有人回头改历史记录。行数差、求和差、孤儿数三个数字进看板,异常当天报警。日志一律不算验证,抽样必须包含全部失败和异常记录,空值不能当 0,动源数据之前先备份并留 md5 校验,迁移脚本必须分批且幂等可重入。这几条不是最佳实践,是踩过才知道疼的底线。