# 使用 Eav 模型构建可无限扩展的数据存储能力

# 举个🌰

假设要做一个电商的商品管理，我们先卖一些衣服，需要管理衣服的*尺码*、*颜色*、*款式*等信息，有一天需要卖电脑了，电脑需要 *主板*、*CPU*、*显卡*、*内存*、*硬盘*、*散热* 等信息，过几天又需要卖手机了，手机有 *颜色*、*版本*、*存储容量*、*套餐类型*等等信息，数据库如何设计。


## 方案一：新增字段

每次新增商品，需要支持不同的信息的话就不停的加字段。

|ID|Name|尺码|颜色|款式|主板|CPU|显卡|内存|存储|散热|版本|套餐类型|
|--|------|-----|----|----|-----|----|----|-----|----|----|-----|--------|
|1|T恤|M|白色|喜羊羊|NULL|NULL|NULL|NULL|NULL|NULL|NULL|NULL|
|2|外星人电脑|NULL|NULL|NULL|A|i99|RTX8090|32G|2T|水冷|NULL|键鼠套装|
|3|香蕉手机|NULL|五彩斑斓黑|NULL|NULL|晓龙999|NULL|12G|512G|风扇|Pro Plus MAX|碎屏险套餐|

这样会造成以下问题：
1. 实现成本高，每次添加商品都需要进行前后端开发、调试，浪费时间和人力。
2. 需要动态变更表结构，在生产环境中安全性太低。
3. 浪费资源，数据库的字段可能会越来越多，而很多字段大部分商品都是不需要的，需要设置为`NULL`，导致内存大量浪费。
4. 扩展能力一般，有上限。

## 方案二：预留字段

给对应的表定义几个预留字段，然后这些预留字段在不同的商品可以重复使用。这样的话可以解决一部分的开发问题，大部分情况不需要开发，直接复用现有字段就可以。

|ID|Name|Ext1|Ext2|Ext3|Ext4|Ext5|Ext6|Ext7|
|--|------|-----|----|----|-----|----|-----|-----|
|1|T恤|M|白色|喜羊羊|NULL|NULL|NULL|NULL|
|2|外星人电脑|A|i99|RTX8090|32G|2T|水冷|键鼠套装|
|3|香蕉手机|五彩斑斓黑|晓龙999|12G|512G|风扇|Pro Plus MAX|碎屏险套餐|

但是这样也有很多问题：
1. 字段一样，但是含义不一样，需要前端做大量适配。
2. 字段的类型可能不一样，预留字段还得考虑不同的类型。
3. 预留字段太少了作用有限，太多了和新增字段一样会有存储和性能问题。
4. 扩展字段是公用的，不能根据字段名顾名思义，得在启用时维护对应关系，使用时查找对应关系。
5. 扩展字段的数量无法精确定义。

## 方案三：JSON_OBJECT

这种情况下有一种简单的解决方案，就是设计一个 extra 字段，类型为 JSON，然后把 尺码、颜色、款色、主板、CPU、显卡、内存等等都放到 JSON 里。

<table>
    <thead>
        <tr>
            <td>ID</td>
            <td>Name</td>
            <td>Ext1</td></tr>
    </thead>
    <tbody>
        <tr>
            <td>1</td>
            <td>T恤</td>
            <td>
                ```
                {
                  "尺码": "M",
                  "颜色": "白色",
                  "款式": "喜羊羊"
                }
                ```
            </td>
        </tr>
        <tr>
            <td>2</td>
            <td>外星人电脑</td>
            <td>
                ```
                {
                  "主板": "A",
                  "CPU": "i9 11900",
                  "显卡": "RTX8090",
                  "内存": "32G",
                  "存储": "2T",
                  "散热": "水冷",
                  "套餐类型": "键鼠套装"
                }
                ```
            </td>
        </tr>
        <tr>
        <td>3</td>
        <td>香蕉手机</td>
        <td>
                ```
                {
                  "颜色": "五彩斑斓黑",
                  "CPU": "骁龙999",
                  "版本": "Pro Plus MAX", 
                  "内存": "12G", 
                  "存储": "512G",
                  "散热": "风扇",
                  "套餐类型": "碎屏险套餐"
                }
                ```
        </td>
        </tr>
    </tbody>
</table>

