最近把搜索后端从 AWS cloudsearch 迁到了 AWS ES 和自建 ES 集群. 测试发现 search latency 高于之前的 benchmark, 可见模拟数据远不如真实数据来的实在. 这次在产线的 backup ES 上直接进行测试和优化, 通过本文记录 search 调优的主要过程.
问题 1: 发现 AWS ES shard 级别的 search latency 是非常小的, 符合期望, 但是最终的查询耗时却非常大(ES response 的 took), 整体的耗时比预期要高出 200ms~300ms.
troubleshooting 过程: 开始明显看出问题在 coordinator node 收集数据排序及 fetch 阶段. 开始怀疑是因为 AWS ES 没有 dedicated coordinator 节点, data node 的资源不足导致这部分耗时较多, 后来给所有 data node 进行来比较大的升级, 排除了 CPU,MEM, search thread_pool 等瓶颈, 并且通过 cloud watch 排除了 EBS IOPS 配额不够的可能, 但是, 发现 search latency 并没有减少. 然后就怀疑是 network 的延时, 就把集群从 3 个 AV 调整到 1 个 AV, 发现问题依旧. 无奈, 联系了 AWS 的 support,AWS ES team 拿我们的数据和 query 语句做了 benchmark, 发现没有某方面的资源瓶颈. 这个开始让我们很疑惑, 因为在自建 ES 集群上 search latency 明显小于 AWS ES, 两个集群的版本, 规格, 数据量都差不多. 后来 AWS 回复说是他们那边的架构问题, 比之自建集群, AWS ES 为了适应公有云上的 security, loadbalance 要求, 在整个请求链路上加了一些组件, 导致了整体延时的增加.
确定方案: 限于 ES cluster 不受控, 我们只能从自身的数据存储和查询语句上去优化.
存储优化:
1. index sort. 我们的查询结果都是按时间 (created_time) 排序的, 所以存储的时候即按 created_time 进行有序存储, 方便提前中断查询.
2. segment merge. 索引是按季度存储的, 把 2019 年之前的索引进行了 force merge, 进行段合并, 2019 年之前的索引确定都是只读的.
3. 索引优化. 合并了一些小索引, 2016,2017 年的数据量比较少, 把这两年的索引进行合并, 减少总 shard 数. 通过创建原索引的别名指向新索引, 保证 search 和 index 的逻辑不用改动.
查询优化.
先通过 profile API 定位耗时的子查询语句.
1. 合并查询字段. 一个比较耗时子查询查询如下, 通常 session_id 的 list size>100,receiver_id 和 sender_id 也会匹配到 n 多条记录.
- {
- "minimum_should_match": "1"
- "should": [
- {
- "terms": {
- "session_id": [
- "ab",
- "cd"
- ],
- "boost": 1
- }
- },
- {
- "term": {
- "receiver_id": {
- "value": "efg",
- "boost": 1
- }
- }
- },
- {
- "term": {
- "sender_id": {
- "value": "hij",
- "boost": 1
- }
- }
- }
- ]
- }
新开一个字段 session_receiver_sender_id, 通过 copy_to 把每条记录的 session_id,receiver_id, sender_id 都放到这个字段上. 把 query 语句改为
- {
- "terms": {
- "session_receiver_sender_id": [
- "ab",
- "cd",
- "efg",
- "hij"
- ],
- "boost": 1
- }
- }
不过, 测试结论显示, 合并之后 query 耗时并没有明显缩短, 感觉改动意义不大. 推测可能是我们的 BoolQuery 字段并不多(就 3 个), 但是 terms 的 size 很多(100 以上), 因为不管是多个字段每个对应一个 termQuery, 还是一个 terms query, 都是转成 BoolQuery, 最终都是多个 termQuery 做 or.
2. 优化 date range 查询. 另外一个比较耗时的查询是 date range.Lucene 会 rewrite 成一个 DocValuesFieldExistsQuery.
- "filter": [
- {
- "range": {
- "created_time": {
- "from": 1560993441118,
- "to": null,
- "include_lower": true,
- "include_upper": true,
- "boost": 1
- }
- }
- },
- ...
- ]
这里匹配到的 docId 的确非常多, date range 结果在构造 docIdset 与别的子查询语句做 conjunction 耗时较大.
采用的一个解决方案是尽量对这个子查询进行缓存, 把这个 date range 查询拆成两段, 分为 3 个月前到昨天, 昨天到今天两段, 一般昨天的数据不再变化, 在没有触发 segment merge 的情况下 3 个月前到昨天到查询结果应该能缓存较长时间.
- "constant_score": {
- "filter": {
- "bool": {
- "should": [
- {
- "range": {
- "created_time": {
- "gte": "now-3M/d",
- "lte": "now-1d/d"
- }
- }
- },
- {
- "range": {
- "created_time": {
- "gte": "now-1d/d",
- "lte": "now/d"
- }
- }
- }
- ]
- }
- }
- }
相应的, 在用户可接受的前提下, 调大索引的 refresh_interval.
问题 2: 在自建 ES 集群上, 发现某个索引 500ms 以上的搜索耗时占比较多.
这个索引每日大概 30w 次查询, 落在 100ms 以内的查询超过 90%, 但是依旧有 1% 的查询落在 500ms 以上. 发现同样的 query 语句模版, 但如果某些子查询条件匹配到的数据比较多, 查询会变对特别慢.
troubleshooting 过程: 同样是通过 profile 参数分析比较耗时的查询子句. 发现一个 PointInSetQuery 非常耗时, 这个子查询是对一个名为 user_type 的 Integer 字段做 terms 查询, 子查询内部又耗时在 build_score 阶段.
通过查找 lucene 的代码和相关文章, 发现 lucene 把 numeric 类型的字段索引成 BKD-tree, 内部的 docId 是无序的, 与其他查询结果做交集前构造 Bitset 比较耗时, 从而把 Integer 类型改成 keyword, 把这个查询转成 TermQuery, 这样哪怕命中的数据很多, 在 build_score 的时候因为倒排链的 docId 有序性, 利用 skiplist, 可以更快速的构建一个 Bitset. 在把这个字段改成 keyword 后, 50th 的查询耗时并没有多大差异, 但是 90th,99th 的 search latency 明显小于之前.
另一个优化, 这个索引里的每条数据都是一个非空的 accout_id 字段, accout_id 在 query 语句里会用于 terms 查询. 遂把这个 accout_id 字段作为 routing 进行存储. 同时可以对查询语句进行修改:
- # 原 query
- "filter": [
- {
- "terms": {
- "account_id": [
- "abc123"
- ],
- "boost": 1
- }
- }
- ...
- ]
- # 改为
- "filter": [
- {
- "terms": {
- "_routing": [
- "abc123"
- ]
- }
- }
- ...
- ]
查询改为_routing 之后, 发现整体的 search latency 大幅降低.
经过这两次改动, 针对这个索引的 search latency 基本满足需求.
另外, 还有一个小改动, 通过 preload docvalue, 可以减少首次查询的耗时.
来源: http://www.bubuko.com/infodetail-3258022.html