tidb这个技术名词很多同学或多或少都曾经耳闻过,但是很多同学觉得他是分布式数据库,自己的业务是使用mysql,基本使用不上这个技术,可能不会去了解他。最近业务上有个需求使用到了tidb,于是学习了一下基本原理,会发现这些原理其实不仅仅局限于分布式数据库这一块,很多技术都是通用的,所以在这里写一下分享一下学习tidb的一些心得。
先说说为什么选择tidb吧,一般来说在咱们的业务中都是使用的mysql,但是单机数据库容量和并发性能都有限,对于一些大容量或者高并发的场景我们会选择sharding-jdbc去做,使用sharding-jdbc的确解决了问题但是增加了开发难度,我需要对我的每一个表都设置分表key,并且每个查询都得带入这个key的值,这样就增加了查询限制,如果不带key的值就得所有库表都得查询一次才行,效率极低,所以我们又异构了一份数据到es来满足其他条件。怎么解决这个问题呢?正好公司最近内部在推tidb,我看了下tidb基本兼容mysql,存储无限扩展,开发成本比较低,性能整体也不错,所以决定使用了tidb。
关系型数据库的开始是以1970年Edgar F.Codd 提出了关系模型。在数据库发展早期阶段,出现了很多优秀的商业数据库产品,如Oracle/DB2。在1990年之后,出现了开源数据库MySQL和PostgreSQL。这些数据库不断地提升单机实例性能,再加上遵循摩尔定律的硬件提升速度,往往能够很好地支撑业务发展。
随着摩尔定律的失效,单体数据库的发展很难应对更高级别的挑战,所以就出现了分布式数据库,分布式数据库拥有应对海量并发,海量存储的能力所以能应对更难的挑战。
在我们学习某个知识的时候,一般都是会带着一些问题去学习,有目的的学习会让你更快的上手,对于tidb或者分布式数据库,我在使用的时候会有这些疑问:
再回答我们上面的那些问题之前,先看一看tidb的整体架构是什么?
tidb其实是典型的计算分离的架构,对计算分离架构不熟悉的可以看看我之前的文章:聊聊计算与分离
我们首先来到我们的第一个问题,Tidb如何做到无限扩展?
首先我们来看看计算层: tidb-server,我们刚才说过在计算层中,是无状态的,所以就可以进行无限扩展,如果你的场景并发度很高或者数据库连接很多,可以考虑多扩展tidb-server。
然后我们来看看存储层,有一类数据云数据库通常也会被误认为是分布式数据库,也就是aws的auroradb和阿里云的polardb,这两个数据库也是采用的计算与存储分离的架构,在计算层也可以无限扩展,但是在存储层他们使用的是一份数据,这个也就是shared-storage架构,这两个数据库依靠这大容量磁盘,来支撑更高容量的数据。
在tidb中是shared-nothing架构,存储层也是分离的:
在每个tikv上会划分出多个Region,这个也就是我们的基本存储单位,大家见这个图是不是发现这个架构似曾相识呢?
从上面看,region就对应这kafka下的partition,partition在kafka中的作用也是用来将topic的压力打散到不同broker上,同样的在tidb的region上也是一样的,我们通过region为最小单位进行存储。
再详细介绍region之前先说一下存储引擎为什么叫tikv呢?原因就是这个存储引擎就是保存的就是一个key-value,你可以理解成java里面的hashmap,在tikv中没有选择自己研发如何将这个map数据去落地,而是通过一个非常优秀的kv存储引擎——rocksdb去进行磁盘落地。RocksDB是Facebook开源的一个KV高性能单机数据库,很多公司基于rocksdb做了很多优秀的存储产品,后面也会详细的写一篇介绍rocksdb的文章。
rocksdb是一个单机的存储引擎那么我们是需要保证数据在分布式环境下是不丢失的,在kafka中有其他partition的副本会不断的拉取leader副本,并且通过一个ISR的机制去维护。在tikv中,直接使用的raft协议去做数据复制,每个数据变更都会落地为一条 Raft 日志,通过 Raft 的日志复制功能,将数据安全可靠地同步到复制组的每一个节点中。不过在实际写入中,根据 Raft 的协议,只需要同步复制到多数节点,即可安全地认为数据写入成功。
可以发现其实这里是写的raft,通过raft接口再写的rocksdb。
我们这里回到region,region还有一个partition不一样的点在于,partition一般不会自动去扩容,在业务开发中他往往是一个恒定得值,而region不一样,region的大小默认是96MB,再实际得业务中,我们的region的个数会随着我们数据量而变多,当然如果我们的数据量变小,他也会自动合并。
如何确定某个数据是在哪个region上呢?一般来说有hash(key)和range(key)的方案,在tikv中选择的是rangekey,因为对于region分裂是比较方便的,每一个region其实就是一个[StartKey,EndKey) 的表示:
出现region的分裂的时候,只需要新增一个region,将老region的数据拿出一部分到新region, 譬如 [a, b) -> [a, ab) + [ab, b),如果是hash来做的话,他会将所有region的数据都会重新hash,所以在tikv中选的是range(key)的方式,合并也是一样。
所以对于tidb来说无论是存储层还是计算层,我们都可以无限扩展。
在mysql中我们可以对于主键直接设置 AUTO_INCREMENT
来达到自增列的效果,mysql是怎么做到自增的呢?
在单机中这些都好做,但是在分布式数据库中,我们就没法保证id的唯一了,我之前有写过相关的文章:如果再有人问你分布式ID,这篇文章丢给他。我们在使用sharding-jdbc的时候就是使用的文章介绍的leaf这个ID生成中间件,来完成ID生成。
在Tidb中同样支持 AUTO_INCREMENT
,实现的原理和leaf中的号段模式一样,不能保证严格递增,只能保证趋势递增,具体原理是:,对于每一个自增列,都使用一个全局可见的键值对用于记录当前已分配的最大 ID。由于分布式环境下的节点通信存在一定开销,为了避免写请求放大的问题,每个 TiDB 节点在分配 ID 时,都申请一段 ID 作为缓存,用完之后再去取下一段,而不是每次分配都向存储节点申请。
tidb还支持 AUTO_RANDOM
,可以用于解决大批量写数据入 TiDB 时因含有整型自增主键列的表而产生的热点问题。因为region是有序的如果一段时间大量有序的数据产生有可能会在同一个region上,所以我们可以使用AUTO_RANDOM来将我们的主键数据打散。
这里我们先回顾一下事务的四大特性ACID,我们来想想在mysql的innodb中这个是怎么做的呢?
在tidb中ACID是什么做到的呢?
在mysql中的事务模型都是悲观事务模型,而在tidb中事务模型提供了乐观和悲观两种,怎么去理解悲观和乐观两种模型呢:
在tidb中是如何实现这两种模式的呢?因为我们是分布式数据库,两阶段提交一般是分布式事务的通用解决方案,之前我写过很多分布式事务相关的文章大家可以自行查阅一下。
tidb同样使用两阶段提交来保证分布式事务的原子性,分为 Prewrite 和 Commit 两个阶段:
整个事务步骤如下:
begintrasaction;
begin;
//step1
insert into xx;
// step3
update xx;
// step3
update xx;
// step3
commit;// step4
在上面的例子中如果是悲观模式step3的时候就会进行加锁检测了,乐观模式下所有的工作都放在了commit中,所以会出现commit出现异常的状态,所以我们使用乐观模式需要更好的处理commit阶段的异常行为,这和我们一般的编程不一样。但是如果数据的竞争不是太激烈的话是可以使用乐观模式来提升性能的。
悲观模式把lock进行了提前,每个 DML 都会加悲观锁,锁写到 TiKV 里,同样会通过 raft 同步,在加悲观锁时检查各种约束,如 Write Conflict、key 唯一性约束等。
悲观事务下能保证我们的commit成功,这种模式比较符合我们的编程模式,所以tidb默认的模式也是悲观模式。
为什么我会想到这个索引查询这个问题呢?当时是在看到了rocksdb是tidb的底层存储介质之后,我想到了在innodb中我们的索引是B+树,如果tidb的索引是b+树的话,那么rocksdb应该怎么去构造呢?
事实上在tidb中的索引也是使用的k-v形式去做的,我们先看看对于每一行的数据是怎么存储的:
Key: tablePrefix{TableID}_recordPrefixSep{RowID}
Value:
[col1, col2, col3, col4]
假定我们的tablePrefix是常量字符t,recordPrefixSep是常量字符r,我们的tableId是1,rowID在这里是我们的主键假定是100,如果有一个用户表的数据,如下:
Key: t1_r100
Value:
[100,
"zhangsan"]
如果我们的主键为整数的情况下,那么上面也可以看作是我们的主键索引,如果我们的主键不为整型或者说在唯一索引的情况下,规则编码如下:
Key: tablePrefix{tableID}_indexPrefixSep{indexID}_indexedColumnsValue
Value:
RowID
indexId是tidb为每个索引分配的ID,所以上面那个情况下一个indexedColumnsValue只能对应一条数据满足唯一性,如果是非唯一索引,我们可以有:
Key: tablePrefix{tableID}_indexPrefixSep{indexID}_indexedColumnsValue_rowId
Value:
null
这样一个indexedColumnsValue就可以有多行数据,所以其实我们region中的数据的索引并不会和region的数据再一起,而是有自己的region分片,同样的我们查询数据的时候需要依靠我们的tidb-server分析出来我们应该用什么样的索引,先根据索引数据查询出来rowId再根据rowId查询出来我们对应的数据。
不管是tidb还是分布式数据库,要学习的知识还有非常的多,上面只是对tidb做了一些粗解的分析,如果大家要学习可以看看下面的一些资料: