周五下午,同事在测试库操作物料管理页面:把一条物料从二级清单里"取消选用",然后想再加回去——按钮点了,弹窗转了两圈,数据库里啥变化都没有。同一个人又去改另一个外购焦粉的状态为"停用",保存成功,刷新列表,状态列还是"启用"。
两个问题看起来不相关,但根因都是同一张关系表的设计缺陷。下面是排查过程。
唯一键没带 del_flag,取消即"软删"却加不回来
关系表叫 SM_MATERIAL_L2_REL,核心字段是 material_id + process + rel_del。建表时唯一键定义为 uk_material_process(material_id, process),没有把 rel_del 或 del_flag 包进去。
后果很直接:
- 用户点"取消选用"→ 后端把 rel_del 改成 'Y',这条记录还在表里
- 用户再点"加入二级"→ 后端试图 INSERT 一条新记录(或把 rel_del 改回 'N')
- 如果是 INSERT,(material_id, process) 已存在,撞唯一键,直接报错
- 如果是 UPDATE 回 'N',逻辑上可行但前端可能走的是 INSERT 分支
我连上测试库查了 3 条 rel_del='Y' 的记录,update_time 全是当天 13:14,update_user 是同一个人——确认是他刚点的取消选用,不是历史遗留数据。后端取消操作确实落库成功了,断链发生在"重新加入"这一步。
修法
把唯一键改成 uk_material_process(material_id, process, rel_del),或者取消时走物理删除。前者对历史数据影响小,但要注意 rel_del='Y' 和 'N' 不能同时存在同一对 (material_id, process) 下——所以逻辑上还是得保证唯一性,不如直接改键定义。
主表停了,关系表还"活着"
第二个 bug 更隐蔽。物料主表 m 有 status 字段(0=停用,1=启用),关系表 r 也有一个 status 字段。问题出在:
- 用户在弹窗里把外购焦粉改成"停用",写的是 m.status=0,落库成功
- 但列表页 SQL 读的是 r.status,那条记录还是 1
- 页面上自然显示"启用"
这不是数据错了,是读的地方不对。列表查询 JOIN 了关系表取状态,而不是取主表。改法有两个思路:
- 列表 SQL 把状态列改为取 m.status,r.status 废弃或仅做冗余同步
- 主表状态变更时,触发器/服务层同步更新 r.status
第一种更干净,少一个字段要维护。但得确认没有别的页面依赖 r.status 做过滤逻辑。
联动面比想象的大:9 个化验页,字典只有 3 个值
确认完两个 bug 的根因,下一步是评估"物料类别"改动影响哪些下游。翻了一遍化验模块的代码,发现 9 个化验页面各自硬绑了一个 c_type:
- 0=煤粉,7=除尘灰,8=自返矿,9=高返,10=混匀矿,11=含铁料,12=生石灰,13=白云石,14=焦粉
但物料类别字典枚举只有三个值:原料、燃料、熔剂。c_type 是细分类,字典是粗分类,中间靠映射表关联。这意味着如果只改字典不加映射,9 个化验页的下拉框会集体空掉。改字典之前必须把映射关系补全,或者化验页直接读 c_type 不经过字典翻译。
别光读代码,连库跑一遍
这次排查我犯的第一个犹豫是:读完 SQL 觉得"应该没问题",没去测试库验证。后来真连上库才发现 3 条 rel_del='Y' 的数据实打实躺在那,update_time 对得上操作时间戳,比读代码猜结论快得多。涉及数据链路的问题,至少跑一次 SELECT ... WHERE rel_del='Y' AND update_time > 今天,看到真实数据再下结论。
可以带走的三件事
- 建唯一键时把"软删除标记"考虑进去。rel_del / del_flag 如果参与业务流转,要么进唯一键,要么取消走物理删除,别留一条"半死不活"的记录卡住后续操作。
- 状态字段只留一个权威来源。主表和关系表各存一个 status,谁读谁就是 bug。列表页统一读主表,关系表只存关联关系本身。
- 改公共字典/枚举前,grep 全仓库的硬编码引用。9 个化验页各绑一个 c_type,字典加一个值它们全得动,这种"一对多硬绑"在设计阶段就该用配置表解耦。
做自动化这几年,最耗时间的从来不是写代码,是摸清每个平台的脾气。
这篇里提到的坑,都是真金白银踩出来的。 如果你手上也有重复度很高的活儿——批量发布、数据搬运、有固定规则的机械操作 ——可以在评论区说说你的场景,我看看能不能自动化掉。