IStarry

编号不是身份:我删掉了亲手建的那个唯一索引

项目早期给设备编号加了唯一索引,后来因为一个 670 台的设备块被整块拦下而删掉它。记录这次建模推翻,以及它连锁引发的两处真实 bug。

IStarry

6 min read

编号不是身份:我删掉了亲手建的那个唯一索引

说明:本文设备编号已脱敏改写,型号名亦改为示例值;代码引用为逐字原文(含其中的示例编号),未做改写。


0. 我做的第一件事,是给编号加了唯一索引

需求澄清阶段,我面对的是一个很自然的判断:设备编号应该是唯一的吧?

一台设备一个编号,跟身份证号一样。如果两台设备编号一样,那一定是录错了。

于是决策 16 定下了编号的唯一性粒度,并落成一条迁移(internal/database/migrations/V002_equipment_unique_no.sql,全文 7 行;唯一索引建在 :5-7):

-- V002 编号唯一粒度(决策 16,2026-09-08)
-- 规则:同一 name+model 内 equipment_no 唯一;不同设备允许同号(如各设备自身 001… 复用合法)。
-- 实现:部分唯一索引,仅约束 equipment_no IS NOT NULL 的行。
 
CREATE UNIQUE INDEX IF NOT EXISTS idx_equipment_name_model_no
    ON equipment(name, model, equipment_no)
    WHERE equipment_no IS NOT NULL;

值得注意的是:这个设计并不粗糙。它已经考虑过现实的复杂性——

  • 粒度不是"全局唯一",而是"同名称+同型号内唯一":各台设备自己那套 001002 复用是合法的;
  • 用了部分唯一索引WHERE equipment_no IS NOT NULL,为空的行不参与约束(因为确实存在无编号设备);
  • 重复编号的处理方式被定义为 BLOCK:导入报告逐条列出,交人工处理(改号,或者确认是两台同号机)。

也就是说,当时的我已经在"业务现实"和"数据洁癖"之间做过一次妥协。问题在于妥协得还不够。


1. 打脸:一个 670 台的设备块

规则上线后的第二天,真实文件就给出了答案。

某个"平车"设备块有 670 台设备,其编号在块内大量重复。按 V3 规则(块内出现重复编号 = BLOCK),整块被拦下——670 台设备无法导入

这 670 台不是脏数据。真实业务情况是:

多台真机共用同一个编号是合法的。

可能是资产标签重复打印、可能是早期没有严格贴标、可能是按批次登记而没有逐台编号。但机器是实打实的 670 台,它们就摆在那里。

这时候有两个选择:

  • 选择 A:坚持规则,让人去改数据。 让业务方给 670 台设备重新编号。代价是数百台设备的手工重新贴标,而且改完之后,数据反而离真实更远——真实的物理标签没变,数据库里却成了一串谁也对不上的新编号。
  • 选择 B:承认规则错,改模型。 编号不是身份,重复编号对应多台真机。

选了 B。这就是决策 18 的核心。

修正后的语义(internal/service/identity.go:12-16 的注释把这条写死在了代码里):

// 设备身份与展示(决策 18):
//   - equipment.id 唯一身份;equipment_no 仅是标签(可重复/空/中文符号);
//   - equipment_seq:同组序号(非身份)。分组键:
//       有编号 = equipment_no + name + model;
//       无编号 = model(空则 name,再空则 未编号设备)。

2. 身份原则:把「标识」和「主键」分开

这次推翻带来的最大认知,是一条可以迁移到任何系统的原则:

业务标识(编号、工号、订单号)是给人看的标签;技术主键是给系统用的身份。二者可以重合,但绝不能假设它们必然重合。

决策 18 把它写成了三条禁令,措辞非常强硬:

  1. 禁止equipment_no 作为唯一键;
  2. 禁止因重复编号整组跳过;
  3. 禁止因中文编号拒绝设备(编号原样 TEXT 保存,可含中文与中文符号)。

