贫瘠之地

华北无浪漫,死海扬起帆
多少个夜晚,独自望着天

0%

ES 的 from size 和 scroll 和 search after

from size

from size 是最常见的分页方式,类比 MySQL 的 offset limit

由于 ES 是天然分布式的,数据分散在各个 shards 上,所以需要查询 from + size 的条数时,coordinate node 就向该 index 的其余的 shards 发送同样的请求,等汇总到 (shards × (from + size)) 条数时在 coordinate node 再进行一次排序,最终抽取出真正 from 后的 size 条结果

显而易见,当 shards 较多、分页深度很大时,这种方式存在很大的性能问题,这也是 ES 默认深度为 from + size <= 10000 的原因

Routing 机制

写入文档时指定存储的分片

官方提供的公式如下:shard_num = hash(_routing) % num_primary_shards

  • _routing 代表提供路由的字段。默认情况下为文档的 ID
  • num_primary_shards 代表的为 primary shard 的个数,这个在每个索引类型创建之前就被设置了,可以手动设置也可以让 ES 默认设置。因为 ES 版本不同,设置的默认值也不同。该值在第一次创建索引类型被设置完成之后无法无法修改(修改路由机制就代表需要迁移数据)
  • shard_num 代表数据落在的 shard 编号

基本流程是 ES 根据路由字段计算其哈希值,再与主分片数量取余,计算得出数据落在的分片编号

此外如果只根据 _routing 会出现数据倾斜,可以采取折中方案,使用 routing_partition_size 参数,来使同一类 _routing 路由到主分片的一个子集中

shard_num = (hash(_routing) + hash(_id) % routing_partition_size) % num_primary_shards

restful

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /hero-index/_search
{
"query": {
"bool": {
"must_not": [
{
"term": {
"name": ""
}
}
]
}
},
"size": 10,
"from": 0
}

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void search() {
// searchRequest
SearchRequest searchRequest = new SearchRequest("hero-index");

// 查询条件
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
// 分页条件
searchSourceBuilder.from(0);
searchSourceBuilder.size(10);
// term name = ""
TermQueryBuilder termQueryBuilder = QueryBuilders.termQuery("name", "");
// bool 类型为 must not
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery().mustNot(termQueryBuilder);
// 查询条件组装进 searchSourceBuilder
searchSourceBuilder.query(boolQueryBuilder);
searchSourceBuilder.timeout(new TimeValue(60, TimeUnit.SECONDS));

// 执行
searchRequest.source(searchSourceBuilder);
SearchResponse search = client.search(searchRequest, RequestOptions.DEFAULT);

System.out.println("totalHits:" + search.getHits().getTotalHits().value);
}

search after

ES 5 引入的新机制

简单概括 search after 如何使用:

  • 必须先指定排序规则(需要获取排序坐标)
  • 比如从第一页开始(从中间开始也无法知道中间的具体位置)
  • 从第一页开始以后每次都带上search_after=lastEmittedDocFieldValue lastEmittedDocFieldValue 就是下一页开始的 keyset 坐标(也就是这个参数把深度分页变成了常数级分页)

和 from size 相比,无论去到第几页,coordinate node 向其它 node 发送的请求始终就是请求 size 个 docs;即无论分页深度是多少,都是常量级的开销

其实现原理和关系型数据库常使用的 keyset 分页思想一致,业务妥协(只能按顺序分页而不能跳转页数)来实现性能的提升

但需要注意,看似 search after 是一个 O(1) 级别的操作,但随着分页深度的增加,其内部逻辑扫描的 doc 数量也在不断增加,依然会影响查询性能,只是相比 from size 方式有一定提升(MySQL 单表查询的分页也是如此)

此外,如果字段区分度不高,则会忽略某些数据;例如根据 age 作为排序条件,此时如果每页数据取 1000 条,而某个 age 数据 count > 1000,则会忽略后续数据;解决方法是排序字段唯一,或者组合后的多个排序条件唯一

restful

第一次查询和普通查询一致

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
POST /hero-index/_search
{
"query": {
"bool": {
"must_not": [
{
"term": {
"name": ""
}
}
]
}
},
"from": 0,
"size": 1,
"sort": [
{
"age": {
"order": "desc"
},
"_id": {
"order": "desc"
}
}
]
}

随后的查询增加 search_after 查询条件,值为最后一条数据排序字段的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
POST /hero-index/_search
{
"query": {
"bool": {
"must_not": [
{
"term": {
"name": ""
}
}
]
}
},
"from": 0,
"size": 1,
"sort": [
{
"age": {
"order": "desc"
},
"_id": {
"order": "desc"
}
}
],
"search_after": [
35,
"i-mnlIQBCTaFCHoWJhbM"
]
}

Java

同 from size 一致

