项目里面的一个分表用到了sharding-jdbchtml
当时纠结过是用mycat仍是用sharding-jdbc的, 可是最终仍是用了sharding-jdbc, 缘由以下:mysql
1. mycat比较重, 相对于sharding-jdbc只需导入jar包就行, mycat还须要部署维护一个中间件服务.因为咱们只有一个表须要分表, 直接用轻量级的sharding-jdbc便可.
2. mycat做为一个中间代理服务, 不免有性能损耗
3. 其余组用mycat的时候出现过生产BUG
复制代码
然而sharding-jdbc也一样是坑坑洼洼不断的, 咱们从2.x版本改为4.x版本, 又从4.x版本降到了3.x版本,每个版本都踩到了坑(有些是sharding-jdbc的, 有些是因为咱们项目依赖的), 最终不得已改动了一下源码才趟过去(其实就是注释了一行代码).算法
今天就来聊一下其中的一个坑--分表分页sql
CREATE TABLE `order_00` (
`id` bigint(18) NOT NULL AUTO_INCREMENT COMMENT '逻辑主键',
`orderId` varchar(32) NOT NULL COMMENT '订单ID',
`CREATE_TM` datetime DEFAULT NULL COMMENT '订单建立时间',
PRIMARY KEY (`ID`) USING BTREE,
UNIQUE KEY `IDX_ORDER_POSTID` (`orderId`) USING BTREE,
KEY `IDX_ORDER_CREATE_TM` (`CREATE_TM`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT COMMENT='订单表';
CREATE TABLE `order_01` (
`id` bigint(18) NOT NULL AUTO_INCREMENT COMMENT '逻辑主键',
`orderId` varchar(32) NOT NULL COMMENT '订单ID',
`CREATE_TM` datetime DEFAULT NULL COMMENT '订单建立时间',
PRIMARY KEY (`ID`) USING BTREE,
UNIQUE KEY `IDX_ORDER_POSTID` (`orderId`) USING BTREE,
KEY `IDX_ORDER_CREATE_TM` (`CREATE_TM`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT COMMENT='订单表';
CREATE TABLE `order_02` (
`id` bigint(18) NOT NULL AUTO_INCREMENT COMMENT '逻辑主键',
`orderId` varchar(32) NOT NULL COMMENT '订单ID',
`CREATE_TM` datetime DEFAULT NULL COMMENT '订单建立时间',
PRIMARY KEY (`ID`) USING BTREE,
UNIQUE KEY `IDX_ORDER_POSTID` (`orderId`) USING BTREE,
KEY `IDX_ORDER_CREATE_TM` (`CREATE_TM`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT COMMENT='订单表';
复制代码
假设有以上三个分表, 分表逻辑用orderId取模, 即orderId=0的写到order_00,orderId=1的写到order_01,orderId=2的写到order_02.数据库
备注: 这里为啥不用时间分表而用orderId作hash, 当时也是很有争议的. 理论上订单表更适合使用时间作分表, 这样一来时间越老的数据访问的频率越小, 旧的分表逐渐就会成为冷表, 再也不被访问到. 当时负责人的说法是, 因为这个表读写频率都高(并且场景中常常须要读主库), 用orderId分表能够均衡写负载和读负载. 虽然是有点牵强, 但也有必定道理, 就先这么实现了apache
业务上须要根据orderId或CREATE_TM进行分页查询, 即查询sql的mybatis写法大概以下:编程
<select id="queryPage" parameterType="xxx" resultMap="BaseResultMap">
select
<include refid="Base_Column_List"/>
from ORDER
<if test="orderId !=null and orderId !='' ">
AND orderId=#{orderId , jdbcType=VARCHAR}
</if>
<if test="createTmStartStr!=null and createTmStartStr!='' ">
AND create_tm >= concat(#{createTmStartStr, jdbcType=VARCHAR},' 00:00:00')
</if>
<if test="createTmEndStr!=null and createTmEndStr!='' ">
AND create_tm <= concat(#{createTmEndStr, jdbcType=VARCHAR},' 23:59:59')
</if>
limit #{page.begin}, #{page.pageSize}
</select>
复制代码
用过sharding-jdbc的都知道, sharding-jdbc一共有5种分片策略,以下图所示. 没用过的能够参考官网json
除了Hint分片策略, 其余的分片策略都要求sql的where条件须要包含分片列(在咱们的表中是orderId), 很明显咱们的业务场景中不能保证sql的where条件中必定会包含有orderId, 因此咱们只能使用HintShardingStrategy,将页面的查询条件传递给分片策略算法中, 再判断查询哪一个表, 大概代码以下缓存
public class OrderHintShardingAlgorithm implements HintShardingAlgorithm {
public static final String ORDER_TABLE = "ORDER";
@Override
public Collection<String> doSharding(Collection<String> availableTargetNames, ShardingValue shardingValue) {
ListShardingValue<String> listShardingValue = (ListShardingValue<String>) shardingValue;
List<String> list = Lists.newArrayList(listShardingValue.getValues());
List<String> actualTable = Lists.newArrayList();
// 页面上的查询条件会以json的方式传到shardingValue变量中
String json = list.get(0);
OrderQueryCondition req = JSON.parseObject(json, OrderQueryCondition.class);
String orderId = req.getOrderId();
// 查询条件没有orderId, 要查全部的分表
if(StringUtils.isEmpty(orderId)){
// 全部的分表
for(int i = 0 ; i< 3; i++){
actualTable.add(ORDER_TABLE + "_0" + i);
}
}else{
// 若是指定了orderId, 只查orderId所在的分表便可
long tableSuffix = ShardingUtils.getHashInteger(orderId);
actualTable.add(ORDER_TABLE + "_0" + tableSuffix);
}
// actualTable中包含sharding-jdbc实际会查询的表
return actualTable;
}
}
复制代码
这样子, 若是咱们根据orderId来查询的话, sharding-jdbc最终执行的sql就是(假设每页10条):bash
select * from ORDER_XX where orderId = ? limit 0 ,10
复制代码
若是查询条件没有orderId, 那么最终执行的sql就是3条(假设每页10条):
select * from ORDER_00 where create_tm >= ? and create_tm <= ? limit 0 ,10 ;
select * from ORDER_01 where create_tm >= ? and create_tm <= ? limit 0 ,10 ;
select * from ORDER_02 where create_tm >= ? and create_tm <= ? limit 0 ,10 ;
复制代码
注意在有多个分表的状况下, 每一个表都取前10条数据出来(一共30条), 而后再排序取前10条, 这样的逻辑是不对的. sharding-jdbc给了个例子, 若是下图:
图中的例子中,想要取得两个表中共同的按照分数排序的第2条和第3条数据,应该是95和90。 因为执行的SQL只能从每一个表中获取第2条和第3条数据,即从t_score_0表中获取的是90和80;从t_score_0表中获取的是85和75。 所以进行结果归并时,只能从获取的90,80,85和75之中进行归并,那么结果归并没有论怎么实现,都不可能得到正确的结果.
那怎么办呢?
sharding-jdbc的作法就改写咱们的sql, 先查出来全部的数据, 再作归并排序
例如查询第2页时
原sql是:
select * from ORDER_00 where create_tm >= ? and create_tm <= ? limit 10 ,10 ;
select * from ORDER_01 where create_tm >= ? and create_tm <= ? limit 10 ,10 ;
select * from ORDER_02 where create_tm >= ? and create_tm <= ? limit 10 ,10 ;
会被改写成:
select * from ORDER_00 where create_tm >= ? and create_tm <= ? limit 0 ,20 ;
select * from ORDER_01 where create_tm >= ? and create_tm <= ? limit 0 ,20 ;
select * from ORDER_02 where create_tm >= ? and create_tm <= ? limit 0 ,20 ;
复制代码
查询第3页时
原sql是:
select * from ORDER_00 where create_tm >= ? and create_tm <= ? limit 20 ,10 ;
select * from ORDER_01 where create_tm >= ? and create_tm <= ? limit 20 ,10 ;
select * from ORDER_02 where create_tm >= ? and create_tm <= ? limit 20 ,10 ;
会被改写成:
select * from ORDER_00 where create_tm >= ? and create_tm <= ? limit 0 ,30 ;
select * from ORDER_01 where create_tm >= ? and create_tm <= ? limit 0 ,30 ;
select * from ORDER_02 where create_tm >= ? and create_tm <= ? limit 0 ,30 ;
复制代码
固然, 你们确定会以为这样处理性能会不好, 其实事实上也的确是, 不过sharing-jdbc是在这个基础上作了优化的,就是上面提到的"归并", 具体归并过程能够戳这里查看官网的说明.篇幅比较长, 我这里就再也不贴出来了
大概的逻辑就是先查出全部页的数据, 而后经过流式处理跳过前面的页,只取最终须要的页,最终达到分页的目的
既然sharding-jdbc都已经优化好了, 那么咱们踩到的坑究竟是什么呢?
听我慢慢道来
在io.shardingsphere.shardingjdbc.jdbc.core.statement.ShardingPreparedStatement#getResultSet()中有个逻辑,
若是查询的分表数只有一个的话, 就不会作归并的逻辑(然而就算只查一个分表, sql的limit子句也会被改写了), 如图:
回到咱们的业务场景, 若是查询条件包含了orderId的话, 由于能够定位到具体的表, 因此最终须要查询的分表就只有一个.
那么问题就来了, 因为sharding-jdbc把咱们的sql的limit子句给改写了, 后面却因为只查一个分表而没有作归并(也就是没有跳过前面的页),因此最终无论是查询第几页,执行的sql都是(假设页大小是10000):
select * from ORDER_XX where orderId = ? limit 0 ,10000
select * from ORDER_XX where orderId = ? limit 0 ,20000
select * from ORDER_XX where orderId = ? limit 0 ,30000
select * from ORDER_XX where orderId = ? limit 0 ,40000
......
复制代码
这样就致使了一个问题, 无论我传的页码是什么, sharding-jdbc都会给我返回同一条数据. 很明显这样是不对的.
固然, 心细的朋友可能会发现了, 因为orderId是个惟一索引, 因此确定只有一条数据, 因此永远不会存在查询第二页的状况.
正常来讲的确是这样, 然而在咱们的代码里面, 还有个老逻辑: 导出查询结果(就是导出全部页的数据)时, 会异步地在后台一页一页地 导出, 直到导出了全部的页或者达到了查询次数上限(假设是查询1万次).
因此在根据orderId导出的时候, 由于每一页都返回相同的数据, 因此判断不了何时是"导完了全部的页", 因此正确结果本应该是只有一条数据的, 可是在sharding-jdbc下却执行了一万次, 导出了一万条相同的数据, 你说这个是否是坑呢?
知道问题所在, 那解决就简单了. 可是本文并非想聊怎么解决这个问题的, 而是想聊聊经过这个问题引发的思考:
在mysql分表环境下, 如何高效地作分页查询?
复制代码
在讨论分表环境下的分页性能以前, 咱们先来看一下单表环境下应该实现分页.
众所周知, 在mysql里面实现分页只须要使用limit子句便可, 即
select * from order limit (pageNo-1) * pageSize, pageSize
复制代码
因为在mysql的实现里面, limit offset, size是先扫描跳过前面的offset条数据,再取size条数据. 当pageNo越大的时候, offset也会越大, mysql扫描的数据也越大, 因此性能会急剧降低.
所以, 分页第一个要解决的问题就是当pageNo过大时, 怎么优化性能.
第一个方案是这篇文章介绍的索引覆盖的方案. 总结来讲就是把sql改写成这样:
select * from order where id >= (select id from order limit (pageNo-1) * pageSize, 1) limit pageSize
复制代码
利用索引覆盖的原理, 先直接定位当前页的第一条数据的最小id, 而后再取须要的数据.
这样的确能够提升性能, 可是我认为仍是没有完全解决问题, 由于当pageNo过大的时候, mysql仍是会须要扫描不少的行来找到最小的id. 而扫描的那些行都是没有意义.
游标查询是elasticSearch里面的一个术语, 可是我这里并非指真正的scroll查询, 而是借鉴ES里面的思想来实现mysql的分页查询.
所谓的scroll就是滚动, 一页一页地查. 大概的思想以下:
1. 查询第1页
select * from order limit 0, pageSize;
2. 记录第1页的最大id: maxId
3. 查询第2页
select * from order where id > maxId limit pageSize
4. 把maxId更新为第2页的最大id
... 以此类推
复制代码
能够看到这种算法对于mysql来讲是毫无压力的, 由于每次都只须要扫描pageSize条数据就能达到目的. 相对于上面的索引覆盖的方案, 能够极大地提升查询性能.
固然它也有它的局限性:
1. 性能的提升带来的代价是代码逻辑的复杂度提升. 这个分页逻辑实现起来比较复杂.
2. 这个算法对业务数据是有要求的, 例如id必须是单调递增的,并且查询的结果须要是用Id排序的.
若是查询的结果须要按其余字段(例如createTime)排序, 那就要求createTime也是单调的, 并把算法中的id替换成createTime.
有某些排序的场景下, 这种算法会不适用.
3. 这个算法是须要业务上作妥协的, 你必须说服你的产品经理放弃"跳转到特定页"的功能, 只能经过点击"下一页"来进行翻页.
(这才是scroll的含义, 在手机或平板上,只能经过滚动来翻页,而没法直接跳转到特定页)
复制代码
如上面讨论, 在单表环境下, 想要实现高效的分页, 仍是相对比较简单的.
那若是在分表环境下, 分页的实现会有什么不一样呢?
正如上面提到的, sharding-jdbc中已经论证过了, 分表环境的分页查询, 若是不把
select * from ORDER_00 where create_tm >= ? and create_tm <= ? limit (pageNo-1) * pageSize ,pageSize ;
select * from ORDER_01 where create_tm >= ? and create_tm <= ? limit (pageNo-1) * pageSize ,pageSize;
select * from ORDER_02 where create_tm >= ? and create_tm <= ? limit (pageNo-1) * pageSize ,pageSize ;
复制代码
改写成
select * from ORDER_00 where create_tm >= ? and create_tm <= ? limit 0 , (pageNo-1) * pageSize + pageSize ;
select * from ORDER_01 where create_tm >= ? and create_tm <= ? limit 0 , (pageNo-1) * pageSize + pageSize;
select * from ORDER_02 where create_tm >= ? and create_tm <= ? limit 0 , (pageNo-1) * pageSize + pageSize ;
复制代码
那么最终查出来的数据, 颇有可能不是正确的数据. 因此在分表环境下, 上面所说的"索引覆盖法"和"游标查询法"确定是都不适用了的. 由于必须查出全部节点的数据,再进行归并, 那才是正确的数据.
所以, 要在分表环境下实现分页功能, 基本上是要对limit子句进行改写了的.
先来看sharing-jdbc的解决方案, 改写后的limit 0 , (pageNo-1) * pageSize + pageSize 和原来的limit (pageNo-1) * pageSize, pageSize对比, 数据库端的查询压力都是差很少的, 由于都是要差很少要 扫描(pageNo-1) * pageSize 行才能取获得数据. 不一样的是改写sql后, 客户端的内存消耗和网络消耗变大了.
sharding-jdbc巧妙地利用流式处理和优先级队列结合的方式, 消除了客户端内存消耗的压力, 可是网络消耗的影响依然是没法消除.
因此真的没有更好的方案了?
那确定是有的, 在业界难题-“跨库分页”的四种方案这篇文章中, 做者提到了一种"二次查询法", 就很是巧妙地解决了这个分页查询的难题. 你们能够参考一下.
可是仔细思考一下, 仍是有必定的局限性的:
1. 当分表数为N时, 查一页数据要执行N*2条sql.(这个无解, 只要分表了就必须这样)
2. 当offset很大的时候, 第一次查询中扫描offset行数据依然会很是的慢, 若是只分表不分库的话, 那么一次查询会在一个库中产生N条慢sql
3. 算法实现起来代码逻辑应该不简单, 若是为了一个分页功能写这么复杂的逻辑, 是否是划不来,
并且后期也很差维护
复制代码
若是算法原做者看到我这里的鸡蛋挑骨头, 会不会有点想鄙视我~~
其实我想表达的意思是, 既然分表环境下的分页查询没有完美的解决方案的话,或者实现起来成本过大的话, 那是否是能够认为: 分表环境下就不该该作分页查询?
上面说到, 其实分表环境下就不适宜再作分页查询的功能. 可是业务上的需求并非说去掉就去掉的, 不少状况下分页功能是必须的, 然而分页查询的存在一般也是为了保护数据库, 去掉了分页功能, 数据库的压力反而更大.
因此分表和分页只能二选一?
不, 我全都要, 分表我要, 分页我也要!
可是分页功能不在分表环境里面作, 而是在另一张汇总表里面作分页查询的功能.
大概的方案就是:
1. 正常的业务读写分表
2. 根据具体的业务需求,例如实时计算/离线计算技术(spark, hadoop,hive, kafka等)生成各分表的一张汇总表
3. 分页查询的接口直接查询汇总表
复制代码
另外还要注意这个方案对业务来讲确定是有损的, 具体表现为:
1. 无论是离线计算仍是实时计算, 都不能保证明时性, 查询结果确定是有时延的
2. 因为汇总表是不可能包含分表的全部数据的, 因此汇总表确定是只包含部分数据的,例如只有一个月内的,具体根据业务场景而定
复制代码
总的来讲, 就是报表系统的数据由数据仓库系统来生成, 但只能生成用户非要不可的数据,其余的都去掉.
写这篇总结在找资料的时候, 看到一句话:
其实分表的根本目的是分摊写负载, 而不是分摊读负载
复制代码
实际上是有必定道理的, 若是读负载太高, 咱们能够增长缓存, 增长数据节点等不少方法, 而写负载太高的话, 分表基本就是势在必行了.
从这个理论来讲, 分页是一个读操做, 根本就没有必要去读取分表, 从其余地方读取(咱们这里是数据仓库)便可
其实大多数mysql的表都没有必要分表的
在mysql5.5以前, 表数量大概在在500W以后就要进行优化, 在mysql5.5以后, 表数量在1KW到2KW左右才须要作优化. 在这个性能拐点以前, 能够认为mysql是彻底有能力扛得住的.固然, 具体还要看qps以及读写冲突等的频率的.
到了性能拐点以后呢? 那就要考虑对mysql的表进行拆分了. 表拆分的手段能够是分表分库, 或者就简单的分区.
基原本说, 分区和分表带来的性能提高是同样的, 因为分区实际上就能够认为是mysql底层来帮咱们实现分表的逻辑了, 因此相对来讲分表会比分区带来更高的编码复杂度(分区就根本不用考虑多表分页查询的问题了). 从这个角度来讲, 通常的业务直接分区就能够了.
固然, 选择分区仍是分表仍是须要作一点权衡的:
1. 表中的数据只有部分热点数据常常访问, 其余的不常访问的话, 适合用分区表
2. 分区表相对容易维护, 能够针对单独一个分区进行检查,优化, 批量删除大量数据时, 分区表会比通常的表更快
3. 分区表能够分布在不一样的物理设备上, 从而能够高效地利用多个硬盘
4. 若是查询条件不包含partition key的话, 分区表不必定有分表效率高
5. 若是分区表中绝对的热点数据, 每一条数据都有可能被访问到, 也不太适合分区
6. 若是数据量超大, 因为mysql只能分1024个分区, 若是1024个分区的数据都是千万以上, 那确定是也不适合分区的了
复制代码
综上所述, 若是分区表就足够知足咱们的话, 那其实就没有必要进行分表了增长编程的复杂度了.
另外, 若是不想将数据表进行拆分, 而表的数据量又的确很大的话, nosql也是一个替代方案. 特别是那些不须要强事务的表操做, 就很适合放在nosql, 从而能够避免编程的复杂度, 同时性能上也没有过多的损耗.
nosql的方案也有不少:
1. mongoDb
2. hbase
3. tidb
4. elasticSearch
复制代码
固然也可使用mysql+nosql结合的方式, 例如常规读写操做mysql, 分页查询走ES等等.
今天就先写到这, 有机会再写写mysql和nosql