前两条来自 670 台那次事故;第三条来自另一类现实——真实编号里就是有中文设备一号 这种),如果系统因为"编号应该是数字"而拒绝它,那就是拿技术洁癖去否定业务事实。

顺带说一句:为什么"禁止"这个词在治理文件里是合适的?因为这三条不是"建议怎么写代码",而是防止已经踩过的坑被重新引入。禁令是事故的沉淀物。


3. 迁移:V003 只做了两件事

V003_equipment_seq.sql 全文 10 行(:8 加字段、:10 删索引):

-- V003 设备身份重构(v1.1,决策 18)
-- 目标:equipment_no 不再是任何唯一性约束的一部分;同 (name,model,equipment_no) 允许存在多台真机。
-- 变更:
--   1) equipment 新增 equipment_seq(组内展示序号,NOT NULL 默认 0;真实身份仍为 id);
--   2) 删除 V002 的部分唯一索引 idx_equipment_name_model_no(同号多台放行);
--   3) equipment_no 普通索引 idx_equipment_no 已在 V001 建立,保持不变。
 
ALTER TABLE equipment ADD COLUMN equipment_seq INTEGER NOT NULL DEFAULT 0;
 
DROP INDEX IF EXISTS idx_equipment_name_model_no;

两件事:加一个字段,删一个索引。

但这两件事的性质完全不同:

  • 加字段是加法,几乎无风险,随时可以再改;
  • 删索引是减法,而且删的是一个"约束"——从此数据库不再帮你挡住"同号多台"。这是把一道安全网主动撤掉,需要非常明确的证据(670 台设备的真实数据就是证据)。

还有一个容易被忽略的细节:第 3 条注释。删掉唯一索引,不等于编号不需要索引。 equipment_no 的普通索引从 V001 就存在,继续保留——因为"按编号查询"仍然是最常用的检索方式之一,只是它不再承担"唯一性保证"的职责。

约束和索引是两个目的:前者保正确,后者保性能。删掉约束时顺手把索引也删了,是这类重构常见的事故。


4. 显示编号:为什么必须由后端算

编号不再唯一之后,一个新的问题冒出来了:界面上该显示什么?

如果两台设备编号都是 6041,列表里出现两行 6041,用户根本分不清哪台是哪台。于是有了"显示编号"(display_no)——视觉上区分同号多台设备。核心实现只有 10 行(DisplayNointernal/service/identity.go:176):

// DisplayNo 生成展示编号(后端统一计算下发,前端禁止自行拼接):
//   - 有编号:同组(groupCount)多台 → 6041(n);单台 → 6041;
//   - 无编号:恒带序号 → JUKI DDL-8700(1)、设备名(1)、未编号设备(1)。
func DisplayNo(no *string, name, model string, seq int, groupCount int64) string {
    label, numbered := SeqGroupLabel(no, name, model)
    if !numbered {
        return fmt.Sprintf("%s%d)", label, seq)
    }
    if groupCount > 1 {
        return fmt.Sprintf("%s%d)", label, seq)
    }
    return label
}

规则本身很朴素:

情形显示
有编号,组内只有 1 台6041(不加后缀,保持清爽)
有编号,组内有多台6041(1)6041(2)
无编号设备恒加序号某型号(1)某名称(1)未编号设备(1)

第三行值得留意:无编号设备为什么要恒加序号,哪怕它只有一台?

因为有编号的设备"有时带后缀、有时不带",无编号的设备如果也"有时带有时不带",用户就无法从显示形式判断"这个编号到底是不是完整的"。恒加序号让两类设备的显示形式在视觉上可区分——看到 某型号(1) 就知道它根本没有编号,看到 6041 就知道它是唯一的 6041

关键决定:后端计算下发,前端禁止拼接

注释里那半句"前端禁止自行拼接"是这一节的重点。

