Skip to content

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

Forma 工程博客 · 系列第一篇

TL;DR

模型今天多学会一个字段,AI 管道不该在凌晨三点崩掉。多接受一个新属性,也不该先去提一张 DBA 工单。至于下游模型,更不该读到迁移中途的半成品数据,然后一本正经地编出结果。

EAV、JSON 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 已经成为大语言模型(LLM)结构化输出的事实标准。OpenAI Structured Outputs 用它定义函数返回格式,Anthropic Tool Use 用它描述工具参数,Google Gemini Function Declarations 同样基于它。

所以当你用 JSON Schema 定义数据结构时,你同时定义了三样东西:LLM 该返回什么结构、写入前要检查的类型、格式和范围、以及 Forma 用来组织存储的数据库 Schema。一个定义,三个用途。

一个 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 靠类型化存储绕开它:列是预分配的,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、情感分数、提取的实体这类增强任务。40 倍的写放大会叠加成几笔账:云存储账单按写入字节计费,PostgreSQL 的预写日志增长快 40 倍,更多数据要同步到副本,更多死元组等着 vacuum 清理。如果你的管道对数百万条记录做小更新(embedding 增强的常见场景),这会成为一个隐形的预算杀手。


跨数据库可移植性

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(下一篇的优化)              │
└─────────────────────────────────────────────────────────────┘

新字段通过更新 JSON Schema 即时生效,不需要 DDL;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;使用追踪则告诉你哪些属性真的有人在读。

防止属性蔓延

有三种策略可以保持属性空间整洁。

第一道防线是 JSON Schema 严格模式。默认情况下,Forma 拒绝任何未在 schema 中声明的字段:

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

AI 输出了 sentment(拼写错误)?拒绝。管道必须对新字段做显式声明。

重复的属性可以用别名合并,不用动数据:

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

剩下的交给 Schema 管理 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 模式曾被认为是"反模式",因为它牺牲了查询性能换取灵活性。有三件事改变了这个取舍:热表设计把高频字段提升为物理列,恢复了 B-tree 索引的速度;JSON Schema 提供类型安全和 AI 集成能力;单查询优化消除了 N+1 问题(下一篇的内容)。这样一来,EAV 同时拥有了灵活性和性能。

在 AI 时代,数据结构的变化速度远超传统软件开发周期。EAV 加 JSON Schema 让 schema 变更不必再走停机和 DDL 审批,类型校验也留在了写入路径上。

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 对比 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 时代设计的灵活数据存储引擎。