4. 多表连接查询
在医疗数据查询中,患者、就诊、诊断通常分散在三张甚至更多表中:患者主索引表保存人口学信息,就诊表记录每次到院事件,诊断表保存每次就诊下的疾病编码。单表查询只能回答“有哪些患者”或“有哪些诊断”,而临床统计、质控指标、病种分析几乎都需要把三者串起来。多表连接查询正是完成这一任务的核心手段,而 INNER JOIN、LEFT JOIN、FULL JOIN 的选择直接决定了结果集是否漏人、是否多算、是否出现空值陷阱。
三张表的典型结构与关联键
先明确本节使用的示例结构(字段为教学简化,非真实生产库结构):
patient(patient_id, name, gender, birth_date)
visit(visit_id, patient_id, visit_date, dept, visit_type)
diagnosis(diag_id, visit_id, icd_code, diag_name, diag_type)
关联关系为:patient.patient_id = visit.patient_id,visit.visit_id = diagnosis.visit_id。也就是说,患者与就诊是一对多,就诊与诊断是一对多。连接查询的本质,就是沿着这两条外键链把行“拼接”起来。理解一对多关系,是理解结果行数膨胀的前提。
INNER JOIN:只保留能关联上的记录
INNER JOIN 返回两侧都匹配成功的行。若查询“每位患者的就诊及诊断”,写法如下:
SELECT p.patient_id, p.name, v.visit_id, v.visit_date, d.icd_code
FROM patient p
INNER JOIN visit v ON p.patient_id = v.patient_id
INNER JOIN diagnosis d ON v.visit_id = d.visit_id;
这条语句的结果只包含“有就诊且有诊断”的患者。没有就诊的患者、有就诊但未录入诊断的就诊,都会被过滤掉。因此 INNER JOIN 适合“只统计已发生且已记录完整的事件”,例如统计某病种的出院诊断例数。它的陷阱在于:当业务口径要求“全部患者”时,INNER JOIN 会静默丢人,且不会报错,容易被误读为“系统里没有这些患者”。
LEFT JOIN:保留左表全部记录
LEFT JOIN 保留左表所有行,右表无匹配时以 NULL 填充。若要以患者为主体,查看每位患者是否有就诊和诊断:
SELECT p.patient_id, p.name, v.visit_id, d.icd_code
FROM patient p
LEFT JOIN visit v ON p.patient_id = v.patient_id
LEFT JOIN diagnosis d ON v.visit_id = d.visit_id;
此时没有就诊的患者仍会出现,其 visit_id 与 icd_code 为 NULL。LEFT JOIN 是“以谁为主体,谁放左边”的直接体现。常见陷阱有三个:第一,把过滤条件写在 WHERE 中会破坏外连接语义。例如 WHERE d.icd_code = 'E11.9' 会把诊断为空的行过滤掉,效果接近 INNER JOIN;正确做法是把条件放到 ON 子句,或明确接受过滤语义。第二,多级 LEFT JOIN 时,若中间表未匹配,后续连接仍以 NULL 继续,需逐级确认主体是否被保留。第三,COUNT 统计时 COUNT(*) 会把 NULL 行计入,而 COUNT(d.icd_code) 只计非空诊断,两者口径不同。
FULL JOIN:两侧都保留
FULL JOIN 返回左右两表所有行,任一侧无匹配时另一侧为 NULL。在患者-就诊-诊断场景中,它适合做“数据一致性核对”:找出有患者无就诊、有就诊无患者(如外键异常)、有就诊无诊断等各类缺口。
SELECT p.patient_id, v.visit_id
FROM patient p
FULL JOIN visit v ON p.patient_id = v.patient_id
WHERE p.patient_id IS NULL OR v.visit_id IS NULL;
该查询可同时暴露“孤立就诊”和“无就诊患者”。需要注意,MySQL 长期不支持 FULL JOIN,需用 LEFT JOIN 与 RIGHT JOIN 的 UNION 模拟;PostgreSQL、SQL Server、Oracle 支持原生 FULL JOIN。此外,FULL JOIN 结果中 NULL 来源不唯一,判断某行属于哪类缺口必须结合两侧主键是否为空来分析。
行数膨胀与去重陷阱
一对多连接会放大行数。若一位患者有 3 次就诊、每次就诊有 2 条诊断,则三表连接后该患者出现 6 行。若直接 SELECT COUNT(*) FROM patient ... 统计患者数,会得到 6 而非 1。正确做法是 COUNT(DISTINCT p.patient_id),或先按业务粒度聚合再连接。另一个隐蔽问题是:当诊断表存在重复录入时,连接结果会进一步膨胀,需先用 ROW_NUMBER() 或 DISTINCT 去重,再参与连接。
连接类型选择决策表
| 业务问题 | 推荐连接 | 主体表位置 | 典型风险 |
|---|---|---|---|
| 统计已出院且有诊断的例数 | INNER JOIN | 无强制主体 | 漏掉未录诊断的就诊 |
| 每位患者就诊次数(含0次) | LEFT JOIN | patient 在左 | WHERE 过滤破坏外连接 |
| 找出无诊断的就诊记录 | LEFT JOIN + IS NULL | visit 在左 | 误用 INNER 导致查不到 |
| 核对患者与就诊外键一致性 | FULL JOIN | 两侧对等 | MySQL 需 UNION 模拟 |
| 病种分析(按诊断聚合) | INNER JOIN | diagnosis 驱动 | 行数膨胀导致重复计数 |
掌握三种连接的语义差异后,实际写查询时还应养成两个习惯:一是先用小样本验证连接后的行数与预期是否一致,二是对关键指标使用 DISTINCT 或子查询聚合,避免一对多膨胀污染统计结果。这样,多表连接才能既查得全,又算得准。