想象一下如果让前端拼:台账页要拼、班组视图要拼、Dashboard 要拼、外借页要拼、导出要拼、导入结果页也要拼。那就是六份实现。而只要其中一份的规则稍有偏差——比如无编号设备忘了加序号,或者同组计数用了"总数"而不是"组内数"——用户就会在两个页面看到同一台设备的不同编号。

这不是假想。v1.5 审查时实测发现:display_no回退实现有 6 处,到 v1.6.0 的清理阶段被收敛为 1 处(后端下发,前端只用)。

同一个业务规则出现在 N 个地方,就有 N−1 个地方会慢慢变错。


5. 三个容易漏掉的细节

显示规则写出来了,但真正让它在 2148 台真实数据上不出错的,是下面三个细节。

细节一:分组键必须显式包含名称与型号

看分组键的构造(SeqGroupKeyinternal/service/identity.go:36):

// SeqGroupKey 返回某设备所属 seq 分组键(供预览展开/全量重算保持一致):
// 有编号 = N|编号\0名称\0型号;无编号 = U|组标签(model→name→未编号设备)。
func SeqGroupKey(no *string, name, model string) string {
    label, numbered := SeqGroupLabel(no, name, model)
    if numbered {
        return "N|" + label + "\x00" + strings.TrimSpace(name) + "\x00" + strings.TrimSpace(model)
    }
    return "U|" + label
}

有编号的分组键是 编号 + 名称 + 型号 三段拼接,用 \x00 分隔。

这个 \x00 不是随手写的。如果直接用 "6041" + "平车" 这类可读分隔符拼接,就可能出现两个不同的组拼出同一个键(比如名称里本身含有分隔符)。用不可能出现在业务文本里的空字符做分隔,是从根上避免"分组串组"。

而"有编号"和"无编号"用 N| / U| 前缀区分,则避免了一个更隐蔽的碰撞:一台编号恰好是 某型号 的设备,和一台无编号、按型号分组的设备,如果不用前缀区分,会落进同一个分组。

细节二:报废设备仍保留序号归属

这条规则写在 countSeqGroup 的注释里(internal/service/identity.go:44):

// countSeqGroup 组内设备数(不含报废则含:报废设备也保留序号归属,避免组号漂移)。

注释里有个笔误式的表述("不含报废则含",实际含义是"含报废"),但它说的规则很重要:已报废的设备仍然算在组内。

否则会发生什么?假设 6041 同组有 3 台,第 2 台报废了。如果报废设备被排除在计数之外,剩下的两台就从 (1)(3) 变成 (1)(2)——而 (2) 这个编号曾经属于那台报废的设备。历史记录里写着"6041(2)转交到某班组",现在却指向了另一台机器。

序号会漂移,历史就会串台。 所以报废设备保留序号归属,宁可空出一个 (2)

细节三:重算要"只写变化行 + 单事务"

序号需要在启动时校验重算(防止历史数据不一致)。最初的实现是每次启动对全部设备逐行 UPDATE——2148 台设备,每次启动写 2148 行。

v1.5 审查把它改成"只写变化行",并把重排移进事务(RenumberAllSeq 的事务段,internal/service/identity.go:152-166):

written := 0
err := db.Transaction(func(tx *gorm.DB) error {
    for _, r := range rows {
        want := target[r.ID]
        if r.EquipmentSeq == want {
            continue          // 值没变就不写
        }
        if err := tx.Model(&models.Equipment{}).Where("id = ?", r.ID).
            UpdateColumn("equipment_seq", want).Error; err != nil {
            return err
        }
        written++
    }
    return nil
})

实测效果:连续启动两次,写入 0 行(数据已经一致,一次 UPDATE 都不需要);全部 1643 个分组(其中 58 个多台组)0 违规(id, seq, display_no) 指纹完全一致。

