十问 TiDB :关于架构设计的一些思考

“我希望能够把 TiDB 的设计的一些理念能够更好的传达给大家,相信大家理解了背后原因后,就能够把 TiDB 用的更好。”

做 TiDB 的缘起是从思考一个问题开始的:为什么在数据库领域有这么多永远也躲不开的坑?从 2015 年我们写下第一行代码,3 年以来我们迎面遇到无数个问题,一边思考一边做,尽量用最小的代价来快速奔跑。

作为一个开源项目,TiDB 是我们基础架构工程师和社区一起努力的结果,TiDB 已经发版到 2.0,有了一个比较稳定的形态,大量在生产环境使用的伙伴们。可以负责任的说,我们做的任何决定都经过了非常慎重的思考和实践,是经过内部和社区一起论证产生的结果。它未必是最好的,但是在这个阶段应该是最适合我们的,而且大家也可以看到 TiDB 在快速迭代进化。

这篇文章是关于 TiDB 代表性“为什么”的 TOP 10,希望大家在了解了我们这些背后的选择之后,能更加纯熟的使用 TiDB,让它在适合的环境里更好的发挥价值。

这个世界有很多人,感觉大于思想,疑问多于答案。感恩大家保持疑问,我们承诺回馈我们的思考过程,毕竟有时候很多思考也很有意思。

一、为什么分布式系统并不是银弹

其实并没有什么技术是完美和包治百病的,在存储领域更是如此,如果你的数据能够在一个 MySQL 装下并且服务器的压力不大,或者对复杂查询性能要求不高,其实分布式数据库并不是一个特别好的选择。 选用分布式的架构就意味着引入额外的维护成本,而且这个成本对于特别小的业务来说是不太划算的,即使你说需要高可用的能力,那 MySQL 的主从复制 + GTID 的方案可能也基本够用,这不够的话,还有最近引入的 Group Replication。而且 MySQL 的社区足够庞大,你能 Google 找到几乎一切常见问题的答案。

我们做 TiDB 的初衷并不是想要在小数据量下取代 MySQL,而是尝试去解决基于单机数据库解决不了的一些本质的问题。

有很多朋友问我选择分布式数据库的一个比较合适的时机是什么?我觉得对于每个公司或者每个业务都不太一样,我并不希望一刀切的给个普适的标准(也可能这个标准并不存在),但是有一些事件开始出现的时候:比如是当你发现你的数据库已经到了你每天开始绞尽脑汁思考数据备份迁移扩容,开始隔三差五的想着优化存储空间和复杂的慢查询,或者你开始不自觉的调研数据库中间件方案时,或者人肉在代码里面做 sharding 的时候,这时给自己提个醒,看看 TiDB 是否能够帮助你,我相信大多数时候应该是可以的。

而且另一方面,选择 TiDB 和选择 MySQL 并不是一刀切的有你没他的过程,我们为了能让 MySQL 的用户尽可能减小迁移和改造成本,做了大量的工具能让整个数据迁移和灰度上线变得平滑,甚至从 TiDB 无缝的迁移回来,而且有些小数据量的业务你仍然可以继续使用 MySQL。所以一开始如果你的业务和数据量还小,大胆放心的用 MySQL 吧,MySQL still rocks,TiDB 在未来等你。

二、为什么是 MySQL

和上面提到的一样,并不是 MySQL 不好我们要取代他,而是选择兼容 MySQL 的生态对我们来说是最贴近用户实际场景的选择:

  1. MySQL 的社区足够大,有着特别良好的群众基础,作为一个新的数据库来说,如果需要用户去学习一套新的语法,同时伴随很重的业务迁移的话,是很不利于新项目冷启动的。

  2. MySQL 那么长时间积累下来大量的测试用例和各种依赖 MySQL 的第三方框架和工具的测试用例是我们一个很重要的测试资源,特别是在早期,你如何证明你的数据库是对的,MySQL 的测试就是我们的一把尺子。

  3. 已经有大量的已有业务正在使用 MySQL,同时也遇到了扩展性的问题,如果放弃这部分有直接痛点的场景和用户,也是不明智的。

