mysql sharding stack overflow_一次shardingjdbc踩坑引起的胡思乱想

项目里面的一个分表用到了sharding-jdbc

当时纠结过是用mycat还是用sharding-jdbc的, 但是最终还是用了sharding-jdbc, 原因如下:

1. mycat比较重, 相对于sharding-jdbc只需导入jar包就行, mycat还需要部署维护一个中间件服务.由于我们只有一个表需要分表, 直接用轻量级的sharding-jdbc即可.

2. mycat作为一个中间代理服务, 难免有性能损耗

3. 其他组用mycat的时候出现过生产BUG

然而sharding-jdbc也同样是坑坑洼洼不断的, 我们从2.x版本改成4.x版本, 又从4.x版本降到了3.x版本,每一个版本都踩到了坑(有些是官方的, 有些是由于我们项目依赖的),

最终不得已改动了一下源码才趟过去(其实就是注释了一行代码).

今天就来聊一下其中的一个坑--分表分页

问题描述

背景

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分表可以均衡写负载和读负载.

虽然是有点牵强, 但也有一定道理, 就先这么实现了

业务上需要根据orderId或CREATE_TM进行分页查询, 即查询sql的mybatis写法大概如下:

select

from ORDER

AND orderId=#{orderId , jdbcType=VARCHAR}

AND create_tm >= concat(#{createTmStartStr, jdbcType=VARCHAR},' 00:00:00')

AND create_tm <= concat(#{createTmEndStr, jdbcType=VARCHAR},' 23:59:59')

limit #{page.begin}, #{page.pageSize}

用过sharding-jdbc的都知道, sharding-jdbc一共有5种分片策略,如下图所示. 没用过的可以参考官网

ef010be2782c039536e8fbba47e32fce.png

除了Hint分片策略, 其他的分片策略都要求sql的where条件需要包含分片列(在我们的表中是orderId), 很明显我们的业务场景中不能保证sql的where条件中一定会包含有orderId, 所以我们只能使用HintShardingStrategy,将页面的查询条件传递给分片策略算法中, 再判断查询哪个表, 大概代码如下

public class OrderHintShardingAlgorithm implements HintShardingAlgorithm {

public static final String ORDER_TABLE = "ORDER";

@Override

public Collection doSharding(Collection availableTargetNames, ShardingValue shardingValue) {

ListShardingValue listShardingValue = (ListShardingValue) shardingValue;

List list = Lists.newArrayList(listShardingValue.getValues());

List 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条):

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给了个例子, 如果下图:

a43aade729015217b86d3b5c31fd4cae.png

图中的例子中,想要取得两个表中共同的按照分数排序的第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子句也会被改写了), 如图:

22d296b47d9d59fe9157451691b6ed2d.png

回到我们的业务场景, 如果查询条件包含了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 优化

在讨论分表环境下的分页性能之前, 我们先来看一下单表环境下应该实现分页.

众所周知, 在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. 而扫描的那些行都是没有意义.

scroll 游标查询

游标查询是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. 分页查询的接口直接查询汇总表

另外还要注意这个方案对业务来说肯定是有损的, 具体表现为:

```

不管是离线计算还是实时计算, 都不能保证实时性, 查询结果肯定是有时延的

由于汇总表是不可能包含分表的所有数据的, 所以汇总表肯定是只包含部分数据的,例如只有一个月内的,具体根据业务场景而定

总的来说, 就是报表系统的数据由数据仓库系统来生成, 但只能生成用户非要不可的数据,其他的都砍掉.

写这篇总结在找资料的时候, 看到一句话:

其实分表的根本目的是分摊写负载, 而不是分摊读负载

其实是有一定道理的, 如果读负载过高, 我们可以增加缓存, 增加数据节点等很多方法, 而写负载过高的话, 分表基本就是势在必行了.

从这个理论来说, 分页是一个读操作, 根本就没有必要去读取分表, 从其他地方读取(我们这里是数据仓库)即可

#### 不分表(分区 tidb mongoDb ES)

其实大多数mysql的表都没有必要分表的

在mysql5.5之前, 表数量大概在在500W之后就要进行优化, 在mysql5.5之后, 表数量在1KW到2KW左右才需要做优化.

在这个性能拐点之前, 可以认为mysql是完全有能力扛得住的.当然, 具体还要看qps以及读写冲突等的频率的.

到了性能拐点之后呢? 那就要考虑对mysql的表进行拆分了. 表拆分的手段可以是分表分库, 或者就简单的分区.

基本来说, 分区和分表带来的性能提升是一样的,

由于分区实际上就可以认为是mysql底层来帮我们实现分表的逻辑了, 所以相对来说分表会比分区带来更高的编码复杂度(分区就根本不用考虑多表分页查询的问题了).

从这个角度来说, 一般的业务直接分区就可以了.

当然, 选择分区还是分表还是需要做一点权衡的:

表中的数据只有部分热点数据经常访问, 其他的不常访问的话, 适合用分区表

分区表相对容易维护, 可以针对单独一个分区进行检查,优化, 批量删除大量数据时, 分区表会比一般的表更快

分区表可以分布在不同的物理设备上, 从而可以高效地利用多个硬盘

如果查询条件不包含partition key的话, 分区表不一定有分表效率高

如果分区表中绝对的热点数据, 每一条数据都有可能被访问到, 也不太适合分区

如果数据量超大, 由于mysql只能分1024个分区, 如果1024个分区的数据都是千万以上, 那肯定是也不适合分区的了

综上所述, 如果分区表就足够满足我们的话, 那其实就没有必要进行分表了增加编程的复杂度了.

另外, 如果不想将数据表进行拆分, 而表的数据量又的确很大的话, nosql也是一个替代方案. 特别是那些不需要强事务的表操作,

就很适合放在nosql, 从而可以避免编程的复杂度, 同时性能上也没有过多的损耗.

nosql的方案也有很多:

mongoDb

hbase

tidb

elasticSearch

```

当然也可以使用mysql+nosql结合的方式, 例如常规读写操作mysql, 分页查询走ES等等.

今天就先写到这, 有机会再写写mysql和nosql

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值