Skip to content

为什么 EAV 是 AI 时代最被低估的数据模型

Forma 工程博客 · 系列第一篇

TL;DR

你的 AI 管道不应该因为模型学会了一个新字段就在凌晨三点崩溃。你的数据层不应该需要提 DBA 工单才能接受新属性。你的下游模型不应该因为读到了迁移中的半成品数据而产生幻觉。

Forma 的 EAV + JSON Schema + 热表 组合解决了这三个问题:

  • 即时 Schema 演进:新字段秒级生效,而不是等待数天
  • 类型安全校验:坏数据在污染训练集之前就被拒绝
  • 性能不崩盘:热字段有 B-tree 索引,冷字段保持灵活

这篇文章解释为什么这种"老派"数据模型,反而是 AI 时代最实用的选择。

从一个真实场景说起

想象你在做一个 AI 驱动的 CRM 系统。用户对着麦克风说:

"帮我记录一下,刚才和张总通了电话,他对我们的新方案很感兴趣,预算大概 50 万,下周二再约。"

你的 AI Agent 把这段话转成结构化数据:

json
{
  "contact_name": "张总",
  "interaction_type": "phone_call",
  "sentiment": "positive",
  "budget_estimate": 500000,
  "next_followup": "2024-01-16",
  "notes": "对新方案感兴趣"
}

现在问题来了:你的数据库能接这个数据吗?

如果你用传统的关系表:

  1. 表里没有 sentiment 字段?停服,ALTER TABLE ADD COLUMN
  2. 新客户需要 industry 字段?再停一次
  3. 不同客户需要不同的自定义字段?每个客户一张表?

这显然不现实。根据我们对 50+ 家企业客户的调研,一次 DDL 变更从提工单到上线平均需要 3-7 个工作日

但一个中等复杂度的 AI Agent,每天可以产出 10-50 种字段组合变化。 周期差了两个数量级。

把 DBA 术语翻译成 AI 工程师的痛点

如果你读过数据库文献,你会看到"ACID 合规"和"事务隔离"这样的术语。以下是它们对你的 AI 管道实际意味着什么:

DBA 说的AI 工程师的体验
"零 DDL"你的数据摄入脚本不会因为出现新字段而崩溃
"Schema 校验"坏数据不会悄悄污染你的训练集
"事务隔离"你的模型不会在半写入的记录上训练
"数据一致性"不用再调试为什么 embedding 在莫名其妙地漂移

当我们说"零脏读"时,我们不是在给数据库学者炫技。我们是在防止这种场景:你的 embedding 模型在一条正在更新的记录上训练——导致细微的、几乎无法调试的模型漂移。

这是关于管道稳定性,不是数据库理论。

JSON Schema:AI 输出的"类型系统"

这就是 JSON Schema 的价值所在。

它不只是校验,它是契约

JSON Schema 已经成为大语言模型(LLM)结构化输出的事实标准

  • OpenAI Structured Outputs:用 JSON Schema 定义函数返回格式
  • Anthropic Tool Use:用 JSON Schema 描述工具参数
  • Google Gemini Function Declarations:同样基于 JSON Schema

这意味着,当你用 JSON Schema 定义数据结构时,你同时定义了:

  1. AI 的输出格式:LLM 知道该返回什么结构
  2. 校验规则:写入前自动检查类型、格式、范围
  3. 数据库 Schema:Forma 直接用它来组织存储

一个定义,三个用途。

一个 JSON Schema 的例子

json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "contact_name": {
      "type": "string",
      "minLength": 1,
      "x-ltbase-column": "text_01"
    },
    "budget_estimate": {
      "type": "integer",
      "minimum": 0,
      "x-ltbase-column": "integer_01"
    },
    "sentiment": {
      "type": "string",
      "enum": ["positive", "neutral", "negative"]
    },
    "next_followup": {
      "type": "string",
      "format": "date"
    },
    "notes": {
      "type": "string"
    }
  },
  "required": ["contact_name"]
}

注意那个 x-ltbase-column: "integer_01"?这是 Forma 的扩展字段,我们稍后会详细解释它的作用。

无需 DDL 的灵活性

当 AI 产出新字段时会发生什么?

传统方式

AI 输出新字段 → 开发者发现 → 提工单 → DBA 审批 → 停服 → ALTER TABLE → 上线

周期:1 天到 1 周

Forma 方式

AI 输出新字段 → 更新 JSON Schema → 立即生效

周期:秒级

因为 EAV 模式下,新增字段只是在 EAV 表中插入新行,不需要修改表结构。JSON Schema 的更新是纯元数据操作,不涉及数据迁移。

二八定律:为什么还需要"热表"?

EAV 解决了灵活性问题,但它有一个固有问题:所有属性都存在同一张表里,查询时需要扫描大量无关数据

想象一个 CRM 系统:

  • 100 万条联系人记录
  • 每条记录平均 30 个属性
  • EAV 表总行数:3000 万行

每次用户搜索"预算大于 10 万的联系人",数据库要扫描 3000 万行,即使最终只返回 100 条记录。

但仔细分析用户行为会发现一个规律:

80% 的查询只涉及 20% 的字段。

用户最常搜索和排序的字段就那么几个:contact_namecreated_atbudget_estimatestatus。而 notescustom_field_42 这些长尾字段,只有在查看详情页时才需要。

这就是帕累托法则(二八定律)在数据库查询中的体现。

热表:把 20% 的热字段"提升"出来

Forma 的解决方案是引入一张"热表"(entity_main),专门存储高频访问的字段:

热表结构示例:

entity_main 表结构:
┌─────────────┬──────────────┬──────────────┬──────────────┐
│ row_id      │ text_01      │ integer_01   │ created_at   │
├─────────────┼──────────────┼──────────────┼──────────────┤
│ uuid-1      │ "张总"       │ 500000       │ 2024-01-09   │
│ uuid-2      │ "李总"       │ 200000       │ 2024-01-08   │
└─────────────┴──────────────┴──────────────┴──────────────┘
  • text_01 映射到 contact_name(通过 JSON Schema 的 x-ltbase-column 标记)
  • integer_01 映射到 budget_estimate
  • 这些列上有 B-tree 索引

当用户搜索"预算大于 10 万"时:

纯 EAV 路径:扫描 3000 万行 → 聚合 → 返回
热表路径:索引扫描 integer_01 > 100000 → 命中 1000 行 → 只聚合这 1000 条的 EAV 数据

性能差距:扫描量下降 99%,延迟从 200-500ms 降至 20-50ms

为什么不直接用 JSONB?

PostgreSQL 的 JSONB 通常是开发者想要灵活性时的第一选择。这是个合理的直觉——但在生产环境的 AI 管道中,它很快就会碰壁。让我们分析具体的失败模式。


索引瓶颈:GIN vs. B-Tree

这是 JSONB 的阿喀琉斯之踵,而且在你调试生产环境的慢查询之前,这个问题并不明显。

GIN 索引擅长包含查询:

sql
-- PostgreSQL: "找出 tags 包含 'urgent' 的记录"
SELECT * FROM records WHERE data @> '{"tags": ["urgent"]}';  -- ✅ GIN 效果很好

但在范围查询上它完全失效——而范围查询恰恰是业务分析的核心:

sql
-- PostgreSQL: "找出高置信度的预测"
SELECT * FROM records 
WHERE (data->>'confidence_score')::float > 0.9;  -- ❌ 全表扫描

-- PostgreSQL: "找出最近 24 小时的记录"  
SELECT * FROM records 
WHERE (data->>'timestamp')::timestamptz > now() - interval '1 day';  -- ❌ 全表扫描

解决方案是创建表达式索引——但这意味着 DDL:

sql
-- PostgreSQL: 每个需要范围查询的字段都需要 DDL
CREATE INDEX idx_confidence ON records ((data->>'confidence_score')::float);
CREATE INDEX idx_timestamp ON records ((data->>'timestamp')::timestamptz);