另一方面来看,MySQL 自从被 Oracle 收购后,不管是性能还是稳定性这几年都在稳步的提升,甚至在某些场景下,已经开始有替换 Oracle 的能力,从大的发展趋势上来说,是非常健康的,所以跟随着这个健康的社区一起成长,对我们来说也是一个商业上的策略。

三、为什么 TiDB 的设计中 SQL 层和存储层是分开的

一个显而易见的原因是对运维的友好性。很多人觉得这个地方稍微有点反直觉,多一个组件不就会增加部署的复杂度吗?

其实在实际生产环境中,运维并不仅仅包含部署。举个例子,如果在 SQL 层发现了一个 BUG 需要紧急的更新,如果所有部件都是耦合在一起的话,你面临的就是一次整个集群的滚动更新,如果分层得当的话,你可能需要的只是更新无状态的 SQL 层,反之亦然。

另外一个更深层次的原因是成本。存储和 SQL 所依赖的计算资源是不一样的,存储会依赖 IO,而计算对 CPU 以及内存的要求会更高,无需配置 PCIe/NVMe/Optane 等磁盘,而且这两者是不一定对等的,如果全部耦合在一起的话,对于资源调度是不友好的。 对于 TiDB 来说,目标定位是支持 HTAP,即 OLTP 和 OLAP 需要在同一个系统内部完成。显然,不同的 workload 即使对于 SQL 层的物理资源需求也是不一样的,OLAP 请求更多的是吞吐偏好型以及长 query,部分请求会占用大量内存,而 OLTP 面向的是短平快的请求,优化的是延迟和 OPS (operation per second),在 TiDB 中 SQL 层是无状态的,所以你可以将不同的 workload 定向到不同的物理资源上做到隔离。还是那句话,对调度器友好,同时调度期的升级也不需要把整个集群全部升级一遍。

另一方面,底层存储使用 KV 对数据进行抽象,是一个更加灵活的选择。

一个好处是简单。对于 Scale-out 的需求,对 KV 键值对进行分片的难度远小于对带有复杂的表结构的结构化数据,另外,存储层抽象出来后也可以给计算带来新的选择,比如可以对接其他的计算引擎,和 TiDB SQL 层同时平行使用,TiSpark 就是一个很好的例子。

从开发角度来说,这个拆分带来的灵活度还体现在可以选择不同的编程语言来开发。对于无状态的计算层来说,我们选择了 Go 这样开发效率极高的语言,而对于存储层项目 TiKV 来说,是更贴近系统底层,对于性能更加敏感,所以我们选择了 Rust,如果所有组件都耦合在一起很难进行这样的按需多语言的开发,对于开发团队而言,也可以实现专业的人干专业的事情,存储引擎的开发者和 SQL 优化器的开发者能够并行的开发。 另外对于分布式系统来说,所有的通信几乎都是 RPC,所以更明确的分层是一个很自然的而且代价不大的选择。

四、为什么不复用 MySQL 的 SQL 层,而是选择自己重写

这点是我们和很多友商非常不一样的地方。 目前已有的很多方案,例如 Aurora 之类的,都是直接基于 MySQL 的源码,保留 SQL 层,下面替换存储引擎的方式实现扩展,这个方案有几个好处:一是 SQL 层代码直接复用,确实减轻了一开始的开发负担,二是面向用户这端确实能做到 100% 兼容 MySQL 应用。

但是缺点也很明显,MySQL 已经是一个 20 多年的老项目,设计之初也没考虑分布式的场景,整个 SQL 层并不能很好的利用数据分布的特性生成更优的查询计划,虽然替换底层存储的方案使得存储层看上去能 Scale,但是计算层并没有,在一些比较复杂的 Query 上就能看出来。另外,如果需要真正能够大范围水平扩展的分布式事务,依靠 MySQL 原生的事务机制还是不够的。

