unsetunset1、前言unsetunset
最近刚结束了一个电商搜索项目的重构工作,回想起这几个月的折腾,真是五味杂陈。
项目初期我们选择了最简单粗暴的方案——把所有商品属性都拍平存储,结果在复杂查询场景下各种翻车。
后来痛定思痛,决定引入嵌套类型来解决问题,这才算是把这个技术债给还清了。
今天就把这次项目的经验总结分享给大家,希望能帮到遇到类似问题的朋友们。
说实话,网上关于嵌套类型的文章不少,但大多数都是理论居多,真正结合业务场景深入分析的不多。
我这篇文章会尽量从实际项目角度出发,把遇到的坑和解决方案都详细说一遍。
unsetunset2、问题的由来unsetunset
问题要从去年说起。我们的电商平台原本用的是 MySQL 存储商品数据,随着业务增长,商品数量突破了百万级别,传统的like查询已经扛不住了。老板拍板要上 Elasticsearch,我们这帮后端开发就开始了迁移之路。
最初的设计很朴素,直接把 MySQL 中的数据结构平移到 ES 中。商品表、规格表、价格表,该怎么设计还怎么设计。
但 ES 毕竟不是关系型数据库,我们很快就发现了问题。 举个具体例子,我们有一个T恤商品,有红色L号售价99元、蓝色M号售价89元两个规格。用传统的对象数组存储是这样的:
{
"product_name": "经典圆领T恤",
"variants": [
{
"color": "红色",
"size": "L",
"price": 99
},
{
"color": "蓝色",
"size": "M",
"price": 89
}
]
}
看起来没什么问题对吧?但当我们想查询"红色且L号" 的商品时,ES 会把 variants 数组中的所有字段都拍平处理,变成:color: ["红色", "蓝色"],size: ["L", "M"],price: [99, 89]。
这样一来,查询"红色 M 号"也会匹配到这个商品,因为红色在 color 数组里,M 号在 size 数组里,但实际上我们根本没有红色 M 号的规格。
这个问题在项目初期就暴露了,但当时业务压力大,我们就用了一些还算凑活的方案,比如把所有属性组合拼接成字符串存储。这种方案虽然能解决查询准确性问题,但维护成本极高,而且不利于聚合分析。
真正让我们下定决心重构的是一次线上事故。用户搜索"红色连衣裙"结果出现了大量蓝色商品,客服电话被打爆。
事后分析才发现,是因为某些商品同时有红色和蓝色规格,被我们的拍平逻辑错误匹配了。
unsetunset3、深入分析问题本质unsetunset
事故复盘会上,技术总监提了一个很关键的问题:我们到底要解决什么问题?经过梳理,发现我们面临的核心挑战是如何在 ES 中保持数据的关联关系。
传统关系型数据库通过外键和 join 操作来维护数据关系,但 ES 作为一个搜索引擎,天然不支持复杂的关联查询。当我们把一对多的数据关系简单地用数组来表示时,就丢失了对象之间的边界信息。
更深层次的问题是,我们对ES的理解还停留在"NoSQL数据库"的层面,没有真正从搜索引擎的角度来思考数据建模。ES的强项是全文搜索和聚合分析,而不是复杂的关系查询。我们需要找到一种方式,既能利用ES的搜索优势,又能保持数据的结构完整性。
经过调研,我们发现嵌套类型(Nested Type)就是为了解决这个问题而生的。
它允许 ES 将数组中的每个对象作为独立的文档来索引,同时保持它们与父文档的关联关系。这样既避免了字段值的交叉污染,又能进行精确的查询匹配。
但引入嵌套类型也不是没有代价的。官方文档明确提到,嵌套查询的性能可能比普通查询慢很多。
https://www.elastic.co/guide/en/elasticsearch/reference/8.18/tune-for-search-speed.html
在我们的测试环境中,同样的数据量下,嵌套查询的响应时间大概是普通查询的 3-5 倍(不同环境可能不同)。这个性能损失是否可以接受,需要结合具体的业务场景来评估。
unsetunset4、解决方案设计unsetunset
经过反复讨论,我们决定分两个阶段来解决这个问题。
第一阶段先解决最核心的商品规格查询问题,第二阶段再考虑用户评论、商品图片等其他关联数据的处理。
针对商品规格这个场景,嵌套类型确实是最合适的选择。
我们的商品通常有5-10个规格,每个规格包含颜色、尺寸、价格、库存等属性,数据量不算大,嵌套查询的性能损失在可接受范围内。而且商品规格的更新频率相对较低,主要是库存变化,完全重建文档的成本也不高。
最终的mapping设计如下:
PUT /products
{
"mappings": {
"properties": {
"product_id": {
"type": "keyword"
},
"product_name": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
},
"category": {
"type": "keyword"
},
"brand": {
"type": "keyword"
},
"variants": {
"type": "nested",
"properties": {
"sku_id": {
"type": "keyword"
},
"color": {
"type": "keyword"
},
"size": {
"type": "keyword"
},
"price": {
"type": "scaled_float",
"scaling_factor": 100
},
"stock": {
"type": "integer"
},
"sales": {
"type": "integer"
},
"status": {
"type": "keyword"
}
}
},
"create_time": {
"type": "date"
},
"update_time": {
"type": "date"
}
}
}
}
这里有几个设计细节值得说明。
首先是price字段用了scaled_float类型而不是double,主要是为了避免浮点数精度问题,同时节省存储空间。
其次是所有的枚举类型字段都用了keyword,因为我们主要是精确匹配查询,不需要分词。
文档的结构大概是这样的:
PUT /products/_doc/1
{
"product_id": "P001",
"product_name": "经典圆领T恤 纯棉舒适",
"category": "服装",
"brand": "优衣库",
"variants": [
{
"sku_id": "P001-RED-L",
"color": "红色",
"size": "L",
"price": 99.00,
"stock": 50,
"sales": 120,
"status": "active"
},
{
"sku_id": "P001-BLUE-M",
"color": "蓝色",
"size": "M",
"price": 89.00,
"stock": 30,
"sales": 85,
"status": "active"
},
{
"sku_id": "P001-GREEN-S",
"color": "绿色",
"size": "S",
"price": 89.00,
"stock": 0,
"sales": 45,
"status": "sold_out"
}
],
"create_time": "2024-01-15T10:30:00",
"update_time": "2024-01-20T14:25:00"
}
unsetunset5、查询实战演练unsetunset
有了新的数据结构,接下来就是改造查询逻辑。嵌套查询的语法跟普通查询有很大区别,需要用专门的nested query。
最基础的需求是查询特定颜色和尺寸的商品。比如查询红色L号的T恤:
GET /products/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"product_name": "T恤"
}
},
{
"nested": {
"path": "variants",
"query": {
"bool": {
"must": [
{
"term": {
"variants.color": "红色"
}
},
{
"term": {
"variants.size": "L"
}
},
{
"term": {
"variants.status": "active"
}
}
]
}
}
}
}
]
}
}
}
这个查询会精确匹配同时满足红色、L号、状态为active的规格,不会出现之前的交叉匹配问题。
但实际业务中,我们还需要返回匹配的具体规格信息,这就要用到 inner_hits 功能:
GET /products/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"product_name": "T恤"
}
},
{
"nested": {
"path": "variants",
"query": {
"bool": {
"must": [
{
"term": {
"variants.color": "红色"
}
},
{
"range": {
"variants.price": {
"lte": 100
}
}
},
{
"term": {
"variants.status": "active"
}
}
]
}
},
"inner_hits": {
"size": 10,
"sort": [
{
"variants.price": {
"order": "asc"
}
}
]
}
}
}
]
}
},
"_source": ["product_id", "product_name", "brand", "category"]
}
inner_hits会在返回结果中包含匹配的嵌套文档,这样前端就能知道具体是哪些规格满足了查询条件。
更复杂的场景是多条件组合查询。比如用户选择了多个颜色和尺寸,我们需要查询任意颜色和任意尺寸的组合:
GET /products/_search
{
"query": {
"bool": {
"must": [
{
"nested": {
"path": "variants",
"query": {
"bool": {
"must": [
{
"terms": {
"variants.color": ["红色", "蓝色"]
}
},
{
"terms": {
"variants.size": ["M", "L"]
}
},
{
"term": {
"variants.status": "active"
}
}
]
}
},
"inner_hits": {
"size": 50
}
}
}
]
}
}
}
在聚合分析场景下,嵌套类型也很有用。比如我们想统计各个颜色的商品数量:
GET /products/_search
{
"size": 0,
"aggs": {
"variants_agg": {
"nested": {
"path": "variants"
},
"aggs": {
"color_count": {
"terms": {
"field": "variants.color",
"size": 20
},
"aggs": {
"avg_price": {
"avg": {
"field": "variants.price"
}
}
}
}
}
}
}
}
这个聚合会统计每种颜色的规格数量以及平均价格,结果比较准确,不会受到其他规格数据的干扰。
unsetunset6、性能优化的血泪史unsetunset
刚开始上线的时候,查询性能确实不太理想。一个简单的商品搜索接口响应时间从原来的50ms飙升到了200ms+,虽然功能正确了,但用户体验明显下降。
经过几轮优化,我们总结出了一些实用的经验。
首先是查询结构的优化,尽量把过滤条件放在最外层,减少嵌套查询的复杂度:
GET /products/_search
{
"query": {
"bool": {
"filter": [
{
"term": {
"category": "服装"
}
},
{
"term": {
"brand": "优衣库"
}
}
],
"must": [
{
"match": {
"product_name": "T恤"
}
},
{
"nested": {
"path": "variants",
"query": {
"bool": {
"filter": [
{
"term": {
"variants.status": "active"
}
},
{
"range": {
"variants.stock": {
"gt": 0
}
}
}
]
}
}
}
}
]
}
}
}
用filter代替must可以避免相关性计算,提升查询效率。
另外,把一些确定性条件(如category、brand)放在外层过滤,可以先缩小候选文档范围,再进行嵌套查询。
其次是合理使用缓存。ES 的 query cache 对嵌套查询也是有效的,但要注意缓存键的设计。
我们发现把一些高频查询条件提取出来作为独立的filter,能够显著提高缓存命中率。
索引结构的优化也很重要。我们尝试了把一些常用的组合字段冗余存储,比如 color_size 字段存储"红色-L"这样的组合值:
{
"variants": [
{
"sku_id": "P001-RED-L",
"color": "红色",
"size": "L",
"color_size": "红色-L",
"price": 99.00
}
]
}
这样对于精确匹配的查询,可以直接用 term 查询 color_size 字段,避免复杂的嵌套查询。虽然增加了存储开销,但在高频查询场景下性能提升明显。
最后是硬件层面的优化。嵌套查询对内存要求比较高,我们适当调整了JVM堆内存大小,并优化了GC参数。监控数据显示,这些调整让查询性能提升了大约20%。
unsetunset7、踩过的坑unsetunset
回顾整个项目过程,我们踩了不少坑,这里分享几个比较典型的。
第一个坑是更新操作的性能问题。嵌套文档的部分更新比想象中复杂,ES需要重新索引整个父文档。刚开始我们没意识到这一点,库存更新接口的性能惨不忍睹。后来改用批量更新的方式,把短时间内的多次库存变更合并成一次操作,性能才有了明显改善。
第二个坑是聚合查询的结果理解。嵌套聚合返回的结果跟普通聚合不太一样,特别是文档计数的含义。我们刚开始做颜色统计的时候,发现数字对不上,后来才明白嵌套聚合统计的是嵌套文档数量,而不是父文档数量。
第三个坑是查询复杂度的控制。随着业务需求的增加,查询条件越来越复杂,嵌套层级也越来越深。有一次为了实现一个复杂的筛选功能,写了一个四层嵌套的查询,结果直接把集群给搞垮了。后来我们制定了查询复杂度的规范,超过一定层级的查询必须经过 review 才能上线。
最痛苦的一个坑是数据一致性问题。嵌套文档的更新是原子性的,但跨多个父文档的更新就不是了。我们有一个促销活动需要同时更新多个商品的价格,结果中途出现了异常,导致部分商品更新成功,部分失败,最后只能手动回滚数据。
unsetunset8、项目总结与思考unsetunset
经过几个月的折腾,这次嵌套类型的引入总体来说是成功的。最直观的改善是搜索准确性大幅提升,用户投诉基本消失了。而且有了准确的数据基础,我们后续开发了很多有用的功能,比如按颜色聚合、价格区间分析等等。
从技术角度来看,嵌套类型确实是处理一对多关系的有效方案,但它不是银弹。在决定使用之前,需要仔细评估数据特征、查询模式和性能要求。如果嵌套对象数量很大,或者更新很频繁,可能需要考虑其他方案,比如父子关系或者数据扁平化。
另外一个重要的收获是对 ES 数据建模的理解更深入了。ES虽然支持复杂的数据结构,但它的设计理念跟关系型数据库有本质区别。我们需要从搜索和分析的角度来思考数据组织方式,而不是简单地把关系型数据库的设计搬过来。
如果让我重新设计这个系统,我可能会采用混合的方案。对于核心的商品规格查询,继续使用嵌套类型,保证准确性。对于一些辅助的统计分析场景,可能会用扁平化的设计,提高查询效率。没有完美的方案,只有最适合的选择。
最后想说的是,技术选型很重要,但更重要的是持续的监控和优化。ES 的性能表现很大程度上取决于数据特征和查询模式,这些在项目初期往往无法完全预测。建立完善的监控体系,及时发现和解决性能问题,是保证系统稳定运行的关键。
希望这次的经验分享能给大家一些参考。嵌套类型是 ES 的一个强大功能,用好了能解决很多实际问题,但也需要谨慎对待。
技术没有绝对的好坏,关键是要结合具体的业务场景来选择和优化。
更短时间更快习得更多干货!
和全球超2000+ Elastic 爱好者一起精进!
elastic6.cn——ElasticStack进阶助手
抢先一步学习进阶干货!