4. ELN数据库选型与Schema实现

电子实验记录系统(ELN)的数据模型有一个显著特点:核心结构高度稳定,但实验内容高度异构。无论记录的是有机合成、材料表征还是生物实验,都必然存在“谁在什么时间做了哪个实验、属于哪个项目、引用了哪些样品”这类强一致、强关联的元数据;而实验步骤、参数、观测值、图谱附件描述等部分,则随实验类型差异极大。数据库选型的关键,不是判断哪种数据库“更好”,而是判断哪些数据必须由关系型数据库保证完整性,哪些数据适合以文档形式灵活承载。

一、两类数据库在ELN中的职责边界

关系型数据库(以 PostgreSQL 为代表)擅长表达实体之间的强关系:实验记录与项目、人员、样品、仪器之间存在明确的外键依赖,删除一个项目前必须确认其下没有未归档的实验记录,这类约束由数据库层强制执行,比应用层校验更可靠。文档数据库(以 MongoDB 为代表)擅长表达单条记录内部结构可变的数据:同一张“实验记录”集合中,不同实验类型的 payload 字段可以拥有完全不同的嵌套结构,无需为每种实验类型建一张表或频繁执行 ALTER TABLE。

因此,ELN 的合理架构通常是混合持久化:用关系型数据库保存实验记录的“骨架”(身份、归属、状态、时间、审计),用文档字段或独立文档集合保存实验的“血肉”(步骤、参数、观测、附件元信息)。下面这张图展示了这一分工关系。

ELN 应用服务层 统一 API / 事务编排 / 权限校验 强一致写入 灵活文档写入 关系型数据库(PostgreSQL) 骨架数据 · 外键约束 · 事务一致性 projects PK id name / owner_id users PK id username / role experiments(核心表) PK id · FK project_id → projects.id FK operator_id → users.id status / created_at / payload JSONB samples PK id FK experiment_id audit_log PK id FK experiment_id 文档数据库(MongoDB) 异构内容 · 嵌套结构 · 快速演进 collection: experiment_payloads { _id, experiment_id, type: "synthesis" } steps: [ { order, reagent, temp, time } ] measurements: { yield, purity, NMR } attachments: [ { name, url, hash } ] type: "characterization" 时结构完全不同 collection: instrument_logs { _id, instrument_id, experiment_id } run_at, duration_sec, operator raw_files: [ { path, size, format } ] status: "completed" | "failed" error: { code, message } // 失败时存在 experiment_id 逻辑关联

二、Schema 设计:以 experiments 表为核心

关系型部分的设计原则是“把约束交给数据库”。以下 DDL 以 PostgreSQL 为例,展示了 ELN 核心骨架表的建表语句。注意 payload 字段使用 JSONB 类型,这是混合架构的关键:它让关系表也能承载异构内容,同时保留 SQL 查询能力。

-- 用户表
CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    username    VARCHAR(64) NOT NULL UNIQUE,
    role        VARCHAR(16) NOT NULL DEFAULT 'researcher'
                CHECK (role IN ('researcher','reviewer','admin')),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 项目表