自己重写整个 SQL 层一开始看上去很困难,但其实只要想清楚,有很多在现代的应用里使用频度很小的语法,例如存储过程什么的,不去支持就好了,至少从 Parser 这层,工作量并不会很大。 同时优化器这边自己写的好处就是能够更好的与底层的存储配合,另外重写可以选择一些更现代的编程语言和工具,使得开发效率也更高,从长远来看,是个更加省事的选择。

五、为什么用 RocksDB 和 Etcd Raft

很多工程师都有着一颗造轮子(玩具)的心,我们也是,但是做一个工业级的产品就完全不一样了,目前的环境下,做一个新的数据库,底层的存储数据结构能选的大概就两种:1. B+Tree, 2. LSM-Tree。

但是对于 B+Tree 来说每个写入,都至少要写两次磁盘: 1. 在日志里; 2. 刷新脏页的时候,即使你的写可能就只改动了一个 Byte,这个 Byte 也会放大成一个页的写 (在 MySQL 里默认 InnoDB 的 Page size 是 16K),虽然说 LSM-Tree 也有写放大的问题,但是好处是 LSM-tree 将所有的随机写变成了顺序写(对应的 B+tree 在回刷脏页的时候可能页和页之间并不是连续的)。 另一方面,LSMTree 对压缩更友好,数据存储的格式相比 B+Tree 紧凑得多,Facebook 发表了一些关于 MyRocks 的文章对比在他们的 MySQL 从 InnoDB 切换成 MyRocks (Facebook 基于 RocksDB 的 MySQL 存储引擎)节省了很多的空间。所以 LSM-Tree 是我们的选择。

选择 RocksDB 的出发点是 RocksDB 身后有个庞大且活跃的社区,同时 RocksDB 在 Facebook 已经有了大规模的应用,而且 RocksDB 的接口足够通用,并且相比原始的 LevelDB 暴露了很多参数可以进行针对性的调优。随着对于 RocksDB 理解和使用的不断深入,我们也已经成为 RocksDB 社区最大的使用者和贡献者之一,另外随着 RocksDB 的用户越来越多,这个项目也会变得越来越好,越来越稳定,可以看到在学术界很多基于 LSM-Tree 的改进都是基于 RocksDB 开发的,另外一些硬件厂商,特别是存储设备厂商很多会针对特定存储引擎进行优化,RocksDB 也是他们的首选之一。

反过来,自己开发存储引擎的好处和问题同样明显,一是从开发到产品的周期会很长,而且要保证工业级的稳定性和质量不是一个简单的事情,需要投入大量的人力物力。好处是可以针对自己的 workload 进行定制的设计和优化,由于分布式系统天然的横向扩展性,单机有限的性能提升对比整个集群吞吐其实意义不大,把有限的精力投入到高可用和扩展性上是一个更加经济的选择。 另一方面,RocksDB 作为 LSM-Tree 其实现比工业级的 B+Tree 简单很多(参考对比 InnoDB),从易于掌握和维护方面来说,也是一个更好的选择。 当然,随着我们对存储的理解越来越深刻,发现很多专门针对数据库的优化在 RocksDB 上实现比较困难,这个时候就需要重新设计新的专门的引擎,就像 CPU 也能做图像处理,但远不如 GPU,而 GPU 做机器学习又不如专用的 TPU。

选择 Etcd Raft 的理由也类似。先说说为什么是 Raft,在 TiDB 项目启动的时候,我们其实有过在 MultiPaxos 和 Raft 之间的纠结,后来结论是选择了 Raft。Raft 的算法整体实现起来更加工程化,从论文就能看出来,论文中甚至连 RPC 的结构都描述了出来,是一个对工业实现很友好的算法,而且当时工业界已经有一个经过大量用户考验的开源实现,就是 Etcd。而且 Etcd 更加吸引我们的地方是它对测试的态度,Etcd 将状态机的各个接口都抽象得很好,基本上可以做到与操作系统的 API 分离,极大降低了写单元测试的难度,同时设计了很多 hook 点能够做诸如错误注入等操作,看得出来设计者对于测试的重视程度。

