我为什么把患者与病例从 1:1 改成 1:N?一次真实数据库模型重构记录

我为什么把患者与病例从 1:1 改成 1:N?一次真实数据库模型重构记录

前言

最近在开发一个医院胸痛中心智慧填报系统时,我做了一次比较大的数据库模型调整:

最初的设计是:

text 复制代码
Patient 1 : 1 PatientCase

后来被我整体推翻,改成了:

text 复制代码
Patient 1 : N PatientCase

表面上看,这似乎只是把"一对一"修改成"一对多",增加一个 patient_id 外键就结束了。

但真正做下来才发现,这次修改影响的远不只是数据库表结构。

它同时牵涉到了:

  • 患者与病例的领域边界;
  • report_no 填报编号应该属于谁;
  • 病例状态应该存在哪里;
  • 时间轴数据如何关联;
  • 会议、附件应该关联患者还是病例;
  • REST API 的资源语义;
  • SQLAlchemy ORM 关系;
  • 多租户条件;
  • 唯一索引设计;
  • 已有数据迁移;
  • 新旧版本兼容和回滚。

更重要的是,这次重构让我重新认识到一个问题:

数据库设计首先是业务建模,其次才是建表和写 SQL。

项目早期,一个错误的模型完全可能"正常运行"。

真正危险的不是代码立即报错,而是随着业务越来越复杂,你会发现为了维持这个错误模型,不得不增加越来越多的特殊字段、特殊判断和补丁逻辑。

本文就完整复盘这次从 1:11:N 的真实数据库模型重构。

本文涉及的业务字段均进行了简化和脱敏,重点讨论数据库设计、领域建模以及工程重构思路,不涉及任何真实患者数据。


一、项目背景:一开始为什么会把患者和病例设计成 1:1?

先简单介绍项目背景。

这个系统主要面向医院胸痛中心的数据填报场景,目前整体技术栈主要包括:

层级 技术
移动端 APP / 移动端业务页面
Web 管理端 Vite 前端
后端 FastAPI
ORM SQLAlchemy 2.x
数据迁移 Alembic
数据库 MySQL
缓存 Redis
架构 多租户

系统中一个租户基本可以理解为一家医院:

text 复制代码
医院 A -> tenant_id = HOSPITAL_A
医院 B -> tenant_id = HOSPITAL_B
医院 C -> tenant_id = HOSPITAL_C

不同医院之间的数据需要进行租户隔离。

整个业务里有两个非常核心的概念:

text 复制代码
Patient
患者档案

PatientCase
患者病例 / 填报记录

患者进入业务流程以后,会涉及大量信息,例如:

text 复制代码
患者基本信息
        │
        ├── 姓名
        ├── 性别
        ├── 出生日期
        ├── 联系方式
        ├── 身份信息
        └── 医保信息

病例信息
        │
        ├── 发病时间
        ├── 呼救时间
        ├── 到达医院时间
        ├── 心电图时间
        ├── 初步诊断
        ├── 双抗
        ├── 抗凝
        ├── 他汀
        ├── 溶栓
        ├── 会诊
        ├── 审核
        └── 归档

1.1 最早的业务流程非常简单

项目早期,页面流程基本是:

text 复制代码
新增患者
    ↓
填写患者基本信息
    ↓
填写胸痛病例信息
    ↓
提交
    ↓
审核
    ↓
归档

从前端页面看,一名患者进入系统以后,似乎永远只对应一份填报。

因此当时很容易产生这样的业务认知:

text 复制代码
一个患者
    =
一份填报
    =
一份病例

于是数据库很自然就变成:

text 复制代码
Patient 1 : 1 PatientCase

早期甚至会出现这种结构:

sql 复制代码
CREATE TABLE patient
(
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    tenant_id   VARCHAR(64) NOT NULL,
    patient_no  VARCHAR(64),
    report_no   VARCHAR(64),
    name        VARCHAR(50),
    gender      TINYINT,
    birthday    DATE,
    phone       VARCHAR(20),
    create_time DATETIME,
    update_time DATETIME
);

然后另外存在:

sql 复制代码
CREATE TABLE patient_case
(
    id             BIGINT PRIMARY KEY AUTO_INCREMENT,
    tenant_id      VARCHAR(64) NOT NULL,
    patient_id     BIGINT NOT NULL,
    report_no      VARCHAR(64),
    onset_time     DATETIME,
    diagnosis_type INT,
    status         INT,
    create_time    DATETIME,
    update_time    DATETIME
);

这里其实已经能够看到一个危险信号:

text 复制代码
patient.report_no

patient_case.report_no

两个表都开始出现 report_no

当同一个业务属性开始不知道应该放在哪张表的时候,通常就意味着:

实体边界可能已经划错了。

但是在项目早期,由于每个患者都只有一条测试病例,所以这个问题完全不会影响 CRUD。

查询患者:

sql 复制代码
SELECT *
FROM patient
WHERE id = 1001;

查询病例:

sql 复制代码
SELECT *
FROM patient_case
WHERE patient_id = 1001;

甚至直接 JOIN:

sql 复制代码
SELECT
    p.*,
    pc.*
FROM patient p
LEFT JOIN patient_case pc
       ON pc.patient_id = p.id
WHERE p.id = 1001;

只要测试数据永远满足:

text 复制代码
一个 patient_id 只出现一次

整个系统看起来就是正确的。

这也是这次重构带给我的第一个经验:

"现在能够正常 CRUD"并不能证明数据库模型是正确的,它只能证明当前测试数据暂时没有击穿这个模型。


二、真正击穿 1:1 模型的问题:同一个患者第二次入院怎么办?