第二次查询及之后需要 searchSourceBuilder.searchAfter(new Object[1]); 来设置 search_after 参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public void searchAfter() {
// searchRequest
SearchRequest searchRequest = new SearchRequest("hero-index");

// 查询条件
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
// 分页条件
searchSourceBuilder.from(0);
searchSourceBuilder.size(10);
// term name = ""
TermQueryBuilder termQueryBuilder = QueryBuilders.termQuery("name", "");
// bool 类型为 must not
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery().mustNot(termQueryBuilder);
// 查询条件组装进 searchSourceBuilder
searchSourceBuilder.query(boolQueryBuilder);
searchSourceBuilder.timeout(new TimeValue(60, TimeUnit.SECONDS));

// 上批数据最后一条的排序字段值
int lastHitOrderFieldValue = 0;
// set 进 searchAfter 属性中
searchSourceBuilder.searchAfter(new Object[]{lastHitOrderFieldValue});
searchRequest.source(searchSourceBuilder);
SearchResponse search = client.search(searchRequest, RequestOptions.DEFAULT);

System.out.println("totalHits:" + search.getHits().getTotalHits().value);
}

scroll

如果一开始就明确地查询全量的数据,无论使用 from size 还是 search after 都不是一个好的选择,要么依然存在深度分页的问题,要么需要多次请求,所以引入了 scroll 方式

scroll 就是把一次的查询结果缓存一定的时间,如 scroll = 1m 则把查询结果在下一次请求上来时暂存 1 分钟

response 比传统的返回多了一个 scroll_id,下次带上这个 scroll_id 即可找回这个缓存的结果

本质上是让各个 shard 将结果缓存,此外也有很多优化(单 shard 减少请求数量、剪枝等)来提高性能

restful

第一次请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
POST /hero-index/_search?scroll=1m
{
"query": {
"bool": {
"must_not": [
{
"term": {
"name": ""
}
}
]
}
},
"size": 1,
"sort": [
{
"age": {
"order": "desc"
}
}
]
}

随后

1
2
3
4
5
POST /_search/scroll
{
"scroll": "1m",
"scroll_id": "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFlN0TlRCWW5pUktHLWxvMEdDN3Zya2cAAAAAAAAA_hZkVXh5THJ1SFR1dVVPQlJJX3JDZWRR"
}

Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public void scrollSearch() {
// searchRequest
SearchRequest searchRequest = new SearchRequest("hero-index");

// 查询条件
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
// 分页条件
searchSourceBuilder.from(0);
searchSourceBuilder.size(10);
// term name = ""
TermQueryBuilder termQueryBuilder = QueryBuilders.termQuery("name", "");
// bool 类型为 must not
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery().mustNot(termQueryBuilder);
// 查询条件组装进 searchSourceBuilder
searchSourceBuilder.query(boolQueryBuilder);
searchSourceBuilder.timeout(new TimeValue(60, TimeUnit.SECONDS));

// 定义 scroll
Scroll scroll = new Scroll(TimeValue.timeValueMinutes(1));

// 执行
searchRequest.source(searchSourceBuilder);
searchRequest.scroll(scroll);
SearchResponse search = client.search(searchRequest, RequestOptions.DEFAULT);

System.out.println("totalHits:" + search.getHits().getTotalHits().value);
}

随后应该循环查询

1
2
3
4
5
6
7
8
9
10
11
12
13
public void scrollAfterSearch() {
// 定义 scroll
Scroll scroll = new Scroll(TimeValue.timeValueMinutes(1));
String scrollId = "";
SearchScrollRequest searchScrollRequest = new SearchScrollRequest(scrollId);
searchScrollRequest.scroll(scroll);

// 执行
searchScrollRequest.scroll(scroll);
SearchResponse search = client.scroll(searchScrollRequest, RequestOptions.DEFAULT);

System.out.println("totalHits:" + search.getHits().getTotalHits().value);
}

拓展

文章 《业界难题 - ”跨库分页” 的四种方案》

需求产生

分页需求

对于数据的查询,很多业务都有分页拉取数据的需求,例如:分页拉取聊天记录、商品信息、图片数据等

除了对数据集拆分,往往还需要业务字段进行排序

比如取第 3 页的订单数据,每页 100 条,根据创建时间倒序;可以根据创建时间建立索引

1
SELECT * FROM `order` ORDER BY `createdTime` DESC LIMIT 200,100;

分库分表

随着业务的发展,为了应对数据量大、请求量大等问题,对数据库进行了横向扩展

引入分库分表后,数据根据 patition key 路由规则写入不同的库表中

同时为了让读写压力尽可能均匀分布在各个库中,往往会设置合适的 patition key

当分库分表后需要进行示例的分页需求时,就无法通过简单的请求一个库实现目的了(假设分表键不是使用的创建时间,事实上使用创建时间范围作为分表键也没任何意义)

排序的依据是时间,分表的依据是其他字段,因此数据库丧失了创建时间排序的全局视野

本文就在讨论如何满足 跨越多个水平拆分数据库的分页查询问题

全局视野

当数据分布在两个库中,无论哪个分库的第三页,都不一定会是全局排序的第三页数据

