代码写完一周能上线,历史脏数据能拖三个月。迁移失败几乎都不是技术问题,是数据问题。
真实事故:德莉玛 collections 表把整个对象存成单行 JSON blob,后果是写坏整库就崩、无法查询、并发串行。根因正是「数据库是用来存可独立查询的最小单元,不是存一坨结构」——改一个字段要重写整行,并发写互相覆盖,没法索引只能全表扫,一行坏了其他正常数据也取不到。
修法:拆成规范表结构 + 加索引(delimao-system 的 packages/db/src/schema.ts 就是 27 张表的规范结构,字段与索引都写实)。延伸:基础 16 · 数据库存东西 讲透B+ 树与拆表原理。
开发的风险是可见的:写不通就报错,跑不起来就崩溃。而数据迁移的风险是沉默的——它不报错,它只是安静地把错数据搬进新系统,然后你在三个月后才发现报价算错了。
| 对比项 | 功能开发 | 数据迁移 |
|---|---|---|
| 工期 | 可估、可拆、可并行 | 高度依赖数据现状,盘点前无法估准 |
| 失败方式 | 立刻报错,当场修 | 沉默写入,事后才发现 |
| 工作量 | 写出来就固定了 | 每发现一类脏数据就要多一套清洗规则 |
| 可否回退 | 代码回滚即可 | 数据回退要靠备份,没有备份就是单向门 |
这一节是整个知识库里最能说明问题的一段。它解释了为什么「直接上引擎」是一个会失败的决策。
德莉玛这条线的全域真实数据是:33,176 笔订单 / 387 家经销商 / 2,799 个型号。规模不小,看起来数据基础应该不错。用这批数据跑一遍覆盖率校验,结果是:
| 校验项 | 真实覆盖率 | 结论 |
|---|---|---|
| 型号覆盖 | 96.6% | ✅ 可用,型号字典能建起来 |
| 厚度覆盖 | 95.2% | ✅ 可用,厚度基本齐 |
| 尺寸覆盖 | 29% | 🔴 致命——没尺寸算不出价 |
| 客户匹配 | 0% | 🔴 致命——不知道给谁报 |
我抽样跑了 400 单,结果是:97% 的订单 spec 为空,也就是根本没有尺寸。有尺寸的只有 3 单——而这 3 单还属于 K / Y 系列,不是主流的 H / S / M 系列。
顺序不能乱。跳步的代价:跳过盘点直接清洗,你会按错误的假设写清洗规则;跳过清洗直接装载,脏数据进了新库;跳过对账,你根本不知道有没有丢。
搞清楚三件事:源数据有多少、字段是什么、脏在哪。产出物是一份字段清单和一份数据画像——每个字段的总行数、空值数、唯一值数、取值分布、最长长度。这一步不做,后面全是猜。
定规则、去重、补空。每一条清洗规则都要写下来并和业务方确认,因为「补空」涉及判断:空的尺寸是「这单确实没要」还是「录的时候漏了」,两种处理方式完全不同。这一步工作量最大,也最需要业务方参与。
结构对齐、编码统一。把源系统的字段名、单位、编码映射到目标结构上。这里最隐蔽的坑是编码和口径:同一个「厚度」单位不同、字符编码不同、时区不同、型号大小写不同。见下面脏数据第3 种形态。
写入目标库,然后必须对账。对账不是看日志说成功——日志只证明你写了,不证明写对了、没写漏、没写重。这一步的具体做法见模块后面第 06 节。
这六种是我在真实项目里反复遇到的。分类的价值在于:每种形态有固定的对治套路,不用重新想。
| # | 形态 | 典型表现 | 对策 |
|---|---|---|---|
| 1 | 空值 / 缺字段 | 德莉玛的 400 单里 97% spec 为空——不是空字符串,是根本没有这个字段的值 | 区分三种情况分别处理:业务上确实不需要(留空并标注,不参与计算);录入了漏填(回溯源系统补,无法补的进待处理队列);源头就没有这个字段(改需求或改流程,别用默认值糊)。最忌讳把空值当 0 处理 |
| 2 | 单字段存 JSON blob | 德莉玛真实踩过的坑:collections 表把整个对象存成单行 JSON,结果是写坏整库就崩、无法查询、并发串行 | 拆成规范表结构 + 加索引。一对多的进子表,标量字段各占一列,需要经常查询的字段必须有独立列和索引,不能塞在 JSON 里靠全表扫描 |
| 3 | 口径不一致 | 同一个「厚度」,一处毫米一处厘米;型号大小写混用;时区一个是 UTC 一个是本地时间;中文里全角半角混用 | 建一张口径映射表,所有换算集中在一处,禁止在业务代码里散落硬编码系数。转换前统一做大小写归一、空白裁剪、全半角转换、时区转换,并保留原始值字段以便追溯 |
| 4 | 重复记录 | 同一笔订单导入两次;同一家经销商两条档案;同一个型号两种写法 | 先定去重键(一般用业务唯一键,比如订单号),再定保留策略(保留最新/保留最全/保留人工确认)。不定义去重键就开跑,一定会出现重复单据 |
| 5 | 历史遗留状态 | 已废弃的型号还在库里,还在参与计算;已停用的经销商还是有效状态;工单早就关闭了但状态还挂在进行中 | 区分有效 / 停用 / 废弃三态并显式建模,不能靠「库里有没有这条」判断。废弃数据保留但隔离(加状态位、不进正常查询),不直接删——删了就没有历史可查 |
| 6 | 越界值 | 尺寸是负数或0;字段超长把列撑爆;金额是负数(退货场景可能是合法的,订单场景不是) | 入库前加校验与约束:非空、范围、检查约束,把非法数据拦在库外。同时产出「异常数据清单」给业务方确认,而不是静默丢弃——丢弃也要有清单和口径 |
下面是门店订购系统 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」并存 |
对账是唯一可信的验证方式。日志说成功只证明你写了,不证明写对了、没漏、没重。五层对账,从便宜到严格依次做:
| 层次 | 做法 | 能发现什么 | 成本 |
|---|---|---|---|
| 行数对账 | 源表总行数 vs 目标表装载行数 vs 成功 + 失败 = 总数 | 整体丢失、批次中断 | 最低,一条 SQL |
| 求和对账 | 数量、金额等可加字段分别求和比对 | 单价或数量算错、累加丢失 | 低,但最能抓计算错误 |
| 关键字段抽样比对 | 按业务键抽样,逐字段对比源和目标 | 字段错位、单位换算错、编码不一致 | 中,样本量自己定 |
| 孤儿记录检查 | 查明细里的 dealer_id / model_id 在字典表里找不到的记录 | 映射表没建全、编码对不上 | 低,但结果往往惊人 |
| 反向验证 | 用接口跑真实数据反查:从新系统读出来,再和源系统原始数据比 | 只有走完整链路才暴露的问题——序列化、精度丢失、单位在应用层又被处理了一次 | 最高,但这层最不可省 |
SELECT count(*) FROM orders o LEFT JOIN dealers d ON o.dealer_id = d.id WHERE d.id IS NULL。这一条 SQL 比任何人工核对都可靠。| 坑 | 说明 / 对策 |
|---|---|
| 只验日志不看数据(最常见) | 「导入成功,写入 3,317 条」——然后收工。对策:日志一律不算验证。最低限度也要跑一遍行数对账 + 求和对账 |
| 抽样只抽成功的 | 从成功清单里抽 100 条对比,全对,宣布迁移完成。失败的那些从来没被看过。对策:抽样必须包含全部失败记录和异常记录,而且要按规则抽(如随机 + 全量异常),不能只抽方便抽的 |
| 空值当 0 处理 | 把空的spec 填成 0,引擎跑得很happy,算出来的价全是错的。对策:空值和 0 是两个不同的语义,必须分开;参与计算前显式判空,不给默认兜底 |
| 直接覆盖源数据没备份 | 迁移脚本直接 update 源表,或者原地清洗。出错后没有回退点,整个项目停摆。德莉玛的做法是:先备份再动,并留 md5 校验——备份文件算 md5,源文件算 md5,一致才允许开始清洗 |
| 一次性全量导入不留回退点 | 一条脚本全量跑完,没有分批、没有开关。对策:分批 + 可重入。按批次号切分,每批独立事务,失败只回滚那一批;重跑时靠幂等键跳过已成功的批次。schema 里那张migration_batches 表就是为这个存在的 |
| 清洗规则没落文档 | 规则散在脚本注释和脑子里,三个月后业务方问「为什么这单型号被改了」,没人答得上来。对策:每条清洗规则一条记录:规则名、影响行数、示例、确认人 |
我的观点是:迁移失败几乎都不是技术问题,是数据问题。开发的风险是可见的——写不通就报错;迁移的风险是沉默的,它不报错,它只是安静地把错数据搬进新系统,然后你三个月后才发现报价算错了。所以我的习惯是不盘点不开工,盘点不干净不报价。
第一步是盘点:每个字段的总行数、空值数、唯一值数、取值分布、最长长度。产出字段清单和数据画像。不做这步,后面全是猜。第二步清洗,每条规则都要和业务方确认,因为「补空」本身就含判断:空的尺寸是「这单确实没要」还是「录的时候漏了」,两种处理完全不同。第三步转换,结构对齐、编码统一。第四步装载 + 对账,这步不能省,也不能只对行数。
讲个我真实做过的例子。德莉玛的门店订购系统,全域真实数据是 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 校验,迁移脚本必须分批且幂等可重入。这几条不是最佳实践,是踩过才知道疼的底线。