这个改动的意义不只是性能。"每次启动都全表重写"意味着启动本身就是一个写操作——如果它中途失败、或者逻辑有偏差,你会在每次开机时慢慢腐蚀自己的数据。改成"只写变化行"之后,稳定状态下的启动是纯读的。


6. 连锁后果一:搜索必须能剥掉 (n)

编号不再是唯一键之后,第一个连锁问题出现在搜索框上。

用户看到列表里是 6041(2),想找这台设备,于是复制这个显示编号去搜。但数据库里 equipment_no 存的是 6041,没有 (2)——按 6041(2) 去 LIKE 匹配,一台都搜不到

修复方式是在筛选里做一次"二次匹配"(internal/service/filter.go:63-70):

// display_no 形如「6041(2)」「JUKI DDL-8700(1)」:再按剥离(n)后的基础串匹配一次,
// 这样用户从列表里复制显示编号来搜也能命中(编号列与名称列都可能带后缀)。
if base := StripDisplaySeq(q); base != "" && base != q {
    bl := "%" + base + "%"
    parts = append(parts,
        "equipment.equipment_no LIKE ?", "equipment.name LIKE ?", "equipment.model LIKE ?")
    vals = append(vals, bl, bl, bl)
}

而剥离函数本身要注意全半角括号StripDisplaySeqinternal/service/filter.go:89-102):

// StripDisplaySeq 剥离 display_no 末尾的(n)序号(全/半角括号均可),返回基础串;
// 不含合法序号后缀时返回 ""(调用方据此判断"无需二次匹配")。
func StripDisplaySeq(q string) string {
    for _, pat := range []struct{ open, close string }{{"(", ")"}, {"(", ")"}} {
        i := strings.LastIndex(q, pat.open)
        j := strings.LastIndex(q, pat.close)
        if i >= 0 && j > i+len(pat.open) {
            if allDigits(q[i+len(pat.open) : j]) {
                return strings.TrimSpace(q[:i])
            }
        }
    }
    return ""
}

两个克制的设计:

  • 两种括号都试:中文全角 () 和英文半角 ()。用户复制粘贴时,输入法可能给你任何一种,而且显示编号本身可能是中文的,全角括号更符合中文语境。
  • 返回 "" 表示"无需二次匹配":调用方通过 base != "" && base != q 判断,只有在真的剥离出了东西时才追加条件。这避免了对每个查询都无脑加 3 个 OR 条件。

7. 连锁后果二:列表搜得到,导出却是空表

这是整篇里我最想讲的 bug,因为它是**"同一个规则有多份实现"的教科书级后果**。

现象

v1.5 审查(真实库副本,实测)记录得很清楚:

q=某型号(1)   列表 total=2    导出 0 行(空表)

列表能搜到 2 条,点"导出"却得到一张空表。 用户会怎么理解?"列表显示有数据,导出说没有"——最可能的结论是导出功能坏了,或者更糟:数据不一致

根因

搜索条件在4 个地方各写了一份,字段集已经不一致:

  • 列表接口 internal/api/equipment.go 里写了一份;
  • 导出 internal/service/export.go 里写了一份;
  • 班组视图 internal/service/teamview.go 里写了一份;
  • ……

剥离 (n) 的逻辑(第 6 节那段)只加在了列表那份里。导出那份没加,于是 某型号(1) 在导出侧匹配不到任何东西 → 空表。

修复:把"共用"变成编译期约束

修复不只是"在导出那份里也加上剥离逻辑"——那只是把两份实现同步了一次,下次还会漂移。真正的修复是从结构上消灭第二份实现EquipmentFilter 与别名,internal/service/filter.go:26-37,别名在 :34):

// EquipmentFilter 台账筛选条件 —— **关键词/条件构造的唯一来源**(v1.5 审查 P1-1)。
type EquipmentFilter struct { /* ... */ }
 
// ExportFilter 是 EquipmentFilter 的别名:导出与列表必须共用同一筛选定义。
type ExportFilter = EquipmentFilter
 
