为什么要做这个测试
二级索引是关系型数据库相较于NoSQL数据库的一个关键差异。二级索引必须是强一致的,因此索引的写入需要与主键的写入放在一个事务当中,事务的性能是二级索引性能的基础。
目前市面上的分布式数据库中,从使用体验的角度看主流有几种形态:
- 以TiDB、CockroachDB等为代表的纯透明的用法。从表现上来看,该种类型的数据库所有表都是分布式表,并且不需要指定分区键,其核心逻辑是使用分布式事务来维护全局索引,并使用全局索引完全替代单机数据库中的二级索引。
- 以OceanBase等为代表的纯手动的用法。从表现上看,该种类型的数据库在不指定分区键的情况下,是以单表的形式存在的,不具备扩展性;创建分布式表需要使用类似分区表的语法。此类型的数据库一般也提供全局索引的能力(不提供的我们一般称之为中间件而不是数据库)。但与第一类不同,它们一般会将全局索引作为一个可选项,由用户手动的指定与创建。此外,他们还会提供基于单机事务实现的本地索引(本地索引)。
- 同时提供以上两种用法的PolarDB-X。在不指定分区键的情况下,与第一类数据库类似,使用分布式表+全局索引来提供透明的分布式体验;也允许手动指定分区键,使用本地索引等技术提升性能。
在之前的文章中《PolarDB-X 数据分布解读(四) :透明 vs 手动》,我们提出:
- 透明(自动)的易用性决定了分布式数据库的使用下限,但性能上是有更高的成本代价
- 手动能够提供最优的性能,但使用门槛会有所增加
分布式数据库需要为大多数场景提供能够透明使用的能力,也要为少数性能要求高的场景提供手动调优消除分布式事务的能力。
这个观点的重要依据是,纯透明的模式本质上是使用分布式事务+全局索引来替代单机数据库中的事务+索引,而分布式事务+全局索引与单机事务+索引存在较大的性能差异。
本次测试将重点关注不同分布式数据库的索引性能,特别关注业内全局索引的性能与MySQL索引的性能差异。
本次测试的产品包括TiDB、OB、PolarDB-X、CockroachDB,选取这几个数据库有以下原因:
- 他们都提供了强一致的全局索引能力,是数据库而不是中间件。
- 都有开源,并且都有云产品提供,历史也都比较悠久,资料比较多,不是PPT数据库,容易搞清楚内部的原理。
- 都是主要面向OLTP的数据库。
此外我们也测试了MySQL的索引性能作为对比。
测试方法
由于硬件配置(比如OB用了6台机器(并且租户设置上并没有占满整个机器),TiDB和TiKV用了5台,PolarDB-X和MySQL是直接公共云购买的等)、系统参数等等,对于每个数据库来说,不是完全相同,也不一定是最优的,所以直接对比TPS是没有意义的。
我们会将每个数据库,不带索引情况下,TPS能够达到的峰值作为基线(100%),比较不同索引数目的性能相对于基线的百分比。
例如,产品A不带索引能跑到10W的TPS,带一个索引跑到5W,那我们就认为带一个索引的情况下是基线的50%。这个百分比我们不妨称之为TPS百分比。
在产品之间的横向对比中,我们会对比相同索引数目下的TPS百分比,而不是TPS的绝对值。
在测试TPS百分比的时候,我们会调整并发度,来找到能够达到最大TPS的并发度,并以最大的TPS来计算TPS百分比。
除了TPS百分比之外,我们还会测试每个产品在不同索引数目情况下,单次写入的RT。在RT测试中,我们会用单线程来进行写入。
本次测试我们只测试insert场景,这是索引写入的最基本的功能了。我们使用sysbench的oltp_insert.lua制造流量。由于我们要测试多个索引,因此我们将sysbench的表结构做了修改,以MySQL为例,修改如下:
CREATE TABLE `sbtest1` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`k` int(11) NOT NULL ,
`k1` int(11) NOT NULL ,
`k2` int(11) NOT NULL ,
`k3` int(11) NOT NULL ,
`k4` int(11) NOT NULL ,
`k5` int(11) NOT NULL ,
`k6` int(11) NOT NULL ,
`k7` int(11) NOT NULL ,
`k8` int(11) NOT NULL ,
`c` char(120) NOT NULL DEFAULT '',
`pad` char(60) NOT NULL DEFAULT '',
PRIMARY KEY (`id`)
);
我们在表中增加了8个列,根据测试的索引数目,会在这个8个列上创建相应数目的二级索引。
同时我们要修改oltp_insert.lua,在INSERT语句中增加这8个列,8个列的值随机生成,没有有序性,避免在基于range进行分区的数据库(TiDB、CockroachDB)上产生热点:
con:query(string.format("INSERT INTO %s (id,k,k1,k2,k3,k4,k5,k6,k7,k8,c,pad) VALUES " ..
"(%d, %d,%d,%d,%d,%d,%d,%d,%d,%d,'%s','%s')",
table_name, i, k_val, sysbench.rand.default(1, sysbench.opt.table_size),sysbench.rand.default(1, sysbench.opt.table_size),sysbench.rand.default(1, sysbench.opt.table_size),sysbench.rand.default(1, sysbench.opt.table_size),sysbench.rand.default(1, sysbench.opt.table_size),sysbench.rand.default(1, sysbench.opt.table_size),sysbench.rand.default(1, sysbench.opt.table_size),sysbench.rand.default(1, sysbench.opt.table_size),c_val, pad_val))
由于不同数据库的语法、特性等不同(当然还有一些坑),每个数据库的建表语句可能会做一些修改,每个数据库最终使用的建表语句放在附录中。
软硬件环境
本次测试使用的机器使用的是阿里云上购买的ECS,规格为ecs.i2g.8xlarge:
操作系统为阿里云上提供的CentOS 8.3:
所有机器均在同一个地域的同一个可用区内。
所有数据库的数据文件均放在本地SSD中。
所有数据库均会在前面挂一个SLB做负载均衡。
sysbench使用的是1.0.20版本。
测试结果
我们可以看到,这些分布式数据库实现的全局索引中,即使只有1个索引,性能也都会下跌到30%以下,在8个索引的情况下,性能基本都会跌倒10%以下。
而像MySQL这种单机数据库,8个索引的情况下,性能依然保持在85%以上。
印证了我们在《PolarDB-X 数据分布解读(四) :透明 vs 手动》 提到过的观点“分布式事务跟单机事务相比,在成本(或者说性能)上依然存在不可逾越的鸿沟,这个差距至少在3倍以上”。
使用全局索引替代单机数据库索引会带来很高的成本,在成本敏感型的场景中,需要适当的使用本地索引来降低使用成本。
在提供了本地索引的数据库中:
● PolarDB-X和OB的本地索引与主键具有Locality上的亲和性,能使用单机事务来对索引进行写入,相对于全局索引,保持了非常高的性能。
● TiDB虽然提供了本地索引,但其索引和主键不具备Locality上的亲和性,无法绑定到同一个机器上,因此其本地索引依然要使用分布式事务进行维护,在性能上和全局索引没有太大差异,成本都很高。
● CockroachDB的本地索引理论上与TiDB的行为类似,不过CockroachDB的partition功能是商业版才提供的,这次就没有进行测试了
对于TiDB和CockroachDB来说,情况就比较尴尬了,因为他们所提供的所有索引,成本都要比单机MySQL高很多。作为用户没有任何手段能消除这个代价,除非,你不用二级索引。
从RT的角度看:
- 单机数据库由于事务的网络交互最少,RT表现的是最好的,并且跟索引的数量几乎没有关系。
- 分布式数据库的事务由于需要更多的跨节点交互,所以RT明显会比单机数据库更大。但由于分布式数据库在多分支的事务上一般都会采用并行写入的策略,因此表现好的数据库RT并不会随索引数量的增加而线性增加。总体说来,RT可以接受。
- CockroachDB全局索引的RT表现是最差的,可能跟事务策略使用HLC有关,其他几个数据库使用的都是TSO的方案。
- TiDB全局索引的RT表现的是最好的,0-8个索引RT几乎没有变化,说明并行优化做的非常好。
- 自家产品PolarDB-X全局索引的RT看起来还有优化的空间,虽然上涨有限,但并没有做到完全的并行。我们会在后续版本进行优化。本地索引RT非常稳定并且低。
- OB的全局索引和本地索引表现和PolarDB-X比较类似,并行优化有提升空间,本地索引表现不错。
测试过程中的一个额外发现,TiDB、OB、CockroachDB的自增主键(auto_increment/serial)都有比较严重的性能问题,都要使用随机等替代方案。TiDB与CockroachDB是因为时间序带来的热点range导致,OB可能是内部的一些锁导致。兼容性包括功能兼容与性能兼容,性能兼容之路漫漫...
下面附上每个数据库测试的情况。
测试详情
MySQL
环境配置
版本:5.7.14-AliSQL-X-Cluster-1.5.1.8-20201229-log
规格:32C128G,独享型
阿里云购买:
测试结果
OceanBase
环境配置
版本:社区版 3.1.4
租户配置:
CREATE RESOURCE UNIT unit1 MAX_CPU 16, MAX_MEMORY '32G', MAX_IOPS 12800,MAX_DISK_SIZE '1000G', MAX_SESSION_NUM 6400, MIN_CPU=8, MIN_MEMORY='16G', MIN_IOPS=12800;
CREATE RESOURCE POOL pool1 UNIT='unit1',UNIT_NUM=2,ZONE_LIST=('zone1','zone2','zone3');
CREATE TENANT idx_test CHARSET='utf8mb4', ZONE_LIST=('zone1','zone2','zone3'), PRIMARY_ZONE='zone1;zone2,zone3', RESOURCE_POOL_LIST=('pool1') ;
需要注意的几点:
OB中的表默认为单表(只分布在一个节点上),需要使用分区表的语法,指定分区键与分区数,才能成为一张分布式表
OB默认的索引是本地索引,需要指定Global关键字才是全局索引
OB的全局索引默认也是一个单表(只有一个分区,只分布在一个节点上),需要手动指定分区数才是一个真正的分布式索引
UNIT_NUM=1的情况下,似乎即使是分区表,也都在同一台机器上,所以测试机器数要>=6,确保UNIT_NUM>=2
OB全局索引如果在建表语句中直接指定,似乎不支持指定分区数,因此全局索引需要使用单独的CREATE INDEX语句来创建
OB分区表的AUTO_INCREMENT属性似乎有严重的性能问题,设置了之后,性能很低,要去掉该属性,并且将sysbench的auto_inc设为off,由sysbench来生成主键值
OB默认使用的时间服务是本地时间服务(LTS),这种模式下是不支持全局索引的,需要手动修改为全局时间服务(GTS):
SET GLOBAL ob_timestamp_service='GTS';
测试结果
全局索引:
本地索引:
TiDB
环境配置
版本:6.1.0 部署结构:
需要注意的点:
- TiDB中语法上没有“全局索引”这个词,但从原理角度看,TiDB任何一张表都是分布式表,任何一个二级索引都是全局索引,它没有单表的概念
- 不要使用AUTO_INCREMENT。TiDB中的AUTO_INCREMENT虽然是分段的,不保证自增连续,但拉长时间后依然是有一定时间序的,所以会导致热点。需要使用AUTO_RANDOM替换AUTO_INCREMENT。
- TiDB中支持Partition语法,但其Partition依然构建于分布式KV之上,意味着每个Partition下面都对应着一个或多个TiKV中的range(注意,这里的range和partition语法中的range是两码事,parition语法中的一个range,也可能对应多个TiKV中的range),这些range是会被自由的调度的。在使用了Partition语法的表上创建的索引,在功能上也叫局部索引。
测试结果
全局索引:
本地索引:
CockroachDB
环境配置
版本:22.1.6
CDB与TiDB的架构是类似的,表构建于分布式KV之上,所有表都是分布式表,所有索引都是全局索引,没有单表的概念。
需要注意的点:
在CDB中,使用Serial类型主键(类似mysql中的auto_increment),或者使用unique_rowid()作为主键,因为这两个都是有一定时间序的,都会产生显著的热点,几乎无法使用。测试中使用UUID类型的列,并使用 gen_random_uuid()生成主键。这个本质是一个字符串,在KV层range的划分上可以认为是无序的。
CDB每个连接的代价比较高,无法创建太多的连接
测试结果
全局索引:
PolarDB-X
环境配置
规格:公有云8C32G*2 版本:5.4.13 注意:
建库时需要使用mode=auto,这种类型的数据库表不指定分区键的情况下是分布式表,同时索引是全局索引。
索引前加Local关键字可以只创建Local索引
测试结果
全局索引:
本地索引:
附录
OceanBase 全局索引建表语句
create database sbtest_gsi8;
use sbtest_gsi8;
CREATE TABLE `sbtest1` (
`id` int(11) NOT NULL,
`k` int(11) NOT NULL ,
`k1` int(11) NOT NULL ,
`k2` int(11) NOT NULL ,
`k3` int(11) NOT NULL ,
`k4` int(11) NOT NULL ,
`k5` int(11) NOT NULL ,
`k6` int(11) NOT NULL ,
`k7` int(11) NOT NULL ,
`k8` int(11) NOT NULL ,
`c` char(120) NOT NULL ,
`pad` char(60) NOT NULL ,
PRIMARY KEY (`id`)
) partition by hash(id) partitions 32;
create index k_1 on sbtest1(k1) global partition by hash(k1) partitions 32;
create index k_2 on sbtest1(k2) global partition by hash(k2) partitions 32;
create index k_3 on sbtest1(k3) global partition by hash(k3) partitions 32;
create index k_4 on sbtest1(k4) global partition by hash(k4) partitions 32;
create index k_5 on sbtest1(k5) global partition by hash(k5) partitions 32;
create index k_6 on sbtest1(k6) global partition by hash(k6) partitions 32;
create index k_7 on sbtest1(k7) global partition by hash(k7) partitions 32;
create index k_8 on sbtest1(k8) global partition by hash(k8) partitions 32;
create database sbtest_gsi4;
use sbtest_gsi4;
CREATE TABLE `sbtest1` (
`id` int(11) NOT NULL,
`k` int(11) NOT NULL ,
`k1` int(11) NOT NULL ,
`k2` int(11) NOT NULL ,
`k3` int(11) NOT NULL ,
`k4` int(11) NOT NULL ,
`k5` int(11) NOT NULL ,
`k6` int(11) NOT NULL ,
`k7` int(11) NOT NULL ,
`k8` int(11) NOT NULL ,
`c` char(120) NOT NULL ,
`pad` char(60) NOT NULL ,
PRIMARY KEY (`id`)
) partition by hash(id) partitions 32;
create index k_1 on sbtest1(k1) global partition by hash(k1) partitions 32;
create index k_2 on sbtest1(k2) global partition by hash(k2) partitions 32;
create index k_3 on sbtest1(k3) global partition by