真正让我决定重新设计数据库的,并不是什么复杂的数据库理论。

而是一个非常简单的问题:

如果同一个患者半年之后再次入院,我应该怎么办?

假设患者:

text 复制代码
姓名:张三
患者编号:P202600001

第一次就诊:

text 复制代码
2026-08-01

病例:
STEMI

填报编号:
BG202608010001

第二次又因为胸痛进入医院:

text 复制代码
2027-02-13

病例:
NSTEMI

填报编号:
BG202702130036

现在原来的:

text 复制代码
Patient 1 : 1 PatientCase

开始出现问题。


2.1 方案一:覆盖旧病例?

显然不可能。

如果第二次病例直接覆盖第一次病例,那么:

text 复制代码
第一次诊断
第一次心电图
第一次双抗
第一次溶栓
第一次时间轴
第一次审核
第一次归档

全部会被破坏。

医疗业务中的历史病例本身就是需要长期保留的数据。

所以:

text 复制代码
覆盖

首先被排除。


2.2 方案二:重新创建一个患者?

例如:

text 复制代码
patient

id      patient_no      name
1       P202600001      张三
2       P202700036      张三

这样虽然可以让两条患者记录分别拥有自己的病例,但又产生了新的问题。

现实世界明明只有:

text 复制代码
一个张三

系统里面却变成:

text 复制代码
张三 A
张三 B

进一步还会影响:

  • 患者历史病例查询;
  • 患者画像;
  • 数据统计;
  • 重复患者识别;
  • 医保信息;
  • 联系方式维护;
  • 以后可能存在的随访记录。

例如我们想查询:

张三过去三年一共发生过多少次胸痛病例?

如果患者本身已经被复制成三条,就不得不重新通过:

text 复制代码
身份证号
手机号
姓名 + 出生日期

等条件去猜它们是不是同一个人。

数据库明明应该帮助业务建立关系,最后却反过来需要业务通过字段去"猜关系"。

这个方向显然也不合理。


2.3 真正的问题:我把"人"和"事件"混成了一个概念

直到这里,我才真正意识到原始模型的根本问题:

Patient 和 PatientCase 根本不是同一种生命周期的实体。

Patient 表示的是:

text 复制代码
这个人是谁?

PatientCase 表示的是:

text 复制代码
这个人在某一次医疗事件中发生了什么?

于是正确模型开始变得非常明显:

text 复制代码
                        Patient
                          张三
                           │
               ┌───────────┼───────────┐
               │           │           │
               ▼           ▼           ▼
          PatientCase  PatientCase  PatientCase
            第一次       第二次       第三次

也就是:

text 复制代码
Patient 1 : N PatientCase

从另外一个角度看:

text 复制代码
Patient

更接近长期存在的主体数据 / 主数据

而:

text 复制代码
PatientCase

更接近一次具体业务过程产生的事件数据 / 交易数据

两者的生命周期完全不同。

Patient:

text 复制代码
创建患者
    ↓
补充患者资料
    ↓
第一次病例
    ↓
第二次病例
    ↓
第三次病例
    ↓
长期存在

PatientCase:

text 复制代码
病例创建
    ↓
填报
    ↓
诊疗过程
    ↓
审核
    ↓
归档

这时候再回来看:

text 复制代码
1:N

就已经不是"为了支持新需求而强行修改数据库"。

而只是:

数据库开始正确描述现实世界。


三、重新梳理领域边界:哪些字段到底属于 Patient,哪些属于 PatientCase?

真正决定改成 1:N 之后,我没有立即开始改表。

我先做的事情是重新检查字段归属。

因为如果只是:

sql 复制代码
ALTER TABLE patient_case
ADD patient_id BIGINT;

而不重新划分字段,那么其实只是把错误的模型换了一种表现形式。

我最后总结出一个很实用的判断方式:

这个字段描述的是"这个人",还是描述"这个人这一次发生的事情"?


3.1 Patient:描述"这个人是谁"

比如:

text 复制代码
姓名
性别
出生日期
证件信息
手机号
民族
教育程度
婚姻状态
职业
户籍地址
医保信息
身高
体重
BMI

这些信息本质上描述的是患者本身。

所以应该属于:

text 复制代码
patient

例如:

sql 复制代码
CREATE TABLE patient
(
    id                BIGINT AUTO_INCREMENT COMMENT '患者主键ID'
        PRIMARY KEY,

    tenant_id         VARCHAR(64) NOT NULL COMMENT '租户ID',

    patient_no        VARCHAR(64) NOT NULL COMMENT '患者编号',

    name              VARCHAR(50) NOT NULL COMMENT '姓名',

    gender            TINYINT NULL COMMENT '性别',

    birthday          DATE NULL COMMENT '出生日期',

    id_card           VARCHAR(32) NULL COMMENT '证件号码',

    phone             VARCHAR(20) NULL COMMENT '手机号',

    nation            VARCHAR(50) NULL COMMENT '民族',

    education         VARCHAR(50) NULL COMMENT '教育程度',

    marital_status    VARCHAR(50) NULL COMMENT '婚姻状态',

    occupation        VARCHAR(100) NULL COMMENT '职业',

    household_address VARCHAR(255) NULL COMMENT '户籍地址',

    insurance_type    VARCHAR(50) NULL COMMENT '医保类型',

    height            DECIMAL(5,2) NULL COMMENT '身高',

    weight            DECIMAL(5,2) NULL COMMENT '体重',

    bmi               DECIMAL(5,2) NULL COMMENT 'BMI',

    create_time       DATETIME NULL,

    update_time       DATETIME NULL,

    UNIQUE KEY uk_tenant_patient_no
        (tenant_id, patient_no),

    KEY idx_tenant_name
        (tenant_id, name)
)
COMMENT '患者档案';