// ApplyEquipmentFilter 把筛选条件套到任意 *gorm.DB(列表 count、列表查询、导出共用)。
func ApplyEquipmentFilter(db *gorm.DB, f EquipmentFilter) *gorm.DB { /* ... */ }

注意 type ExportFilter = EquipmentFilter 里的那个 =

这不是 type ExportFilter EquipmentFilter(那会定义一个新类型,需要显式转换,两边可以各自演化),而是类型别名——ExportFilterEquipmentFilter同一个类型。这意味着:

  • 导出侧想多加一个字段,就必须加在唯一的那个结构体上,列表侧会同时获得它
  • 如果有人想给导出单独定义一套筛选,编译器不会阻止他,但他必须显式写出一个新类型——这个动作是显眼的,review 时能被看见。

修复后实测:某型号(1) 列表 2 条 / 导出 2 行;某型号 列表 2 条 / 导出 2 行;半角写法 某型号(1) 同样 2 / 2。

用类型别名把"应该共用"变成"几乎无法不共用",比写十行注释管用。


8. 连锁后果三:既然编号不是身份,历史就必须靠 id

编号不再唯一,还有一个更深的影响:所有关联历史的地方,都必须用 id,绝不能用编号。

这带来两条设计要求。

要求一:历史记录必须冗余"当时的名称"

编号可能重复,但名称和班组也会变(班组改名、外借方改名)。如果历史记录只存 team_id,那么班组改名之后,回看两年前的一条流转记录,看到的是今天的班组名——而当时它叫另一个名字。

所以 transaction 冗余保存流转当时的名称快照(决策 14):from_team_name / to_team_name / borrower_name

可追溯性的关键不是"存了 id",而是"存了当时的语义"。

要求二:状态与历史必须同一事务

任何一次流转,必须在单个事务内完成(docs/state-machine.md:62 §4「一致性实现契约」):

  1. 校验(设备存在、from_status 与当前状态一致、动作在允许集合内、必填字段完整);
  2. 更新 equipment:status、current_team_id / current_borrower_id、current_since
  3. 追加 transaction:from/to + 名称快照 + occurred_at / operator / remark;
  4. 外借/归还联动 borrow_record
  5. 任一步失败,整单回滚。

文档里对这条的措辞是:

更新与追加必须在同一 DB 事务内完成 —— 禁止只改当前状态不写历史,反之亦然

配一条硬约束:流转历史禁止删除(数据安全铁律 5)。

一条配套规则:编号不可修改

如果编号是身份,那"改编号"就等于改身份证——所以决策 13 干脆把这条路封掉:

编号是设备身份与历史锚点,页面禁止直接修改;录入错误走「受限更正」(填原因 + 写 audit 更正记录)或报废重建。

注意这里的设计取舍:不是"不许改",而是"改必须留痕"。录入错误是客观存在的,堵死会逼用户去删库重来(更糟)。所以给一条受限通道:填原因、写审计、可追溯。管理的目标是"可追溯",不是"不可变更"。


9. 复盘:业务标识什么时候可以做唯一键

这次推翻给出的判据,我认为可以复用:

只有当下面三条同时成立时,业务标识才适合做唯一键:

  1. 它由系统生成,而不是由人输入。 人输入的东西就会有重复、缺失、格式不一。
  2. 它的变化是可枚举、可审计的(比如订单号作废要留痕),而不是"发现错了就悄悄改一下"。
  3. 业务上认可"重复即错误"——重复出现时,业务方的第一反应是"这里有问题,去修",而不是"哦,那可能是两台"。

编号在前两条上就已经不合格:它是人输入的(还是十年前手工录的),而且发现录错时大家希望的是"改一下"而不是"走审计流程"。

反过来说,这个项目里真正适合做唯一键的是 id(系统生成)和内部码 EQ- 前缀那套(也是系统生成,用于承载无编号设备的身份,见决策 6)。


Related Articles

Knowledge Relations