与其自己重新实现一个 Raft,不如借力社区,互相成长。现在我们也是 Etcd 社区的一个活跃的贡献者,一些重大的 Features 例如 Learner 等新特性,都是由我们设计和贡献给 Etcd 的,同时我们还在不断的为 Etcd 修复 Bug。

没有完全复用 Etcd 的主要的原因是我们存储引擎的开发语言使用了 Rust,Etcd 是用 Go 写的,我们需要做的一个工作是将他们的 Raft 用 Rust 语言重写,为了完成这个事情,我们第一步是将 Etcd 的单元测试和集成测试先移植过来了(没错,这个也是选择 Etcd 的一个很重要的原因,有一个测试集作为参照),以免移植过程破坏了正确性。另外一方面,就如同前面所说,和 Etcd 不一样,TiKV 的 Raft 使用的是 Multi-Raft 的模型,同一个集群内会存在海量的互相独立 Raft 组,真正复杂的地方在如何安全和动态的分裂,移动及合并多个 Raft 组,我在我的 《基于 Raft 构建弹性伸缩的存储系统的一些实践》 这篇文章里面描述了这个过程。

六、为什么有这样的硬件配置要求

我们其实对生产环境硬件的要求还是蛮高的,对于存储节点来说,SSD 或者 NVMe 或者 Optane 是刚需,另外对 CPU 及内存的使用要求也很高,同时对大规模的集群,网络也会有一些要求 (详见我们的官方文档推荐配置的 相关章节),其中一个很重要的原因是我们底层的选择了 RocksDB 的实现,对于 LSM Tree 来说因为存在写放大的天然特性,对磁盘吞吐需求会相应的更高,尤其是 RocksDB 还有类似并行 Compaction 等特性。 而且大多数机械磁盘的机器配置倾向于一台机器放更大容量的磁盘(相比 SSD),但是相应的内存却一般来说不会更大,例如 24T 的机械磁盘 + 64G 内存,磁盘存储的数据量看起来更大,但是大量的随机读会转化为磁盘的读,这时候,机械磁盘很容易出现 IO 瓶颈,另一方面,对于灾难恢复和数据迁移来说,也是不太友好的。

另外,TiDB 的各个组件目前使用 gRPC 作为 RPC 框架,gRPC 是依赖 HTTP2 作为底层协议,相比很多朴素的 RPC 实现,会有一些额外的 CPU 开销。TiKV 内部使用 RocksDB 的方式会伴随大量的 Prefix Scan,这意味着大量的二分查找和字符串比较,这也是和很多传统的离线数据仓库很不一样的 Pattern,这个会是一个 CPU 密集型的操作。在 TiDB 的 SQL 层这端,SQL 是计算密集型的应用这个自然不用说,另外对内存也有一定的需求。由于 TiDB 的 SQL 是一个完整的 SQL 实现,表达力和众多中间件根本不是一个量级,有些算子,比如 Hashjoin,就是会在内存里开辟一块大内存来执行 Join,所以如果你的查询逻辑比较复杂,或者 Join 的一张子表比较大的情况下(偏 OLAP 实时分析业务),对内存的需求也是比较高的,我们并没有像单机数据库的优化器一样,比如 Order by 内存放不下,就退化到磁盘上,我们的哲学是尽可能的使用内存。 如果硬件资源不足,及时的通过拒绝执行和失败通知用户,因为有时候半死不活的系统反而更加可怕。

另外一方面,还有很多用户使用 TiDB 的目的是用于替换线上 OLTP 业务,这类业务对于性能要求是比较高的。 一开始我们并没有在安装阶段严格检查用户的机器配置,结果很多用户在硬件明显没有匹配业务压力的情况下上线,可能一开始没什么问题,但是峰值压力一上来,可能就会造成故障,尽管 TiDB 和 TiKV 对这种情况做了层层的内部限流,但是很多情况也无济于事。 所以我们决定将配置检查作为部署脚本的强制检查,一是减少了很多沟通成本,二是可以让用户在上线时尽可能的减少后顾之忧。