MySQL 呢?更加受限:

sql
-- MySQL 8.0+: JSON 提取可以工作...
SELECT * FROM records 
WHERE JSON_EXTRACT(data, '$.confidence_score') > 0.9;

-- 但对 JSON 的函数索引有限制:
-- MySQL 需要先创建一个虚拟生成列
ALTER TABLE records 
ADD COLUMN confidence_score FLOAT 
GENERATED ALWAYS AS (JSON_EXTRACT(data, '$.confidence_score')) VIRTUAL;

CREATE INDEX idx_confidence ON records (confidence_score);
-- 每个字段需要两条 DDL 语句!

每个需要范围查询或排序的新字段都需要 DBA 介入。你又回到了"每个字段 3-7 个工作日"的瓶颈——这正是 JSONB 本应解决的问题。

EAV 如何解决这个问题:

Forma 使用类型化存储——预分配的列,已有 B-tree 索引:

entity_main 表(热表):
┌─────────────┬──────────────┬──────────────┬────────────────┐
│ row_id      │ float_01     │ float_02     │ timestamp_01   │
├─────────────┼──────────────┼──────────────┼────────────────┤
│ uuid-1      │ 0.95         │ 0.87         │ 2024-01-09     │
│ uuid-2      │ 0.72         │ 0.91         │ 2024-01-08     │
└─────────────┴──────────────┴──────────────┴────────────────┘
         ↑              ↑              ↑
    B-tree 索引    B-tree 索引    B-tree 索引
    (已存在)      (已存在)      (已存在)

当一个新的数值属性出现(比如 confidence_score),你只需通过 JSON Schema 的 x-ltbase-column 将它映射到 float_01。B-tree 索引已经存在。无需 DDL。

场景JSONB (PostgreSQL)JSON (MySQL)EAV + 热表
新字段需要范围查询CREATE INDEX (DDL)2× DDL (列 + 索引)仅元数据映射
新字段需要排序CREATE INDEX (DDL)2× DDL仅元数据映射
上线时间3-7 天3-7 天秒级

写放大:隐性成本

JSONB 将整个文档存储为单个二进制 blob。更新一个字段?PostgreSQL 重写整个 blob。

操作JSONBEAV
更新 50 字段记录中的 1 个字段重写约 4KB blob插入/更新 1 行(约 100 字节)
为 100 万条记录添加 embedding 向量100万 × 4KB = 写入 4GB100万 × 100B = 写入 100MB
写放大倍数40×

对于频繁进行部分更新的 AI 管道——添加 embedding、情感分数、提取的实体等增强任务——这会叠加成:

  • I/O 成本:云存储账单按写入字节计费
  • WAL 膨胀:PostgreSQL 的预写日志增长快 40 倍
  • 复制延迟:更多数据要同步到副本
  • Vacuum 压力:更多死元组需要清理

如果你的管道对数百万条记录进行小更新(embedding 增强的常见场景),JSONB 的写放大会成为一个隐形的预算杀手。


跨数据库可移植性

JSONB 将你锁定在 PostgreSQL 生态系统中。这比你想象的更重要。

企业环境中数据库多样性的现实:

能力PostgreSQLMySQL 8.0+Aurora DSQLCockroachDBSpanner
JSONB + GIN 索引部分支持
JSON 表达式索引有限
函数生成列
标准 EAV 表
类型列的 B-tree

EAV 是纯标准 SQL。 实体表、属性表、带类型列的值表——这种模式自 1980 年代以来在所有关系型数据库上都能工作。

有趣的是,许多云原生存储服务的底层就是基于类 EAV 模型构建的:

  • Amazon SimpleDB:键-属性-值结构
  • Azure Table Storage:实体-属性模型
  • Google Cloud Datastore:实体-属性-值设计

