主要内容
o一 业界难题-跨库分页需求
o二 解决方案
o三 elasticsearch采用的解决方案&源码解析
o四 由分页问题引发对es性能的思考
一 业界难题-跨库分页需求
1.1分页查询的业务需求&常用的解决方式
互联网分页拉取获取数据的需求:
(1)微信消息过多时,拉取第N页消息
(2)京东下单过多时,拉取第N页订单
(3)浏览58同城,查看第N页帖子
(4)乐高后台,查看第N页的视频信息
分页拉取的特点:
(1)有一个业务主键id, 例如msg_id, order_id, tiezi_id
(2)分页排序是按照非业务主键id来排序的,业务中经常按照时间time来排序order by
常用的解决方案:
可以通过在排序字段time上建立索引,利用SQL提供的offset/limit功能就能满足分页查询需求:
select * from t_msg order by time offset 200 limit 100
select * from t_order order by time offset 200 limit 100
select * from t_tiezi order by time offset 200 limit 100
此处假设一页数据为100条,均拉取第3页数据。
1.2分库分页需求
分库需求
高并发大流量的互联网架构中,随着数据量的增加,数据库需要进行水平切分,将数据分布到不同的数据库实例上,来达到数据扩容的目的。
Eg:使用分库依据patition key进行分库【示例为id取模】
问题
select * from t_user order by time offset 200 limit 100
变成两个库后,分库依据是uid,排序依据是time。如果跨越多个水平切分数据库,且分库依据与排序依据为不同属性,并需要进行分页,实现:
select * from T order by time offset X limit Y的跨库分页SQL
数据库层失去了排序字段的全局视野,数据分布在两个库上,如何解决?
二 全局视野法解决跨库分页
服务层通过uid取模将数据分布到两个库上去之后,每个数据库都失去了全局视野,数据按照time局部排序之后,不管哪个分库的第3页数据,都不一定是全局排序的第3页数据
2.1全局排序场景分类
(1)极端情况,两个库的排序字段的数据完全一样。方案:每个库offeset一半,再取半页,就是最终需要的结果:
(2)极端情况:数据分布极不均匀,结果数据全部来自于一个库(eg.db1前三页的time排序字段均小于db0的前三页中的time字段)。
方案:只取结果库db1中的第3页。
(3)一般情况,每个库数据各包含一部分。方案:由于不清楚到底是哪种情况,所以必须每个库都返回3页数据【每个库返回X+Y条数据,X为偏移量,Y为每个分页的大小】,所得到的6页数据在服务层进行内存排序,得到数据全局视野,再取第3页数据,便能够得到想要的全局分页数据【该方案为elasticsearch分页排序采用的方案】。
2.1.2 分库(X+Y)*N聚合详细步骤
一般情况步骤:
(1)将order by time offset X limit Y,改写成order by time offset 0 limit X+Y
(2)服务层将改写后的SQL语句发往各个分库:即例子中的各取3页数据
(3)假设共分为N个库,服务层将得到N*(X+Y)条数据:即例子中的6页数据
(4)服务层对得到的N*(X+Y)条数据进行内存排序,内存排序后再取偏移量X后的Y条记录,就是全局视野所需的一页数据
eg:索引有5个分片,现在要请求size大小为100,获取第200页的数据。则偏移量X=100*200 ,Y=100,N=5,需要在内存中进行排序SIZE=5*(100*200+100)=100500
方案优点:通过服务层修改SQL语句,扩大数据召回量,能够得到全局视野,业务无损,精准返回所需数据。
方案缺点:
(1)每个分库需要返回更多的数据,增大了网络传输量(耗网络);
(2)除了数据库按照time进行排序,服务层还需要进行二次排序,增大了服务层的计算量(耗CPU);
(3)最致命的,这个算法随着页码的增大,性能会急剧下降,这是因为SQL改写后每个分库要返回X+Y行数据:返回第3页,offset中的X=200;假如要返回第100页,offset中的X=9900,即每个分库要返回100页数据,数据量和排序量都将大增,性能平方级下降【es只允许最多对1w条数据分页排序的原因,0.9版本不做限制,导致频繁OOM,性能急剧下降】。
2.2.2 业务折衷法解决分页排序【允许精度缺失】
数据库分库-数据均衡原理
使用patition key进行分库,在数据量较大,数据分布足够随机的情况下,各分库所有非patition key属性,在各个分库上的数据分布,统计概率情况是一致的。
例如,在uid随机的情况下,使用uid取模分两库,db0和db1:
(1)性别属性,如果db0库上的男性用户占比70%,则db1上男性用户占比也应为70%
(2)年龄属性,如果db0库上18-28岁少女用户比例占比15%,则db1上少女用户比例也应为15%
(3)时间属性,如果db0库上每天10:00之前登录的用户占比为20%,则db1上应该是相同的统计规律
利用数据均衡原理原理,要查询全局100页数据,offset 9900 limit 100改写为offset 4950 limit 50,每个分库偏移4950(一半),获取50条数据(半页),得到的数据集的并集,基本能够认为,是全局数据的offset 9900 limit 100的数据,当然,这一页数据的精度,并不是精准的。
使用允许精度缺失的技术方案,技术复杂度大大降低了,既不需要返回更多的数据,也不需要进行服务内存排序。典型的应用如论坛帖子(1024 你懂得) 、58同城早期招聘网站。
2.3 二次查询法
为了方便举例,假设一页只有5条数据,查询第200页的SQL语句为select * from T order by time offset 1000 limit 5;
步骤一:查询改写
将select * from T order by time offset 1000 limit 5
改写为select * from T order by time offset 500 limit 5
并投递给所有的分库,注意,这个offset的500,来自于全局offset的总偏移量1000,除以水平切分数据库个数2。
如果是3个分库,则可以改写为select * from T order by time offset 333 limit 5(offset=1000/3)
假设这三个分库返回的数据(time, uid)如下(每页的返回结果都是按照time排序):
步骤二:找到所返回3页全部数据的最小值,以及各个库的最大值。
第一个库,5条数据的time最小值是1487501123
第二个库,5条数据的time最小值是1487501133
第三个库,5条数据的time最小值是1487501143
在全局中,三个库的最小值为1487501123
步骤三:查询二次改写
第一次改写的SQL语句是select * from T order by time offset 333 limit 5
第二次要改写成一个between语句,between的起点是time_min,between的终点是原来每个分库各自返回数据的最大值:
第一个分库,第一次返回数据的最大值是1487501523
所以查询改写为select * from T order by time where time between time_min and 1487501523
第二个分库,第一次返回数据的最大值是1487501323
所以查询改写为select * from T order by time where time between time_min and 1487501323
第三个分库,第一次返回数据的最大值是1487501553
所以查询改写为select * from T order by time where time between time_min and 1487501553
相对第一次查询,第二次查询条件放宽了,故第二次查询会返回比第一次查询结果集更多的数据,假设这三个分库返回的数据(time, uid)如下:
可以看到:
由于time_min来自原来的分库一,所以分库一的返回结果集和第一次查询相同(所以其实这次访问是可以省略的);
分库二的结果集,比第一次多返回了1条数据,头部的1条记录(time最小的记录)是新的(上图中粉色记录);
分库三的结果集,比第一次多返回了2条数据,头部的2条记录(time最小的2条记录)是新的(上图中粉色记录);
步骤四:在每个结果集中虚拟一个time_min记录,找到time_min在全局的offset
在第一个库中,time_min在第一个库的offset是333
在第二个库中,(1487501133, uid_aa)的offset是333(根据第一次查询条件得出的),故虚拟time_min在第二个库的offset是331
在第三个库中,(1487501143, uid_aaa)的offset是333(根据第一次查询条件得出的),故虚拟time_min在第三个库的offset是330
综上,time_min在全局的offset是333+331+330=994
步骤五:既然得到了time_min在全局的offset,就相当于有了全局视野,根据第二次的结果集,就能够得到全局offset 1000 limit 5的记录
在步骤二的子结果集中,取子结果集为:limit+1000-time_min_offset的数据,然后对三个子数据集进行汇总,排序。
2.2二次查询法存在的问题
步骤1、2的作用在于找到最小值time_min,偏移量在X/N,对es来说该步骤在建立在列式存储的结构中【time列式存储,已经排好序】速度很快,可以迅速返回。步骤3二次查询的范围是 order by time between time_min and time_i_max。 假设time_min在分库1,其他分库返回的结果量为:分库比time_min大的结果数量+size,
极端情况下,其他分库的结果都比time_min大,那么其他库返回的结果最大数据量为X/N+size。N为常数,则x越大,返回的结果集越大,需要在步骤5中汇总排序的量就越大。极端情况下需要汇总排序的量为
假如偏移量x为10亿分片个数为3,分页大小为5,深度翻页需要排序的量为(3-1)*(10亿/3+5)+5=6.7亿。
因此即使采用这种方式,在数据分布极其不均匀的情况下,进行深度翻页,一次进行汇总排序总量也是非常大的。推测这也是es不采用这种方式来进行深度翻页的原因。
三 elasticsearch采用的解决方案&源码解析
ES search_after采用的方式为业务折衷法。每次查询的时候,为常量的查询延迟和开销。但是对大规模数据导出来说,短时间内需要重复一个查询成百上千次也是一个非常巨大的消耗。以下图为示例,如果批量导出数据时,每次都使用业务折衷法,则会使得各个分片再次执行一遍请求。
3.1 es的search_after&scroll实现基本原理
ES scroll查询是对业务折中法做了进一步的优化。将汇总的结果缓存一段时间。如scroll=1m,respose比传统的查询返回多了一个scroll_id【相当用于唯一标识】,下次查询时候,用scroll_id即可找到上次缓存的结果。
【注:如果业务处理时间超过缓存时间,则缓存会失效,导致导出数据不全。缓存时间太长会大量消耗内存。目前搜索系统设置默认时长为5min。阅读业务曾出现业务处理数据太慢,导致导出数据量不全,解决方式为同步导出,异步处理业务数据】
ES scroll查询原生API使用举例(size代表一次交互所返回的数据总量):
Elasticsearch系统架构图
Elasticsearch系统交互图
Elasticsearch 中处理REST相关的客户端请求的类都放在包:org.elasticsearch.action.rest下。该包下所有的类均继承自抽象类BaseRestHandler【使用了设计模式:模板方法】,业务逻辑均类似。需要对Restrequest请求作出不同的响应。
ScrollAction向控制器注册包含滚动查询关键字【scroll】的路径,当httpServer获取客户端发来的请求时,调用restController.dispatchRequest(),restctroller通过路径判断将请求转发给scrollAction.
四 由分页问题引发对es性能的思考
“任何脱离业务的架构设计都是耍流氓”。对于深度翻页场景来说,除了网络爬虫,很少有人去深度逐页去查询,查看每条数据,即便是百度、google搜索引擎,也对返回页数做了限制。ES对分页排序,使用全局视野法,业务无损,精准返回全所需要的数据,但是限制分页数据的最大size大小(默认1万条),避免页码太大导致占用大量网络和CPU资源,性能急剧下降。在我看来,ES在资源占用和性能之间找到一个平衡点。
同时ES对业务做了折中,支持search_after查询方式,禁止跳页查询,降低了对内存的大量消耗以及CPU资源的占用,为业务需求提供了另外的解决方式。
对于批量导出数据的场景,ES使用了scroll查询,其是通过对search_after加入缓存,做了进一步优化,实现对多分库的数据快速、高效导出。