写在前面:如果对分库分表还不是很熟悉的,可以参考笔者之前的文章《分库分表技术演进暨最佳实践》。
在这篇文章中提到了一个场景,即电商的订单。我们都知道订单表有三大主要查询:基于订单ID查询,基于商户编号查询,基于用户ID查询。且那篇文章给出的方案是基于订单ID、商户编号、用户ID都有一份分库分表的数据。那么为什么要这么做?能否只基于某一列例如用户ID分库分表,答案肯定是不能。
笔者基于sharding-sphere(GitHub地址:https://github.com/apache/incubator-shardingsphere)进行了一个简单的测试,测试环境如下:
- 128个分表:image_${0..127};
- 数据库服务器:32C64G;
- 数据库版本:MySQL-5.7.23;
- 操作系统:CentOS 6.9 Final;
- 连接池:druid 1.1.6;
- mysql-connector-java:6.0.5;
- mybatis:3.4.5;
- mybatis-spring:1.3.1;
- springboot:1.5.9.RELEASE;
- sharding-sphere-3.1.0;
- JVM参数:-Xmx2g -Xms2g -Xmn1g -Xss256k -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -XX:+AlwaysPreTouch;
- druid配置:默认参数;
表信息如下:
-- id是分片键。备注,DDL是伪SQL
CREATE TABLE `image_${0..127}` (
`id` varchar(32) NOT NULL,
`image_no` varchar(50) NOT NULL,
`file_name` varchar(200) NOT NULL COMMENT '影像文件名称',
`source` varchar(32) DEFAULT NULL COMMENT '影像来源',
`create_date` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '影像文件创建时间',
PRIMARY KEY (`id`),
KEY `idx_image_no` (`image_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
第1个测试场景如下:
- 每个分表大概160w数据;
- 累计1w次跨分片(imageNo)查询 PK. 带分片(id)查询;
测试结果如下:
结论:由测试结果可知,跨分片查询相比带分片键查询的性能衰减了很多。
第2个测试场景如下:
- 每个分表大概160w数据;
- 累计1w次分别测试跨1个分表,8个分表、16个分表、32个分表、64个分表、128个分表,结果如下:
结论:跨的分表数量越大,跨分表查询的性能越差;
- 为什么慢
我们要弄明白跨分片查询为什么这么慢之前,首先要掌握跨分片查询原理。以sharding-sphere为例,其跨分片查询的原理是:通过线程池并发请求到所有符合路由规则的目标分表,然后对所有结果进行归并。需要说明的是,当路由结果只有1个,即不跨分片操作时sharding-sphere不会通过线程池异步执行,而是直接同步执行,这么做的原因是为了减少线程开销,核心源码在ShardingExecuteEngine.java中)。
既然是这个执行原理,为什么跨分片查询,随着跨分片数量越多,性能会越来越差?我们再看一下第2个测试场景,当测试跨1个分表时,1w次查询只需要5889ms,即平均1次查询不到1ms。所以性能瓶颈不应该在SQL执行阶段,而应该在结果归并阶段。为了验证这个猜想,笔者空跑sharding-sphere依赖的并发执行组件google-guava的MoreExecutors。其结果如下:
结论:由这个测试结果可知,当并发执行越来越多,其结果归并的代价越来越大。
附--空跑sharding-sphere依赖的并发执行组件google-guava的MoreExecutors的部分源码如下:
public class ConcurrentExecutorTest {
private static final ListeningExecutorService executorService;
public static final int CONCURRENT_COUNT = 64;
public static final int batchSize = CONCURRENT_COUNT;
public static final int EXECUTOR_SIZE = 8;
static {
executorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(EXECUTOR_SIZE));
MoreExecutors.addDelayedShutdownHook(executorService, 60, TimeUnit.SECONDS);
}
private static <I, O> List<O> execute(final Collection<I> inputs) {
if (inputs.isEmpty()) {
return Collections.emptyList();
}
// 并发执行
Collection<ListenableFuture<O>> allFutures = asyncExecute(inputs);
// 结果归并
return getResults(allFutures);
}
private static <I, O> Collection<ListenableFuture<O>> asyncExecute(final Collection<I> inputs) {
Collection<ListenableFuture<O>> result = new ArrayList<>(inputs.size());
for (final I each : inputs) {
// 异步执行时直接返回结果
result.add(executorService.submit(() -> (O) each));
}
return result;
}
private static <O> List<O> getResults(final Collection<ListenableFuture<O>> allFutures) {
List<O> result = new LinkedList<>();
for (ListenableFuture<O> each : allFutures) {
result.add(each.get());
}
return result;
}
}
-- 最后总结
跨分片查询的性能这么差,为什么sharding-sphere还要去做呢?笔者认为首先sharding-sphere是一个通用的分库分表中间件,而不是在某些特定条件才能使用的中间件,所以应该要尽可能的兼容所有SQL。其次,即使跨分片查询性能这么差,这个主要是在OLTP系统中使用时要小心,在一些OLAP或者后台管理系统等一些低频次操作的系统中,还是可以使用的。
比如,账户表已经根据账户ID分表,但是在运营操作的后台管理系统中维护账户信息时,肯定有一些操作的SQL是不会带有分片键账户ID的,比如查询账户余额最多的88个土豪用户。这个时候,我们可以通过sharding-sphere中间件直接执行这条低频次SQL。而不需要为了这些操作引入es或者其他组件来解决这种低频次的问题(当然,随着系统的演进,最后可能还是需要引入es等一些中间件来解决这些问题)。所以,分库分表中间件的跨分片查询在项目特定阶段能够大大减少开发成本,从而以最短的时间上线业务需求。