白菜Java自习室 涵盖核心知识算法
分库分表难题(一) 分表分页/跨库分页 难玩却不表明没有玩法
分库分表难题(二) 跨库/跨实例 Join 链接 不是非得依赖中间件数据库
互联网不少业务都有分页拉取数据的需求,例如:服务器
- 电商商城系统运营端,分页拉取订单列表查看;
- 贴吧社区系统看帖子,分页拉取帖子的回复;
- 手机APP右上角的小红点,点开拉取消息列表;
这些业务场景若是用数据库去实现,每每有着这样一些共性:markdown
- 数据量每每比较大;
- 通常都会设计业务主键ID;
- 分页排序并不是按主键排序,而是按照建立时间排序;
在数据量不大时,能够经过在排序字段 time 上创建索引,利用 SQL 提供的 offset/limit 功能就能知足分页查询需求:网络
SELECT * FROM `table` ORDER BY `time` LIMIT #{offset}, #{limit}
复制代码
当业务数据达到必定量级(好比:MySql单表记录量大于1千万)后,一般会考虑“分库分表”将数据分散到不一样的库或表中(数据的水平切分),这样能够大大提升读/写性能。架构
高并发大流量的互联网架构,通常经过服务层来访问数据库,随着数据量的增大,数据库须要进行水平切分,分库后将数据分布到不一样的数据库实例(甚至物理机器)上,以达到下降数据量,增长实例数的扩容目的。 一旦涉及分库,逃不开“分库依据”(patition key) 的概念,使用哪个字段来水平切分数据库呢:大部分的业务场景,会使用业务主键ID。并发
肯定了分库依据(patition key)后,接下来要肯定的是分库算法:大部分的业务场景,会使用 业务主键ID取模的算法 来分库,这样 既可以保证每一个库的数据分布是均匀的,又可以保证每一个库的请求分布是均匀的,实在是简单实现负载均衡的好方法,此法在互联网架构中应用颇多。负载均衡
可是问题来了,对于 SELECT * FROM table ORDER BY time LIMIT #{offset}, #{limit}
这种分页方式,原来一条语句就能够简单搞定的事情会变得很复杂,本文将与你们一块儿探讨分库分表后"分页"面临的新问题。高并发
注意:本文主要探讨“分页”面临的问题(数据水平切分场景),上边只是举了个最简单的分库分表算法例子,实际生产环境中会复杂的多,须要根据具体业务需求来肯定分库分表方案。post
如图所示,服务层经过 id 取模将数据分布到两个库上去以后,每一个数据库都失去了全局视野,数据按照 time 局部排序以后,无论哪一个分库的第 3 页数据,都不必定是全局排序的第 3 页数据。
database1 (id%2=0) | database2 (id%2=1) |
---|---|
db0-page1 | db1-page1 |
db0-page2 | db1-page2 |
db0-page3 | db1-page3 |
... (order by time) | ... (order by time) |
若是两个库的数据彻底相同,只须要每一个库 offset 一半,再取半页,就是最终想要的数据(如图所示):
database1 (id%2=0) | database2 (id%2=1) |
---|---|
db0-page1 | db1-page1 |
db0-page2 | db1-page2 |
db0-page3(取一半) | db1-page3(取一半) |
... (order by time) | ... (order by time) |
也可能两个库的数据分布及其不均衡,例如 db0 的全部数据的 time 都大于 db1 的全部数据的 time,则可能出现:一个库的第 3 页数据,就是全局排序后的第 3 页数据(如图所示):
database1 (id%2=0) | database2 (id%2=1) |
---|---|
db0-page1 | db1-page1 |
db0-page2 | db1-page2 |
db0-page3(同一个库) | db1-page3 |
... (order by time) | ... (order by time) |
正常状况下,全局排序的第 3 页数据,每一个库都会包含一部分(如图所示):
database1 (id%2=0) | database2 (id%2=1) |
---|---|
db0-page1 | db1-page1 |
db0-page2(包含部分) | db1-page2 |
db0-page3 | db1-page3(包含部分) |
... (order by time) | ... (order by time) |
因为不清楚究竟是哪一种状况,因此 必须每一个库都返回 3 页数据,所获得的 6 页数据在服务层进行内存排序,获得数据全局视野,再取第 3 页数据,便可以获得想要的全局分页数据。
总结一下这个方案的步骤:
- 将
order by time offset X limit Y
,改写成order by time offset 0 limit X+Y
;- 服务层将改写后的 SQL 语句发往各个分库:即例子中的各取 3 页数据;
- 假设共分为 N 个库,服务层将获得 N*(X+Y) 条数据:即例子中的 6 页数据;
- 服务层对获得的 N*(X+Y) 条数据进行内存排序,内存排序后再取偏移量 X 后的 Y 条记录,就是全局视野所需的一页数据。
方案优势:
方案缺点(显而易见):
“全局视野法”虽然性能较差,但其业务无损,数据精准,不失为一种方案,有没有性能更优的方案呢? “任何脱离业务的架构设计都是耍流氓”,技术方案须要折衷,在技术难度较大的状况下,业务需求的折衷可以极大的简化技术方案。
在数据量很大,翻页数不少的时候,不少产品并不提供“直接跳到指定页面”的功能,而只提供“下一页”的功能,这一个小小的业务折衷,就能极大的下降技术方案的复杂度。
如图所示,不容许跳页,那么第一次只可以查第一页:
- 将查询
order by time offset 0 limit 100
,改写成order by time where time > 0 limit 100
;- 上述改写和
offset 0 limit 100
的效果相同,都是每一个分库返回了一页数据(如图所示);- 服务层获得 2 页数据,内存排序,取出前 100 条数据,做为最终的第一页数据,这个全局的第一页数据,通常来讲每一个分库都包含一部分数据(如图所示);
database1 (id%2=0) | database2 (id%2=1) |
---|---|
db0-page1(第一页) | db1-page1(第一页) |
db0-page2 | db1-page2 |
db0-page3 | db1-page3 |
... (order by time) | ... (order by time) |
疑问:这个方案也须要服务器内存排序,岂不是和“全局视野法”同样么?第一页数据的拉取确实同样,但每一次“下一页”拉取的方案就不同了。
点击“下一页”时,须要拉取第二页数据,在第一页数据的基础之上,可以找到第一页数据 time 的最大值:
database1 (id%2=0) | database2 (id%2=1) |
---|---|
db0-page1(time 最大值) | db1-page1 |
db0-page2 | db1-page2(time 最大值) |
db0-page3 | db1-page3 |
... (order by time) | ... (order by time) |
这个上一页记录的 time_max,会做为第二页数据拉取的查询条件:
- 将查询
order by time offset 100 limit 100
,改写成order by time where time > > $time_max limit 100
;- 这下不是返回 2 页数据了(“全局视野法,会改写成
offset 0 limit 200
”),每一个分库仍是返回一页数据(如图所示);- 服务层获得 2 页数据,内存排序,取出前 100 条数据,做为最终的第 2 页数据,这个全局的第 2 页数据,通常来讲也是每一个分库都包含一部分数据(如图所示);
- 如此往复,查询全局视野第 100 页数据时,不是将查询条件改写为
offset 0 limit 9900+100
(返回 100 页数据),而是改写为time > $time_max99 limit 100
(每一个分库仍是返回一页数据),以保证数据的传输量和排序的数据量不会随着不断翻页而致使性能降低。
“全局视野法”可以返回业务无损的精确数据,在查询页数较大,例如第 100 页时,会有性能问题,此时业务上是否可以接受,返回的 100 页不是精准的数据,而容许有一些数据误差呢?
数据库分库-数据均衡原理
使用 patition key 进行分库,在数据量较大,数据分布足够随机的状况下,各分库全部非 patition key 属性,在各个分库上的数据分布,统计几率状况是一致的。
例如,在 uid 随机的状况下,使用 uid 取模分两库,db0 和 db1:
database1 (id%2=0) | database2 (id%2=1) |
---|---|
db0-page1 | db1-page1 |
db0-page2 | db1-page2 |
db0-page3(取一半) | db1-page3(取一半) |
... (order by time) | ... (order by time) |
利用这一原理,要查询全局 100 页数据,
offset 9900 limit 100
改写为offset 4950 limit 50
,每一个分库偏移 4950(一半),获取 50 条数据(半页),获得的数据集的并集,基本可以认为,是全局数据的offset 9900 limit 100
的数据,固然,这一页数据的精度,并非精准的。
根据实际业务经验,用户都要查询第 100 页网页、帖子、邮件的数据了,这一页数据的精准性损失,业务上每每是能够接受的,但此时技术方案的复杂度便大大下降了,既不须要返回更多的数据,也不须要进行服务内存排序了。
有没有一种技术方案,即可以知足业务的精确须要,无需业务折衷,又高性能的方法呢?这就是接下来要介绍的终极武器:“二次查询法”。
为了方便举例,假设一页只有 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
,为了更加直观一点,咱们按照 3 个分库的例子来演示,而且 time 用简单的 8 位数字来表示(如图所示):
database1 (id%3=0) | database2 (id%3=1) | database3 (id%3=2) |
---|---|---|
10000123 | 10000133 | 10000143 |
10000223 | 10000233 | 10000243 |
10000323 | 10000333 | 10000343 |
10000423 | 10000433 | 10000443 |
10000523 | 10000533 | 10000543 |
能够看到,每一个分库都是返回的按照 time 排序的一页数据。
database1 (id%3=0) | database2 (id%3=1) | database3 (id%3=2) |
---|---|---|
10000123(最小值) | 10000133 | 10000143 |
10000223 | 10000233 | 10000243 |
10000323 | 10000333 | 10000343 |
10000423 | 10000433 | 10000443 |
10000523 | 10000533 | 10000543 |
这三页数据中,time 最小值来自第一个库,time_min = 10000123
,这个过程只须要比较各个分库第一条数据,时间复杂度很低。
第一次改写的 SQL 语句是 select * from T order by time offset 333 limit 5
;
第二次要改写成一个 between 语句,between 的起点是 time_min,between 的终点是原来每一个分库各自返回数据的最小值(between 是指 >= 和 <=):
select * from T order by time where time between time_min and 10000133
;select * from T order by time where time between time_min and 10000143
;第二次查询,假设这三个分库返回的数据以下(固然咱们只须要查询 2 个库):
database1 (id%3=0) | database2 (id%3=1) | database3 (id%3=2) |
---|---|---|
- | - | 10000141 |
- | 10000132 | 10000142 |
10000123 | 10000133 | 10000143 |
咱们保持 time 排序不变,把二次查询的结果集拼起来,获得一个最新的结果集(如图所示):
database1 (id%3=0) | database2 (id%3=1) | database3 (id%3=2) |
---|---|---|
- | - | 10000141(第二次) |
- | 10000132(第二次) | 10000142(第二次) |
10000123 | 10000133 | 10000143 |
10000223 | 10000233 | 10000243 |
10000323 | 10000333 | 10000343 |
10000423 | 10000433 | 10000443 |
10000523 | 10000533 | 10000543 |
如今咱们来作一个简单的思惟推理:
- 咱们最初的需求是要
select * from T order by time offset 1000 limit 5
;- 而后由于分库的缘由,咱们分别对 3 个分库中
select * from T order by time offset 333 limit 5
;- 此时咱们获得最小值 time_min,因此能够先假设这 3 个分库中比 time_min 小的结果,一共有 333 * 3 = 999 个(SQL 语句第 1 个结果的 offset 是 0, offset = 333 实际上是第 334 个结果),因此假设 time_min 的 offset = 999;
- 若是不进行二次查询,咱们没法获得 offset = 1000 ~ 1004 的结果,由于我没法肯定在 time_min(10000123) 和(10000133)之间,time_min(10000123) 和(10000143)之间是否存在其它结果,并不能获得全局视野;
- 进行二次查询,第二个分库,改写为
select * from T order by time where time between time_min and 10000133
,第三个分库,改写为select * from T order by time where time between time_min and 10000143
,获得全局视野,咱们就能在内存中排序进行标号;- 进行二次查询之前,假设比 time_min 小的结果一共有 999 个,因为二次查询出告终果,(10000132,10000141,10000142)三个结果是算在咱们假设的 999 个之中的,也就是这三个结果须要后移,此时 time_min 的 offset = 996 = 999 - 3;
获得了 time_min 在全局的 offset,就至关于有了全局视野,根据总共二次的结果集,就可以获得全局offset 1000 limit 5
的记录。
database1 (id%3=0) | database2 (id%3=1) | database3 (id%3=2) |
---|---|---|
- | - | 10000141(offset=999) |
- | 10000132(offset=997) | 10000142(offset=1000) |
10000123(offset=996) | 10000133(offset=998) | 10000143(offset=1001) |
10000223(offset=1002) | 10000233(offset=1003) | 10000243(offset=1004) |
10000323(offset=......) | 10000333 | 10000343 |
10000423 | 10000433 | 10000443 |
10000523 | 10000533 | 10000543 |
方案优势:
方案缺点:
分库分表难题(一) 分表分页/跨库分页 难玩却不表明没有玩法
分库分表难题(二) 跨库/跨实例 Join 链接 不是非得依赖中间件