读 TiDB 源码聊设计:引子
本文的的源码分析全部基于 TiDB6.5 来做分析。首发于 2024.1.29。
TiDB 是个非常好用的开源数据库系统。我在生产中一直有接触到 TiDB,也是踩了一些坑,故而陆陆续续读了很多关于它的文档,慢慢被这个系统的设计吸引到,因此我决定深入它的内核去读源码,并将一些有意思的设计剖出来聊聊。所以有了今天这篇引子。
Trade Off
Serving、Trascantion、Analytics
根据业界常识,我们会发现:
面向在线应用,高并发、快速、简单,如:HBase、Redis
面向分析的,大规模数据扫描、过滤、汇总,如:Hive、Presto
面向事务、随机读写的:MySQL,PostgreSQL
这三种类型的数据引擎在内核的设计上完全不一样。但随着业务的发展与成本的要求,三者的边界之间开始出现混合结构。如 HTAP(Hybird Transcation Analytics Processing)和 HSAP(Hybrid Serving Analytics Processing),TiDB 就是典型的 HTAP 的实现,典型的 HTAP 有以下特征:
数据来源于业务系统
需要事务机制保证 ACID
需要保证 TP 和 AP 的一致性
适合模型简单,简单分析场景
Buffer、Mutable、Ordered
存储结构有三个常见变量:是否使用缓冲、使用不可变的还是可变的文件,以及是否按顺序存储值(有序性)。
由于 TiDB 底层的 RocksDB 用了 LSM。因此使用了缓冲、不可变性以及顺序性。
RUM
有一种流行的存储结构开销模型考虑了如下三个因素:读取(Read)、更新(Update)和内存(Memory)开销。它被称为 RUM 猜想。
RUM 猜想指出,减少其中两项开销将不可避免地导致第三项开销的恶化,并且优化只能以牺牲三个参数中的一个为代价。我们可以根据这三个参数对不同的存储引擎进行比较,以了解它们针对哪些参数进行了优化,以及其中隐含着哪些可能的权衡。
一个理想的解决方案是拥有最小的读取开销,同时保持较低的内存与写入开销。但在现实中,这是无法实现的,因此我们需要进行取舍。
由于 TiDB 底层的 RocksDB 用了 LSM,以及 LRU 缓存。因此会使用较多的内存空间,读取也需要遍历多个表(用了一些过滤算法优化),但对写入友好。但从系统整体的角度来看,在 TiDB 上的读取是支持水平扩展的,对于点查和区间查询都有较好的支持。
架构概览
职责如下:
TiDB Server:SQL 层,对外暴露 MySQL 协议的连接 endpoint,负责接受客户端的连接,执行 SQL 解析和优化,最终生成分布式执行计划。TiDB 层本身是无状态的,实践中可以启动多个 TiDB 实例,通过负载均衡组件(如 LVS、HAProxy 或 F5)对外提供统一的接入地址,客户端的连接可以均匀地分摊在多个 TiDB 实例上以达到负载均衡的效果。TiDB Server 本身并不存储数据,只是解析 SQL,将实际的数据读取请求转发给底层的存储节点 TiKV(或 TiFlash)。
PD (Placement Driver) Server:整个 TiDB 集群的元信息管理模块,负责存储每个 TiKV 节点实时的数据分布情况和集群的整体拓扑结构,提供 TiDB Dashboard 管控界面,并为分布式事务分配事务 ID。PD 不仅存储元信息,同时还会根据 TiKV 节点实时上报的数据分布状态,下发数据调度命令给具体的 TiKV 节点,可以说是整个集群的“大脑”。此外,PD 本身也是由至少 3 个节点构成,拥有高可用的能力。建议部署奇数个 PD 节点。
存储节点
TiKV Server:负责存储数据,从外部看 TiKV 是一个分布式的提供事务的 Key-Value 存储引擎。存储数据的基本单位是 Region,每个 Region 负责存储一个 Key Range(从 StartKey 到 EndKey 的左闭右开区间)的数据,每个 TiKV 节点会负责多个 Region。TiKV 的 API 在 KV 键值对层面提供对分布式事务的原生支持,默认提供了 SI (Snapshot Isolation) 的隔离级别,这也是 TiDB 在 SQL 层面支持分布式事务的核心。TiDB 的 SQL 层做完 SQL 解析后,会将 SQL 的执行计划转换为对 TiKV API 的实际调用。所以,数据都存储在 TiKV 中。另外,TiKV 中的数据都会自动维护多副本(默认为三副本),天然支持高可用和自动故障转移。
TiFlash:TiFlash 是一类特殊的存储节点。和普通 TiKV 节点不一样的是,在 TiFlash 内部,数据是以列式的形式进行存储,主要的功能是为分析型的场景加速。
架构图与组件职责说明皆来自官网。
一般都会将优化器和元数据管理放在一块儿(为了内部通信更快。优化器需要一些元数据对输出进行预测做 CBO),比如 Starrocks,这个组件就叫 Front end。这样做显然是基于单一职责原则,比如我链接量很大,但是元数据也就那样。完全可以部署 3 个 PD,多个 TiDB Server。
存储节点这块儿,TiFlash 是个很有意思的设计——他并不直接对外接受数据写入,而是会 Flow TiKV 的 Leader 做副本传输。默认情况下通过 CBO 来分析 SQL,决定请求到 TiKV 还是 TiFlash,但也可以强制请求到 TiFlash。
内幕鸟瞰
存储
TiKV 底层本质是 RocksDB,所有的数据都以 K-V 形式保存。那么有人就会问了,这种点查是很快,Range 呢?TiDB 会将一段段顺序 Key,固定大小为单位存储到 RocksDB,称为 Region。每个 Region 都会有自己的 Replica。
看起来是占尽了点查和顺序查的优点。其实并不是,因为这里涉及了扩缩容,这是 PD 来做调度的。缺点就是大量删除、大量插入的时候,代价是比较大的。官方文档里也有着重提到过一批写入、删除量不要太大。
写入提示:另外注意,无论是大小限制还是行数限制,还要考虑事务执行过程中,TiDB 做编码以及事务额外 Key 的开销。在使用的时候,为了使性能达到最优,建议每 100 ~ 500 行写入一个事务。
但 500 行这种限制更多是因为木桶的短板效应——考虑到内存,因为事务在提交前都是在内存中,对内存压力很大。
删除数据见:https://docs.pingcap.com/zh/tidb/v6.5/dev-guide-delete-data#批量删除
里面会有各种姿势,比如小于 1w 怎么删,大于 1w 怎么删,还有非事务性删除等等。话说回来,这种大写大删的场景的确不是 TiDB 首要考虑的场景。
读写删、索引实现的具体代码,在后续文章中会做源码解析。
计算
TiDB 的计算过程和大多数分布式数据库很像,上方做 SQL 解析,语句重写,再做成逻辑算子,优化,然后做成物理算子,优化。
语句重写一般是 RBO 实现的,主要是对原来的语法树进行等价语义的重写,通常是根据预先定义好的规则来进行重写,优化掉一些无效或者无意义的操作。换句话说,有时候程序员写的 SQL 通常是结果导向的,并不专门针对执行去优化,而且,很多时候还会有意无意引入无意义的操作。
比如 SQL 里写个 JOIN,优化器结合元数据一看:
Join 的表数据少,直接用 BoradCast 吧,小表广播到大表地方。
表数据多,那走 Join 呗。逻辑上是走 Join 了,那么物理算子是啥?HashJoin 还是 SortMergeJoin?都是要看元数据的——Join 键值分布,以及是否有索引等等。
这部分一般都是 CBO 实现,这块后面会做源码解析。
在执行模式上支持 materialization 和 vector,便于用于不同的场景。
materialization 模式:执行的过程自底向上,每个节点都一次性处理所有数据。优势是实现简单,但对于数据量很大的 OLAP 语句不太合适,但比较适合单次操作数据量较小的 OLTP(online transactional processing)语句。
vector 模式:批量处理数据。更好地利用 SIMD 来提高执行速度。对于大量数据处理比迭代模式高效,所以也更适合 OLAP 语句。
调度
这块几乎就是 TiDB 集群维护成本低的核心。这块官网上的文章写得很好,我就不重复了:
作为一个分布式高可用存储系统,必须满足的需求,包括四种:
副本数量不能多也不能少
副本需要分布在不同的机器上
新加节点后,可以将其他节点上的副本迁移过来
节点下线后,需要将该节点的数据迁移走
作为一个良好的分布式系统,需要优化的地方,包括:
维持整个集群的 Leader 分布均匀
维持每个节点的储存容量均匀
维持访问热点分布均匀
控制 Balance 的速度,避免影响在线服务
管理节点状态,包括手动上线/下线节点,以及自动下线失效节点
文章链接:https://cn.pingcap.com/blog/tidb-internal-3/
Balance 与热点分布均匀的实现我将会做源码分析。
事务
前面提到了 TiDB 实现了 SI(快照隔离)级别的事务,这意味着对每一个事务,分配了一个独有的数据库快照。事务可以安心地读取这个快照中的数据而不需要去担心其他事务(因此只读事务是不会失败也不会被等待的)。同理,事务对数据的更新也首先暂存在这个独有的快照中,只有当事务提交的时候,这些更新才会试图被写回真正的数据库版本里。当一个事务准备提交时,它依然要确保没有其他事务更新了它所更新过的数据,否则,这个事务会被回滚。
听起来很美好,但这并不能解决一切问题。
因此 TiDB 引入了乐观锁、悲观锁。默认采用了悲观锁,乐观锁的重试机制会引起一些时序问题,所以是按需打开。
当然,在前面也是会有一系列冲突机制。TiDB 可以灵活开启,TiKV 上则是 prewrite 实现的。
CDC
TiDB 提供了 CDC 的能力。那么如何在分布式数据库中,收集数据的变化来实现 CDC 的吐出?比如写入前主动发送、另起组件直接查数据 or 拉 redo log 等等,TiDB 在这里面选择了什么样的 Trade Off,又有什么隐藏的坑。这块我将会做源码分析。
小结
总得来说,TiDB 主打一个低运维成本的系统。在业界中的实践正如官网中写的:当需要加工的数据量为中等规模(100 TB 以内)、数据加工调度流程相对简单、并发度不高(10 以内)时,你可能希望简化技术栈,替换原本需要使用多个不同技术栈的 OLTP、ETL 和 OLAP 系统,使用一个数据库同时满足交易系统以及分析系统的需求,降低技术门槛和运维人员需求。
在学习的过程中,我还注意到国外也有一个类似定位的系统:CockroachDB。有兴趣的同学可以看它的官网https://www.cockroachlabs.com/docs/stable/architecture/overview。总体的实现和 TiDB 非常像,除了底层的 RocksDB——他们为了能够更好掌控稳定性而自己用 Go 写了一版。
本文的内容更多是为后续的源码分析做一些铺垫,敬请期待。
版权声明: 本文为 InfoQ 作者【泊浮目】的原创文章。
原文链接:【http://xie.infoq.cn/article/6b6c168fd133b4464227cb1c8】。文章转载请联系作者。
评论