为什么 EAV 是 AI 时代最被低估的数据模型
Forma 工程博客 · 系列第一篇
TL;DR
模型今天多学会一个字段,AI 管道不该在凌晨三点崩掉。多接受一个新属性,也不该先去提一张 DBA 工单。至于下游模型,更不该读到迁移中途的半成品数据,然后一本正经地编出结果。
EAV、JSON 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 已经成为大语言模型(LLM)结构化输出的事实标准。OpenAI Structured Outputs 用它定义函数返回格式,Anthropic Tool Use 用它描述工具参数,Google Gemini Function Declarations 同样基于它。
所以当你用 JSON Schema 定义数据结构时,你同时定义了三样东西:LLM 该返回什么结构、写入前要检查的类型、格式和范围、以及 Forma 用来组织存储的数据库 Schema。一个定义,三个用途。
一个 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 靠类型化存储绕开它:列是预分配的,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、情感分数、提取的实体这类增强任务。40 倍的写放大会叠加成几笔账:云存储账单按写入字节计费,PostgreSQL 的预写日志增长快 40 倍,更多数据要同步到副本,更多死元组等着 vacuum 清理。如果你的管道对数百万条记录做小更新(embedding 增强的常见场景),这会成为一个隐形的预算杀手。
跨数据库可移植性
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(下一篇的优化) │
└─────────────────────────────────────────────────────────────┘新字段通过更新 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 表,作为集中的注册表:
-- 简化的 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 中声明的字段:
{
"additionalProperties": false, // 拒绝未声明的字段
"properties": {
"contact_name": { "type": "string" },
"budget": { "type": "integer" }
}
}AI 输出了 sentment(拼写错误)?拒绝。管道必须对新字段做显式声明。
重复的属性可以用别名合并,不用动数据:
-- "budget_estimate" 和 "estimated_budget" 都存在
-- 将它们指向同一个热列
UPDATE schema_attributes
SET hot_column = 'integer_01'
WHERE attr_path IN ('budget_estimate', 'estimated_budget');剩下的交给 Schema 管理 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 让 schema 变更不必再走停机和 DDL 审批,类型校验也留在了写入路径上。
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 对比 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 时代设计的灵活数据存储引擎。