4. FHIR资源存储模型设计
FHIR 的资源模型是“以资源为中心、以引用相连”的图状结构,而存储层却必须落到具体的数据库引擎上。设计存储模型的核心矛盾在于:FHIR 资源是半结构化、可扩展、嵌套深度不定的 JSON/XML,而关系型数据库擅长固定列与强约束,文档型数据库擅长整块读写与灵活模式。选型不是“哪个更好”,而是“哪一层用哪种能力”。
一、关系型与文档型选型:按访问路径分层
实际工程中常见的做法是混合存储,而不是二选一。判断依据可以归纳为三条访问路径:
- 按 id 读写整资源:这是最高频操作(read、vread、update)。文档型(如 MongoDB、PostgreSQL JSONB)把整个资源作为一个文档/JSON 列存取,天然匹配,写入与读取都是一次 IO。
- 按搜索参数检索:这是 FHIR 的难点。搜索需要把资源中分散的字段抽取成可索引的列或索引表,关系型的 B-tree、GIN、部分索引表达力强,适合承担这一层。
- 按引用遍历与事务:跨资源的引用完整性、条件更新、事务性写入,关系型的外键与事务语义更可靠。
因此一个可落地的架构是:资源正文用 JSONB/文档列存储,搜索参数抽取为独立的索引表,版本历史单独成表。以 PostgreSQL 为例,主表只保留 id、resource_type、version_id、last_updated、content(JSONB)等少量列,搜索走 search_index 表。若选 MongoDB,则主集合存完整资源,另建 search_index 集合并对 (resourceType, paramName, value) 建复合索引。
二、资源表结构设计
主表设计要克制。以 patient 为例,不建议为每个 FHIR 字段建列,否则扩展(extension)和版本升级会不断改表。推荐结构:
CREATE TABLE resource (
id BIGSERIAL PRIMARY KEY,
resource_type VARCHAR(64) NOT NULL,
logical_id VARCHAR(64) NOT NULL,
version_id INTEGER NOT NULL DEFAULT 1,
last_updated TIMESTAMPTZ NOT NULL DEFAULT now(),
is_deleted BOOLEAN NOT NULL DEFAULT false,
content JSONB NOT NULL,
UNIQUE (resource_type, logical_id, version_id)
);
CREATE INDEX idx_resource_type_id ON resource (resource_type, logical_id);
这里 logical_id 是 FHIR 的 Patient/123 中的 123,version_id 是版本号。当前版本通过 is_current 标记或取最大 version_id 得到。资源正文整体存入 content,保证 read 一次命中。
三、版本管理:历史与当前分离
FHIR 要求支持 vread(读指定版本)和 _history(读版本历史)。版本管理有两种常见策略:
- 全量版本表:每次更新插入新行,
version_id自增,旧版本保留。优点是 vread 简单,缺点是存储膨胀。 - 当前表 + 历史表:当前表只存最新版本,历史表存旧版本。读取当前走小表,历史查询走大表,兼顾性能与成本。
版本号必须单调递增且与 last_updated 一致。更新时用乐观锁:UPDATE ... WHERE version_id = :expected,影响行数为 0 则返回 409 Conflict。删除采用软删除,写入一条 is_deleted=true 的新版本,保留历史可追溯。
四、搜索参数索引设计
FHIR 搜索参数分为 string、token、date、reference、quantity、number、uri 等类型,每种类型的索引结构不同。统一做法是建一张 search_index 表:
CREATE TABLE search_index (
resource_type VARCHAR(64) NOT NULL,
logical_id VARCHAR(64) NOT NULL,
version_id INTEGER NOT NULL,
param_name VARCHAR(64) NOT NULL,
param_type VARCHAR(16) NOT NULL,
value_string TEXT,
value_token TEXT,
value_date TIMESTAMPTZ,
value_number NUMERIC,
value_ref TEXT
);
CREATE INDEX idx_si_token ON search_index (resource_type, param_name, value_token);
CREATE INDEX idx_si_date ON search_index (resource_type, param_name, value_date);
写入资源时,由搜索参数提取器解析 JSON,把 Patient.name 拆成 string 索引,把 Patient.identifier 拆成 token 索引(system|value),把 Observation.effectiveDateTime 拆成 date 索引。查询 Patient?identifier=...&birthdate=ge1990-01-01 时,先在 search_index 上按条件取交集得到 logical_id 集合,再回主表取正文。
最后要强调的是,搜索参数索引不是“建完就完”。当资源更新时,必须同步删除旧索引行、写入新索引行,否则会出现“资源已改、搜索仍命中旧值”的脏读。建议把索引更新放在同一事务内,或采用“先写主表、再异步重建索引”的最终一致方案,并在查询侧容忍短暂延迟。对于 _include、_revinclude 这类跨资源查询,索引表还需记录引用方向,才能高效地反向查找。