属性越来越多,不想开发 —— EAV 异构优化

需求背景

EAV 模型引入之后,扩展属性的存储问题解决了。但随着搜索标签越来越多,一个新的痛点开始显现:每次新增一个扩展属性,都需要写代码、上线

问题现状

典型的开发流程是这样的:

产品提需求:新增"字数"搜索属性
  ↓
开发写异构代码:把 EAV 表里的 word_count 字段同步到 ES
  ↓
开发写查询接口:支持按字数范围搜索
  ↓
代码审查 → 上线 → 等待发布窗口
  ↓
总耗时:2-3 天

问题不在于单次开发量大,而在于每次都要重复这个流程。EAV 模型的灵活性让扩展属性的种类越来越多,但每次新增都要走一遍完整的开发上线流程,上线等待的时间往往比开发时间还长。

核心矛盾

EAV 表里的数据结构是统一的(entity_id + attribute_key + attribute_value),但异构到 ES 时,每个属性的处理逻辑却是分散的——不同属性有不同的类型,需要写不同的代码。

能不能把这个过程也配置化,让新增扩展属性不再需要写代码?

方案设计

问题的根源在于 ES 是类型敏感的:同一个字段,存字符串和存整数的 mapping 不一样,查询方式也不一样。所以之前每次新增属性都要手动处理类型转换。

解决思路是:改造 ES 文档结构,用 nested 类型统一存储所有扩展属性,通过不同的字段名区分不同的值类型

新的 ES 文档结构

{
  "mappings": {
    "extension_attribute": {
      "type": "nested",
      "properties": {
        "key":       { "type": "keyword" },
        "strValue":  { "type": "keyword" },
        "intValue":  { "type": "integer" },
        "longValue": { "type": "long" },
        "doubleValue":{ "type": "double" },
        "floatValue": { "type": "float" },
        "dateValue":  { "type": "date", "format": "yyyy-MM-dd HH:mm:ss" }
      }
    }
  }
}

每个扩展属性存为一个 nested 对象,key 是属性名,值根据类型存入对应的字段(strValueintValue 等)。

配置化异构

维护一张元数据配置表,记录每个扩展属性的类型信息:

create table extension_attribute_config (
  attribute_key   varchar(255) primary key comment '属性键',
  attribute_name  varchar(255) comment '属性名称',
  attribute_type  varchar(32) comment '属性类型',
  is_searchable   boolean comment '是否可搜索',
  display_order   int comment '展示顺序'
)

异构逻辑变成通用的:

public List<ExtensionAttribute> buildExtensionAttributes(Long bookId) {
    List<BookExtensionAttribute> eavList = eavRepository.findByBookId(bookId);

    return eavList.stream().map(eav -> {
        ExtensionAttribute attr = new ExtensionAttribute();
        attr.setKey(eav.getAttributeKey());

        switch (eav.getAttributeType()) {
            case "Integer": attr.setIntValue(Integer.parseInt(eav.getAttributeValue())); break;
            case "Long":    attr.setLongValue(Long.parseLong(eav.getAttributeValue())); break;
            case "Double":  attr.setDoubleValue(Double.parseDouble(eav.getAttributeValue())); break;
            case "String":  attr.setStrValue(eav.getAttributeValue()); break;
            case "Date":    attr.setDateValue(parseDate(eav.getAttributeValue())); break;
        }
        return attr;
    }).collect(Collectors.toList());
}

这段代码是通用的,不需要针对每个属性单独写。新增一个扩展属性,只需要在 extension_attribute_config 里插入一条配置记录,异构逻辑自动生效。

查询方式

扩展属性的查询改用 ES nested 查询:

{
  "query": {
    "nested": {
      "path": "extension_attribute",
      "query": {
        "bool": {
          "must": [
            { "term": { "extension_attribute.key": "author_dynasty" } },
            { "term": { "extension_attribute.strValue": "唐朝" } }
          ]
        }
      }
    }
  }
}

根据属性类型不同,值字段也不同:String/Date 用 strValue,Integer 用 intValue,以此类推。

性能影响

Nested 查询比普通字段查询略慢,性能上会有轻微损耗。不过在长期的对标签指标的监控发现,稀疏属性相关的标签,普遍都是非热点搜索项,相较于书名,作者,品类,评分这些热点搜索项,这些非热点搜索项的相关查询性能略有下降完全可以接受。

当然,并非所有的 EAV 我们都一股脑塞到这个 nested 字段里面,当然可以做一些特殊配置化,让一些虽然稀疏但热点的字段作为 ES 字段查询

成本对比

优化前(新增"字数"属性):写异构代码 + 写查询接口 + 代码审查 + 上线,耗时 2-3 天

优化后:插入一条元数据配置,耗时 30 分钟,无需上线。

维度优化前优化后
新增属性周期2-3 天30 分钟
代码上线必须无需
性能影响部分场景下较慢,可接受

这一步解决了什么

从此以后,新增扩展属性不需要写代码了。EAV 模型的灵活性终于被完整地释放出来。

但系统的熵增并没有停止。标签嵌套规则的需求越来越多,硬编码的代码越堆越高,下一个大问题已经在路上了。

这引出了下一篇:标签嵌套规则的挑战,以及系统走向大迭代的转折点。