IStarry

5,693,048 台设备:一次差点写进生产库的导入事故

有人上传了一份“另一种版式”的 Excel,导入预览显示“预计新增设备 5,693,048 台”,而文件里只有 230 台。我逐列拆解根因,以及事后长出来的三条防线。

IStarry

5 min read

5,693,048 台设备:一次差点写进生产库的导入事故

从 5,693,048 台到 230 台

2026-09-11,有人上传了一个文件。解析完成,预览页弹出来,上面写着:预计新增设备 5,693,048 台。而那个文件里只有 230 台。

这篇文章只讲一件事:这个数字是怎么被算出来的,以及我事后补了哪几条防线。


0. 事故

这个系统从一张手工维护了十年的 Excel 台账起步,导入通道只有一个,服务于那一种版式:单 Sheet,第 2 行是表头,A–K 共 11 列——类别 / 设备名称 / 设备型号 / 台账数量 / 财务数量 / 台账设备编号 / 时间 / 公司 / 台数 / 借出设备编号 / 备注。第 5 行到第 466 行是数据,第 467 行是合计。

那天上传的文件版式是:到达时间 / 外借方 / 数量 / 设备编号。

解析完成,页面弹出预览。在"预计新增设备"这一行,数字是:

5,693,048。

单位是"台"。


1. 现场:这个数字是从哪来的

先看这一行是怎么渲染的(web/src/ImportPage.tsx:498):

<Descriptions.Item label="预计新增设备"><b>{summary.ok_devices}</b> 台</Descriptions.Item>

ok_devices 是解析器算出来的"这一批能建多少台设备"。所以 5,693,048 不是界面 bug,也不是渲染溢出——是解析器认真地数出了 569 万台设备

这个数字的构成是:D 列里有 20 个数字型单元格,它们的合计是 5,693,048。在错误版式里,D 列是"设备编号";但解析器认为 D 列是"台账数量",于是把单元格里的编号当成了台数。

按解析器的逻辑,这一步完全自洽:D 列写着 6418320,那就是这个设备块有 6,418,320 台;F 列(台账设备编号)没有内容,那就是"全部无编号",按数量逐台展开。它忠实地执行了规则,只是规则的前提不成立。

那个文件真实有多少台?230 台(214 台有编号 + 16 台无编号)。文件自己的表末合计行写的是 208,业务方确认那是笔误,以数量列(C)合计为准。这个差异后来变成了导入报告里的一条 WARN:"表末合计 208 与数量列(C)实际合计 230 不一致(以 C 列为准,请在源文件更正合计)"

事故的形态不是崩溃,而是一个巨大的、看起来像真的数字。


2. 根因:逐列错位

把两套列语义并排放,问题一目了然:

文件实际含义解析器认为后果
A到达时间类别日期被当作设备类别
B外借方设备名称公司名被当作设备名
C数量设备型号台数被当作型号
D设备编号台账数量编号被当作台数 → 569 万台
E—(空)财务数量空值,无异常
F—(空)台账设备编号空 → 判定"全部无编号"
G–K—(空)借出事件列空 → 无借出事件

这张表最值得看的地方,是它不是全错

A、B、C 三列虽然语义错了,但都是文本,解析器把"到达时间"当成类别、把"某分厂"当成设备名称,一路顺畅——没有任何类型错误、没有任何异常抛出。真正引爆的是 D 列:它是唯一一个"看起来像数字、而且数字很大"的列。而 F 列为空,则把"编号"这条唯一的兜底线索也切断了:解析器找不到任何编号,就按"无编号设备"逐台展开,于是把那个巨大的数字逐台建成了设备

所以:

  • 列错位本身不会报警,因为每一列的解析在局部都是合法的;
  • 只有当一个错误恰好落在"数量/规模"这类会自我放大的字段上时,错位才会显形
  • 反过来说,如果那份文件的 D 列是文本编号而不是数字,这次错位可能会静默产生一批语义完全错误的设备,而不是一个离谱的数字。后者更可怕。

3. 为什么没炸到生产库

这是全文最该讲清楚的一段,因为它决定了这是一篇"事故复盘"还是"灾难复盘"。

导入被拆成了两个端点:

端点动作是否写库
POST /api/import/parse读取文件、解析、落一个解析会话(parse_id
POST /api/import/runparse_id 取出会话结果,单事务写库

当天日志里只有 /api/import/parse没有 /api/import/run。看到那个数字之后没有点"确认导入",数据库因此从未被触碰。

挡住这次事故的是三层结构,每一层单独看都不新鲜:

  1. 解析与写入分离。解析是一次纯读操作,产出的是"预览"和"报告",不是数据。
  2. 预览是显式门禁。生成的 parse_id 会话必须被单独确认才能进入写入路径;跳过预览直接调用 run,需要显式构造 parse_id
  3. 危险操作走独立确认。同一时期的"清空重导"要求输入确认词,和恢复备份同一门槛。

但这里必须诚实说一句:这层保护当时并没有被测试覆盖。 测试覆盖的是"正确版式的文件能正确导入",没有一条用例问过"错误版式的文件会发生什么"。三层结构挡住了它,但真正让 569 万台进入视野的原因,恰恰是没有第四层——输入校验

结构救了数据库;缺失的校验让人看到了一个荒唐的数字。


4. 修复一:让表头成为门禁

修复的思路很朴素:既然解析器的全部正确性都建立在"列位语义固定"这个前提上,那就在解析前把这个前提验证掉。

internal/importer/parser.go:36  validateTemplate(title, header []string) error

核心逻辑是逐列严格比对(下面是精简后的代码,非全文):

// templateHeader 是唯一权威的列语义定义(A–K,11 列)
var templateHeader = []string{
    "类别", "设备名称", "设备型号", "台账数量", "财务数量", "台账设备编号",
    "时间", "公司", "台数", "借出设备编号", "备注",
}
 
func validateTemplate(title, header []string) error {
    var mismatch []string
    for i, want := range templateHeader {
        got := cellAt(header, i)
        col := string(rune('A' + i))
        switch {
        case got == "":
            mismatch = append(mismatch, fmt.Sprintf("%s列(期望 %q,实际为空)", col, want))
        case got != want:
            mismatch = append(mismatch, fmt.Sprintf("%s列(期望 %q,实际 %q)", col, want, got))
        }
    }
    if len(mismatch) == 0 {
        return nil
    }
    // …拼装错误信息:回显实际 R2 与 R1、列出前 6 条不一致、给出下一步指引
    return fmt.Errorf("%w%s", ErrTemplateMismatch, strings.Join(parts, ";"))
}

这段代码里有三个刻意的设计决定,比代码本身更重要。

决定一:严格相等,不做模糊匹配。

没有"猜列"、没有"按内容相似度识别表头"、没有"如果列名包含'编号'就当作编号列"。got != want 就是拒绝。

理由写在函数的注释里:

不一致即返回 ErrTemplateMismatch,并在错误信息中回显 R2(表头行)与 R1(标题行)的实际内容,便于用户判断"版式不对"还是"表头挪了行";不做模糊匹配、不猜测列语义

这条约束来自项目的"不猜测"原则:字段含义无法确定时,交回给用户确认,绝不擅自定夺。放在这里就是——当列语义不确定时,宁可拒绝解析,也不要在错误的语义下继续。

决定二:错误信息要能让人当场判断问题。

拒绝很容易,难的是让用户知道为什么被拒绝。所以错误信息回显三样东西:期望的表头(A–K 全列)、实际识别到的第 2 行内容、实际第 1 行内容(标题行)。

因为"版式不对"其实有两种情况:一种是拿错了文件(版式完全不同),另一种是表头挪了行(比如前面多插了一行说明)。回显 R1/R2 就能当场区分这两者。另外不一致项最多列 6 条,避免错误信息刷屏;相邻重复值只回显一次,因为横向合并的标题行会被表格库回填到整行。

决定三:立身之本不许动。

这是整次修复里最克制的一笔(internal/importer/parser.go:305):

// ⚠️ 有意**不**把列/行解析动态化(决策 19 的模板校验立身之本),只加上界校验。

修复过程中很容易"顺手做得更好":既然表头都校验了,不如让解析器自动识别列位置、自动适配不同版式——听起来更健壮,实际上是把这次事故的根因重新引入一遍:只要解析器开始"猜"列,它就有机会猜错,而猜错的表现就是 569 万台。所以我只做了两件事:拒绝错版式(表头校验)、报告被忽略的行(上界校验),一行动态化都没有加

验证方式:先复现事故,再断言拒绝。

我没有"测一下新函数",而是把事故本身钉成用例——用事故文件的那种表头构造一个 xlsx,断言解析必须失败,并且错误信息里必须出现"到达时间""设备名称""台账数量"这些关键词(internal/importer/template_test.go):

// TestParseRejectsWrongTemplate 复现事故场景:工作簿1.xlsx 版式
// (到达时间/外借方/数量/设备编号)被当作总账模板解析。
func TestParseRejectsWrongTemplate(t *testing.T) {
    path := writeXLSXAt(t, map[string]string{
        "A1": "到达时间", "B1": "外借方", "C1": "数量", "D1": "设备编号",
        "A2": "2012.12.22", "B2": "某厂(分部甲)", "C2": "10",
        "D2": "11221 11307 12583 11310 21163 11545",
        // …
    })
    res, err := Parse(path)
    if err == nil {
        t.Fatalf("列布局不符的模板必须拒绝解析,实际解析出 %d 个分组、台账数量合计 %d", len(res.Groups), res.TotalD)
    }
    if !errors.Is(err, ErrTemplateMismatch) {
        t.Fatalf("错误应为 ErrTemplateMismatch,实际 %v", err)
    }
    // …断言错误信息回显了期望列
}

同一批测试里还有三条:单列改名(F 列"台账设备编号"改成"设备编号")必须被拒绝、表头行为空必须被拒绝、标准模板必须仍然通过(防误拒)、真实总账文件必须仍然可解析(防误伤既有通道)。

修复效果:那份文件从**"预计新增 5,693,048 台"变成了"明确拒绝,并说明哪一列不符合、实际是什么"**。


5. 修复二:给第二种版式开独立通道

表头门禁解决了"错版式被硬解析",但立刻带来一个新问题:这份文件本身是真实业务文件,它的数据是需要的。

这里有一个岔路口,两条路都"能work":

  • 路线 A:让总账解析器兼容两种版式。 先探测表头,再决定按哪套列语义解析。
  • 路线 B:为第二种版式写一个独立解析器,各自校验各自的表头。

我选的是 B。

// internal/importer/borrow_detail.go:361
// validateDetailHeader 校验 R1 表头:A–D 必需且同名;E/F 可选,出现则必须同名;更后列非空即拒绝。

第二个通道的校验规则比第一个更细,因为它要处理"可选列":A–D(到达时间/外借方/数量/设备编号)必须存在且逐列同名;E(设备名称)、F(设备型号)可选,但一旦出现就必须同名;更靠后的列只要有内容就拒绝。

路线 A 的问题在于:一个解析器一旦要"兼容多种版式",它就必须在运行时判断"我现在在处理哪种版式"——而这个判断本身又会引入新的猜测点。两个各自只认一种版式的解析器,比一个能认两种版式的解析器更安全,因为前者的失败模式是"拒绝",后者的失败模式是"猜错"。这也是我在选型时唯一在意的一条:输入不可信时,不要用"更聪明的解析器"解决,用"更窄的入口"解决。


6. 修复三:一个一周后才发现的洞

上面两条是事故当天(2026-09-11)的修复。但这个故事还有一个后续,出现在事故后一周(2026-09-18)的全库只读审查里,它比原事故更能说明问题。

后端那道"逐项确认"门禁,其实从来没有生效过。

先看后端逻辑(internal/api/import.go,精简):

// REVIEW 门禁(§三十三)
_, _, reviews, _, _ := importer.BuildReviewView(sess.res)
if len(reviews) > 0 {
    ack := map[string]bool{}
    for _, k := range body.AcknowledgedReview {
        ack[k] = true
    }
    var pending []string
    for _, r := range reviews {
        if !ack[r.Key] {
            pending = append(pending, r.Key)
        }
    }
    if len(pending) > 0 {
        writeError(c, http.StatusUnprocessableEntity,
            fmt.Sprintf("仍有 %d 项需人工确认(REVIEW)未处理,请逐项确认后再导入", len(pending)))
        return
    }
}

后端是对的:它逐项比对,只要有一项没确认就返回 422。语义是"每一项都必须被人看过"。

问题在前端(web/src/ImportPage.tsx):

// 【v1.5 审查 P1-3 修复】只上报**已勾选**的 REVIEW 项。
// 原实现恒上报全部 key(reviews.map(...)),使后端逐项确认门禁
// (internal/api/import.go 的 422 分支)永不触发 —— 任何直接调用者都能绕过
// "逐项确认"语义;后端逻辑本身正确,故只改这里。
const ack = reviews.filter((r) => reviewAck[r.key]).map((r) => r.key);

修复前这一行是 reviews.map((r) => r.key)——把全部 key 都上报了。于是 pending 永远是空的,422 分支是一次都没走过的死代码。"逐项确认"的语义在界面上看起来还在(复选框照样要勾,因为前端另有 reviewPending > 0 禁用按钮),但对任何不经界面的调用者来说,这道门禁等于不存在。

为什么它能藏这么久?因为测试只覆盖了两个极端

  • 一个都不确认 → 422(有)
  • 全部确认 → 成功(有)
  • 只确认一部分 → ?(没有)

我补上就是这个第三条(internal/api/import_api_test.go):

// 1.5) 只确认**部分** REVIEW → 仍应 422(v1.5 审查 P1-3 补充的边界)
if len(parsed.Preview.Reviews) > 1 {
    // 只确认第 1 项,其余不确认
    // …断言必须 422
}

在真实台账上跑这条用例:57 项 REVIEW 中只确认 1 项 → 422。这条边界此前从未被执行过。

教训:一个"每一项都必须满足"的门禁,它的测试必须包含部分满足。只测"全不满足"和"全满足",恰好漏掉了唯一能暴露"恒真判断"的那个区间。

顺带记一笔代价。这次修复本身只有几行,但它的门槛落在流程上:门禁型逻辑的测试量大约要加 1/3,而且得专门构造"部分满足"的数据;我把它当成硬要求,是因为只测两端的测试会让这类"恒真判断"长期存活——这段代码从上线到被发现隔了 7 天,后端的逐项比对逻辑完全正确,错的只有调用方。


7. 复盘:为什么这个 bug 能活到线上

四个原因,每一个都不神秘:

  1. 没有第二份真实样本。 开发全程只见过一种版式的 Excel。解析器的所有假设都来自这一份文件,而"假设"在只有一份样本时看起来像"事实"。
  2. 校验缺失,且缺失得很安静。 没有表头校验、没有上界校验(超出 R466 的行当时既不导入也不报错,属于静默丢数据)。
  3. 测试只覆盖正确输入。 "用正确的文件跑通"是当时测试的全部内容。没有一条用例问"如果我给它一个错的文件会怎样"。
  4. 数量级异常没有告警。 一个原来 2000 台规模的数据集,突然要新增 569 万台——这个量级跳变本身就该触发拦截,但系统当时只有"能算就算"。

第 4 点特别值得说:569 万这个数字之所以有价值,正是因为它离谱。 如果错位后算出来是 2300 台——一个完全合理的数量——这次事故会以"静默导入了一批语义错误的设备"的形式发生,而那种错误可能很久都不会被发现。这也是量级告警值得做的原因:它是唯一能在"错误看起来合理"时救你的东西。


8. 事后长出来的四条防线

  1. 表头白名单门禁。 只要解析逻辑依赖列位,就必须逐列严格校验表头,并且拒绝而不是适配。附带条件:错误信息要回显实际内容,让人能当场判断是"拿错文件"还是"表头挪行"。
  2. 解析与写入分离。 解析产出报告和预览,写入是另一个必须显式触发的动作。这条结构把"错误的影响面"从"数据库"缩小到"一个页面"。
  3. 预览作为门禁,而不是装饰。 预览页要显示规模(新增多少台、影响多少行),因为规模是错误最容易暴露的地方。
  4. 量级告警。 为目标表设置"预期数量级",超出即拦截并要求显式确认。

Related Articles

Knowledge Relations