1. 患者主索引到底在解决什么问题
1.1 从一个真实场景说起
你在医院信息科待过就知道,患者数据来源有多杂。门诊系统一套、住院系统一套、体检系统一套、急诊系统一套,再加上LIS、PACS、手术麻醉、电子病历,每个系统都有自己的患者信息表。同一个患者,在门诊系统里叫“张三”,身份证号填了;在住院系统里叫“张 三”(中间多了个空格),身份证号没填;在体检系统里叫“张三”,但手机号是家属的。这三条记录,系统层面看是三个不同的患者,但现实中就是同一个人。
患者主索引(Enterprise Master Patient Index,简称EMPI)要干的事情,就是把这些散落在不同系统里的患者记录,归拢到同一个“主索引”下面,给每个人一个全局唯一的标识符。这个标识符一旦确定,不管患者在哪个系统里出现,都能通过它把所有相关信息串起来。
没有EMPI会怎样?最直接的后果就是:医生调阅患者既往病史时,只能看到当前系统的数据,跨系统的信息完全断裂。检验结果对不上人、影像报告找不到对应患者、重复建档导致同一个人的用药记录分散在多个档案里——这些都是没有EMPI或者EMPI做得不到位的典型症状。
1.2 核心需求拆解
从数据库设计的角度,EMPI要解决的核心问题可以拆成三层:
第一层是去重。给定一批患者记录,判断哪些记录指向同一个人。这本质上是一个实体解析(Entity Resolution)问题,需要定义“什么算同一个人”的规则。
第二层是合并。确认是同一个人的多条记录,需要合并成一条主记录,同时保留各来源系统的原始数据。合并不是简单的覆盖,而是要有策略地选择哪个字段取哪个来源的值。
第三层是关联。主索引建立之后,各业务系统需要通过某种方式与主索引建立映射关系,确保后续新增的数据能正确挂载到已有的主索引上。
这三层不是孤立的,去重的准确率直接影响合并的质量,合并的策略又决定了关联的复杂度。数据库设计必须同时考虑这三层需求,而不是先做完去重再想合并。
1.3 适合谁参考
这篇内容适合以下几类人:医院信息科的数据库开发人员、做区域医疗平台的数据工程师、医疗信息化厂商的架构师,以及任何需要处理多源人员数据去重合并的开发者。即使你不做医疗行业,只要涉及“多个来源的同一个人/实体如何归并”的问题,思路是相通的。
我下面会从表结构设计开始,一步步拆到去重算法、合并策略、关联维护,尽量把每个设计决策背后的“为什么”讲清楚。
2. 数据库表结构设计:主索引、来源映射与合并日志
2.1 为什么不能只用一张表
很多人第一反应是设计一张患者表,把所有来源的数据都塞进去,用身份证号做唯一键。这个方案在小规模场景下能跑,但放到真实医院环境里很快就会崩。
原因有几个:第一,不是所有来源都有身份证号,急诊无名氏患者、新生儿、外籍患者都可能没有;第二,同一个患者在不同系统里的身份证号可能不一致(录入错误、升位前后差异);第三,你需要保留每个来源系统的原始数据,不能因为合并就把原始记录改掉,否则回溯和对账都做不了。
所以合理的做法是分三层:主索引表存合并后的“黄金记录”,来源映射表存各系统原始记录与主索引的对应关系,合并日志表记录每次合并的操作痕迹。
2.2 主索引表设计
主索引表存的是经过合并后的患者核心信息。关键字段如下:
CREATE TABLE mpi_patient ( mpi_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主索引全局唯一ID', patient_no VARCHAR(32) NOT NULL COMMENT '对外展示的患者编号', name VARCHAR(64) NOT NULL COMMENT '姓名', gender TINYINT COMMENT '性别 0未知 1男 2女', birth_date DATE COMMENT '出生日期', id_card VARCHAR(32) COMMENT '身份证号', phone VARCHAR(20) COMMENT '联系电话', address VARCHAR(256) COMMENT '联系地址', merge_status TINYINT DEFAULT 0 COMMENT '合并状态 0正常 1已合并 2已拆分', master_flag TINYINT DEFAULT 1 COMMENT '是否主记录 1是 0否', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_patient_no (patient_no), KEY idx_id_card (id_card), KEY idx_name_birth (name, birth_date) );这里有几个设计点值得展开说。
mpi_id是系统内部使用的全局唯一标识,一旦分配永不改变。patient_no是对外展示的编号,可以理解为“患者看到的那串号”。为什么要分开?因为合并发生时,两个患者编号需要合并成一个,但mpi_id不能变——所有已经关联的业务数据都是通过mpi_id挂载的,如果mpi_id变了,关联关系就断了。
merge_status和master_flag是配合使用的。当两条记录被判定为同一人时,我们不会物理删除任何一条,而是把其中一条标记为“非主记录”(master_flag=0),另一条保持为主记录。这样做的原因是:被合并的那条记录可能已经被某些业务系统引用了,直接删掉会导致外键断裂。
idx_name_birth这个联合索引是为去重服务的。姓名+出生日期是最常用的初步匹配条件,虽然不能唯一确定一个人,但能快速缩小候选集。
2.3 来源映射表设计
来源映射表记录的是“哪个系统的哪条记录对应到哪个主索引”。
CREATE TABLE mpi_source_mapping ( id BIGINT PRIMARY KEY AUTO_INCREMENT, mpi_id BIGINT NOT NULL COMMENT '关联的主索引ID', source_system VARCHAR(32) NOT NULL COMMENT '来源系统标识', source_pk VARCHAR(64) NOT NULL COMMENT '来源系统的主键值', source_data JSON COMMENT '来源系统原始数据快照', match_score DECIMAL(5,2) COMMENT '匹配得分', match_type TINYINT COMMENT '匹配类型 1自动 2人工确认', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_source (source_system, source_pk), KEY idx_mpi_id (mpi_id) );uk_source这个唯一约束很关键。它保证了同一个来源系统的同一条记录只能映射到一个主索引,防止重复映射。source_data用JSON存原始数据快照,是为了在合并策略调整后能够回溯——比如后来发现某个字段的取值策略不对,可以从快照重新计算。
match_score记录匹配得分,match_type区分是算法自动匹配还是人工确认的。这两个字段在排查问题和优化算法时非常有用。
2.4 合并日志表设计
合并日志表是可追溯性的保障。
CREATE TABLE mpi_merge_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, operation_type TINYINT NOT NULL COMMENT '操作类型 1合并 2拆分 3字段更新', primary_mpi_id BIGINT NOT NULL COMMENT '主记录mpi_id', merged_mpi_id BIGINT COMMENT '被合并的mpi_id', field_changes JSON COMMENT '字段变更详情', operator VARCHAR(32) COMMENT '操作人', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_primary (primary_mpi_id), KEY idx_merged (merged_mpi_id) );field_changes用JSON记录合并时每个字段的取值变化,比如“name从‘张 三’更新为‘张三’,来源为住院系统”。这个信息在患者投诉“为什么我的名字变了”的时候,能直接给出答案。
2.5 三张表的关系
用一个简单的例子串一下:门诊系统有一条记录(source_system=‘OUTPATIENT’,source_pk=‘MZ001’),住院系统有一条记录(source_system=‘INPATIENT’,source_pk=‘ZY002’)。去重算法判定这两条是同一人,于是创建一个mpi_patient记录(mpi_id=1001),然后在mpi_source_mapping里插入两条映射,都指向mpi_id=1001。同时在mpi_merge_log里记录这次合并操作。
后续如果PACS系统新增了一条记录,通过关联逻辑找到它应该挂到mpi_id=1001下面,就再插入一条映射即可。
3. 去重逻辑:从规则匹配到评分模型
3.1 为什么不能只用身份证号去重
身份证号看起来是天然的唯一标识,但实际数据里问题很多。我遇到过的情况包括:身份证号录入时多了一位、少了一位、最后一位X大小写不一致、15位和18位混用、港澳台患者没有大陆身份证号、外籍患者用护照号、新生儿还没上户口。
如果只用身份证号做去重条件,上述这些情况全部会漏掉。而如果身份证号匹配就认为是同一人,又可能因为录入错误导致误合并——比如两个人身份证号只差一位,恰好录错了,就会被错误地合并。
所以实际做法是多字段加权评分:每个字段根据其区分度赋予不同权重,综合得分超过阈值才判定为同一人。
3.2 字段权重设计
权重的核心逻辑是:区分度越高的字段,权重越大。什么叫区分度?就是这个字段的值在人群中越分散,区分能力越强。
| 字段 | 权重 | 说明 |
|---|---|---|
| 身份证号 | 40 | 完全匹配得满分,部分匹配按编辑距离折算 |
| 姓名 | 20 | 完全匹配满分,同音字、形近字按相似度折算 |
| 出生日期 | 15 | 完全匹配满分,年月匹配但日不同得部分分 |
| 性别 | 5 | 匹配得分,不匹配扣分 |
| 手机号 | 10 | 完全匹配满分 |
| 地址 | 10 | 按文本相似度折算 |
这套权重不是拍脑袋定的,是根据实际数据跑出来的。我试过用信息增益来计算每个字段的区分度,结论和上面这张表基本一致——身份证号和姓名的区分度远高于其他字段。
3.3 姓名相似度计算
姓名匹配不能只做字符串相等判断。“张三”和“张 三”应该匹配,“张三”和“张叁”也应该匹配,“李四”和“李肆”同理。实际实现中通常组合使用以下几种方法:
编辑距离(Levenshtein Distance):计算两个字符串之间的最小编辑操作数。“张三”和“张 三”的编辑距离是1(插入一个空格),相似度可以折算为1 - 1/max(len) = 0.67。
拼音匹配:把姓名转成拼音后再比较。“张三”和“张叁”的拼音都是“zhangsan”,完全匹配。这个对同音字特别有效。
形近字处理:建立常见形近字映射表,比如“己/已/巳”、“未/末”等。这个需要维护一个字典,但覆盖常见情况就够了。
实际代码里可以这样组合:
def name_similarity(name1, name2): if name1 == name2: return 1.0 # 编辑距离相似度 edit_sim = 1 - levenshtein(name1, name2) / max(len(name1), len(name2)) # 拼音相似度 pinyin_sim = 1.0 if to_pinyin(name1) == to_pinyin(name2) else 0.0 # 综合 return max(edit_sim, pinyin_sim * 0.9)拼音匹配给0.9而不是1.0,是因为同音字虽然大概率是同一人,但也不能完全排除巧合。
3.4 评分模型与阈值选择
综合得分是各字段得分的加权和:
total_score = sum(field_score[i] * weight[i])阈值的设定需要权衡漏合并和误合并。阈值太高,同一人的记录匹配不上(漏合并);阈值太低,不同人的记录被错误合并(误合并)。
根据实际经验,可以设两档阈值:
- 自动合并阈值:总分 ≥ 85,直接自动合并,不需要人工介入。
- 人工审核区间:60 ≤ 总分 < 85,进入待审核队列,由人工确认。
- 不合并:总分 < 60,判定为不同患者。
这个分档策略的好处是:高置信度的自动处理,低置信度的不处理,中间地带人工兜底。实际运行下来,自动合并的准确率能到99%以上,人工审核的量也不会太大。
3.5 分块策略:避免全量比对
如果系统里有100万患者,新来一条记录要和100万条比对,计算量是100万次评分。这个量级还能接受,但如果每天新增几千条,累积起来就很可观了。
实际做法是先做分块(Blocking),把候选集缩小到几百条以内,再做精细评分。分块的条件通常用姓名首字母+出生年份,或者身份证号前6位+姓名拼音首字母。
SELECT * FROM mpi_patient WHERE LEFT(name_pinyin, 1) = 'Z' AND YEAR(birth_date) = 1985 AND merge_status = 0;这样能把候选集从百万级降到百级,评分计算量下降几个数量级。分块的关键是保证“同一人的记录一定在同一个块里”,所以分块条件要选那些即使有录入误差也不会变的字段。姓名首字母可能因为多音字出错,出生年份可能因为录入错误偏差一年,所以实际实现中通常会做多个分块条件的并集。
4. 合并策略:字段取值与冲突消解
4.1 合并不是覆盖
确认两条记录是同一人之后,接下来的问题是怎么合并。最粗暴的做法是用新记录覆盖旧记录,但这会丢失信息。比如旧记录里有手机号,新记录里没有,覆盖后手机号就丢了。
合理的合并策略是逐字段决策:每个字段独立判断取哪个来源的值。判断依据包括数据完整性、数据新鲜度、来源可信度。
4.2 字段取值优先级
我通常按以下优先级来决定每个字段的取值:
第一优先级:数据完整性。非空值优先于空值。如果A记录有手机号,B记录没有,取A的。
第二优先级:来源可信度。如果两个来源都有值,取可信度更高的来源。可信度排序通常是:身份证读卡器 > 人工录入 > 自助机录入。读卡器读出来的身份证号和姓名几乎不会错,人工录入的出错概率就高很多。
第三优先级:数据新鲜度。如果来源可信度相同,取更新时间更近的。比如患者最近一次住院时登记的手机号,比三年前门诊登记的手机号更可能有效。
第四优先级:值长度。在以上都相同的情况下,取更长的值。这个规则对地址字段特别有用——“北京市朝阳区XX路1号”比“北京朝阳XX路”更完整。
4.3 冲突消解的实际案例
说一个我实际遇到的案例。门诊系统里患者姓名是“张三”,住院系统里是“张 三”(中间有空格),体检系统里是“张三丰”(录入时多打了一个字)。
按照上面的优先级:三个来源的可信度相同(都是人工录入),新鲜度也差不多。这时候姓名相似度算法会判定“张三”和“张 三”高度相似,“张三丰”和“张三”相似度较低。合并时取“张三”作为主记录的姓名,因为它是多数来源的值,且没有多余字符。
但“张三丰”这个值不能丢,它被记录在mpi_merge_log的field_changes里,同时mpi_source_mapping里保留了体检系统的原始数据快照。如果后来发现“张三丰”才是正确的(比如患者身份证上就是这个名字),可以从日志里回溯修正。
4.4 合并操作的原子性
合并涉及多张表的操作:更新mpi_patient的master_flag、插入mpi_source_mapping、写入mpi_merge_log。这些操作必须在一个事务里完成,否则中途失败会导致数据不一致。
START TRANSACTION; -- 1. 将非主记录标记为已合并 UPDATE mpi_patient SET merge_status = 1, master_flag = 0 WHERE mpi_id = ?; -- 2. 更新来源映射,指向主记录 UPDATE mpi_source_mapping SET mpi_id = ? WHERE mpi_id = ?; -- 3. 写入合并日志 INSERT INTO mpi_merge_log (...) VALUES (...); COMMIT;这里有一个细节:更新来源映射时,被合并记录的mpi_id要改成主记录的mpi_id。但uk_source唯一约束是(source_system, source_pk),所以不会冲突。如果担心冲突,可以在更新前先检查。
4.5 拆分:合并错了怎么办
再好的算法也会出错。如果发现两条记录被错误合并了,需要支持拆分操作。拆分的逻辑是合并的逆操作:把master_flag=0的记录恢复为独立记录,把来源映射改回去,写入拆分日志。
拆分比合并更复杂的地方在于:合并期间可能有新的业务数据挂载到了主记录上。拆分时需要判断这些新数据应该归属到哪个记录。实际做法是:拆分时把新数据保留在主记录上,同时通知相关业务系统人工确认归属。
所以拆分操作通常不是全自动的,而是半自动——系统给出拆分建议,人工确认后执行。
5. 关联维护:新数据如何正确挂载
5.1 关联的两种模式
主索引建立之后,各业务系统的新数据需要与主索引建立关联。关联有两种模式:
实时关联:业务系统在写入患者数据时,同步调用EMPI服务,获取mpi_id后一起写入。这种模式数据一致性最好,但对EMPI服务的可用性要求高——EMPI挂了,业务系统也写不了。
异步关联:业务系统先写入自己的数据,然后通过消息队列异步通知EMPI,EMPI完成匹配后回写mpi_id。这种模式对业务系统影响小,但存在延迟,且需要处理匹配失败的情况。
实际项目中通常是混合模式:门诊、住院等核心系统用实时关联,体检、随访等非核心系统用异步关联。
5.2 实时关联的实现
实时关联的核心是一个匹配接口:输入患者信息,输出mpi_id。如果匹配到已有记录,返回对应的mpi_id;如果匹配不到,创建新的主索引记录并返回新的mpi_id。
def match_or_create(patient_info, source_system, source_pk): # 1. 先查来源映射,看是否已经关联过 mapping = query_mapping(source_system, source_pk) if mapping: return mapping.mpi_id # 2. 分块缩小候选集 candidates = blocking_search(patient_info) # 3. 评分匹配 best_match = None best_score = 0 for candidate in candidates: score = calculate_score(patient_info, candidate) if score > best_score: best_score = score best_match = candidate # 4. 根据阈值决策 if best_score >= 85: # 自动关联 create_mapping(best_match.mpi_id, source_system, source_pk) return best_match.mpi_id elif best_score >= 60: # 进入人工审核队列,先创建临时主索引 new_mpi_id = create_patient(patient_info) create_mapping(new_mpi_id, source_system, source_pk) enqueue_review(new_mpi_id, best_match.mpi_id, best_score) return new_mpi_id else: # 创建新主索引 new_mpi_id = create_patient(patient_info) create_mapping(new_mpi_id, source_system, source_pk) return new_mpi_id这个接口的响应时间很关键。分块查询和评分计算都要走索引,避免全表扫描。实际测试下来,单次匹配的响应时间能控制在50ms以内。
5.3 异步关联的消息设计
异步关联通过消息队列实现。业务系统发送的消息包含患者信息和来源标识,EMPI消费消息后执行匹配,然后通过回调接口把mpi_id回写给业务系统。
消息的格式建议用JSON,包含以下字段:
{ "source_system": "HEALTH_CHECK", "source_pk": "TJ2024001", "patient_info": { "name": "张三", "gender": 1, "birth_date": "1985-06-15", "id_card": "110101198506151234", "phone": "13800138000" }, "timestamp": "2024-01-15T10:30:00" }异步关联需要处理消息重复消费的问题。解决方案是在mpi_source_mapping的uk_source唯一约束上做文章——重复消费时插入映射会触发唯一约束冲突,捕获这个异常并忽略即可。
5.4 关联关系的维护
关联关系不是一成不变的。患者可能换手机号、改名字、迁地址,这些变更需要同步到主索引。实际做法是:业务系统的变更通过消息通知EMPI,EMPI根据合并策略决定是否更新主索引的字段值。
这里有一个容易忽略的点:变更通知的顺序。如果患者先改了名字,又改了手机号,两条消息到达EMPI的顺序可能颠倒。如果EMPI简单地用后到的消息覆盖先到的,可能导致名字被旧值覆盖。解决方案是在消息里带时间戳,EMPI只接受时间戳更新的变更。
5.5 关联查询的性能优化
EMPI最频繁的操作是“根据来源系统的患者ID查mpi_id”和“根据mpi_id查所有关联的来源记录”。这两个查询都要走索引。
第一个查询走uk_source索引,速度很快。第二个查询走idx_mpi_id索引,如果关联的来源记录很多(比如一个患者有几十条来自不同系统的记录),查询会稍慢。优化方法是在应用层做缓存,把mpi_id到来源映射的对应关系缓存在Redis里,设置合理的过期时间。
6. 实操中踩过的坑与排查技巧
6.1 姓名中的生僻字导致匹配失败
生僻字在医疗数据里很常见,比如“燚”、“淼”、“犇”等。这些字在不同系统里的编码可能不一致,导致字符串比较失败。
解决方案是在入库时统一做Unicode规范化,把所有姓名转成NFC格式。同时建立生僻字映射表,把异体字统一为标准字。比如“喆”和“哲”在某些系统里会被混用,需要人工确认后加入映射表。
6.2 身份证号中的X大小写问题
身份证号最后一位可能是X,有些系统存的是大写,有些是小写。字符串比较时如果不做处理,会判定为不匹配。
解决方案很简单:入库时统一转大写,比较时也转大写。但要注意,这个转换只针对身份证号字段,不要影响其他字段。
6.3 手机号归属判断的陷阱
手机号看起来是强标识,但实际上问题不少。一个手机号可能被多人使用(家属代填),也可能一个人有多个手机号。如果简单地用手机号匹配就判定为同一人,误合并的概率很高。
我的做法是:手机号只作为辅助字段,权重给10分,不单独作为判定依据。同时记录手机号的来源和时间,如果同一个手机号关联了多个患者,在人工审核时给出提示。
6.4 合并后业务数据找不到的排查
有一次上线后发现,某个患者的检验报告查不到了。排查发现是合并操作把来源映射的mpi_id改了,但检验系统还在用旧的mpi_id查询。
这个问题的根因是:检验系统没有实时关联,而是缓存了mpi_id。合并发生后,缓存没有失效。解决方案是在合并时发送缓存失效通知,或者让检验系统改用来源系统的患者ID查询,通过EMPI实时解析mpi_id。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 同一患者有多条主索引 | 分块条件太严格,候选集遗漏 | 检查分块字段的取值分布 | 放宽分块条件,增加分块维度 |
| 不同患者被合并 | 阈值太低或权重不合理 | 查看合并日志的match_score | 提高阈值,调整字段权重 |
| 合并后数据丢失 | 字段取值策略覆盖了有效值 | 检查field_changes日志 | 从来源映射的原始数据恢复 |
| 关联接口响应慢 | 候选集太大或索引缺失 | 查看慢查询日志 | 优化分块策略,补充索引 |
| 异步关联消息丢失 | 消息队列配置问题 | 检查消息队列的消费位点 | 增加消息持久化和重试机制 |
6.6 人工审核队列的设计要点
人工审核是保证准确率的最后一道防线,但设计不好会成为瓶颈。我的经验是:
审核界面要同时展示两条记录的完整信息和匹配得分,让审核员能快速判断。对于得分接近阈值的记录,给出重点提示。审核结果要反馈到算法里,用于调整权重和阈值——如果审核员频繁地把某类记录判定为同一人,说明这类记录的权重给低了。
审核队列要有优先级。急诊、住院等核心业务的记录优先审核,体检、随访等可以稍后处理。审核超时的记录要有兜底策略,比如超过24小时未审核的自动按“不合并”处理,避免阻塞后续流程。
6.7 性能压测的关键指标
上线前一定要做压测。关键指标包括:单次匹配的P99响应时间(目标<100ms)、每秒能处理的匹配请求数(目标>500)、合并操作的事务耗时(目标<200ms)、人工审核队列的积压量(目标<100条)。
压测数据要用真实数据的脱敏副本,不要用生成的假数据。假数据的分布和真实数据差异很大,压测结果没有参考价值。我试过用假数据压测通过,上线后用真实数据一跑就崩了——因为真实数据里姓名重复率远高于假数据,导致候选集爆炸。
7. 一些个人体会
做EMPI这几年,最大的感受是:算法只占三成,数据治理占七成。再好的匹配算法,如果来源系统的数据质量差,也跑不出好结果。所以实际项目中,我会花大量时间在数据清洗和标准化上——统一姓名格式、规范身份证号、清洗手机号、标准化地址。这些工作看起来不起眼,但对最终效果的影响远大于调算法参数。
另一个体会是:不要追求100%的自动化。人工审核不是失败,而是必要的兜底。我见过一些团队为了追求自动化率,把阈值调得很低,结果误合并一大堆,最后花更多时间做拆分。合理的自动化率在85%到90%之间,剩下的交给人工,整体效率反而更高。
最后分享一个小技巧:在mpi_source_mapping的source_data字段里存原始数据快照时,不要只存当前值,把历史变更也存进去。这样当合并策略调整时,可以从历史数据重新计算,而不需要回源到业务系统去查。这个设计在后期优化时能省很多事。