4. 临床数据索引设计与优化
临床数据表的索引设计,本质上是在三类字段上做取舍:患者标识(patient_id、住院号、门诊号)、时间序列(就诊时间、检验报告时间、医嘱开始/停止时间)、编码字段(诊断 ICD 码、手术码、药品码、检验项目码)。这三类字段的取值分布、更新频率和查询模式差异极大,用同一种索引策略套用,往往在数据量增长到千万级后集中暴露问题。
一、先看基数与选择性,再决定索引类型
索引选型的第一判断依据是字段的基数(distinct 值数量)和选择性(选择性 = 不同值数 / 总行数)。以一张 2000 万行的检验结果表为例:
| 字段 | 典型基数 | 选择性 | 适合的索引 |
|---|---|---|---|
| patient_id | 约 80 万 | 0.04 | B树(复合前置列) |
| report_time | 约 2000 万(近唯一) | ≈1.0 | B树 |
| item_code | 约 3000 | 0.00015 | 位图 / 复合索引后置列 |
| gender | 2~3 | ≈0 | 位图(仅数据仓库场景) |
| abnormal_flag | 3~5 | ≈0 | 位图 |
B树索引在选择性高的列上表现最好,因为一次索引查找能快速定位到很少的行。位图索引则相反:它把每个不同值映射成一个位图,对低基数列做 AND/OR 组合极快,但位图索引不适合高并发 OLTP 写入——一次 DML 会锁住整个位图段,多个会话同时更新同一编码列时极易产生锁等待。因此临床生产库(OLTP)通常用 B树,而临床数据仓库/科研分析库(OLAP)才用位图。
二、复合索引:列顺序决定成败
临床查询最常见的形态是"某患者 + 某时间段 + 某类项目"。例如:
SELECT item_code, result_value, report_time
FROM lab_result
WHERE patient_id = 'P00012345'
AND report_time >= DATE '2024-01-01'
AND report_time < DATE '2024-04-01'
AND item_code IN ('GLU','HBA1C','CREA');
针对这个查询,复合索引应建为 (patient_id, report_time, item_code)。原因是:等值条件列放最前,范围条件列放中间,其余等值列放最后。patient_id 是等值,report_time 是范围,item_code 是等值但基数低。如果顺序写成 (report_time, patient_id, item_code),那么范围列在前会导致后续列无法有效用于索引定位,只能做索引扫描过滤,效率大幅下降。
复合索引还遵循最左前缀原则:(patient_id, report_time, item_code) 可以支持 WHERE patient_id=?、WHERE patient_id=? AND report_time>?,但不能单独支持 WHERE report_time>?。因此不要为每个字段单独建索引,而应围绕真实查询模式设计少量高价值复合索引。
三、函数索引:让"变形条件"重新走索引
临床 SQL 中大量存在对列做函数运算的写法,这会直接让普通 B树索引失效:
-- 索引失效:对列使用函数
WHERE TO_CHAR(report_time,'YYYY-MM') = '2024-03'
WHERE UPPER(item_code) = 'GLU'
WHERE SUBSTR(patient_id,1,4) = 'P000'
解决办法有两种。第一种是改写 SQL,把函数移到常量侧:
WHERE report_time >= DATE '2024-03-01'
AND report_time < DATE '2024-04-01'
第二种是建立函数索引(Oracle 称 function-based index,PostgreSQL 称 expression index):
CREATE INDEX idx_lab_month ON lab_result (TO_CHAR(report_time,'YYYY-MM'));
CREATE INDEX idx_item_upper ON lab_result (UPPER(item_code));
函数索引的陷阱在于:查询表达式必须与索引表达式完全一致,包括大小写、空格、函数参数。写成 to_char(report_time,'yyyy-mm') 可能无法匹配索引定义。此外,函数索引要求函数是确定性的(DETERMINISTIC),使用 SYSDATE、CURRENT_DATE 等非确定性函数的表达式不能建索引。
四、覆盖索引:消除回表
当查询所需的所有列都包含在索引中时,数据库可以直接从索引返回结果,无需回表读取数据行,这就是覆盖索引(covering index)。对临床统计类查询收益显著:
-- 查询只需三列
SELECT report_time, item_code, result_value
FROM lab_result
WHERE patient_id = 'P00012345';
-- 覆盖索引
CREATE INDEX idx_lab_cover
ON lab_result (patient_id, report_time, item_code, result_value);
覆盖索引的代价是索引体积增大、写入变慢。因此只对高频、列数少的查询建覆盖索引,不要把所有列都塞进去。在 PostgreSQL 中还可以用 INCLUDE 子句把非键列附加到索引叶子节点,既保持键列有序,又避免键列过长。
五、索引设计决策路径
下图给出从查询条件到索引类型的判断路径,覆盖本节五类索引的适用边界与主要陷阱。
六、临床场景中的典型陷阱
- 患者标识用字符串拼接:把
patient_id || visit_no作为查询条件,导致索引失效。应拆成两个独立列分别建索引,或建组合列并保持查询写法一致。 - 时间序列用隐式转换:
WHERE report_time = '2024-03-01'在部分数据库中会触发隐式类型转换,使索引失效。应显式写DATE '2024-03-01'或TIMESTAMP '2024-03-01 00:00:00'。 - 编码字段用 LIKE '%XX%':前导通配符无法使用 B树索引。若必须模糊匹配,考虑全文索引或倒排索引,而非普通 B树。
- 索引过多拖慢写入:临床表常有高频 INSERT(如监护数据),每多一个索引就多一次维护开销。建议单表索引控制在 5~6 个以内,并定期用执行计划验证索引是否被使用。
EXPLAIN (ANALYZE, BUFFERS)(PostgreSQL)或 EXPLAIN PLAN FOR + DBMS_XPLAN.DISPLAY(Oracle),观察执行计划中是否出现 INDEX RANGE SCAN / INDEX UNIQUE SCAN,以及是否出现 TABLE ACCESS BY INDEX ROWID(回表)。若出现全表扫描或索引全扫描,说明索引未被有效利用。索引设计不是一次性的工作。临床数据随时间增长、查询模式随业务变化,应建立定期审查机制:每月检查一次执行计划中代价最高的 Top 20 SQL,确认其访问路径是否仍走索引,及时调整复合索引列序或补充覆盖索引。