我为什么把患者与病例从 1:1 改成 1:N?一次真实数据库模型重构记录
前言
最近在开发一个医院胸痛中心智慧填报系统时,我做了一次比较大的数据库模型调整:
最初的设计是:
text
Patient 1 : 1 PatientCase
后来被我整体推翻,改成了:
text
Patient 1 : N PatientCase
表面上看,这似乎只是把"一对一"修改成"一对多",增加一个 patient_id 外键就结束了。
但真正做下来才发现,这次修改影响的远不只是数据库表结构。
它同时牵涉到了:
- 患者与病例的领域边界;
report_no填报编号应该属于谁;- 病例状态应该存在哪里;
- 时间轴数据如何关联;
- 会议、附件应该关联患者还是病例;
- REST API 的资源语义;
- SQLAlchemy ORM 关系;
- 多租户条件;
- 唯一索引设计;
- 已有数据迁移;
- 新旧版本兼容和回滚。
更重要的是,这次重构让我重新认识到一个问题:
数据库设计首先是业务建模,其次才是建表和写 SQL。
项目早期,一个错误的模型完全可能"正常运行"。
真正危险的不是代码立即报错,而是随着业务越来越复杂,你会发现为了维持这个错误模型,不得不增加越来越多的特殊字段、特殊判断和补丁逻辑。
本文就完整复盘这次从 1:1 到 1: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 设计方面参考了以下官方资料:
-
SQLAlchemy 2.0 Documentation --- Relationship Configuration
- One To Many
- Many To One
- Relationship Loading Techniques
-
MySQL 8.4 Reference Manual --- FOREIGN KEY Constraints
- Foreign Key
- Referential Actions
- Index Requirements
-
MySQL 8.4 Reference Manual --- CREATE INDEX Statement
- Unique Index
- 数据唯一性约束
-
Alembic Documentation --- Operation Reference
upgrade()downgrade()create_table()create_index()create_unique_constraint()
本文中的数据库结构与业务代码均根据实际项目进行简化和脱敏,仅用于说明数据库领域建模与重构思路。