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
代表提供路由的字段。默认情况下为文档的 IDnum_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 | POST /hero-index/_search |
Java
1 | public void search() { |
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 | POST /hero-index/_search |
随后的查询增加 search_after
查询条件,值为最后一条数据排序字段的值
1 | POST /hero-index/_search |
Java
同 from size 一致
第二次查询及之后需要
searchSourceBuilder.searchAfter(new Object[1]);
来设置
search_after
参数
1 | public void searchAfter() { |
scroll
如果一开始就明确地查询全量的数据,无论使用 from size 还是 search after 都不是一个好的选择,要么依然存在深度分页的问题,要么需要多次请求,所以引入了 scroll 方式
scroll 就是把一次的查询结果缓存一定的时间,如
scroll = 1m
则把查询结果在下一次请求上来时暂存 1 分钟
response 比传统的返回多了一个 scroll_id
,下次带上这个
scroll_id
即可找回这个缓存的结果
本质上是让各个 shard 将结果缓存,此外也有很多优化(单 shard 减少请求数量、剪枝等)来提高性能
restful
第一次请求
1 | POST /hero-index/_search?scroll=1m |
随后
1 | POST /_search/scroll |
Java
1 | public void scrollSearch() { |
随后应该循环查询
1 | public void scrollAfterSearch() { |
拓展
文章 《业界难题 - ”跨库分页” 的四种方案》
需求产生
分页需求
对于数据的查询,很多业务都有分页拉取数据的需求,例如:分页拉取聊天记录、商品信息、图片数据等
除了对数据集拆分,往往还需要业务字段进行排序
比如取第 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 | SELECT * FROM `order_B` WHERE `createdTime` BETWEEN 523 AND 423 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)