为什么 EAV 是 AI 时代最被低估的数据模型
Forma 工程博客 · 系列第一篇
TL;DR
你的 AI 管道不应该因为模型学会了一个新字段就在凌晨三点崩溃。你的数据层不应该需要提 DBA 工单才能接受新属性。你的下游模型不应该因为读到了迁移中的半成品数据而产生幻觉。
Forma 的 EAV + JSON Schema + 热表 组合解决了这三个问题:
- 即时 Schema 演进:新字段秒级生效,而不是等待数天
- 类型安全校验:坏数据在污染训练集之前就被拒绝
- 性能不崩盘:热字段有 B-tree 索引,冷字段保持灵活
这篇文章解释为什么这种"老派"数据模型,反而是 AI 时代最实用的选择。
从一个真实场景说起
想象你在做一个 AI 驱动的 CRM 系统。用户对着麦克风说:
"帮我记录一下,刚才和张总通了电话,他对我们的新方案很感兴趣,预算大概 50 万,下周二再约。"
你的 AI Agent 把这段话转成结构化数据:
{
"contact_name": "张总",
"interaction_type": "phone_call",
"sentiment": "positive",
"budget_estimate": 500000,
"next_followup": "2024-01-16",
"notes": "对新方案感兴趣"
}现在问题来了:你的数据库能接这个数据吗?
如果你用传统的关系表:
- 表里没有
sentiment字段?停服,ALTER TABLE ADD COLUMN - 新客户需要
industry字段?再停一次 - 不同客户需要不同的自定义字段?每个客户一张表?
这显然不现实。根据我们对 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 定义数据结构时,你同时定义了:
- AI 的输出格式:LLM 知道该返回什么结构
- 校验规则:写入前自动检查类型、格式、范围
- 数据库 Schema:Forma 直接用它来组织存储
一个定义,三个用途。
一个 JSON Schema 的例子
{
"$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_name、created_at、budget_estimate、status。而 notes、custom_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 索引擅长包含查询:
-- PostgreSQL: "找出 tags 包含 'urgent' 的记录"
SELECT * FROM records WHERE data @> '{"tags": ["urgent"]}'; -- ✅ GIN 效果很好但在范围查询上它完全失效——而范围查询恰恰是业务分析的核心:
-- 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:
-- PostgreSQL: 每个需要范围查询的字段都需要 DDL
CREATE INDEX idx_confidence ON records ((data->>'confidence_score')::float);
CREATE INDEX idx_timestamp ON records ((data->>'timestamp')::timestamptz);MySQL 呢?更加受限:
-- 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。
| 操作 | JSONB | EAV |
|---|---|---|
| 更新 50 字段记录中的 1 个字段 | 重写约 4KB blob | 插入/更新 1 行(约 100 字节) |
| 为 100 万条记录添加 embedding 向量 | 100万 × 4KB = 写入 4GB | 100万 × 100B = 写入 100MB |
| 写放大倍数 | 40× | 1× |
对于频繁进行部分更新的 AI 管道——添加 embedding、情感分数、提取的实体等增强任务——这会叠加成:
- I/O 成本:云存储账单按写入字节计费
- WAL 膨胀:PostgreSQL 的预写日志增长快 40 倍
- 复制延迟:更多数据要同步到副本
- Vacuum 压力:更多死元组需要清理
如果你的管道对数百万条记录进行小更新(embedding 增强的常见场景),JSONB 的写放大会成为一个隐形的预算杀手。
跨数据库可移植性
JSONB 将你锁定在 PostgreSQL 生态系统中。这比你想象的更重要。
企业环境中数据库多样性的现实:
| 能力 | PostgreSQL | MySQL 8.0+ | Aurora DSQL | CockroachDB | Spanner |
|---|---|---|---|---|---|
| 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 表,作为集中的注册表:
-- 简化的 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 中声明的字段:
{
"additionalProperties": false, // 拒绝未声明的字段
"properties": {
"contact_name": { "type": "string" },
"budget": { "type": "integer" }
}
}AI 输出了 sentment(拼写错误)?拒绝。强制管道对新字段进行显式声明。
2. 属性别名
当你发现重复时,可以在不迁移数据的情况下合并它们:
-- "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 的路线图中,尚未上线。
我们正在构建一个管理界面来简化元数据管理:
# 列出所有属性及使用统计
$ 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 要求你管理属性空间。但考虑一下替代方案:
| 方案 | 添加新字段 | 删除未使用字段 | 查找重复 |
|---|---|---|---|
| 传统 SQL | ALTER 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 是我们找到的答案。
Forma 与 NoSQL 的对比
如果目标是灵活性,为什么不用 MongoDB 或 DynamoDB?以下是平衡的对比:
| 能力 | MongoDB | DynamoDB | Forma (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 条记录) | 1000ms | 25ms |
| 提升幅度 | — | 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 读脏数据"的信任危机。
系列导航
- [第一篇] 为什么 EAV 是 AI 时代最被低估的数据模型 ← 当前
- [第二篇] 杀死 N+1:一次 SQL 优化如何让延迟从 1 秒降到 25 毫秒
- [第三篇] 零脏读的 Serverless 湖仓:我们如何用 DuckDB 解决一致性难题(完结)
本文基于 Forma 项目的工程实践。Forma 是一个为 AI 时代设计的灵活数据存储引擎。