但是 JSON 有以下几个问题：
1. JSON 数据仅仅只能用于展示，如果用于条件查询、数据更新其效率是很低的。查询时需要遍历表解析 JSON。
2. 虽然 MySQL 支持了 JSON 类型，但 MySQL 作为关系型数据库，对标准化的 column-per-value 支持更好，包括数据类型限制、长度限制，唯一索引限制，查询索引优化，外键关联，关联查询支持，运算支持等，这些都是 JSON 中 Key 无法达到的。
3. 将常用的查询字段从 JSON 数据中剥离出来形成单独的字段，虽然可以改善查询问题，但需要有先见之明，如果后期进行剥离就会涉及代码修改和数据迁移，遇到多版本的话，还可能出现数据冗余的问题，处理不好还会出现数据不一致问题，并不仅仅这么简单，一定慎用。
4. 大 JSON 的解析性能较差。
5. 每条数据都需要同时保存 Key 和 Value，对于中文数据，纯 JSON 太占空间了。
基于以上几个原因，在一些复杂的情况下，不建议使用 JSON 存数据。
以上三种都不是理想的解决方案，后续经过不断的经验积累，提出来了 EAV 模型，可以在一定程度上解决以上问题。

# 什么是 EAV 模型

**E**ntity-**A**ttribute-**V**alue ([wiki](https://en.wikipedia.org/wiki/Entity%E2%80%93attribute%E2%80%93value_model))是一种数据模型，用于以节省空间的方式对实体进行编码。
- Entity：实体，代表一个业务对象，比如上面的例子里的商品。
- Attribute：对象的属性，属性并不是作为实体单独的一列来进行存放，而是存储在一组单独的数据库表中。
- Value：指特定属性所关联的值。

## 几个概念

### 稀疏属性（Sparseness of Attributes）

在数学和计算机科学中，如果一个对象仅包含大量潜在属性中的几个属性，称之为“[稀疏矩阵](https://zh.wikipedia.org/wiki/%E7%A8%80%E7%96%8F%E7%9F%A9%E9%98%B5)”。在讨论EAV 时，采用“稀疏”来描述大多数无值的属性。如上文中的主板、CPU、显卡等属性。

### 行模型（Row Modeling）

基于行模型的表，其描述实体的数据记录为多行，每组新的数据在数据库中存为额外的行而非额外的列。行模型是数据库设计的标准数据建模技术，它仅适用于满足如下两个条件的情况：
- 特定实体的数据是稀疏的。
- 数据是经常变动的。

行模型是不适用于稀疏且数据非波动的情形，此时，应采用传统的列模型。

## EAV 与行模型

EAV 模型是行模型的泛化。行模型的表的数据是均匀的，这意味着整个数据库所有类型的数据存储在一张表中；此外，行模型表中值列的数据类型是预先确定的。而在 EAV表中，特定行其值的数据类型由对应的属性确定。

选择数据模型的最佳方法是很难的，但作为一个准则，如满足如下条件时使用EAV模型而非行模型：
- 数据记录中的单个属性的数据类型不同；采用行模型时一张表中很难存储不同类型的值。
- 需要表示多种类型的数据，其数量可能出现波动。与此同时，即使不稀疏的属性，然每类数据都非常少。这种情况下，传统的数据模型将使成百的表却只有几行数据。
- 在一定的环境中，其属性必须动态创建，某些类在原型随后的周期中常常会被省略。
- 某些实体有混合型的属性，这意味着一些属性是非稀疏的，而其他属性则是高度稀疏的。在这种情况下，非稀疏属性存储在传统表中，而稀疏的属性存储在EAV或行建模的格式。

# 表结构设计

## 方案一：行模型

先来一个简单的方案，使用行建模的方式。商品作为实体存入 Goods 表中，其余的属性和值存入商品属性表 Attribute 中。

**商品表（Goods）**

|GoodsID|Name|
|--------|-------|
| 1 |  T恤 |
| 2 |外星人电脑|
| 3 | 香蕉手机 |


**商品属性表（Attribute）**


|GoodsID|Attribute|Value|
|--------|-------|-------|
|1|尺码|M|
|1|颜色|白色|
|1|款式|喜羊羊|
|2|主板|A|
|2|CPU|i99|
|2|显卡|RTX8090|
|2|内存|32G|
|2|存储|2T|
|2|散热|水冷|
|2|套餐类型|键鼠套装|
|3|颜色|五彩斑斓黑|
|3|CPU|骁龙999|
|3|内存|12G|
|3|存储|512G|
|3|散热|风扇|
|3|版本|Pro Plus MAX|
|3|套餐类型|碎屏险套餐|

上面这个方案是行模型的方案，通过这种方式我们可以无限的扩展商品的属性。这种方案比较适用于每个实体的属性都不固定的情况。

但是这种方案有以下几个问题：
1. 每条数据都需要同时保存 Key 和 Value，对于中文数据，Key 有限，行无限的情况下，会浪费大量空间，性能也比较差。
2. Value 没有类型限制，都是 VARCHAR 的，对数据库不友好，会导致内存浪费，而且存取都需要进行数据格式转换。对存储为字符串的值创建的索引不允许针对数值型和日期型的搜索范围优化，这是采用混合数据类型的键-值对描述数据的公共问题。

## 方案二：简单 EAV

针对方案一的第一个问题进行优化。

商品表保持不变。商品属性表拆分为属性表和值表。

**属性表（Attribute）**

|AttributeID|Name|
|--------|-------|
|1|颜色|
|2|尺码|
|3|款式|
|4|主板|
|5|CPU|
|6|显卡|
|7|内存|
|8|存储|
|9|散热|
|10|版本|
|11|套餐类型|

**值表（Value）**

|GoodsID|AttributeID|Value|
|--------|-------|-------|
|1|2|M|
|1|1|白色|
|1|3|喜羊羊|
|2|4|A|
|2|5|i99|
|2|6|RTX8090|
|2|7|32G|
|2|8|2T|
|2|9|水冷|
|2|11|键鼠套装|
|3|1|五彩斑斓黑|
|3|5|骁龙999|
|3|7|12G|
|3|8|512G|
|3|9|风扇|
|3|10|Pro Plus MAX|
|3|11|碎屏险套餐|

这样属性都保存在属性表里，每个属性值都保存在值表里，同时与商品表和属性表做关联，这样就可以大大节省内存。

## 方案三：优化 EAV

为了解决方案一的第二个问题，则需要对值表基于数据类型进行分割，每个不同的数据类型拆为一个单独的表，同时通过 属性表（Attribute） 添加 类型决定去哪里存取数据。

**属性表（Attribute）**

|AttributeID|Name|Type|
|--------|-------|-------|
|1|颜色|VARCHAR|
|2|尺码|INT|
|3|款式|INT|
|4|主板|VARCHAR|
|5|CPU|INT|
|6|显卡|INT|
|7|内存|INT|
|8|存储|INT|
|9|散热|VARCHAR|
|10|版本|TEXT|
|11|套餐类型|VARCHAR|

**值表**

eav_int

|GoodsID|AttributeID|Value(INT)|
|--------|-------|-------|
|1|2|2(M)|
|1|3|1(喜羊羊)|
|2|5|99(i99)|
|2|6|8090(RTX8090)|
|2|7|32(32G)|
|2|8|2048(2T)|
|3|5|999(骁龙999)|
|3|7|12(12G)|
|3|8|512(512G)|

eav_varchar

|GoodsID|AttributeID|Value(VARCHAR)|
|--------|-------|-------|
|1|1|白色|
|2|4|A|
|3|1|五彩斑斓黑|
|3|9|风扇|
|3|11|碎屏险套餐|
|2|11|键鼠套装|

eav_text

|GoodsID|AttributeID|Value(TEXT)|
|--------|-------|-------|
|3|10|Pro Plus MAX|

这种就是比较标准的 EAV 模型。解决了方案一的两个问题。

# EAV 的优/缺点

## 优点

EAV模型的主要优点是其灵活性。属性描述表不限制数量，这意味着每次新增属性不需要重新设计数据结构；扩展数据库时，属性的数量可以垂直增加，而无需改变数据结构。

EAV只处理非空属性，不需要为空值保留额外的存储空间。这使得EAV模型相当节省空间。

物理数据格式是非常干净，类似于JSON/XML，很容易将数据映射为JSON/XML格式。

EAV模型可以极好地迅速扩展应用，因为它可以防止(属性)不断变化的后果。可以简单地记录任何结构的新数据，而不需要修改任何数据结构。

## 缺点

当考虑EAV时，确定数据是否稀疏和数据量非常重要，因为采用不恰当的数据集时，EAV设计的复杂性超过了其优势所在。相对静态或简单数据选用传统的表结构更为合适。

相较于传统的数据结构，EAV的一个主要缺点是它在检索大容量数据时效率较低。在EAV模型中，数据更加分散，所以查询一个完整实体的记录需要多个表JOIN查询。更重要的是，当EAV模型应用于大数据量时，对于同一组EAV建模的数据描述，需要短暂或永久地在进行矩阵转积处理(Pivoting)(行列转换)。该操作易于出错且是CPU密集型的任务。

EAV模型的另一个局限性，需要制定额外的逻辑来完成传统数据结构下自动进行的事务。但是，利用现有的EAV工具可以降低此类工作的成本。

最后，理解EAV模型确实需要时间。它有一个明确的学习曲线，使的初级开发人员在真正理解其概念前，需要为此付出更多的精力。

# 结论

应用 EAV  模型时，应考虑以下条件：
- 数据是稀疏的、异构的，一个实体的属性范围较广，且常引入新的属性。
- 数据量非常大，有许多不同类型的数据，即使属性是非稀疏的。
- 有许多混合属性，既具有稀疏也具有非稀疏属性。通常情况下，并不是所有的数据类满足EAV建模的要求。

在实际在生产环境中，往往采用混合模式（mixed schema），包括传统的关系、EAV或合适的混合方法。

# Magento 2数据库EAV模型结构

Magento是一个用PHP程序语言编写的开源电子商务平台；其主要面向企业级应用，可处理各方面的需求，号称世界排名第一的电子商务系统。据统计，全球电子商务网站中使用magento搭建的占比高达31.4%！

![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1651835010001/Nx-NtQ9Fw.png align="left")

## Table

- `eav_entity`：(E)实体表。
- `eav_attribute`：(A)属性表。
- `eav_entity_{type}`：(V)值表。
- `eav_entity_attribute`：所有实体的属性。 
- `eav_entity_type`：类型实体表，可以支持多个实体，所以需要区分实体类型。
- `eav_attribute_set`：属性集合，可以把一种实体的一些属性聚合起来。

## 值表类型

- `int`：对应mysql字段的int类型。
- `varchar`：对应mysql字段的varchar类型。
- `text`：对应mysql字段的text类型。
- `decimal`：对应mysql字段的decimal类型。
- `datetime`：对应mysql字段的datetime类型。

## 一些字段含义

- `entity_id`：产品的唯一标识符。
- `attribute_id`：它是每个属性的唯一标识符，也是此表的主键。
- `value`：属性的值。
- `value_id`：值表唯一标识符和主键。
- `entity_type_id`：实体表支持不同的实体类型，如商品、客户、订单等等，通过该字段进行区分。
- `attribute_set_id`：产品属性可在本地组成不同的属性集(attribute set)，属性集让产品结构具备更强的灵活性，因为各产品不必去使用所有已定义的属性。
- `type_id`：产品类型，每种类型都有不同的设置与功能 。
- `sku`：Stock Keeping Unit (SKU)可以是一串数字或字母的组合，用于唯一标识网站上所销售的产品，由用户来进行定义。
- `has_options`：用于标识产品是否具有自定义选项(如下拉列表和文本框等)。
- `required_options`：用于标识自定义项是否为必填或必选项。
- `entity_type_id`：此项将属性与特定的EAV模型类型进行关联。
- `attribute_code`：此项为属性的名称或键名，用于生成魔术方法的getter和setter方法。
- `backend_model`：后台模型用于向数据库中加载或存储数据的管理。
- `backend_type`：该项指定属性是否不是保存在默认的EAV表中而是保存在一个特定的表中。
- `frontend_model`：前端模型负责属性元素在浏览器上的输出效果。
- `frontend_input`：与前端模型相似，前端输入指定在浏览器上输入项目的类型。
- `frontend_label`：此项是属性在浏览器上输出的标签或名称。
- `source_model`：源模型用于向一个属性传递可能存在的值，Magento自带有一些预定义的源模型，如国家，yes或no值，区域等等。

# 参考

1. [Understanding the EAV data model and when to use it](https://inviqa.com/blog/understanding-eav-data-model-and-when-use-it)
2. [实体-属性-值(EAV)模型 | Alan Hou的个人博客](https://alanhou.org/entity-attribute-value-model/)
3. [自定义字段的几种实现方式](https://whoosy.cn/2020/04/11/web%E5%BC%80%E5%8F%91/%E8%87%AA%E5%AE%9A%E4%B9%89%E5%AD%97%E6%AE%B5%E7%9A%84%E5%87%A0%E7%A7%8D%E5%AE%9E%E7%8E%B0%E6%96%B9%E5%BC%8F/)