这里特别注意一个问题。

系统是多租户架构,所以很多业务唯一性不能只考虑:

text 复制代码
patient_no

而要考虑:

text 复制代码
tenant_id + patient_no

因此:

sql 复制代码
UNIQUE KEY uk_tenant_patient_no
    (tenant_id, patient_no)

比:

sql 复制代码
UNIQUE KEY uk_patient_no
    (patient_no)

更符合当前系统的业务边界。


3.2 PatientCase:描述"这个人这一次发生了什么"

例如:

text 复制代码
填报编号
发病时间
呼救时间
本次诊断
本次双抗
本次抗凝
本次他汀治疗
本次溶栓
本次会诊
本次审核状态
本次归档状态

它们全部具有非常明显的:

text 复制代码
"本次"

属性。

所以应该属于:

text 复制代码
patient_case

简化后的结构:

sql 复制代码
CREATE TABLE patient_case
(
    id                         BIGINT AUTO_INCREMENT COMMENT '病例主键ID'
        PRIMARY KEY,

    tenant_id                  VARCHAR(64) NOT NULL COMMENT '租户ID',

    patient_id                 BIGINT NOT NULL COMMENT '患者ID',

    report_no                  VARCHAR(64) NOT NULL COMMENT '填报编号',

    onset_time                 DATETIME NULL COMMENT '发病时间',

    call_help_time             DATETIME NULL COMMENT '呼救时间',

    hospital_gate_arrival_time DATETIME NULL COMMENT '到达医院大门时间',

    diagnosis_type             INT NULL COMMENT '诊断类型',

    diagnosis_time             DATETIME NULL COMMENT '诊断时间',

    status                     INT DEFAULT 0 COMMENT '病例状态',

    create_time                DATETIME NULL,

    update_time                DATETIME NULL,

    UNIQUE KEY uk_tenant_report_no
        (tenant_id, report_no),

    KEY idx_tenant_patient
        (tenant_id, patient_id),

    CONSTRAINT fk_case_patient
        FOREIGN KEY (patient_id)
        REFERENCES patient(id)
        ON DELETE RESTRICT
        ON UPDATE RESTRICT
)
COMMENT '患者病例';

MySQL 官方文档中对外键的定义,本质也是用父表和子表之间的引用关系维护关联数据的一致性。

在这里:

text 复制代码
patient

就是父实体。

text 复制代码
patient_case

就是子实体。

关系变成:

text 复制代码
patient.id
     ▲
     │
     │ patient_case.patient_id
     │
patient_case

3.3 为什么我没有选择 ON DELETE CASCADE?

这里顺便说一个真实项目里非常容易被忽略的问题:

sql 复制代码
ON DELETE CASCADE

是否应该使用?

如果配置:

sql 复制代码
FOREIGN KEY (patient_id)
REFERENCES patient(id)
ON DELETE CASCADE

那么删除患者时,理论上可以级联删除:

text 复制代码
患者
 ↓
病例
 ↓
时间节点
 ↓
会议
 ↓
附件

从数据库技术上看很方便。

但在这类业务中,我反而更倾向:

sql 复制代码
ON DELETE RESTRICT

原因很简单:

患者档案误删,不应该顺手把所有历史病例一起物理删除。

更加安全的方式是:

text 复制代码
业务逻辑删除
+
显式数据清理
+
权限控制

而不是让一条:

sql 复制代码
DELETE FROM patient
WHERE id = ?;

自动触发大量历史数据删除。

这也是数据库设计里面非常重要的一点:

技术上支持级联删除,不代表业务上就应该使用级联删除。


四、原来的模型到底错在哪里?4 个字段问题已经给出了答案

重新梳理模型以后,我发现其实在决定重构之前,系统里早就出现了很多信号。

只是当时没有把它们联系起来。


4.1 report_no 到底属于谁?

这是最明显的一个问题。

原来患者表里存在:

sql 复制代码
report_no VARCHAR(64)

report_no 的含义是:

text 复制代码
填报编号

假设:

text 复制代码
患者:张三

有三次病例:

text 复制代码
病例 A -> BG202608010001
病例 B -> BG202702130036
病例 C -> BG202801050088

那么:

text 复制代码
patient.report_no

应该存哪个?

这个问题实际上没有答案。

因为它本来就不属于 Patient。

正确关系应该是:

text 复制代码
Patient
  │
  ├── PatientCase
  │      └── report_no = BG202608010001
  │
  ├── PatientCase
  │      └── report_no = BG202702130036
  │
  └── PatientCase
         └── report_no = BG202801050088

所以 report_no 应该成为:

text 复制代码
PatientCase 的业务唯一标识之一

在多租户场景下,我最终更倾向:

sql 复制代码
UNIQUE KEY uk_tenant_report_no
    (tenant_id, report_no)

从数据库层直接避免同一个医院出现重复填报编号。


4.2 "病例状态"不应该成为"患者状态"

假设病例存在这些状态:

text 复制代码
0 待填报
1 填报中
2 待审核
3 待归档
4 已归档

如果 Patient 和 PatientCase 没有真正拆开,很容易出现:

sql 复制代码
patient.status

最后这个字段同时承担:

text 复制代码
患者状态
+
病例状态

问题马上就出现了。

假设张三现在有:

text 复制代码
病例 A -> 已归档
病例 B -> 填报中

那么:

张三的 patient.status 到底是多少?

无论存:

text 复制代码
已归档

还是:

text 复制代码
填报中

都无法完整表达真实情况。

因为真正具有这些状态的是:

text 复制代码
病例

而不是:

text 复制代码
患者

因此最终应该是:

text 复制代码
Patient
    │
    ├── Case A
    │      └── ARCHIVED
    │
    └── Case B
           └── FILLING

这实际上就是典型的:

把子实体状态错误提升到了父实体。


4.3 时间字段进一步暴露了领域错误

胸痛中心业务里存在大量时间节点,例如:

sql 复制代码
onset_time
call_help_time
hospital_gate_arrival_time

pre_hospital_first_ecg_time
pre_hospital_ecg_diagnosis_time

pre_hospital_consultation_notify_time
pre_hospital_consultation_time

initial_diagnosis_time

dual_antiplatelet_time

informed_consent_start_time
informed_consent_sign_time

thrombolysis_start_time
thrombolysis_end_time

如果仔细观察,就会发现这些字段都有共同特征:

它们全部描述某一次医疗过程。

比如:

text 复制代码
张三第一次病例
onset_time = 2026-08-01 08:30

张三第二次病例
onset_time = 2027-02-13 16:20

因此:

text 复制代码
Patient

根本不存在唯一的:

text 复制代码
onset_time

只存在:

text 复制代码
PatientCase.onset_time

对于时间采集量非常大的业务,我后来甚至继续将其拆成单独的时间采集表:

text 复制代码
Patient
   │
   │ 1:N
   ▼
PatientCase
   │
   │ 1:1
   ▼
PatientCaseTimeCollection

例如:

sql 复制代码
CREATE TABLE patient_case_time_collection
(
    id                                      BIGINT AUTO_INCREMENT
        PRIMARY KEY,

    tenant_id                               VARCHAR(64) NOT NULL,

    patient_id                              BIGINT NOT NULL,

    case_id                                 BIGINT NOT NULL,

    report_no                               VARCHAR(64),

    onset_time                              DATETIME NULL,

    call_help_time                          DATETIME NULL,

    hospital_gate_arrival_time              DATETIME NULL,

    pre_hospital_first_ecg_time             DATETIME NULL,

    pre_hospital_ecg_diagnosis_time         DATETIME NULL,

    pre_hospital_consultation_notify_time   DATETIME NULL,

    pre_hospital_consultation_time          DATETIME NULL,

    initial_diagnosis_time                  DATETIME NULL,

    dual_antiplatelet_time                  DATETIME NULL,

    informed_consent_start_time             DATETIME NULL,

    informed_consent_sign_time              DATETIME NULL,

    thrombolysis_start_time                 DATETIME NULL,

    thrombolysis_end_time                   DATETIME NULL,

    UNIQUE KEY uk_case_time (tenant_id, case_id),

    KEY idx_patient_case
        (tenant_id, patient_id, case_id)
);

这里:

text 复制代码
patient_id

方便按照患者维度查询。

而:

text 复制代码
case_id

才真正决定这些时间属于哪一次病例。


4.4 会议系统也证明 patient_id 已经不够用了

后面系统继续增加:

text 复制代码
会议
会议附件
会议模板

例如简化后的会议结构:

sql 复制代码
CREATE TABLE meeting
(
    id           BIGINT AUTO_INCREMENT
        PRIMARY KEY,

    tenant_id    VARCHAR(64) NOT NULL,

    meeting_no   VARCHAR(64),

    meeting_type INT,

    patient_id   BIGINT,

    case_id      BIGINT,

    report_no    VARCHAR(64),

    meeting_date DATETIME
);

为什么最终需要:

text 复制代码
patient_id
case_id
report_no

三个字段?

因为如果只有:

text 复制代码
patient_id = 1001

系统只能知道:

这场会议讨论的是张三。

却不知道:

讨论的是张三哪一次病例?

假设:

text 复制代码
Patient 1001
   │
   ├── Case 2001
   └── Case 2002

会议真正需要关联的其实是:

text 复制代码
case_id = 2002

因此:

text 复制代码
patient_id

解决的是:

text 复制代码
"这个人是谁?"

而:

text 复制代码
case_id

解决的是:

text 复制代码
"这次讨论的是这个人的哪一次医疗事件?"

做到这里,原来的 1:1 模型基本已经没有继续维护的理由了。


五、从数据库到 ORM:1:N 不是改一张表,而是重新定义对象关系

数据库关系确定以后,ORM 层也需要同步修改。

SQLAlchemy 官方对于 One-To-Many 的典型模型就是:

text 复制代码
Parent
    ↓
List[Child]

子表通过 Foreign Key 指向父表。

在这个项目里就是:

text 复制代码
Patient
    ↓
List[PatientCase]

5.1 Patient 模型

简化后的 SQLAlchemy 2.x 写法:

python 复制代码
from datetime import date
from typing import Optional

from sqlalchemy import BigInteger, Date, String
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db.base import Base


class Patient(Base):
    __tablename__ = "patient"

    id: Mapped[int] = mapped_column(
        BigInteger,
        primary_key=True,
        autoincrement=True
    )

    tenant_id: Mapped[str] = mapped_column(
        String(64),
        nullable=False,
        index=True
    )

    patient_no: Mapped[str] = mapped_column(
        String(64),
        nullable=False
    )

    name: Mapped[str] = mapped_column(
        String(50),
        nullable=False
    )

    birthday: Mapped[Optional[date]] = mapped_column(
        Date,
        nullable=True
    )

    phone: Mapped[Optional[str]] = mapped_column(
        String(20),
        nullable=True
    )

    cases: Mapped[list["PatientCase"]] = relationship(
        "PatientCase",
        back_populates="patient",
        lazy="selectin"
    )

这里最关键的变化就是:

python 复制代码
cases: Mapped[list["PatientCase"]]

Patient 不再拥有:

python 复制代码
case: PatientCase

而是拥有:

python 复制代码
cases: list[PatientCase]

这实际上就是代码层面对业务模型变化的直接表达。


5.2 PatientCase 模型

python 复制代码
from datetime import datetime
from typing import Optional

from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db.base import Base


class PatientCase(Base):
    __tablename__ = "patient_case"

    id: Mapped[int] = mapped_column(
        BigInteger,
        primary_key=True,
        autoincrement=True
    )

    tenant_id: Mapped[str] = mapped_column(
        String(64),
        nullable=False,
        index=True
    )

    patient_id: Mapped[int] = mapped_column(
        BigInteger,
        ForeignKey(
            "patient.id",
            ondelete="RESTRICT"
        ),
        nullable=False,
        index=True
    )

    report_no: Mapped[str] = mapped_column(
        String(64),
        nullable=False
    )

    onset_time: Mapped[Optional[datetime]] = mapped_column(
        DateTime,
        nullable=True
    )

    diagnosis_type: Mapped[Optional[int]] = mapped_column(
        Integer,
        nullable=True
    )

    status: Mapped[int] = mapped_column(
        Integer,
        nullable=False,
        default=0
    )

    patient: Mapped["Patient"] = relationship(
        "Patient",
        back_populates="cases"
    )

这样 ORM 关系就变成:

python 复制代码
patient.cases

可以得到:

text 复制代码
这个患者所有病例

而:

python 复制代码
case.patient

可以得到:

text 复制代码
当前病例所属患者

这和数据库关系保持一致:

text 复制代码
Patient     1
            │
            │
            │
            N
      PatientCase

5.3 为什么不能直接把所有病例都 joinedload?

1:1 改成 1:N 后,还有一个容易被忽略的性能问题。

以前:

text 复制代码
Patient -> PatientCase

只有一条。

直接 JOIN 问题不大。

但是变成:

text 复制代码
Patient -> N 个 PatientCase

之后,如果查询患者列表时把所有病例全部 JOIN 出来,很容易造成:

text 复制代码
患者行重复
+
结果集膨胀
+
无用字段大量传输

因此列表接口通常不应该默认返回全部病例详情。

例如:

text 复制代码
GET /patients

更适合返回:

json 复制代码
{
  "id": 1001,
  "name": "张三",
  "patientNo": "P202600001",
  "caseCount": 3
}

而不是:

json 复制代码
{
  "id": 1001,
  "name": "张三",
  "cases": [
    {
      "几百个病例字段": "..."
    },
    {
      "几百个病例字段": "..."
    }
  ]
}

SQLAlchemy 官方对于一对多集合加载也提供了不同的加载策略。

对于这类集合关系,我更倾向:

python 复制代码
lazy="selectin"

或者根据具体接口显式控制加载,而不是所有地方无脑 JOIN。

这也是模型从 1:1 变成 1:N 后必须重新考虑的性能问题。


六、API 也必须重构:Patient API 和 Case API 不能再混在一起

模型变化之后,如果 API 还是原来的设计,那么整个重构其实只完成了一半。

以前可能有这样的接口:

http 复制代码
GET /patient/{id}

直接返回:

json 复制代码
{
  "id": 1001,
  "name": "张三",
  "reportNo": "BG202608010001",
  "onsetTime": "2026-08-01 08:30:00",
  "status": 4
}

这里最大的问题是:

text 复制代码
name

属于 Patient。

而:

text 复制代码
reportNo
onsetTime
status

属于 PatientCase。

一个接口同时把两个领域实体混在一起。

当 Patient 只有一个 Case 的时候看不出问题。

出现第二个 Case 以后:

text 复制代码
reportNo 应该返回哪个?
status 应该返回哪个?
onsetTime 应该返回哪个?

API 语义立刻崩掉。

因此最终我把接口语义重新拆开。


6.1 查询患者档案

http 复制代码
GET /patients/{patientId}

返回:

json 复制代码
{
  "id": 1001,
  "patientNo": "P202600001",
  "name": "张三",
  "gender": 1,
  "birthday": "1985-06-12",
  "phone": "13800000000"
}

它只回答一个问题:

这个患者是谁?


6.2 查询患者的历史病例

http 复制代码
GET /patients/{patientId}/cases

返回:

json 复制代码
[
  {
    "id": 2003,
    "reportNo": "BG202801050088",
    "status": 1,
    "onsetTime": "2028-01-05 08:10:00"
  },
  {
    "id": 2002,
    "reportNo": "BG202702130036",
    "status": 4,
    "onsetTime": "2027-02-13 16:20:00"
  },
  {
    "id": 2001,
    "reportNo": "BG202608010001",
    "status": 4,
    "onsetTime": "2026-08-01 08:30:00"
  }
]

回答:

这个患者发生过哪些病例?


6.3 查询具体病例

http 复制代码
GET /cases/{caseId}

返回:

json 复制代码
{
  "id": 2002,
  "patientId": 1001,
  "reportNo": "BG202702130036",
  "diagnosisType": 2,
  "onsetTime": "2027-02-13 16:20:00",
  "status": 4
}

回答:

这一次病例具体发生了什么?

于是 API 开始和领域关系保持一致:

text 复制代码
/patients/{patientId}
        │
        └── 患者档案

/patients/{patientId}/cases
        │
        └── 患者所有病例

/cases/{caseId}
        │
        └── 某一具体病例

6.4 创建病例时必须同时验证 tenant_id

因为项目本身是多租户系统,所以这里还有一个非常重要的安全细节。

下面这种代码是不够安全的:

python 复制代码
patient = (
    session.query(Patient)
    .filter(Patient.id == patient_id)
    .first()
)

因为理论上用户可以提交另外一个医院的:

text 复制代码
patient_id

因此实际查询应该同时包含:

text 复制代码
patient_id
+
tenant_id

例如:

python 复制代码
def create_case(
    session,
    tenant_id: str,
    patient_id: int,
    report_no: str
):

    patient = (
        session.query(Patient)
        .filter(
            Patient.id == patient_id,
            Patient.tenant_id == tenant_id
        )
        .first()
    )

    if patient is None:
        raise ValueError("患者不存在或无权访问")

    case = PatientCase(
        tenant_id=tenant_id,
        patient_id=patient.id,
        report_no=report_no
    )

    session.add(case)
    session.commit()
    session.refresh(case)

    return case

否则就有可能出现:

text 复制代码
医院 A
    │
    └── 创建病例
            │
            └── patient_id 指向医院 B

这种跨租户关联问题。

因此数据库关系重构的时候不能只考虑:

text 复制代码
1:N

还必须同时考虑:

text 复制代码
租户边界

最终关系实际上应该理解成:

text 复制代码
Tenant
   │
   │ 1:N
   ▼
Patient
   │
   │ 1:N
   ▼
PatientCase

而不是孤立地看:

text 复制代码
Patient -> PatientCase

七、真正困难的部分:历史数据怎么从 1:1 安全迁移到 1:N?

如果项目还没有上线:

text 复制代码
DROP TABLE
重新建表

当然最快。

但如果已经存在正式数据,事情就完全不同了。

假设系统现在已经存在:

text 复制代码
100000 条 patient
100000 份旧病例数据

你不可能简单执行:

sql 复制代码
ALTER TABLE patient
DROP COLUMN report_no;

然后告诉业务:

text 复制代码
重构完成。

真正安全的数据模型重构,至少应该考虑:

text 复制代码
Schema 变更
数据迁移
兼容代码
数据校验
业务切换
回滚方案

我更倾向使用:

text 复制代码
Expand
   ↓
Migrate
   ↓
Verify
   ↓
Switch
   ↓
Contract

这种渐进式思路。


7.1 第一阶段:Expand,先扩展,不立即删除

首先创建新的:

text 复制代码
patient_case

结构。

例如 Alembic Migration:

python 复制代码
from alembic import op
import sqlalchemy as sa


def upgrade():

    op.create_table(
        "patient_case",

        sa.Column(
            "id",
            sa.BigInteger(),
            primary_key=True,
            autoincrement=True
        ),

        sa.Column(
            "tenant_id",
            sa.String(64),
            nullable=False
        ),

        sa.Column(
            "patient_id",
            sa.BigInteger(),
            nullable=False
        ),

        sa.Column(
            "report_no",
            sa.String(64),
            nullable=False
        ),

        sa.Column(
            "status",
            sa.Integer(),
            nullable=False,
            server_default="0"
        ),

        sa.Column(
            "create_time",
            sa.DateTime(),
            nullable=True
        ),

        sa.ForeignKeyConstraint(
            ["patient_id"],
            ["patient.id"],
            name="fk_case_patient"
        )
    )

    op.create_index(
        "idx_case_tenant_patient",
        "patient_case",
        ["tenant_id", "patient_id"]
    )

    op.create_unique_constraint(
        "uk_case_tenant_report_no",
        "patient_case",
        ["tenant_id", "report_no"]
    )

这时候注意:

旧字段先不要删除。

因为此时:

text 复制代码
旧版本代码

可能依然依赖:

text 复制代码
patient.report_no

7.2 第二阶段:迁移旧数据

将旧数据复制到新结构:

sql 复制代码
INSERT INTO patient_case
(
    tenant_id,
    patient_id,
    report_no,
    create_time
)
SELECT
    tenant_id,
    id,
    report_no,
    create_time
FROM patient
WHERE report_no IS NOT NULL;

迁移逻辑看起来只有几行 SQL。

但真正上线时绝对不能执行完就结束。

下一步必须是验证。


7.3 第三阶段:验证迁移结果

验证数量

迁移前:

sql 复制代码
SELECT COUNT(*)
FROM patient
WHERE report_no IS NOT NULL;

假设:

text 复制代码
100000

迁移后:

sql 复制代码
SELECT COUNT(*)
FROM patient_case;

理论上至少应该满足当前迁移规则下的预期数量。


验证是否存在孤儿病例

sql 复制代码
SELECT
    pc.id,
    pc.patient_id,
    pc.tenant_id
FROM patient_case pc
LEFT JOIN patient p
       ON p.id = pc.patient_id
      AND p.tenant_id = pc.tenant_id
WHERE p.id IS NULL;

期望结果:

text 复制代码
0 rows

如果有结果,就说明出现:

text 复制代码
病例存在
但是患者不存在

或者:

text 复制代码
患者与病例 tenant_id 不一致

这两种情况都必须处理。


验证填报编号重复

sql 复制代码
SELECT
    tenant_id,
    report_no,
    COUNT(*) AS count
FROM patient_case
GROUP BY
    tenant_id,
    report_no
HAVING COUNT(*) > 1;

理想结果:

text 复制代码
0 rows

如果这里出现记录,就不能直接创建:

sql 复制代码
UNIQUE(tenant_id, report_no)

而应该先调查旧数据为什么出现重复。


验证关联关系

sql 复制代码
SELECT COUNT(*)
FROM patient_case pc
INNER JOIN patient p
        ON p.id = pc.patient_id
       AND p.tenant_id = pc.tenant_id;

