美团外卖搜索基于Elasticsearch的优化实践

1. 前言

最近十年,Elasticsearch 已经成为了最受欢迎的开源检索引擎,其作为离线数仓、近线检索、B端检索的经典基建,已沉淀了大量的实践案例及优化总结。然而在高并发、高可用、大数据量的 C 端场景,目前可参考的资料并不多。因此,我们希望通过分享在外卖搜索场景下的优化实践,能为大家提供 Elasticsearch 优化思路上的一些借鉴。

美团在外卖搜索业务场景中大规模地使用了 Elasticsearch 作为底层检索引擎。其在过去几年很好地支持了外卖每天十亿以上的检索流量。然而随着供给与数据量的急剧增长,业务检索耗时与 CPU 负载也随之上涨。通过分析我们发现,当前检索的性能热点主要集中在倒排链的检索与合并流程中。针对这个问题,我们基于 Run-length Encoding(RLE)[1] 技术设计实现了一套高效的倒排索引,使倒排链合并时间(TP99)降低了 96%。我们将这一索引能力开发成了一款通用插件集成到 Elasticsearch 中,使得 Elasticsearch 的检索链路时延(TP99)降低了 84%。

2. 背景

当前,外卖搜索业务检索引擎主要为 Elasticsearch,其业务特点是具有较强的 Location Based Service(LBS) 依赖,即用户所能点餐的商家,是由商家配送范围决定的。对于每一个商家的配送范围,大多采用多组电子围栏进行配送距离的圈定,一个商家存在多组电子围栏,并且随着业务的变化会动态选择不同的配送范围,电子围栏示意图如下:

图1 电子围栏示意图

图1 电子围栏示意图

考虑到商家配送区域动态变更带来的问题,我们没有使用 Geo Polygon[2] 的方式进行检索,而是通过上游一组 R-tree 服务判定可配送的商家列表来进行外卖搜索。因此,LBS 场景下的一次商品检索,可以转化为如下的一次 Elasticsearch 搜索请求:

POST food/_search
{
   "query": {
      "bool": {
         "must":{
            "term": { "spu_name": { "value": "烤鸭"} }
           //...
         },
         "filter":{
           "terms": {
              "wm_poi_id": [1,3,18,27,28,29,...,37465542] // 上万
            }
         }
      }
   }
  //...
}

对于一个通用的检索引擎而言,Terms 检索非常高效,平均到每个 Term 查询耗时不到0.001 ms。因此在早期时,这一套架构和检索 DSL 可以很好地支持美团的搜索业务——耗时和资源开销尚在接受范围内。然而随着数据和供给的增长,一些供给丰富区域的附近可配送门店可以达到 20000~30000 家,这导致性能与资源问题逐渐凸显。这种万级别的 Terms 检索的性能与耗时已然无法忽略,仅仅这一句检索就需要 5~10 ms。

3. 挑战及问题

由于 Elasticsearch 在设计上针对海量的索引数据进行优化,在过去的 10 年间,逐步去除了内存支持索引的功能(例如 RAMDirectory 的删除)。为了能够实现超大规模候选集的检索,Elasticsearch/Lucene 对 Term 倒排链的查询流程设计了一套内存与磁盘共同处理的方案。

一次 Terms 检索的流程分为两步:分别检索单个 Term 的倒排链,多个 Term 的倒排链进行合并。

3.1 倒排链查询流程

  1. 从内存中的 Term Index 中获取该 Term 所在的 Block 在磁盘上的位置。
  2. 从磁盘中将该 Block 的 TermDictionary 读取进内存。
  3. 对倒排链存储格式的进行 Decode,生成可用于合并的倒排链。

可以看到,这一查询流程非常复杂且耗时,且各个阶段的复杂度都不容忽视。所有的 Term 在索引中有序存储,通过二分查找找到目标 Term。这个有序的 Term 列表就是 TermDictionary ,二分查找 Term 的时间复杂度为 O(logN) ,其中 N 是 Term 的总数量 。Lucene 采用 Finite State Transducer[3](FST)对 TermDictionary 进行编码构建 Term Index。FST 可对 Term 的公共前缀、公共后缀进行拆分保存,大大压缩了 TermDictionary 的体积,提高了内存效率,FST 的检索速度是 O(len(term)),其对于 M 个 Term 的检索复杂度为 O(M * len(term))。

3.2 倒排链合并流程

在经过上述的查询,检索出所有目标 Term 的 Posting List 后,需要对这些 Posting List 求并集(OR 操作)。在 Lucene 的开源实现中,其采用 Bitset 作为倒排链合并的容器,然后遍历所有倒排链中的每一个文档,将其加入 DocIdSet 中。

伪代码如下:

Input:  termsEnum: 倒排表;termIterator:候选的term
Output: docIdSet : final docs set
for term in termIterator:
  if termsEnum.seekExact(term) != null:
     docs = read_disk()  // 磁盘读取
     docs = decode(docs) // 倒排链的decode流程
     for doc in docs:
        docIdSet.or(doc) //代码实现为DocIdSetBuilder.add。
end for
docIdSet.build()//合并,排序,最终生成DocIdSetBuilder,对应火焰图最后一段。

假设我们有 M 个 Term,每个 Term 对应倒排链的平均长度为 K,那么合并这 M 个倒排链的时间复杂度为:O(K * M + log(K * M))。 可以看出倒排链合并的时间复杂度与 Terms 的数量 M 呈线性相关。在我们的场景下,假设一个商家平均有一千个商品,一次搜索请求需要对一万个商家进行检索,那么最终需要合并一千万个商品,即循环执行一千万次,导致这一问题被放大至无法被忽略的程度。

我们也针对当前的系统做了大量的调研及分析,通过美团内部的 JVM Profile 系统得到 CPU 的火焰图,可以看到这一流程在 CPU 热点图中的反映也是如此:无论是查询倒排链、还是读取、合并倒排链都相当消耗资源,并且可以预见的是,在供给越来越多的情况下,这三个阶段的耗时还会持续增长。

图2 profile 火焰图

图2 profile 火焰图

可以明确,我们需要针对倒排链查询、倒排链合并这两个问题予以优化。

4. 技术探索与实践

4.1 倒排链查询优化

通常情况下,使用 FST 作为 Term 检索的数据结构,可以在内存开销和计算开销上取得一个很好的平衡,同时支持前缀检索、正则检索等多种多

### 将美团外卖数据导入 Elasticsearch 的集成方案 #### 数据准备阶段 在将美团外卖的数据导入 Elasticsearch 前,需先完成数据的提取与预处理。根据已有资料[^3],美团外卖的数据来源于多个方面,包括业务库数据、流量日志、集团数据和三方数据。 - **业务库数据**:通过 Binlog 同步技术获取用户端、商家端和运营端的数据,并采用全量同步、增量同步或快照同步的方式将其存储至 Hive 表中。 - **流量日志**:利用统一的终端埋点 SDK 和配套工具确保数据规范性和质量,随后进行清洗并存入 Hive 表。 - **集团数据**:遵循安全仓机制,按照权限申请流程对接所需数据。 - **三方数据**:对外部渠道采集的数据进行规范化清洗后再接入数仓。 这些数据可通过 ETL 工具抽取到中间层,以便后续加载到 Elasticsearch 中。 --- #### 导入 Elasticsearch 的方法 以下是几种常见的将美团外卖数据导入 Elasticsearch 的方法: 1. **批量导入** 使用 Apache Spark 或其他大数据框架读取已有的 Hive 表数据,转换为适合 Elasticsearch 的 JSON 格式后写入索引。此方法适用于历史数据的一次性迁移。 ```python from pyspark.sql import SparkSession spark = SparkSession.builder.appName("HiveToES").getOrCreate() df = spark.read.format("hive").table("your_hive_table_name") es_config = { "es.nodes": "elasticsearch_host", "es.port": "9200", "es.index.auto.create": "true" } df.write.format("org.elasticsearch.spark.sql") \ .options(**es_config) \ .mode("overwrite") \ .save("index_name") ``` 上述代码展示了如何使用 PySpark 从 Hive 提取数据并写入 Elasticsearch 索引[^4]。 2. **实时流式传输** 对于准实时需求,可以借助 Kafka Connect 插件实现从消息队列到 Elasticsearch 的自动转发。具体来说,在美团外卖的离线数仓建设实践中提到过对实时场景的支持,可以通过 Flink 或 Storm 处理流式数据并将结果发送给 Elasticsearch 进行索引构建。 3. **API 接口调用** 如果目标是少量特定记录而非大规模数据集,则可以直接通过 RESTful API 请求向 Elasticsearch 添加文档。例如: ```bash curl -X POST 'http://localhost:9200/orders/_doc' -H 'Content-Type: application/json' -d ' { "order_id":"123456", "customer_name":"John Doe", "total_amount":89.99, "status":"completed" }' ``` --- #### 性能优化建议 为了提升 Elasticsearch 在处理大量美团外卖数据时的表现,可以从以下几个角度入手: - 配置合理的分片数量以平衡查询速度与存储成本之间的关系; - 利用 Colocate Join 特性减少不必要的网络开销(尽管该功能属于 Doris 而非 ES); - 定期执行 force merge 操作来降低 segment 数目从而提高检索效率; - 设置合适的副本数目保障高可用的同时兼顾磁盘空间利用率; --- #### 注意事项 当实施上述任一种策略之前,请务必确认源系统的兼容性以及目标环境容量规划是否充分考虑到未来增长潜力等因素影响。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

架构未来

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值