数据库冗余字段设计笔记
关联问题:删除区域/合作商时的外键约束、
tb_emp.region_name脏数据涉及表:
tb_region、tb_partner、tb_node、tb_emp、tb_vending_machine、tb_channel
一、什么是冗余字段
同一个语义的数据,在多个表里各存了一份。
按第三范式(3NF)的要求,tb_emp 只需要存 region_id,员工属于哪个区域应该去 tb_region 表 JOIN 查出来。如果在 tb_emp 里再存一个 region_name,这个 region_name 就是冗余字段------它是"能推导出来、本不该存"的数据。
判断方法:
这个字段的值,能不能通过关联表推导出来?能,就是冗余。
二、本项目中的冗余字段实例
| 表 | 冗余字段 | 本该来自 | 为什么冗余 |
|---|---|---|---|
tb_emp |
region_name |
tb_region.region_name |
员工列表页直接显示区域名,省一次 JOIN |
tb_emp |
role_code、role_name |
tb_role |
同上 |
tb_vending_machine |
region_id、partner_id、addr |
可由 tb_node 推出 |
设备按区域/合作商统计时不用绕点位表 |
tb_channel |
inner_code |
tb_vending_machine.inner_code |
货道按软编号查设备,避免 JOIN |
tb_order_collect |
partner_name |
tb_partner.partner_name |
报表直接展示 + 历史快照 |
tb_task_details |
sku_name、sku_image |
tb_sku |
工单历史快照 |
tb_order_month_collect |
partner_name、region_name |
关联表 | 统计表,纯展示 |
典型坑:tb_emp.region_name
区域改名后,tb_emp.region_name 里的旧名字不会自动跟着变,于是出现:
region_id = 2 (指向"北京市海淀区")
region_name = '北京市朝阳区' (旧名字,脏数据)
同理,把 tb_emp_ibfk_2 改成 ON DELETE SET NULL 后,region_id 会被置空,但 region_name 仍是旧值,脏数据更明显。
三、什么时候应该设计冗余字段
1. 读多写少,且查询频繁
列表页、详情页每次都要显示关联表名称。低频查询 JOIN 无所谓,但高频接口 + 大表关联时,冗余一个名称字段能省掉整条 JOIN 链。
典型场景:列表页的「所属区域」「所属合作商」。
2. 关联层级太深
A 关联 B,B 关联 C,查 A 需要 JOIN 三层才能拿到 C 的名称。此时把 C 的关键字段冗余到 A 上,查询从"三次 JOIN"变成"单表扫描"。
本项目的 tb_vending_machine 属于这种情况:设备 → 点位 → 区域/合作商,两层关联,所以直接在设备表冗余 region_id、partner_id。
3. 需要保留"当时的快照"(最不容妥协)
订单、工单、流水、报表这类表,冗余是必须的,不是可选项:
- 商品今天叫「可口可乐」,明天改名「可口可乐 500ml」,三个月前的订单里必须还是「可口可乐」
- 商品价格从 3 元涨到 3.5 元,历史订单金额不能跟着变
所以 tb_task_details.sku_name、tb_task_details.sku_image 必须冗余。
这类冗余的目的不是性能,而是历史事实不可被后续修改影响 。若只存
sku_id,那就是设计错误。
4. 跨库、分表、微服务拆分
数据不在同一个库,JOIN 做不了,只能冗余;或分表后 JOIN 成本极高。
5. 允许短暂不一致,或有对账机制
如商品表冗余"销量""评论数",通过定时任务或消息异步更新,短时间不准可接受。
四、什么时候不该设计冗余字段
1. 源数据频繁变化
region_name 一天改十次,冗余在 5 张表里就要同步 5 次,漏一次就是脏数据。
变化越频繁,冗余成本越高。
2. 强一致性要求
财务金额、库存数量、账户余额这类数据宁可 JOIN,也不能存两份------两处数字不一致时无法判断哪份是对的。
3. 能用其他手段解决时
优先考虑以下方案,它们不承担一致性维护成本:
- 加缓存(Redis)
- 建视图(View)
- 加合适索引让 JOIN 变快
- 搜索引擎(ES)做宽表
4. 冗余对象是"一对多"的集合
tb_emp.region_name 冗余的是单个值,尚可控;若在区域表里存"该区域所有点位名称",则绝对不能做。
五、冗余的代价
| 收益 | 代价 |
|---|---|
| 查询少 JOIN,性能好 | 存储空间增加 |
| 代码简单,不用拼多表 | 一致性风险:源数据变了,冗余数据不会自己变 |
| 历史数据可追溯 | 更新逻辑变复杂,需同步改多处 |
一致性维护方式(按可靠性排序)
- 同一事务内同步更新 (最常用)------改区域名时,事务里同时更新
tb_emp.region_name - 不冗余易变字段------只冗余"商品快照"这类天然不该变的数据
- 定时任务/对账脚本校准------接受最终一致
- 数据库触发器------能实现,但业务逻辑藏进数据库,排查困难,一般不推荐
六、本项目落地建议
当前 Region / Partner / Node 三个模块都没有做冗余字段的同步更新,具体表现:
| 冗余关系 | 隐患 |
|---|---|
tb_emp.region_name |
区域改名后不同步 |
tb_vending_machine.region_id / partner_id / addr |
点位改所属区域后,设备表不会跟着变 |
按项目定位选择处理方式:
练手项目
不处理也可以,知道它是冗余、知道有隐患即可。
正式项目
tb_emp.region_name这类纯展示用的名称冗余 :建议去掉,改由后端返回时根据region_id组装,或前端查字典,彻底消灭不一致。tb_vending_machine.region_id这类用于统计和筛选的冗余:建议保留,但在"点位改区域"的 Service 里加同步更新设备的逻辑,保证同一事务内改完。
七、速查口诀
读多写少 + 值相对稳定 + 不一致可接受(或本就是历史快照)→ 可以冗余
写多、值易变、要求强一致 → 老老实实 JOIN
决策清单
设计字段前依次自问:
- 这个值能从别的表推导出来吗?能 → 它才是"冗余"
- 查询频率高吗?关联层级深吗?
- 是历史快照吗?(订单/工单/流水 → 必须冗余)
- 源字段一年改几次?频繁 → 不冗余
- 业务能接受短暂不一致吗?不能 → 不冗余
- 有缓存/视图/索引等替代方案吗?有 → 优先用替代方案
- 如果决定冗余,更新逻辑在哪?是否在同一事务内同步?
八、配套注意:冗余字段与外键的关系
冗余字段和外键约束是两件事,别混淆:
- 外键 保证"引用的 id 真实存在"(如
tb_node.region_id指向的tb_region必须存在) - 冗余字段 是"另存的一份展示数据"(如
tb_emp.region_name),数据库不会校验它与源表是否一致
所以:
| 现象 | 原因 |
|---|---|
删除区域报 Cannot delete or update a parent row |
外键 RESTRICT,子表还有引用 |
| 员工列表显示的区域名和区域表不一致 | 冗余字段未同步,数据库不管 |
删除区域时想区分是"点位"还是"员工"在拦,可在 GlobalExceptionHandler 中解析外键异常里的子表名,翻译成中文提示(参考 dkd-framework/.../GlobalExceptionHandler.java)。