Database 设计:70+ Columns 还是 Multiple Tables?
结论
70 多个 columns 不一定说明设计有问题。不要只因为列数多,就把成员资料机械地拆成 basic_info、contact_info、education_info 等多个 1
是否拆分,应该看数据之间的关系:
- 每位成员始终只有一个值,而且生命周期一致的属性,可以留在
membertable 中。 - 可能有多个值的属性,如电话号码、地址、专业方向和学历,适合拆成独立 table。
- payments、requests、penalties 这类持续产生的业务记录,应继续使用 1 tables。
- 只有需要隔离权限、保护敏感信息、分离很少访问的数据,或者由不同模块独立维护时,才有必要拆分 1 table。
判断重点不在于“70 列是不是太多”,而在于这些列是否描述同一种实体,是否包含重复数据、数量可变的属性,或者拥有独立生命周期的数据。
先给字段分类
可以先把现有的 70 多个字段按性质分类。
单值成员属性
例如:
- membership number
- first name
- last name
- date of birth
- registration date
- membership status
如果每位成员最多只有一个对应值,这些属性可以直接放在 member table 中。
CREATE TABLE member (
member_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
membership_number VARCHAR(30) NOT NULL UNIQUE,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
date_of_birth DATE,
joined_at DATE NOT NULL,
membership_status VARCHAR(30) NOT NULL
);
姓名不适合作为主键,因为姓名可能重复,也可能更改。内部标识应使用稳定的 member_id,业务上的会员编号则添加 UNIQUE constraint。
多值属性
如果一位医生可能有多个电话号码,就不适合设计成:
home_phone
office_phone
mobile_phone
fax
second_mobile_phone
每增加一种电话号码,就要增加新的 column,而且这种结构无法自然表达“两个办公电话”之类的情况。更合适的做法是建立子表:
CREATE TABLE member_phone (
member_phone_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
member_id BIGINT NOT NULL,
phone_type VARCHAR(30) NOT NULL,
phone_number VARCHAR(50) NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_member_phone_member
FOREIGN KEY (member_id)
REFERENCES member (member_id)
ON DELETE CASCADE
);
这样,每名成员都可以有零个、一个或多个电话号码。
地址也可以采用类似的结构:
CREATE TABLE member_address (
member_address_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
member_id BIGINT NOT NULL,
address_type VARCHAR(30) NOT NULL,
address_line_1 VARCHAR(200) NOT NULL,
address_line_2 VARCHAR(200),
city VARCHAR(100),
postal_code VARCHAR(30),
country_code CHAR(2),
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT fk_member_address_member
FOREIGN KEY (member_id)
REFERENCES member (member_id)
ON DELETE CASCADE
);
address_type 可以表示 home、office、mailing 等类型。实际系统中,最好用 lookup table 或 CHECK constraint 限制合法值,避免同时出现 office、Office、work 等含义相近但写法不同的数据。
多对多属性
一名医生可能拥有多个 specialty,同一个 specialty 也可能属于多名医生,因此两者通常是 M
relationship:CREATE TABLE specialty (
specialty_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
specialty_name VARCHAR(150) NOT NULL UNIQUE
);
CREATE TABLE member_specialty (
member_id BIGINT NOT NULL,
specialty_id BIGINT NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (member_id, specialty_id),
CONSTRAINT fk_member_specialty_member
FOREIGN KEY (member_id)
REFERENCES member (member_id)
ON DELETE CASCADE,
CONSTRAINT fk_member_specialty_specialty
FOREIGN KEY (specialty_id)
REFERENCES specialty (specialty_id)
);
如果业务规则明确规定每位医生只能有一个 specialty,也可以直接在 member 中保存 specialty_id。是否拆表应由真实的业务规则决定,不能因为当前样本中恰好只有一个值就下结论。
会重复发生的业务记录
payments、requests 和 penalties 不是成员的静态属性。一名成员可以产生多条此类记录,所以它们应该保存在独立 table 中:
CREATE TABLE subscription_payment (
payment_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
member_id BIGINT NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
paid_at TIMESTAMP NOT NULL,
payment_status VARCHAR(30) NOT NULL,
reference_number VARCHAR(100),
CONSTRAINT fk_payment_member
FOREIGN KEY (member_id)
REFERENCES member (member_id)
);
CREATE INDEX idx_payment_member_id
ON subscription_payment (member_id);
不要在成员表中增加 payment_2024、payment_2025,也不要使用 payment_1、payment_2 这样的 columns。每次付款都应该单独保存为一行记录。
Education 是否需要独立 table
这要看系统需要记录哪些信息。
如果每位成员只填写一项固定的最高学历,例如:
highest_degree
graduation_year
institution_name
这些字段留在成员表中没有问题。
如果系统需要保存完整的教育经历,而一名成员可能有多条记录,就应该建立 1
table:CREATE TABLE member_education (
education_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
member_id BIGINT NOT NULL,
institution_name VARCHAR(200) NOT NULL,
degree_name VARCHAR(150),
field_of_study VARCHAR(150),
started_on DATE,
completed_on DATE,
CONSTRAINT fk_education_member
FOREIGN KEY (member_id)
REFERENCES member (member_id)
ON DELETE CASCADE
);
这个原则同样适用于执照、证书、工作经历、医院任职记录和协会职务。
不建议机械拆成多个 1 tables
下面这种设计通常只是把一行数据横向切成几段:
member
member_basic_info
member_contact_info
member_professional_info
member_private_info
如果每个 table 都以 member_id 为主键,而且读取完整成员资料时总要把它们全部 JOIN 回来,这种拆分并没有解决数据建模问题,反而会带来:
- 更多 foreign keys;
- 更多
JOIN; - 更复杂的新增、修改和删除逻辑;
- 部分子表缺失时的完整性问题;
- 更繁琐的事务处理。
“字段属于同一主题”可以用来组织命名和代码,但不足以成为新建 table 的理由。数据库 normalization 主要处理函数依赖、重复数据和多值关系,并不要求按照页面区块或表单章节拆表。
1 拆分什么时候有价值
在下面这些场景中,1
table 可能是合理的。敏感信息需要单独控制
医疗协会的成员资料可能包含身份证件、私人联系方式或其他受保护信息。可以把这些数据与普通成员资料分开,并设置更严格的访问权限:
CREATE TABLE member_private_detail (
member_id BIGINT PRIMARY KEY,
national_identifier VARCHAR(100),
private_notes TEXT,
CONSTRAINT fk_private_detail_member
FOREIGN KEY (member_id)
REFERENCES member (member_id)
ON DELETE CASCADE
);
但单独建表不会自动带来安全保护,还需要数据库权限、应用层授权、加密、审计和备份策略共同配合。
大量可选字段只适用于少数成员
如果一组字段只适用于某类成员,例如退休会员、海外会员或持有特定认证的医生,可以把它们放进独立的扩展表,减少大量没有意义的 NULL。
字段有不同的维护方式或生命周期
例如,核心会员身份由协会管理员维护,公开个人简介则由成员自行维护。如果两组数据使用不同的权限、审核流程或更新时间,可以考虑拆分。
极少访问的大型字段
照片、签名、大段履历或其他大型数据,如果很少与基本资料一起读取,可以独立保存。这样做能否改善性能,取决于具体数据库、字段类型和查询方式,需要通过真实查询和执行计划来验证,不能只根据列数判断。
一个更接近实际业务的结构
常见结构可以整理为:
member
├── member_phone 1:N
├── member_address 1:N
├── member_education 1:N
├── member_license 1:N
├── member_specialty M:N
├── subscription_payment 1:N
├── member_request 1:N
└── member_penalty 1:N
member 保存成员自身的单值属性。其他 tables 则保存数量不固定、可以独立新增,或者拥有自身业务生命周期的数据。
这种结构比把 70 个 columns 平均拆成几个 1
tables 更符合关系模型。实施步骤
1. 为每个字段写出业务规则
不要只记录字段名,还要回答这些问题:
- 每位成员最多可以有几个值?
- 这个值是否需要保留历史记录?
- 是否需要单独搜索、排序或统计?
- 是否存在共享字典,例如 specialty?
- 是否只适用于部分成员?
- 是否有独立的权限或保留期限?
这些答案会决定字段应该留在 member 中,还是拆成 1
2. 找出重复字段组
重点检查下面这类命名:
phone_1, phone_2, phone_3
specialty_1, specialty_2
school_1, school_2
previous_employer_1, previous_employer_2
这类字段通常意味着表中存在尚未拆分的重复集合,应该转换为子表中的多行记录。
3. 找出可以复用的实体
例如 specialty、hospital、country 或 membership status。如果这些数据有自己的属性,需要统一维护,或者会被大量记录引用,可以建立对应的 table。
但没有必要为了消除每一个重复字符串而不断拆表。例如,是否为城市名建立独立 table,要看系统是否真的需要维护城市代码、地区层级等信息。
4. 添加约束和索引
合理的 schema 不只是把数据拆进不同 table,还要用约束保证数据质量:
PRIMARY KEY用于标识记录;FOREIGN KEY保证关联有效;UNIQUE防止会员编号等业务标识重复;NOT NULL表示字段确实必填;CHECK用于限制状态或金额;- 为常用查询和 foreign key columns 建立合适的索引。
例如:
ALTER TABLE subscription_payment
ADD CONSTRAINT chk_payment_amount
CHECK (amount >= 0);
具体语法可能会因 MySQL、PostgreSQL、SQL Server 等数据库而略有不同。
5. 根据真实查询验证设计
列出系统中最常见的操作,例如:
- 按会员编号查询完整档案;
- 搜索某个 specialty 的医生;
- 查询未缴费会员;
- 获取成员的主要办公地址;
- 统计某段时间内的 penalties。
用这些查询检查 schema 是否清晰,再借助数据库的执行计划工具验证索引和性能。不要为了尚未出现的性能问题提前做过度拆分。
避免使用 EAV 代替正常字段
面对 70 多个属性时,很容易想到下面这种设计:
member_id | attribute_name | attribute_value
例如:
10 | first_name | Alice
10 | graduation_year | 2012
10 | active | true
这种 Entity-Attribute-Value 设计看起来灵活,却会失去清晰的数据类型,让约束、查询、排序、索引和报表都变得困难。graduation_year、布尔值和日期最终可能都被迫保存成字符串。
只有当属性确实由用户动态定义,无法预先确定 schema 时,才应谨慎考虑 EAV 或 JSON。对于姓名、生日、地址、电话号码和专业等稳定的业务字段,普通 columns 和关系表通常更可靠。
其他注意事项
- 不要把完整地址塞进一个无法解析的字符串,除非系统只负责展示,完全不需要搜索或统计地址的组成部分。
- 电话号码通常应该使用字符串类型,而不是整数,因为其中可能包含
+、前导零、分机号或格式字符。 - 邮政编码、执照编号和会员编号通常也不适合使用数值类型。
- 如果需要保留历史变化,不要直接覆盖旧值。可以建立历史表,或者增加有效期字段。
- 是否使用
ON DELETE CASCADE,要根据审计和合规要求决定。payments、penalties 等财务或纪律记录通常不应在成员删除时一并物理删除。 - medical association 数据可能涉及隐私和监管要求。具体需要遵守哪些规则取决于所在国家或地区,应由熟悉当地法规的人员确认。
最终保留一张拥有较多 columns 的 member table 完全可以接受。只要其中保存的都是成员的单值属性,而且数据依赖关系正确,70 多列并不“丑”。真正需要调整的是重复字段、多值属性和独立业务记录,而不是单纯追求更少的列数。
备注:内容仅供参考。