CREATE TABLE projects (
    id          BIGSERIAL PRIMARY KEY,
    name        VARCHAR(128) NOT NULL,
    owner_id    BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
    status      VARCHAR(16) NOT NULL DEFAULT 'active'
                CHECK (status IN ('active','archived')),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 实验记录主表
CREATE TABLE experiments (
    id           BIGSERIAL PRIMARY KEY,
    project_id   BIGINT NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
    operator_id  BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
    title        VARCHAR(200) NOT NULL,
    exp_type     VARCHAR(32) NOT NULL,
    status       VARCHAR(16) NOT NULL DEFAULT 'draft'
                 CHECK (status IN ('draft','submitted','reviewed','archived')),
    payload      JSONB NOT NULL DEFAULT '{}'::jsonb,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 样品表
CREATE TABLE samples (
    id             BIGSERIAL PRIMARY KEY,
    experiment_id  BIGINT NOT NULL REFERENCES experiments(id) ON DELETE CASCADE,
    sample_code    VARCHAR(64) NOT NULL,
    amount_mg      NUMERIC(10,3),
    UNIQUE (experiment_id, sample_code)
);

-- 审计日志表
CREATE TABLE audit_log (
    id             BIGSERIAL PRIMARY KEY,
    experiment_id  BIGINT NOT NULL REFERENCES experiments(id) ON DELETE CASCADE,
    action         VARCHAR(32) NOT NULL,
    actor_id       BIGINT NOT NULL REFERENCES users(id),
    detail         JSONB,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

这里有三处约束值得强调。ON DELETE RESTRICT 用于 projects 和 users:只要项目下还有实验记录,或用户仍是某条记录的负责人,就不允许删除,防止出现“孤儿记录”。ON DELETE CASCADE 用于 samples 和 audit_log:它们从属于实验记录,实验记录被删除时随之清理是合理的。CHECK 约束把状态枚举限制在数据库层,避免应用层写入非法状态值。

三、索引设计:围绕真实查询路径

索引不是越多越好,应针对 ELN 中最频繁的查询模式建立。典型查询包括:按项目列出实验记录、按操作人筛选、按状态过滤待审核记录、按时间倒序分页、在 payload 中检索特定实验参数。

-- 项目维度查询:列出某项目下所有实验
CREATE INDEX idx_exp_project ON experiments(project_id, created_at DESC);

-- 操作人维度:某人的实验记录
CREATE INDEX idx_exp_operator ON experiments(operator_id, created_at DESC);

-- 状态过滤:待审核队列
CREATE INDEX idx_exp_status ON experiments(status) WHERE status = 'submitted';

-- JSONB 内容检索:查找特定试剂
CREATE INDEX idx_exp_payload_gin ON experiments USING GIN (payload jsonb_path_ops);

-- 审计日志按实验追溯
CREATE INDEX idx_audit_exp ON audit_log(experiment_id, created_at DESC);

其中 idx_exp_status 是部分索引,只索引 submitted 状态的记录。由于待审核记录通常只占总量很小比例,部分索引体积远小于全表索引,查询待审核队列时效率更高。idx_exp_payload_gin 使用 GIN 索引支持 JSONB 包含查询,例如 payload @> '{"reagent":"Pd/C"}',让异构内容也能被高效检索。

四、初始化数据与验证

Schema 建好后,需要插入最小可用数据集来验证约束和索引是否按预期工作。以下初始化脚本创建两名用户、一个项目、一条实验记录及其样品。

INSERT INTO users (username, role) VALUES
  ('zhang_wei', 'researcher'),
  ('li_na',     'reviewer');

INSERT INTO projects (name, owner_id) VALUES
  ('催化剂筛选-2024Q3', 1);

INSERT INTO experiments
  (project_id, operator_id, title, exp_type, status, payload)
VALUES
  (1, 1, 'Pd/C 催化加氢条件筛选', 'synthesis', 'submitted',
   '{"reagent":"Pd/C","temp_c":60,"time_h":4,
     "measurements":{"yield":0.87,"purity":0.95}}'::jsonb);

INSERT INTO samples (experiment_id, sample_code, amount_mg)
VALUES (1, 'S-2024-001', 125.500);

INSERT INTO audit_log (experiment_id, action, actor_id, detail)
VALUES (1, 'submit', 1, '{"from":"draft","to":"submitted"}'::jsonb);

插入完成后,用几条查询验证设计是否生效。第一条验证外键约束:尝试删除 projects 中 id=1 的记录,应被拒绝,因为存在引用它的实验记录。第二条验证 JSONB 检索:

SELECT id, title
FROM experiments
WHERE payload @> '{"reagent":"Pd/C"}'
  AND status = 'submitted';

若返回刚才插入的记录,说明 GIN 索引路径可用。第三条验证部分索引:执行 EXPLAIN ANALYZE 查看待审核查询是否命中 idx_exp_status,若执行计划中出现 Index Scan using idx_exp_status,则索引生效。若出现 Seq Scan,需检查数据量是否过小导致优化器放弃索引,或统计信息是否过期。

在开发环境可用 EXPLAIN (ANALYZE, BUFFERS) 观察实际扫描行数与缓存命中情况。数据量小于几百行时,顺序扫描往往比索引更快,这是正常现象,不代表索引设计有误。

五、选型判断的实用准则

回到选型本身,可以用三条准则快速判断某类数据应放在哪一侧。第一,是否需要跨实体引用完整性:需要外键、需要事务保证多表一致,放关系型。第二,结构是否随实验类型剧烈变化:同一集合内文档字段差异大、且会持续新增字段,放文档型或 JSONB。第三,是否需要复杂聚合与关联查询:需要多表 JOIN、窗口函数、严格排序分页,放关系型。

对于多数中小型 ELN,PostgreSQL 单库配合 JSONB 已能覆盖绝大部分场景,无需引入第二套数据库,从而避免跨库一致性问题。只有当实验内容体量极大、写入吞吐很高、或需要独立演进文档结构时,才考虑引入 MongoDB 等文档数据库,并通过 experiment_id 建立逻辑关联,由应用层保证两侧数据同步。无论选择哪种方案,外键约束、状态枚举、审计日志这三项都应在数据库层落实,它们是 ELN 数据可信度的底线。