简介:SNOMED CT 数据库构建脚本集合,面向医疗信息化开发者、数据工程师与医学术语标准研究人员,用于将 RF2 分发格式的 SNOMED CT 术语版本导入关系型数据库或图数据库,减少术语数据落地、结构映射与批量装载中的重复工作。压缩包共 115 个文件、约 434KB,主体为 64 个 SQL 脚本,辅以 11 个 Python 脚本、8 份 Markdown 说明、5 个模板,以及 Perl、Shell、Bat 等批处理与自动化脚本;cfg、ini、cnf 配置文件用于设置路径和连接参数,Cypher 脚本服务于 Neo4j 图数据库场景,整体覆盖 MySQL、PostgreSQL、MSSQL 与 Neo4j 多套加载流程。已有 1104 人学习下载。读者可参考 SNOMED CT 的关系模型与建表思路、RF2 数据解析和导入参数配置,对比不同数据库脚本的实现差异,并据此搭建本地术语库、排查装载问题或进行二次开发。
1. 为什么把 SNOMED CT 塞进关系数据库不是「建三张表」那么简单
第一次拿到 SNOMED CT 发布包的人,多半会在解压后愣一下:几百个制表符分隔的 txt 文件,字段值全是裸数字 ID,description表里躺着一堆带括号后缀的英文长串,想查「糖尿病及其所有子类」,随手写了个自连接,结果跑了一分多钟才回来。于是得出一个错误结论——关系数据库不适合存术语。真实原因通常不在 SNOMED CT 的关系数据库表示本身,而在于把一套带版本、带多语言、带描述逻辑语义的术语体系,当成了普通的字典表来建模。这篇笔记按我自己落地的顺序讲:发布包怎么读、表怎么建、百万行怎么装、IS-A 层级怎么查到秒级、闭包表怎么增量维护,以及那几个能让整批数据静默出错的坑。
2. RF2 发布包拆解:Snapshot、Full、Delta 三种视图到底该选哪个
SNOMED CT 的发布格式(Release Format 2,下称 RF2)不是一份文件,而是一棵目录树。解压后你会看到三个平级的视图目录:Snapshot、Full、Delta,每个目录下面再按Terminology和Refset分组,文件名形如sct2_Concept_Snapshot_INT_YYYYMMDD.txt。三个目录里的文件名除了视图标识之外完全一样,行数量级却相差很远。选错目录是新手最常见的第一道坎,而且它不报错——你只是查出来的数据不全。
2.1 三种视图的差别决定了你的主键设计
Snapshot是「当前这一刻的状态」,一个组件 ID 只出现一次,就是最新版本那一行。Full是「全历史」,同一个组件 ID 会随着effectiveTime出现多次,每次变更留一行。Delta是「相对上一个版本的增量」,只包含本次发布中新增或修改的行。
这个差别直接映射到数据库主键上:装 Snapshot,主键可以是id;装 Full,主键必须是(id, effective_time),否则第二次装载就会撞主键。我一般建议生产环境装Full,装完立刻物化出一张Snapshot表给在线查询用。理由是增量更新和审计追溯迟早会有人提需求,那时候如果没有历史行,只能重新灌一次全量;而后悔药这种东西,能提前吞就别等出事再找。
也要注意一个成本:Full 的体积通常是 Snapshot 的几倍到十几倍,description表尤其明显,因为描述文本的修订最频繁。如果你的场景就是纯查询、不带版本追溯,直接装 Snapshot 完全够用,别为了「看起来专业」多背一份历史包袱。
2.2 三类核心文件与 Refset 的通用骨架
核心组件文件只有三类,其余的都可以视作附属:
| 文件前缀 | 内容 | 关键列 |
|---|---|---|
sct2_Concept_ | 概念 | id, effectiveTime, active, moduleId, definitionStatusId |
sct2_Description_ | 描述(术语名) | id, effectiveTime, active, moduleId, conceptId, languageCode, typeId, term, caseSignificanceId |
sct2_Relationship_ | 关系 | id, effectiveTime, active, moduleId, sourceId, destinationId, relationshipGroup, typeId, characteristicTypeId, modifierId |
关系文件里有两套并行数据:一套是推断关系(inferred),一套是陈述关系(stated),两者通过characteristicTypeId区分,在某些发布包里干脆拆成sct2_Relationship_和sct2_StatedRelationship_两个文件。推断关系是经过分类服务计算后的结果,层级干净、适合查询;陈述关系更贴近原始建模意图,但可能有冗余和多路径。做层级查询用推断关系,做定义编辑或二次推理才用陈述关系。
Refset 是另一大类,文件前缀通常是der2_,骨架高度统一:前六列固定为id, effectiveTime, active, moduleId, refsetId, referencedComponentId,从第七列开始才是这个具体 refset 的私有字段。语言偏好、映射、历史替代关系、OWL 公理表达式,全都挂在这套骨架上。理解了这个「六列通用 + N 列私有」的结构,你就不需要为每个 refset 单独写一套加载器。
至于那些满屏的数字 ID,比如 Is a 关系、根概念、FSN 描述类型,硬编码之前建议先反查确认一次,避免拿到错误常量:
-- 从描述表反查关键常量的 conceptId,确认后再写进代码 SELECT concept_id, term FROM snomed.description WHERE type_id = 900000000000003001 -- Fully specified name AND term IN ( 'Is a (attribute)', 'SNOMED CT Concept (SNOMED RT+CTV3)' );这段查询依赖description表已经装载完成,所以只适合做一次性的核对。type_id里的 FSN 常量本身也是可以反查的,用languageCode = 'en'加term LIKE '%fully specified name%'兜底即可。核对完把结果写成配置项,别散落在各段 SQL 里。
2.3 装载前先跑一次发布包体检
在往数据库写第一行之前,我会先跑一个纯 Python 的体检脚本,只看三件事:每个文件有多少行、列数是否一致、文件里有没有 CRLF 行尾。这三项能提前暴露后面 80% 的装载问题。
import csv from pathlib import Path # RF2 核心文件的固定列数,用于校验字段是否错位 EXPECTED_COLS = { "concept": 5, "description": 9, "relationship": 10, } def inspect(path: Path) -> None: raw = path.read_bytes() crlf = raw.count(b"\r\n") with path.open("r", encoding="utf-8", newline="") as fh: # RF2 不使用引号转义,必须禁用 csv 的引号处理逻辑 reader = csv.reader(fh, delimiter="\t", quoting=csv.QUOTE_NONE) header = next(reader) rows = 0 bad = 0 for row in reader: rows += 1 if len(row) != len(header): bad += 1 kind = next((k for k in EXPECTED_COLS if k in path.name.lower()), None) expect = EXPECTED_COLS.get(kind) flag = "" if expect is None or expect == len(header) else f" 列数异常! 期望{expect}" print(f"{path.name}\trows={rows}\tcols={len(header)}{flag}" f"\tcrlf={crlf}\t错列行={bad}") if __name__ == "__main__": import sys root = Path(sys.argv[1]) for p in sorted(root.glob("**/*.txt")): inspect(p)这段脚本的逻辑很直白:newline=""是为了让 Python 不要替我转换行尾,这样read_bytes()统计到的 CRLF 才是文件里的真实情况;quoting=csv.QUOTE_NONE是因为 RF2 规范里字段不做引号包裹,如果按默认的引号规则解析,遇到术语里出现的双引号就会把后续字段全部吞掉,导致列数对不上。
参数上,EXPECTED_COLS里的数字不是必须的,用len(header)自校验也能发现「同一文件内某些行列数不同」这种更隐蔽的问题。真正的价值在crlf那列:如果它等于行数,说明整个文件是 Windows 行尾,直接 COPY 会把\r塞进最后一列,后面第 5 章会细说这个坑。体检跑完,行数记下来,后面装载完成后拿来对账,比任何日志都可靠。
3. 建表与装载:把上百万行 RF2 变成 PostgreSQL 里的可查结构
体检通过之后进入建模。这一步的思路不是「一个文件一张表」机械映射,而是先想清楚查询会怎么打过来,再倒推字段类型、主键和索引。常见的查询模式无非四种:按 ID 取概念和它的显示名、按文本模糊搜术语、按语义类型筛子集、沿 IS-A 上下遍历。前三种靠单表加索引就能解决,第四种需要额外的结构,放到第 4 章。
3.1 三张主表的字段、类型与主键
先建 schema 和三张主表。这里假设装的是 Full 视图,所以主键统一带上effective_time:
CREATE SCHEMA IF NOT EXISTS snomed; CREATE TABLE snomed.concept ( id bigint NOT NULL, effective_time date NOT NULL, active boolean NOT NULL, module_id bigint NOT NULL, definition_status_id bigint NOT NULL, PRIMARY KEY (id, effective_time) ); CREATE TABLE snomed.description ( id bigint NOT NULL, effective_time date NOT NULL, active boolean NOT NULL, module_id bigint NOT NULL, concept_id bigint NOT NULL, language_code varchar(8) NOT NULL, type_id bigint NOT NULL, term text NOT NULL, case_significance_id bigint NOT NULL, PRIMARY KEY (id, effective_time) ); CREATE TABLE snomed.relationship ( id bigint NOT NULL, effective_time date NOT NULL, active boolean NOT NULL, module_id bigint NOT NULL, source_id bigint NOT NULL, destination_id bigint NOT NULL, relationship_group integer NOT NULL, type_id bigint NOT NULL, characteristic_type_id bigint NOT NULL, modifier_id bigint NOT NULL, PRIMARY KEY (id, effective_time) );有两个类型选择值得说明。第一,所有组件 ID 用bigint而不是varchar。SNOMED CT 的 ID 是纯数字,最长 18 位,bigint完全装得下;换成varchar之后,等值比较和索引体积都会明显变差,连接查询尤其明显。第二,term用text不用varchar(n),因为术语长度没有可靠上界,而且 PostgreSQL 里text和varchar的性能没有差别,限制长度只会带来截断风险。
装完 Full 之后,物化一张「当前状态」表给在线查询用。不要用DISTINCT ON建视图硬查,35 万概念加百万级描述,每次查询都排序一遍太浪费:
-- 只保留每个组件的最新一行,作为在线查询主表 CREATE TABLE snomed.concept_current (LIKE snomed.concept INCLUDING ALL); INSERT INTO snomed.concept_current SELECT DISTINCT ON (id) * FROM snomed.concept ORDER BY id, effective_time DESC; ANALYZE snomed.concept_current;DISTINCT ON (id)配合ORDER BY id, effective_time DESC的效果是按 ID 分组取最新行。这里的关键是排序字段顺序必须和DISTINCT ON的括号内一致,否则 PostgreSQL 直接报错。description和relationship用同样的方式物化。每次发布增量时,只需要重算effective_time变化过的那些 ID,不必重建全表。
3.2 用 COPY 装载:最小命令与提速参数
百万级行不要用 INSERT 或 ORM,直接COPY。我习惯用\copy(客户端侧),避免服务端文件权限问题,也方便在部署脚本里传相对路径:
#!/usr/bin/env bash set -euo pipefail SNAP=/data/snomed/snapshot/Terminology REL=YYYYMMDD # 换成发布包里的实际日期串 PG="psql -h 127.0.0.1 -U term -d terminology -v ON_ERROR_STOP=1" # 装载期间放宽持久化要求,出错可重灌,速度优先 $PG -c "SET synchronous_commit = off;" $PG -c "\copy snomed.concept (id, effective_time, active, module_id, definition_status_id) \ FROM '${SNAP}/sct2_Concept_Snapshot_INT_${REL}.txt' \ WITH (FORMAT text, DELIMITER E'\t', NULL '', HEADER true, ENCODING 'UTF8')" $PG -c "\copy snomed.description \ FROM '${SNAP}/sct2_Description_Snapshot-en_INT_${REL}.txt' \ WITH (FORMAT text, DELIMITER E'\t', NULL '', HEADER true, ENCODING 'UTF8')" $PG -c "\copy snomed.relationship \ FROM '${SNAP}/sct2_Relationship_Snapshot_INT_${REL}.txt' \ WITH (FORMAT text, DELIMITER E'\t', NULL '', HEADER true, ENCODING 'UTF8')"FORMAT text而不是csv,是因为 RF2 本身不是 CSV 语义,字段里不做引号转义,用text格式最贴合。DELIMITER E'\t'里的E前缀不能省,否则反斜杠会被当成普通字符。NULL ''表示空字符串按 NULL 处理,RF2 里确实有可选列留空的情况。HEADER true跳过第一行表头。
提速上,真正有用的只有三件事:装载前把目标表的索引和主键约束全部删掉、装载完再重建;临时把maintenance_work_mem调到 1GB 以上,重建索引能快一倍;最后一定跑一次ANALYZE,否则查询计划会基于空统计信息做出离谱选择。分区在这个数据量级上基本没必要,几十万到千万行的表,PostgreSQL 单表加对索引就跑得很好,过早分区只会让维护脚本变复杂。
3.3 给 IS-A 和文本检索配索引:哪些索引真有用
装完数据建索引,别贪多。每个索引都会拖慢装载和后续增量更新,只建查询路径上真正用到的:
-- 按概念取描述:术语详情页、批量导出都会打这个 CREATE INDEX idx_desc_concept ON snomed.description (concept_id) WHERE active; -- IS-A 向上遍历(找父)和向下遍历(找子)方向相反,需要两个索引 CREATE INDEX idx_rel_source ON snomed.relationship (source_id) WHERE active AND type_id = 116680003; CREATE INDEX idx_rel_destination ON snomed.relationship (destination_id) WHERE active AND type_id = 116680003; -- 按文本模糊搜术语 CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_desc_term_trgm ON snomed.description USING gin (term gin_trgm_ops) WHERE active;三个索引都带WHERE active,这是部分索引,只索引有效行。好处是体积小、命中率高,因为历史失效行在查询里几乎永远被过滤掉。代价是查询条件里必须显式写出active,否则优化器不会用这个索引——这一点很容易被忽略,然后在压测时发现索引「失效」了。
文本检索这里有个边界要提前说清楚:pg_trgm是按三字符片段匹配的,对英文术语效果不错,对中文术语的支持取决于库里到底有没有中文描述。如果发布包里只有英文,而业务侧需要中文搜索,靠pg_trgm解决不了,得单独维护一张中英对照表,那是另一个工程。别指望在 SNOMED CT 的描述表里直接搜到中文,除非你装的语言 refset 里确实带了对应语种。
4. 层级查询:递归 CTE、闭包表与「查一次要几秒」的取舍
层级是 SNOMED CT 最核心的语义。Is a 关系构成一张有向无环图,一个概念可以有多个父概念,遍历时不能假设树结构。查询需求通常分两类:交互式单点查询(点开一个概念,看它的祖先链或直接子类),和批量查询(导出某个根概念下的全部后代,用于子集提取或人群筛选)。这两类需求的最优解不一样,混着用就会两头都不讨好。
4.1 递归 CTE 的正确姿势与环检测
先看单点向上遍历,找某个概念的全部祖先:
WITH RECURSIVE up(ancestor_id, depth, path) AS ( -- 起点:直接父概念 SELECT r.destination_id, 1, ARRAY[r.source_id, r.destination_id] FROM snomed.relationship r WHERE r.active AND r.type_id = 116680003 AND r.source_id = :concept_id UNION ALL -- 递归:继续往父方向走 SELECT r.destination_id, u.depth + 1, u.path || r.destination_id FROM up u JOIN snomed.relationship r ON r.source_id = u.ancestor_id AND r.active AND r.type_id = 116680003 WHERE NOT r.destination_id = ANY(u.path) -- 环检测 ) SELECT ancestor_id, MIN(depth) AS depth FROM up GROUP BY ancestor_id ORDER BY depth;方向的语义必须先记死:source_id是更具体的概念(子),destination_id是更一般的概念(父)。Is a 关系写成三元组就是「子 Is a 父」,所以向上找祖先时,从source_id出发,落到destination_id。第 5 章会专门讲方向搞反的后果。
path数组承担两件事:环检测和深度限制。SNOMED CT 的推断关系理论上无环,但如果你误用了陈述关系,或者增量更新时出现了失效关系残留,环是可能出现的。没有环检测的递归 CTE 会一直跑下去,直到内存耗尽。加上NOT ... = ANY(path)之后,遇到重复节点自动剪枝。MIN(depth)是因为一个概念到某个祖先可能有多条路径,取最短的作为层级深度,这对「显示第几层」之类的 UI 需求有意义。
实际性能:单点向上遍历深度通常不超过二十层,每层展开几个到几十个父节点,走idx_rel_source索引,毫秒级返回。问题出在向下遍历——找一个概念的全部后代,递归方向反过来,每层可能展开上千个节点,跑到几万行结果集时就需要几百毫秒以上,再叠加应用层的 N+1 查询,用户体验就崩了。
4.2 闭包表的生成与增量维护
批量查询必须用闭包表。思路是把「所有祖先-后代对」预先算好落盘,查询时退化成一次索引扫描:
CREATE UNLOGGED TABLE snomed.isa_closure ( ancestor_id bigint NOT NULL, descendant_id bigint NOT NULL, min_depth integer NOT NULL, PRIMARY KEY (ancestor_id, descendant_id) ); INSERT INTO snomed.isa_closure (ancestor_id, descendant_id, min_depth) WITH RECURSIVE walk(ancestor_id, descendant_id, depth, path) AS ( -- 自反行:每个概念是自己的祖先,深度 0 SELECT id, id, 0, ARRAY[id] FROM snomed.concept_current WHERE active UNION ALL SELECT w.ancestor_id, r.destination_id, w.depth + 1, w.path || r.destination_id FROM walk w JOIN snomed.relationship r ON r.source_id = w.descendant_id AND r.active AND r.type_id = 116680003 WHERE NOT r.destination_id = ANY(w.path) ) SELECT ancestor_id, descendant_id, MIN(depth) FROM walk GROUP BY ancestor_id, descendant_id;这段递归从每个概念出发,一路向上走,把路径上所有 (祖先, 后代) 对都记下来。自反行(深度 0)是有意加的,因为「包含自身」在子集提取时经常是需要的语义,加进去比事后 UNION 一行省事。
表先建成UNLOGGED,等全部写完之后再ALTER TABLE ... SET LOGGED切换。UNLOGGED跳过了 WAL 写入,批量插入能快不少,代价是崩溃时表会被清空——构建期间重来一次可接受,构建完立刻转成持久表。构建完记得加索引:
ALTER TABLE snomed.isa_closure SET LOGGED; -- 向下查(给一个祖先,找全部后代) CREATE INDEX idx_closure_desc ON snomed.isa_closure (ancestor_id, descendant_id); -- 向上查(给一个后代,找全部祖先) CREATE INDEX idx_closure_asc ON snomed.isa_closure (descendant_id, ancestor_id);有了闭包表,查某概念全部后代就变成一条普通 SQL:
SELECT c.id FROM snomed.isa_closure cl JOIN snomed.concept_current c ON c.id = cl.descendant_id WHERE cl.ancestor_id = :concept_id AND c.active;千万级的闭包表,这条查询通常几十毫秒。真正的难点在增量维护:每次发布新版本,不能整表重算,得只重算受影响的部分。做法分三步:先用delta找出本次变更的 IS-A 关系,得到受影响的source_id集合;再向上展开这些概念的祖先集合、向下展开它们的后代集合;最后删掉闭包表里「后代在受影响后代集合里且祖先在受影响祖先集合里」的行,重新递归计算。这个交集条件很关键,漏掉一半会导致新旧数据混杂,查询结果时对时错,排查起来非常费劲。
4.3 把常用查询封成视图和函数
散落的递归 CTE 迟早会被复制粘贴到各处,然后某一处忘了过滤active。我一般把高频查询封成视图和函数,让调用方只关心参数:
-- 概念的规范显示名:优先取首选同义词,没有则回退到 FSN CREATE OR REPLACE VIEW snomed.concept_display AS SELECT c.id AS concept_id, COALESCE( (SELECT d.term FROM snomed.description d WHERE d.concept_id = c.id AND d.active AND d.type_id = 900000000000013009 AND d.language_code = 'en' ORDER BY d.id LIMIT 1), (SELECT d.term FROM snomed.description d WHERE d.concept_id = c.id AND d.active AND d.type_id = 900000000000003001 AND d.language_code = 'en' LIMIT 1) ) AS display_name FROM snomed.concept_current c WHERE c.active; -- 判断 A 是否为 B 的祖先,闭包表上的等值查询 CREATE OR REPLACE FUNCTION snomed.is_ancestor_of(anc bigint, desc_id bigint) RETURNS boolean LANGUAGE sql STABLE AS $$ SELECT EXISTS ( SELECT 1 FROM snomed.isa_closure WHERE ancestor_id = anc AND descendant_id = desc_id ); $$;视图里那个ORDER BY d.id LIMIT 1是个简化写法,严谨的做法是去语言 refset 里查 acceptability,取preferred的那条。但语言 refset 的结构比描述表复杂一层,很多项目在 MVP 阶段会先用这个简化版顶着。如果业务对显示名精确度要求高,比如要做患者端展示,还是老老实实把语言 refset 也装进来,别在视图里埋雷。
5. 避坑:装载和查询阶段最容易翻车的五个地方
下面这五条都是我自己或者身边同行实际踩过的,症状看着莫名其妙,根因都很朴素。每条按「现象 → 原因 → 解决」写,方便你按症状对照排查。
5.1 把 Delta 当 Snapshot 装,查出来少一半数据
现象:装载过程一切正常,没有任何报错,但查询时大量概念查不到,description表里一个概念只有零星几条描述,行数比发布说明里写的少一个数量级。
原因:Delta目录和Snapshot目录的文件名只差一个词,部署脚本里用了通配符**/*Concept*.txt就会把三个视图全抓进来,或者抓到了先遍历到的Delta。而 Delta 里只有本次变更的行,装进去自然是一片空洞。更麻烦的是它不报错,因为格式完全合法。
解决:装载脚本里对视图目录做显式断言,不要用通配符跨目录匹配。在装载完成后加一步行数对账:
# 装载后核对行数,和体检脚本记录的 Snapshot 行数比对 psql -d terminology -tAc "SELECT count(*) FROM snomed.concept_current;"行数差异超过 1% 就中断流水线。另外在文件名解析时把视图标识提取出来,遇到Delta直接拒绝加载到在线表,只允许写入增量暂存区。
5.2 行尾的 \r 让 active 列全部变成脏值
现象:COPY报错invalid input syntax for type boolean: "1",但肉眼看文件内容明明是干净的1。或者更隐蔽:不报错,但active全被解析成了 false,查询结果为空。
原因:文件是 Windows 行尾(CRLF),FORMAT text不会自动剥离\r,它被当成最后一列的最后一个字符。如果最后一列是modifierId这种数字列,"900000000000451002\r"转 bigint 直接失败;如果最后一列恰好是active,"1\r"在某些宽松解析路径下会被当成非法值或误判。
解决:在装载前统一转换行尾,别指望数据库替你处理:
# 转成 Unix 行尾,原地替换前先备份 for f in "$SNAP"/*.txt; do sed -i 's/\r$//' "$f" done如果不想改动原始文件(有些场景要求发布包保持原样以便校验),可以用管道把转换嵌进 COPY 的输入。但\copy不支持PROGRAM,得换成服务端COPY ... FROM PROGRAM,或者写一个小的装载程序用 Python 的io层做换行归一。我自己的习惯是在体检脚本里就检测 CRLF,检测到直接转,后面的流程就都不用操心了。
5.3 拿 FSN 当显示名,界面上全是「(disorder)」
现象:功能上线后临床同事反馈,所有诊断名称后面都跟着(disorder)、(finding)、(procedure)这样的后缀,看着很不专业。
原因:FSN(Fully specified name)是给机器和人做唯一识别用的,它的设计目标就是全局唯一,所以必须带语义标签。真正的显示名应该是同义词(synonym)里被语言 refset 标记为首选(preferred)的那一条。
解决:显示名不要从type_id = FSN的描述里取。正确路径是走语言 refset,找到acceptabilityId为首选的那条描述。如果没有装语言 refset,退而求其次用同义词,但要去掉末尾的括号标签,并且接受「可能选到非首选同义词」的不精确性。我在视图里会给两种来源都加上注释标记,方便后续切换,而不是让下游猜。
顺带一个相关坑:FSN 和同义词的caseSignificanceId会影响大小写规范化,做搜索索引时如果忽略了这一列,搜索「diabetes」匹配不到「Diabetes mellitus」的情况会时有发生。搜索索引里统一lower(term)是最省事的做法。
5.4 IS-A 方向搞反,查后代返回了祖先
现象:点开「糖尿病」节点,展开的「子类」列表里出现了「疾病」这种明显更宽泛的概念,或者层级树整个上下颠倒。
原因:source_id和destination_id的语义记反了。RF2 里source是子、destination是父,但直觉上很多人觉得「源应该是起点、更宽泛」,于是写反。
解决:在表注释和查询函数里把方向写死,并且写单元测试。最直接的办法是拿根概念做验证——所有概念的祖先集合里都应该包含根概念,反过来根概念的后代集合应该覆盖全部有效概念。任何一次改查询,跑一遍这个断言就能立刻发现问题:
-- 验证:除根概念外,每个有效概念都应能到达根概念 SELECT count(*) AS orphan FROM snomed.concept_current c WHERE c.active AND c.id <> 138875005 AND NOT snomed.is_ancestor_of(138875005, c.id);正常结果应该是 0。如果这个数是几十万,大概率是方向反了,而不是数据有问题。
5.5 Full 视图不过滤 effectiveTime,count 翻倍
现象:统计概念总数时得到的结果比发布说明里的数字大不少,GROUP BY之后发现有些概念出现了两三次。
原因:直接查了 Full 表而不是concept_current,或者自己写了DISTINCT ON但排序方向写成了ASC,取到了最老的一行。还有一种情况是增量更新时把新版本 INSERT 进去了,但忘了失效旧版本,导致同一 ID 有两行都标记为 active。
解决:约定所有在线查询只能访问*_current表,Full 表加前缀_history并在权限上做隔离,只给审计和回滚脚本用。增量更新写成「先失效旧行、再插入新行」的固定顺序,并且用effective_time做幂等键,重跑时先删除同effective_time的行再插入。这个习惯能省掉后期大量「数据为什么重复」的排查时间。
6. 从「能查」到「敢上线」:子集提取与三条不变量校验
系统能跑通之后,真正决定它能不能上生产的是两件事:能不能稳定地切出业务要用的子集,以及出了问题能不能第一时间发现。子集提取靠闭包表,一条 SQL 就能把某个根概念下的全部后代捞出来,再和业务配置的白名单求交集。我一般把它做成物化视图,每次发布后刷新一次,而不是每次请求现算。
6.1 用闭包表切出科室级子集
-- 以某个根概念为范围,叠加发布方提供的 refset 白名单 CREATE MATERIALIZED VIEW snomed.subset_cardio AS SELECT cl.descendant_id AS concept_id FROM snomed.isa_closure cl JOIN snomed.concept_current c ON c.id = cl.descendant_id WHERE cl.ancestor_id = :root_concept_id AND c.active AND EXISTS ( SELECT 1 FROM snomed.refset_member m WHERE m.referenced_component_id = cl.descendant_id AND m.refset_id = :subset_refset_id AND m.active ); CREATE UNIQUE INDEX ON snomed.subset_cardio (concept_id);EXISTS而不是JOIN是有意为之:同一个概念在 refset 里可能有多条历史成员记录,JOIN会产生重复行,EXISTS只判断存在性。物化视图上加唯一索引,既保证幂等,又让REFRESH MATERIALIZED VIEW CONCURRENTLY可用,刷新时不用锁表。
6.2 上线前必跑的三条不变量
| 不变量 | 校验方式 | 期望值 |
|---|---|---|
| 每个有效概念恰有一个有效 FSN | 按concept_id分组计数 | 异常数 = 0 |
| 除根概念外每个有效概念都能到达根 | 闭包表反向存在性检查 | 孤立数 = 0 |
| 闭包表自反性完整 | 检查(id, id, 0)是否存在 | 缺失数 = 0 |
这三条不需要复杂工具,写成一段 SQL 挂到发布流水线的最后一步即可。第一条能抓住描述装载不全,第二条能抓住关系方向错误或环,第三条能抓住闭包表增量维护时的漏算。三条全绿,我才会让这套术语服务接真实流量。
一个血泪教训是:早期我把校验放在发布之后人工抽查,结果有一次增量发布漏了几个概念的闭包重算,查询结果里少了两个诊断,直到业务方反馈才被发现。后来我把校验前置成流水线的硬门禁,不过就不允许发布,这类问题再没出现过。术语数据的问题往往不会报错,只会静默地少给答案,所以校验不是可选项,是这套东西能长期维护下去的前提。希望帮到你。
本文还有配套的精品资源,点击获取