情况如下:

  • 一般情况:两个库各占第三页数据的一部分
  • 极端情况:两个库各占一半
  • 极端情况:第三页数据完全来自于一个库

由于查询前并不清楚数据到底是如何分布在各个分库上的,所以每个库都返回 3 页数据,所得到的 6 页数据再进行排序,此时获得了根据创建时间排序的全局视野,再从 6 页数据中抽取需要的第 3 页数据

优点:

  • 业务无损,精准返回

缺点:

  • 每次需要向各分库查询的数据量和 shard 数量倍数级相关,和页码(深度)指数级相关

  • 需要二次排序

  • 需要更多 IO 资源

禁止跳页

由业务进行妥协,不允许进行页数的随意跳转,只允许依次进行 下一页 操作,就能减少深度分页带来的性能影响

被称为 keyset 分页、search after 等

首先这种方式需要先进行排序,比如使用创建时间作为排序字段,第一页时各分库执行

1
SELECT * FROM `order` ORDER BY `createdTime` DESC LIMIT 0,100;

当点击下一页时,根据第一页数据的最后一条,即第一页中最小创建时间的数据(假设是 1669820860000),各分库执行

1
SELECT * FROM `order` WHERE `createdTime` > 1669820860000 ORDER BY `createdTime` DESC LIMIT 0,100;

可以发现,除第一次查询时,后面每次查询都需要带上排序条件下,上一次查询最后数据的排序值,这也是无法进行跳页的原因,因为不通过查询无法知道所需页数据的 search after 条件是多少

禁止跳页后每个分库一次都只查询一页数据,查询数据量只和分库数量倍数级相关

优点:

  • 性能相对较好,不受全局视野中深度分页指数级影响

缺点:

  • 业务进行不能跳页的妥协
  • 排序字段需要具有区分度

允许精度损失

正常情况下,patition key 规则都尽可能使数据均匀分布在各分库中

所以可以理想认为,每个分库都完整包含了数据的一部分

假设需要取第 100 页数据,有 2 个分库,可以各取每个分库的半页数据再得到数据的并集

1
SELECT * FROM `order` ORDER BY `createdTime` DESC LIMIT 9450,50;

这种方式最终结果只能是近似结果,并不精准

优点:

  • 性能相对较好,不受全局视野中深度分页指数级影响

缺点:

  • 业务进行精度损失的妥协

二次查询

这种方式既能做到精准数据,也能减少查询量

基本的思想是通过多次查询来获得全局视野,再根据全局视野在结果集中抽取需要的数据

假设一页查询 5 条数据,查询第 201 页,有三个分库;单表查询如下

1
SELECT * FROM `order` ORDER BY `createdTime` DESC LIMIT 1000,5;

第一步:

改写查询,各分库按理想情况下数据分布进行查询,即

1
SELECT * FROM `order` ORDER BY `createdTime` DESC LIMIT 333,5;

返回结果

A B C
523 423 500
423 421 400
323 400 300
223 320 200
123 320 100

第二步:

结果集排序后,找到排序字段第一位的值,createdTime DESC,即创建时间的最大值

可以得到是分库 A 返回的 523

第三步:

扩大查询范围,查询条件使用 between 语句,最大值为全局最大值(523),最小值为该分库返回结果集的最大值(B-423,C-500)

因为最大值在分库 A 结果集中,所以 A 是不需要二次请求的(523 和 123 数据中必然还是上一次的结果集)

1
2
SELECT * FROM `order_B` WHERE `createdTime` BETWEEN 523 AND 423 ORDER BY `createdTime` DESC;
SELECT * FROM `order_C` WHERE `createdTime` BETWEEN 523 AND 500 ORDER BY `createdTime` DESC;

B、C 可能会返回更多的数据,结果集更新为

A B C
520
499 510
523 423 500
423 421 400
323 400 300
223 320 200
123 320 100

第四步:

推断全局视野

在分库 A 中,523 是第 333 条数据

在分库 B 中,加上新返回的 1 条数据,523 是第 333 - 1 - 1 = 331 条数据

在分库 C 中,加上新返回的 2 条数据,523 是第 333 - 2 -1 = 330 条数据

此时得到整个结果集中的最大值 523 在全局的 offset 应该是 333 + 331 + 330 = 994

第五步:

已经有了 523 这个数据的全局视野,offset 994

又有了结果集 18 条数据

那么排序后就可以在结果集中找到 offset 1000 limit 5 的数据集了

优点:

  • 既能精准获取结果,又能避免深度分页

缺点:

  • 逻辑复杂

  • 需要多次查询

总结

方式 优点 缺点
全局视野 精确 性能问题
禁止跳页 避免深度分页 业务妥协
允许精度损失 避免深度分页 业务妥协
二次查询 精确 + 避免深度分页 多次查询

参考

Elasticsearch 5.x 源码分析(3)from size, scroll 和 search after - 简书 (jianshu.com)

业界难题-“跨库分页”的四种方案-阿里云开发者社区 (aliyun.com)