PAYATHON 2026

Database 设计:70+ Columns 还是 Multiple Tables?

支付老李

结论

70 多个 columns 不一定说明设计有问题。不要只因为列数多,就把成员资料机械地拆成 basic_info、contact_info、education_info 等多个 1

tables。

是否拆分,应该看数据之间的关系:

  • 每位成员始终只有一个值,而且生命周期一致的属性,可以留在 member table 中。
  • 可能有多个值的属性,如电话号码、地址、专业方向和学历,适合拆成独立 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

、M
或少量的 1
table。

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 多列并不“丑”。真正需要调整的是重复字段、多值属性和独立业务记录,而不是单纯追求更少的列数。

备注:内容仅供参考。