选择 EAV 使你的架构与这些平台保持一致,让未来的迁移更加顺滑。如果客户突然要求使用 MySQL(企业中很常见),或者你需要扩展到 CockroachDB,或者 AWS 发布了一个有吸引力的新数据库服务——你的存储层可以随之迁移。


OLTP/OLAP 分离策略

Forma 的架构不仅仅是"用 EAV 替代 JSONB"——它是一种深思熟虑的关注点分离:

┌─────────────────────────────────────────────────────────────┐
│  OLTP 侧(写入)              │  OLAP 侧(分析)             │
│  ────────────                │  ────────────               │
│  EAV + 热表                  │  DuckDB + Parquet           │
│  • 最大兼容性                 │  • 列式处理                  │
│  • 在任何 SQL 数据库上运行    │  • 复杂聚合                  │
│  • 零 DDL 写入               │  • Serverless 湖仓           │
└─────────────────────────────────────────────────────────────┘

如果你把所有赌注都押在 JSONB 上:

  • 你的写入只为 PostgreSQL 优化
  • 范围查询每个字段都需要 DDL
  • 数据库迁移 = 重写存储层

使用 EAV + DuckDB:

  • 写入在任何 SQL 数据库上工作(今天 PostgreSQL,明天 Aurora DSQL)
  • 范围查询使用预先存在的类型化索引
  • 重度分析卸载到 DuckDB 的列式引擎(详见第三篇

这种"宽进(EAV 写入)严出(Parquet/DuckDB 分析)"的策略比绑定到 PostgreSQL 特定功能更具弹性。


JSONB 适用的场景

公平地说,JSONB 在特定场景下确实更优:

  • 真正非结构化的数据:日志条目、你永远不会查询的原始 API 响应
  • 低频查询:仅用于展示、从不过滤/排序的数据
  • 单文档查找:按 ID 获取,返回整个 blob
  • 纯 PostgreSQL 环境:如果你确定永远不会迁移

Forma 的定位不是"JSONB 不好"——而是"了解每种工具的优势所在"。用 JSONB 存储不透明的数据块,用 EAV + 热表处理可查询的、不断演进的结构——这些结构需要能够跨数据库迁移,并支持无需 DDL 的高效范围查询。

AI 工作流的完整闭环

让我们把所有模块串起来,看一个完整的 AI 数据写入流程:

┌─────────────────────────────────────────────────────────────┐
│  1. AI 生成结构化数据                                         │
│     LLM 输出: {"contact_name": "张总", "budget": 500000, ...} │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│  2. JSON Schema 校验                                         │
│     - contact_name: string, minLength 1  ✓                  │
│     - budget: integer, minimum 0         ✓                  │
│     - sentiment: enum [positive/neutral/negative]  ✓        │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│  3. Forma 写入                                               │
│     - 热字段 → entity_main 表(contact_name → text_01)       │
│     - 全部字段 → EAV 表(保持灵活性)                          │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│  4. 查询优化                                                  │
│     - 过滤/排序 → 热表 + B-tree 索引(毫秒级)                  │
│     - 详情聚合 → EAV 表 + JSON_AGG(下一篇的优化)              │
└─────────────────────────────────────────────────────────────┘

整个流程的特点:

  • 零 DDL:新字段通过更新 JSON Schema 即时生效
  • 类型安全:AI 输出在写入前自动校验
  • 性能可控:热字段索引扫描 + 冷字段按需聚合

实战:JSON Schema 编译流程

当你创建或更新一个 Schema 时,Forma 在后台做了什么?

1. 解析与验证

输入: JSON Schema 定义
输出: 验证通过 / 错误信息(循环引用、类型冲突等)

2. attr_id 分配

每个属性获得一个 schema 内唯一的整型 ID:

contact_name → attr_id: 1
budget       → attr_id: 2
sentiment    → attr_id: 3

这样查询时用整型比较,而不是字符串匹配——更快,也避免了拼写错误。

3. 热表列映射

标记了 x-ltbase-column: "text_01" 的字段被分配到热表列:

contact_name (string)  → text_01
budget (integer)       → integer_01

展平(flatten)的字段映射会在更新 JSON Schema 时生成,这个产物被缓存起来,查询时直接使用,无需每次都解析 JSON Schema。

管理元数据税

EAV 的灵活性是有代价的:属性蔓延。如果没有纪律,你可能会产生数百个属性——有些是重复的,有些是拼写错误,有些是废弃的实验。

这就是批评者指出的"元数据税"。以下是 Forma 如何应对这个问题。

Schema 注册表

Forma 维护一个 schema_attributes 表,作为集中的注册表:

sql
-- 简化的 schema_attributes 结构
CREATE TABLE schema_attributes (
    schema_id       UUID NOT NULL,
    attr_id         INTEGER NOT NULL,
    attr_path       TEXT NOT NULL,        -- "contact.name", "budget_estimate"
    json_type       TEXT NOT NULL,        -- "string", "integer", "boolean"
    hot_column      TEXT,                 -- "text_01", "integer_01", 冷字段为 NULL
    created_at      TIMESTAMPTZ DEFAULT now(),
    last_used_at    TIMESTAMPTZ,          -- 用于识别过期属性
    usage_count     BIGINT DEFAULT 0,     -- 用于识别热门候选
    PRIMARY KEY (schema_id, attr_id)
);

这给你带来:

  • 重复检测:相同路径不能注册两次
  • 类型一致性budget 不能在一条记录中是 integer,在另一条中是 string
  • 使用追踪:知道哪些属性真正在被使用

防止属性蔓延

三种保持属性空间整洁的策略:

1. JSON Schema 严格模式

默认情况下,Forma 拒绝任何未在 schema 中声明的字段:

json
{
  "additionalProperties": false,  // 拒绝未声明的字段
  "properties": {
    "contact_name": { "type": "string" },
    "budget": { "type": "integer" }
  }
}

AI 输出了 sentment(拼写错误)?拒绝。强制管道对新字段进行显式声明。

2. 属性别名

当你发现重复时,可以在不迁移数据的情况下合并它们:

sql
-- "budget_estimate" 和 "estimated_budget" 都存在
-- 将它们指向同一个热列
UPDATE schema_attributes 
SET hot_column = 'integer_01' 
WHERE attr_path IN ('budget_estimate', 'estimated_budget');

3. 规划中的功能:Schema 管理 CLI

注意:以下 CLI 工具在 Forma 的路线图中,尚未上线。

我们正在构建一个管理界面来简化元数据管理:

bash
# 列出所有属性及使用统计
$ forma schema attributes list --schema crm_contacts
┌─────────────────────┬──────────┬────────────┬─────────────┬───────────────┐
 属性 类型 热列 使用次数 最后使用
├─────────────────────┼──────────┼────────────┼─────────────┼───────────────┤
 contact_name string text_01 1,234,567 2 分钟前
 budget_estimate integer integer_01 987,654 5 分钟前
 sentiment string text_02 543,210 1 小时前
 legacy_field_xyz string NULL 0 6 个月前 可删除候选
└─────────────────────┴──────────┴────────────┴─────────────┴───────────────┘

# 查找潜在重复
$ forma schema attributes duplicates --schema crm_contacts
发现潜在重复:
  - "budget_estimate" vs "estimated_budget"(87% 字符串相似度)
  - "contact_name" vs "contactName"(驼峰命名变体)

# 将冷属性提升为热属性
$ forma schema attributes promote sentiment --hot-column text_02
 属性 'sentiment' 已提升到热列 'text_02'
 回填任务已排队(预计时间:3 分钟,543,210 条记录)

在此 CLI 上线之前,你可以直接查询 schema_attributes 获取相同的洞察。

元数据税的底线

是的,EAV 要求你管理属性空间。但考虑一下替代方案:

方案添加新字段删除未使用字段查找重复
传统 SQLALTER TABLE + 迁移ALTER TABLE + 小心翼翼人工代码审查
JSONB直接写入字段永远不会真正"消失"grep 搜索 JSON blob
EAV + 注册表更新 JSON Schema查询 last_used_at查询 schema_attributes

EAV 配合合适的注册表不会消除元数据管理——它让元数据管理变得可查询、可自动化

总结:为什么 EAV 适合 AI 时代?

传统关系表Forma (EAV + 热表)
新字段需要 ALTER TABLE新字段即时生效
Schema 变更需要停服零停机
AI 输出需要人工适配JSON Schema 直接对接
索引设计需要提前规划热字段自动索引

EAV 模式曾被认为是"反模式",因为它牺牲了查询性能换取灵活性。但通过:

  1. 热表设计:把高频字段提升为物理列,恢复 B-tree 索引的速度
  2. JSON Schema:提供类型安全和 AI 集成能力
  3. 单查询优化:消除 N+1 问题(下一篇的内容)

我们让 EAV 同时拥有了灵活性性能

在 AI 时代,数据结构的变化速度远超传统软件开发周期。你的数据库要么适应这种速度,要么成为瓶颈。

EAV + JSON Schema 是我们找到的答案。

Forma 与 NoSQL 的对比

如果目标是灵活性,为什么不用 MongoDB 或 DynamoDB?以下是平衡的对比:

能力MongoDBDynamoDBForma (EAV + 热表)
Schema 灵活性✅ 优秀(无 Schema)✅ 优秀(无 Schema)✅ 优秀(JSON Schema)
范围查询✅ 好(有索引)⚠️ 有限(需要 GSI)✅ 好(热列 B-tree)
ACID 事务⚠️ 默认仅单文档⚠️ 有限(最多 25 项)✅ 完整 PostgreSQL ACID
JOIN 支持❌ 手动聚合❌ 无原生 JOIN✅ 完整 SQL JOIN
现有 SQL 生态❌ 需要新工具❌ 需要新工具✅ 标准 SQL,现有工具
大规模成本⚠️ 计算密集⚠️ RCU/WCU 可能飙升✅ 可预测(PostgreSQL + S3)
冷数据归档⚠️ 手动分片⚠️ TTL + 手动导出✅ 内置(DuckDB + Parquet)

NoSQL 胜出的场景:

  • 无关系查询的纯文档工作负载
  • 需要多区域写入的全球分布式应用(DynamoDB Global Tables)
  • 已经投入 MongoDB/DynamoDB 生态的团队

Forma 胜出的场景:

  • 需要关系 JOIN 的 AI 管道(数据增强、跨实体分析)
  • 已有 PostgreSQL 基础设施的团队
  • 混合 OLTP(实时)和 OLAP(分析)查询的工作负载
  • 对冷数据存储成本敏感(S3 + Parquet vs. MongoDB Atlas 归档)

预告:可以实现什么

在进入下一篇之前,先预告一下第二篇中介绍的性能提升:

指标优化前优化后
数据库往返次数101 次1 次
延迟(100 条记录)1000ms25ms
提升幅度97%

这不是理论值。这是使用 PostgreSQL 的 CTE + JSON_AGG 的生产系统的真实数据——这些功能从 9.4 版本就存在了,但一直被严重低估。

第二篇会展示具体如何实现,附带可以直接复制粘贴的 SQL。

下一步:解决 EAV 的性能问题

这篇文章介绍了 EAV + JSON Schema + 热表的架构选型。但 EAV 有一个众所周知的问题:N+1 查询

下一篇文章将展示我们如何用 PostgreSQL 的 CTE + JSON_AGG 解决这个问题,把查询次数从 101 次降到 1 次,延迟从 1 秒降到 25 毫秒。

而当历史数据积累到亿级,PostgreSQL 单机扛不住时,第三篇将介绍我们如何用 DuckDB + CDC + Parquet 构建 Serverless 湖仓架构——以及最关键的,如何解决大家对"Lakehouse 读脏数据"的信任危机。

系列导航

本文基于 Forma 项目的工程实践。Forma 是一个为 AI 时代设计的灵活数据存储引擎。