七、为什么用 Range-based 的分片策略,而不是 Hash-based

Hash-based 的问题是实现有序的 Scan API 会比较困难,我们的目标是实现一个标准的关系型数据库,所以会有大量的顺序扫描的操作,比如 Table Scan,Index Scan 等。用 Hash 分片策略的一个问题就是,可能同一个表的数据是不连续的,一个顺序扫描即使几行都可能会跨越不同的机器,所以这个问题上没得选,只能是 Range 分片。 但是 Range 分片可能会造成一些问题,比如频繁读写小表问题以及单点顺序写入的问题。 在这里首先澄清一下,静态分片在我们这样的系统里面是不存在的,例如传统中间件方案那样简单的将数据分片和物理机一一对应的分片策略会造成:

  • 动态添加节点后,需要考虑数据重新分布,这里必然需要做动态的数据迁移;

  • 静态分片对于根据 workload 实时调度是不友好的,例如如果数据存在访问热点,系统需要能够快速进行数据迁移以便于将热点分散在不同的物理服务商上。

回到刚才提到的基于 Range 分片的问题,刚才我说过,对于顺序写入热点的问题确实存在,但也不是不可解。对于大压力的顺序写入的场景大多数是日志或者类似的场景,这类场景的典型特点是读写比悬殊(读 « 写),几乎没有 Update 和随机删除,针对这种场景,写入压力其实可以通过 Partition Table 解决,这个已经在 TiDB 的开发路线图里面,今年之内会和大家见面。

另外还有一个频繁读写小表造成的热点问题。这个原因是,在底层,TiDB 的数据调度的最小单位是 Region,也就是一段段按字节序排序的键值 Key-Value Pairs (默认大小 96M),假设如果一个小表,总大小连 96M 都不到,访问还特别频繁,按照目前的机制,如果不强制的手动 Split,调度系统无论将这块数据调度到什么位置,新的位置都会出现热点,所以这个问题本质上是无解的。因此建议如果有类似的访问 pattern,尽可能的将通用的小表放到 Redis 之类的内存缓存中,或者甚至直接放在业务服务的内存里面(反正小)。

八、为什么性能(延迟)不是唯一的评价标准

很多朋友问过我,TiDB 能替换 Redis 吗?大家对 Redis 和 TiDB 的喜爱之情我也很能理解,但是很遗憾,TiDB 并不是一个缓存服务,它支持跨行强一致事务,在非易失设备上实现持久化存储,而这些都是有代价的。

简单来说,写磁盘的 IO 开销 (WAL,持久化),多副本高可用和保证分布式事务必然会牺牲延迟,更不用说做跨数据中心的同步了,在这点上,我认为如果需要很低延迟的响应速度(亚毫秒级)就需要在业务端做缓存了。TiDB 的定位是给业务提供一个可扩展的 The Source of Truth (真相之源),即使业务层的缓存失效,也有一个地方能够提供强一致的数据,而且业务不用关心容量问题。另一方面,衡量一个分布式系统更有意义的指标是吞吐,这个观点我在很多文章里已经提到过,提高并发度,如果系统的吞吐能够随着集群机器数量线性提升,而且延迟是稳定的才有意义,而且这样才能有无限的提升空间。 在实际的环境中,单个 TiDB 集群已经有一些用户使用到了百万级别的 QPS,这个在单机架构上是几乎不可能实现的。另外,这几年硬件的进步速度非常快,特别是 IO 相关的创新,比如 NVMe SSD 的普及,还有刚刚商用的持久化内存等新的存储介质。很多时候我们在软件层面上绞尽脑汁甚至牺牲代码的优雅换来一点点性能提升,很可能换块磁盘就能带来成倍的提升。

我们公司内部有一句话:Make it right before making it fast。正确性和可靠性的位置是在性能之前的,毕竟在一个不稳定的系统上谈性能是没有意义的。