确认所有病例都能够正确关联患者。

这一步非常重要。

因为:

Migration 执行成功不等于数据迁移正确。

数据库只能够告诉你:

text 复制代码
SQL 没报错

但它不会自动告诉你:

text 复制代码
业务关系是正确的

7.4 第四阶段:Switch,应用代码切换

当新表和新数据准备完成后,再逐步把:

text 复制代码
patient.report_no

替换成:

text 复制代码
patient_case.report_no

同时调整:

text 复制代码
Controller / Router
Service
Repository
ORM
DTO
VO
前端页面
统计逻辑

这里不能只全局搜索:

text 复制代码
report_no

还需要检查一些隐藏的业务依赖,例如:

text 复制代码
患者列表按照病例状态筛选
患者详情默认展示最新病例
统计报表按照 report_no 聚合
会议通过 patient_id 找病例

这些代码即使不会报错,也可能已经不再符合新的领域模型。


7.5 第五阶段:Contract,最后删除旧字段

确认新代码稳定以后,再执行:

sql 复制代码
ALTER TABLE patient
DROP COLUMN report_no;

而不是:

text 复制代码
第一天创建新表
第一天迁移
第一天立即删字段
第一天立即上线

完整过程应该是:

text 复制代码
┌───────────────────────┐
│ 1. 创建新结构 Expand   │
└───────────┬───────────┘
            ↓
┌───────────────────────┐
│ 2. 迁移数据 Migrate    │
└───────────┬───────────┘
            ↓
┌───────────────────────┐
│ 3. 数据验证 Verify     │
└───────────┬───────────┘
            ↓
┌───────────────────────┐
│ 4. 应用切换 Switch     │
└───────────┬───────────┘
            ↓
┌───────────────────────┐
│ 5. 观察运行            │
└───────────┬───────────┘
            ↓
┌───────────────────────┐
│ 6. 删除旧结构 Contract │
└───────────────────────┘

这比:

text 复制代码
一次 ALTER TABLE 搞定

麻烦得多。

但对于已经承载真实数据的系统:

重构目标不是"最快把表改完",而是"确保任何阶段都知道数据是否正确,并且出现问题时有退出路径"。


八、这次重构真正让我学到的 5 个数据库设计原则

这次从:

text 复制代码
Patient 1:1 PatientCase

改成:

text 复制代码
Patient 1:N PatientCase

最大的收获其实不是学会怎么写一对多。

一对多本身可能是数据库里最基础的关系之一。

真正重要的是这次让我对"怎么判断一个数据模型是否合理"有了更加清晰的认识。


8.1 不要根据页面设计数据库

这是我这次最明显的失误之一。

早期页面是:

text 复制代码
患者详情
-------------------------

姓名:张三
性别:男

发病时间:08:30
呼救时间:08:45

双抗:已完成
溶栓:已完成

从 UI 看:

text 复制代码
患者信息
+
病例信息

就在同一个页面里。

所以很容易产生:

text 复制代码
一个页面
=
一个业务对象

的错觉。

但真实的数据可能来自:

text 复制代码
Patient
+
PatientCase
+
PatientCaseTimeCollection
+
Meeting
+
Medication
+
Attachment

六个不同对象。

页面只是:

text 复制代码
View

它可以聚合任意数量的数据源。

数据库描述的是:

text 复制代码
Domain

二者不能直接划等号。

以后再设计数据库的时候,我会先问自己一句:

我现在是在根据页面拆表,还是在根据真实业务实体拆表?


8.2 判断 1:1 还是 1:N,可以先问"它会发生第二次吗?"

这个方法虽然很简单,但非常有效。

比如:

一个患者会不会第二次入院?

会。

那么:

text 复制代码
Patient : PatientCase

就应该重点考虑:

text 复制代码
1:N

再比如:

一个订单会不会发生多次支付尝试?

可能会。

那么:

text 复制代码
Order : Payment

就不要轻易设计成:

text 复制代码
1:1

再比如:

一个项目会不会存在多个里程碑?

会。

那么:

text 复制代码
Project : Milestone

自然就是:

text 复制代码
1:N

当然,这不是绝对规则。

但它非常适合在建模初期帮助我们发现:

text 复制代码
"是不是把一次业务事件错误地绑定到了长期主体?"

8.3 两个实体生命周期不同,就要警惕强行 1:1

Patient 生命周期:

text 复制代码
创建
 ↓
长期存在
 ↓
资料更新
 ↓
关联病例
 ↓
继续存在

PatientCase 生命周期:

text 复制代码
创建
 ↓
填报
 ↓
审核
 ↓
归档
 ↓
历史记录

完全不同。

如果两个实体:

text 复制代码
创建时间不同
结束时间不同
状态机不同
更新频率不同
业务责任不同

那么即使它们当前:

text 复制代码
恰好一个对应一个

也应该谨慎考虑是否真的属于永久 1:1


8.4 唯一约束应该表达业务规则,而不是只靠 Service 判断

例如:

text 复制代码
同一个医院里面 report_no 不能重复

如果只在 Service 里面:

python 复制代码
exists = query(report_no)

if exists:
    raise Exception(...)

在并发情况下仍然存在:

text 复制代码
Request A 查询不存在
Request B 查询不存在

Request A INSERT
Request B INSERT

最后插入两条重复数据的可能。

因此业务规则:

text 复制代码
tenant_id + report_no 唯一

除了业务校验以外,还应该让数据库真正表达:

sql 复制代码
UNIQUE KEY uk_tenant_report_no
(
    tenant_id,
    report_no
);

Service 负责:

text 复制代码
给用户友好的错误提示

数据库负责:

text 复制代码
守住最终数据一致性

两层并不冲突。


8.5 重构数据模型时,要同时考虑"现在"和"退出路径"

一个真正可以上线的数据重构方案不应该只有:

text 复制代码
怎么升级?

还应该考虑:

text 复制代码
如果升级失败怎么办?

这也是我为什么使用 Alembic 管理 Schema 迁移,而不是手动把所有 SQL 直接执行完的重要原因之一。

迁移脚本至少要考虑:

python 复制代码
def upgrade():
    ...

以及:

python 复制代码
def downgrade():
    ...

不过这里也有一个非常现实的问题:

Schema 可以 downgrade,不代表业务数据一定能够无损 downgrade。

例如原来:

text 复制代码
1 Patient
:
1 Case

后来已经产生:

text 复制代码
1 Patient
:
3 Case

那么你想重新降回:

text 复制代码
1:1

的时候:

三条病例应该保留哪一条?

这已经不是 DDL 能够解决的问题。

因此对于涉及数据语义变化的 migration:

text 复制代码
Schema rollback

和:

text 复制代码
Data rollback

必须分开考虑。

这也是这次重构后我认为非常值得记录的一点。


总结

回过头来看,这次重构表面上只是:

text 复制代码
Patient 1:1 PatientCase

            ↓

Patient 1:N PatientCase

但真正修改的是整个系统对于:

text 复制代码
"患者"

和:

text 复制代码
"病例"

的认知。

最终模型变成:

text 复制代码
                         Tenant
                           │
                           │ 1:N
                           ▼
                        Patient
                           │
                           │ 1:N
                           ▼
                     PatientCase
                           │
             ┌─────────────┼─────────────┐
             │             │             │
             ▼             ▼             ▼
          时间采集        会议         其他业务

其中:

text 复制代码
Patient

回答:

这个人是谁?

而:

text 复制代码
PatientCase

回答:

这个人这一次发生了什么?

这样以后很多原本很难回答的问题都会自然得到答案:

问题 重构后的答案
report_no 属于谁? PatientCase
发病时间属于谁? PatientCase
审核状态属于谁? PatientCase
归档状态属于谁? PatientCase
一个患者能有几份病例? 多份
第二次入院要重新创建患者吗? 通常不需要
历史病例如何查询? patient_id 查询 PatientCase
会议关联患者还是病例? 业务主体优先关联 case_id
病例如何保证租户安全? tenant_id + patient_id 双重校验
填报编号如何避免重复? (tenant_id, report_no) 唯一约束

所以现在如果让我重新回答标题中的问题:

我为什么把患者与病例从 1:1 改成 1:N?

我的答案已经不是:

因为以后一个患者可能会有很多病例。

而是:

因为 Patient 是长期存在的业务主体,PatientCase 是一次具体业务事件。一个主体可以经历多次事件,因此 1:N 才是在描述真实业务,而不是在迎合当前页面。

这次重构也让我越来越认同一个观点:

数据库表不是为了方便页面 CRUD 而存在的,它首先应该准确表达业务事实。

项目早期设计错了并不可怕。

真正可怕的是当:

text 复制代码
report_no 不知道放哪里
status 不知道表示谁
时间字段开始重复
历史数据越来越难解释
接口语义越来越混乱

这些信号已经出现以后,仍然因为:

text 复制代码
"代码已经写这么多了"

继续在旧模型上增加:

text 复制代码
second_report_no
latest_case_id
history_case_json
current_status
latest_status

这样的补丁字段。

最后你并没有避免重构。

你只是:

把今天一次能够完成的模型重构,推迟成了未来十次更昂贵的数据治理。

而这,也是我认为这次 1:1 -> 1:N 重构真正值得记录下来的地方。


参考资料

本文在 ORM 关系、数据库约束以及 Schema Migration 设计方面参考了以下官方资料:

  1. SQLAlchemy 2.0 Documentation --- Relationship Configuration

    • One To Many
    • Many To One
    • Relationship Loading Techniques
  2. MySQL 8.4 Reference Manual --- FOREIGN KEY Constraints

    • Foreign Key
    • Referential Actions
    • Index Requirements
  3. MySQL 8.4 Reference Manual --- CREATE INDEX Statement

    • Unique Index
    • 数据唯一性约束
  4. Alembic Documentation --- Operation Reference

    • upgrade()
    • downgrade()
    • create_table()
    • create_index()
    • create_unique_constraint()

本文中的数据库结构与业务代码均根据实际项目进行简化和脱敏,仅用于说明数据库领域建模与重构思路。

相关推荐
wno7041 小时前
Spring Boot整合MongoDB
spring boot·后端·mongodb
GreenTea2 小时前
🔥别再用压缩了,Codex、NVIDIA、DeepSeek、Uber 给出 Agent 上下文管理的新答案
前端·后端·算法
xujuzheng2 小时前
2026深圳小程序/App/AI智能体开发公司选型指南(附本地服务商盘点)
数据库·科技·微信小程序·小程序·uni-app
码事漫谈2 小时前
全球最聪明的几个人,本周突然一起说“别卷了”
后端
IT_陈寒2 小时前
React重渲染这坑,我跳进去又爬出来了
前端·人工智能·后端
不剪发的Tony老师2 小时前
DuckDB教程:常用文本函数
数据库·sql·数据分析·duckdb
ManageEngineITSM2 小时前
什么是IT服务连续性管理?灾难恢复与业务连续性一文讲清
数据库·microsoft·工单系统·变更管理
像风一样自由20202 小时前
29.Redis在大模型应用中有哪些用途缓存会话与限流
数据库·人工智能·redis·缓存·大模型·milvus·智能体