这两天实习的时候,研究了一下测试库的表,发现很多表都是同时记录部门代码和部门名称字段的。
对于这一类问题,我过去的理解一直很模糊。因为此前考虑时,只粗糙地思考过两种情况:
-
只记录
部门代码。节省存储空间,但是查询时需要联表查询。 -
同时记录
部门代码和部门名称,查询性能好,但是空间占用略大。
就是这么的粗糙简单......
也就是说,我一直把它理解成一个简单的 空间与时间 的选择问题。
但仔细看这些表后发现,它们大多是:
- 收货单
- 结算单
- 采购单
- 业务流水
它们记录的是已经发生的业务事实,也就是一种 业务快照。
一、关键不是 JOIN,而是"这个值会不会随时间变化"
假设部门信息是:
部门代码:1001
部门名称:采购部
后来部门改名:
部门代码:1001
部门名称:供应链采购部
现在有两种场景。
员工表
员工表只保存:
employee.department_id
查询员工当前部门时 JOIN:
csharp
select e.name, d.name
from employee e
left join department d
on e.department_id = d.id;
这是合理的。
因为我们关心的是:
这个员工现在属于什么部门?
部门改名以后,员工页面也应该显示新名称。
所以这里:
部门名称 = 当前属性
适合 JOIN。
收货单
假设一张历史收货单生成时:
部门代码:1001
部门名称:采购部
几年后部门改名。
如果收货单只保存部门代码,再 JOIN 当前部门表,那么历史单据会变成:
部门名称:供应链采购部
这就改变了历史。
因此这类表同时保存:
department_code
department_name
是合理的。
这里的 department_name 表示:
单据生成那一刻的部门名称。
它不是普通字段冗余,而是 历史快照。
所以核心不是:
这个字段能不能从其他表查出来?
而是:
我要的是当前状态,还是当时的状态?
二、派生字段要不要存?
另一种容易纠结的是:
ini
amount = qty * price
age = today - birthday
profit = revenue - cost
它们都可以通过其他字段计算出来。
但"能算出来"并不意味着"一定不存"。
1. 可以稳定重算:通常不存
例如:
ini
age = today - birthday
year = YEAR(order_date)
只要输入确定,结果就可以随时重新得到。
特别是年龄、逾期天数这种数据:
即使数据库没有发生任何修改,它也会随着时间失效。
所以应该保存:
birthday
due_date
而不是:
age
overdue_days
2. 最终业务结果:应该存
假设:
ini
qty = 3
price = 9.99
理论金额:
29.97
但由于:
- 折扣
- 优惠
- 税费
- 尾差
- 人工调整
最终结算金额可能是:
29.96
这时 29.96 已经不是简单的计算值,而是:
最终确认的业务结果。
这种数据应该保存。
例如:
实际结算金额
最终成交金额
人工调整后的金额
绩效最终得分
所以:
能计算出来 ≠ 不应该保存。
3、可以用"重算测试"
面对一个派生字段 X,可以问:
如果把 X 删除,以后还能不能根据其他数据 100% 重建出相同结果?
能,而且计算简单
例如:
ini
year = YEAR(order_date)
→ 不存。
能,但计算很贵
例如:
scss
用户累计消费 = SUM(历史订单)
每次都扫描几十万条订单不现实。
→ 可以保存为 缓存 / 预计算字段。
以后未必能重建
例如绩效算法发生变化:
ini
2025:
score = sales * 0.7 + rating * 0.3
2026:
score = sales * 0.5 + rating * 0.5
如果保存的是:
yaml
2025 年最终绩效
→ 应该存。
根本无法重建
例如金额经过人工调整。
→ 必须存。
三、当冗余只是为了性能
例如:
markdown
article
-------
id
author_id
用户昵称在:
markdown
user
----
id
nickname
正常来说应该 JOIN。
但为了查询性能,也可以复制:
css
article.author_nickname
但是这里和业务快照完全不同。
用户昵称修改以后:
Tom → Thomas
文章中的昵称也必须同步修改,当用户有几十万篇文章,那简直是灾难.....
这时,真正要权衡的其实不是"多存一个字段占多少空间",而是:
少一次 JOIN,值不值得换来额外的数据同步成本?
还是以 article.author_nickname 为例。
假设用户把昵称从 Tom 改成 Thomas。
如果文章表只保存:
author_id
那么只需要修改一次:
ini
update user
set nickname = 'Thomas'
where id = 1;
之后所有文章通过 JOIN 都能立即得到最新昵称。
但如果为了查询性能,在文章表中冗余了:
author_nickname
事情就变成了:
css
user.nickname 修改
↓
找到这个用户的所有文章
↓
同步 article.author_nickname
如果这个用户只有 10 篇文章,更新 10 行可能无所谓。
但如果有 100 万条内容,一次改昵称就可能产生 100 万行更新。此时不仅要考虑 UPDATE 本身,还有日志、复制、IO,以及同步失败后的数据不一致。
不过反过来,如果这个字段:
每天读取几亿次
一年只修改几次
而且实际压测已经证明 JOIN 是主要瓶颈,那么用一次偶尔发生的批量同步,换取大量查询的性能提升,也可能是值得的。
甚至还可以接受短暂的不一致:
ini
user.nickname = Thomas
article.nickname = Tom
通过异步任务几秒后再同步完成。
所以,性能冗余更适合这样的数据:
sql
读非常多
改非常少
允许短暂不一致
JOIN 确实已经成为瓶颈
而如果只是因为:
"感觉 JOIN 会慢。"
就提前把名称复制到各张表里,通常只是在用未来的数据一致性问题,解决一个还没有发生的性能问题。
在真正冗余字段之前,更合理的顺序通常是:
sql
先确认 JOIN 是否真的慢
↓
检查索引和 SQL
↓
考虑缓存或批量查询
↓
仍然存在明确瓶颈
↓
再考虑反范式化
因此,性能冗余应该是一种有数据依据的优化,而不是数据库设计时的默认选择。
总结
现在再看"一个字段该不该多存一份",我更倾向于先判断它的业务语义,而不是先想 JOIN 和存储空间。
简单来说:
- 当前状态:优先 JOIN,保持单一数据源。
- 历史事实:保存快照,避免未来修改影响历史。
- 派生结果:能稳定、低成本重算就不存;已经成为最终业务结果则保存。
- 性能冗余:只有确认 JOIN 确实是瓶颈后再做,并接受随之而来的一致性成本。
所以,数据库里的"冗余"并不一定是不合理设计。
真正需要搞清楚的是:
这个字段为什么要存,以及哪份数据才是真正的数据源。
欢迎讨论、补充、纠正