九、为什么弹性伸缩能力如此重要

在业务初期,数据量不大,业务流量和压力不大的时候,基本随便什么数据库都能够搞定,但很多时候业务的爆发性增长可能是没有办法预期的,特别是一些 ToC 端的应用。早期的 Twitter 用户一定对时不时的大鲸鱼(服务不可用)深恶痛绝,近一点还有前两年有一段时间爆红的足记 App,很短的时间之内业务和数据量爆发性增长,数据库几乎是所有这些案例中的核心瓶颈。 很多互联网的 DBA 和年轻的架构师会低估重构业务代码带来的隐形成本,在业务早期快速搞定功能和需求是最重要的。想象一下,业务快速增长,服务器每天都因为数据库过载停止服务的时候,DBA 告诉你没办法,先让你重新去把你的业务全改写成分库分表的形式,在代码里到处加 Sharding key,牺牲一切非 Sharding key 的多维关联查询和相关的跨 Shard 的强一致事务,然后数据复制好多份……这种时候是真正的时间等于金钱,决定这个公司生死存亡的时候不是去写业务和功能代码,而是因为基础设施的限制被迫重构,其实是非常不好的。 如果这个时候,有一个方案,能够让你几乎无成本的,不修改业务代码的时候对数据库吞吐进行线性扩展(无脑加机器其实是最便宜的),最关键的是为了业务的进化争取了时间,我相信这个选择其实一点都不难做。

其实做 TiDB 的初心正是如此,我们过去见到了太多类似的血和泪,实在不能忍了,分库分表用各种中间件什么的炫技是很简单,但是我们想的是真正解决很多开发者和 DBA 眼前的燃眉之急。

最近这段时间,有两个用户的例子让我印象很深,也很自豪,一个是 Mobike,一个是转转,前者是 TiDB 的早期用户,他们自己也在数据增长很快的时候就开始使用 TiDB,在快速的发展过程中没有因为数据库的问题掉链子;后者是典型的快速发展的互联网公司,一个 All-in TiDB 的公司,这个早期的技术选型极大的解放了业务开发的生产力,让业务能够更放开手脚去写业务代码,而不是陷入无休止的选择 Sharding key,做读写分离等等和数据库较劲的事情。

为业务开发提供更加灵活便捷和低成本的智能基础存储服务,是我们做 TiDB 的出发点和落脚点,分布式/高可用/方便灵活的编程接口/智能省心,这些大的方向上也符合未来主流的技术发展趋势。对于CEO 、 CTO 和架构师这类的管理者而言,在解决眼前问题的同时,跟随大的技术方向,不给未来多变的业务埋坑,公司尽可能快速发展,这个才是核心要去思考的问题。

十、如何根据自己的实际情况参考业内的使用案例

TiDB 是一个通用的数据库,甚至希望比一般的数据库更加通用,TiDB 是很早就尝试融合 OLTP 和 OLAP 的边界的数据库产品,我们是最早将 HTAP 这个概念从实验室和论文里带到现实的产品之一。这类通用基础软件面临的一个问题就是我们在早期其实很难去指导垂直行业的用户把 TiDB 用好,毕竟各自领域都有各自的使用场景和特点,TiDB 的开发团队的背景大部分是互联网行业,所以天然的会对互联网领域的架构和场景更熟悉,但是比如在金融,游戏,电商,传统制造业这些行业里其实我们不是专家,不过现在都已经有很多的行业专家和开发者已经能将 TiDB 在各自领域用得很好。

我们的 Blog,公众号,官网等平台会作为一个案例分享的中心,欢迎各位正在使用 TiDB 的用户,将你们的思考和使用经验分享给我们,就像现在已有案例背后的许多公司一样,我们会将你们的经验变成一篇篇的用户案例,通过我们的平台分享给其他的正在选型的公司。

分享到微信

打开微信,使用 “扫一扫” 即可将网页分享至朋友圈。