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) 建复合索引。

选型速判:读多写少、搜索复杂、需要事务 → 关系型为主;写入吞吐极高、资源结构频繁变化、以 id 访问为主 → 文档型为主;两者都要 → 混合,正文与索引分离。

二、资源表结构设计

主表设计要克制。以 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 集合,再回主表取正文。

常见坑:token 索引必须同时支持“仅 value”和“system|value”两种匹配;date 索引要处理精度(年/月/日)与范围查询;string 索引默认前缀匹配,需注意大小写与分词策略。
FHIR 资源存储模型:正文、版本与搜索索引分层 FHIR 资源写入 Patient / Observation 搜索参数提取器 解析 JSON → 索引行 resource 主表(JSONB) id · type · logical_id · version_id last_updated · is_deleted · content 版本历史表 vread / _history · 乐观锁 version_id 更新时归档 search_index 表 token / date / string / ref FHIR 搜索请求 ?identifier=...&date=ge... 索引条件求交 得到 logical_id 集合 回主表取 content 返回 Bundle 搜索结果 分层原则 ① 正文按 id 整块存取,避免为每个字段建列;② 版本历史独立存储,支持 vread 与 _history; ③ 搜索参数抽取为索引行,按类型建索引,查询先走索引再回主表。

最后要强调的是,搜索参数索引不是“建完就完”。当资源更新时,必须同步删除旧索引行、写入新索引行,否则会出现“资源已改、搜索仍命中旧值”的脏读。建议把索引更新放在同一事务内,或采用“先写主表、再异步重建索引”的最终一致方案,并在查询侧容忍短暂延迟。对于 _include、_revinclude 这类跨资源查询,索引表还需记录引用方向,才能高效地反向查找。