写这篇文章的起因是有一次和朋友聊到一个很有趣的话题:如果我要为 1 亿用户提供免费的数据库云服务,这个系统应该如何设计?为了回答这个问题,我在之前的两篇 Blog 中隐隐约约提到,我们在使用一些全新的视角和思路去看待和构建数据库服务,并将这些思考变成了实际的产品:tidb.cloud/serverless,很多朋友很想了解 TiDB Cloud Serverless (下面会简称为 TiDB Serverless)的更多细节,所以便有了这篇博客。
有些朋友可能还不太了解 TiDB,请允许我先快速介绍一下 TiDB 本身。对 TiDB 比较熟悉的朋友可以跳过这段,主要是一些关键的名词和定义:
2015 年初,我们启动了一个非常有野心的计划。受到 Google Spanner 和 F1 的启发(我更愿意称之为鼓舞),我们希望构建一个理想的分布式数据库:支持 SQL、使用 MySQL 协议和语法、支持 ACID 事务语义、不对一致性妥协;对业务透明且无上限的水平扩展能力,能够根据而业务流量和存储特征动态调整数据的物理分布达到极高的硬件利用率;高可用及故障自愈能力,降低人工运维的负担。
在这些目标的驱动下,从一开始我们就做出了如下的技术决定:
可以看得出,这个设计就是奔着极强的扩展性和超大规模的场景去的,早期也确实如此。最初,TiDB 的想法来自于替换一些超大规模的 MySQL 分库分表方案,这个策略很成功,在无数的客户那已经得到验证,甚至还替换了一些以规模见长的 NoSQL 的系统,例如 Pinterest 在他们的 Blog 中提到他们将他们的 HBase 成功替换成了 TiDB(https://medium.com/pinterest-engineering/online-data-migration-from-hbase-to-tidb-with-zero-downtime-43f0fb474b84)
回顾当年做的一些决定,TiDB 是很正确的:
但是随着时间的推移,我们渐渐发现了两个趋势:
上面的需求,在传统的 Shared-nothing 架构下,会有以下几点限制。
对于第一个趋势来说,OLTP 和 OLAP 确实在融合,但是在传统的 Shared-nothing 架构下其实是成本很高的,原因是对于 OLAP 应用来说,大多数时候 CPU 会成为瓶颈,CPU 是最昂贵且的资源。为了追求性能,我们只能增加更多的计算资源(也就是物理服务器),而 OLAP 业务通常又不是在线服务,所以这些 workload 其实并不要求 7x24 小时独占这些资源,但是为了运行这些 workload,我们不得不提前将这些资源准备好,即使 TiFlash 在存储层已经能很好的弹性伸缩(可以按需对特定的表 Ad-hoc 的创建 TiFlash 副本)但很难在非云的环境下对计算资源进行弹性伸缩。
对于第二个趋势,传统 shared-nothing 架构的分布式数据库通常假设所有物理节点的硬件配置对等,否则会加大调度的难度;另外为了追求极致的性能,通常会放弃多租户的设计,因为在云下大多数的数据库部署对于应用来说都是独占式的。而要降低数据库的使用成本不外乎两条路:
另外就是业务开始上云,这点提的比较多,就不赘述了。
基于以上的假设,我们思考一个问题:如果今天重写 TiDB,应该有哪些新的假设、作出哪些选择,去覆盖更多应用场景和更高的稳定性。
假设:
选择:
更重要的选择是我在之前文章提到的:与其把数据库放到云上运维,更应该将云数据库作为一个完整的云服务设计。在这个指导思想下,我们开始了 TiDB Cloud Serverless,因为细节太多,我不可能在这篇文章内一一照顾到,思来想去挑了两个重点在这篇文章中介绍一下:存储引擎和多租户。
在传统的上下文中,我们提到存储引擎时一般都想到 LSM-Tree、B-Tree、Bitcask 什么的,另外在传统的语境中,这些存储引擎都是单机的,对于一个分布式数据库来说,需要在本地存储引擎之上再构建一层 Sharding 逻辑决定数据在不同物理节点上的分布。
但是在云上,尤其对于构建一个 Serverless 的存储服务,数据的物理分布不再重要,因为 S3 (对象存储,这里包含但也并不特指 AWS S3) 已经将扩展性和分布式可用性的问题解决得足够好(而且在开源社区也有高质量的对象存储实现,如 MinIO 等)。如果我们将在 S3 的基础之上构建的一个新的“存储引擎“看成整体的话,我们会发现整个分布式数据库的设计逻辑被大大简化了。
为什么强调存储引擎?因为计算层抽象得好的话是可以做到无状态的(参考 TiDB 的 SQL 层),无状态系统的弹性伸缩相对容易做到,真正的挑战在于存储层。从 AWS S3 在 2022 年的论文中我们可以看到,AWS 在解决 S3 的弹性和稳定性问题上花了极大的功夫(其实 S3 才是 AWS 真正的 Serverless 产品!)。
但是 S3 的问题在于小 I/O 的延迟,在 OLTP workload 的主要读写链路上不能直接穿透到 S3,所以对于写入而言,仍然需要写入本地磁盘,但是好在只要确保日志写入成功就好。实现一个分布式、低延迟、高可用的日志存储(append-only)比起实现一个完整的存储引擎简单太多了。所以日志的复制仍然是需要单独处理的,可以选择 Raft 或者 Multi-Paxos 之类的 RSM 算法,偷懒一点也可以直接依赖 EBS。
对于读请求来说,极低的延迟(<10ms) 基本上只有依靠内存缓存,所以优化的方向也只是做几级的缓存,或者是否需要用本地磁盘、是按照 page 还是按照 row 来做缓存的问题。另外在分布式环境下还有一个好处是,由于可以在逻辑上将数据分片,所以缓存也可以分散在多台机器上。
对于这个新的存储引擎,有以下的几个设计目标:
对于 OLTP 业务来说,通常读是远大于写的,这里的一个隐含假设是对于强一致读(read after write)的需求可能只占全部读请求的很小部分,大多数读场景可以容忍 100ms 左右 inconsistent gap,但是写请求对于硬件资源的消耗是更大的(不考虑 cache miss 的场景)。在单机的环境下,这其实是无解的,因为就那么一亩三分地,读 / 写 /compaction 都要消耗本地资源,所以能做的只能有平衡。
但是在云的环境下,完全可以将读写的节点分开:本地通过 Raft 协议复制到多个 Peer 上,同时在后台持续异步同步日志数据到 S3(远端的 compact 节点进行 compaction)。如果用户需要强一致读,那么可以在 Raft Leader 上读,如果没有强一致的需求,那么可以在其他的 Peer 节点上读(考虑到即使异步复制日志的 gap 也不会太长,毕竟是 Raft),即使在 Non-Leader Peer 上想实现强一致读,也很简单,只需要向 Leader 请求一下最新的 Log Index,然后在本地等待这个 Index 的 log 同步过来即可;新增的读节点,只需要考虑从 S3 上加载 Snapshot 然后缓存预热后即可对外提供服务。
另外,通过 S3 传输 snapshot 数据的额外好处是,在同一个 region 内部,跨 az 的流量是不计的。而且对于 HTAP 场景中的分析请求,因为数据都已经在 S3 上,且大多数分析的场景大约只需要 Read-committed 的隔离级别 + Stale Read 就能满足需求,因此只需要按照上面描述的读节点来处理即可,ETL 的反向数据回填也可以通过 S3 完成。
Before
After
对于 OLTP 场景来说,我们观察到尽管总数据量可能很大,有时 OLTP 单个业务上百 TB 我们也经常见到,但是绝大多数场景下都有冷热之分,而且对于冷数据来说,只需要提供“可接受”的体验即可,例如读写冷数据的延迟容忍度通常会比较高。
但是就像开始提到的,我们多数时候并不能二元地划分冷热,但是我们又希望能通过冷热分离将冷数据使用更便宜的硬件资源存储,同时不牺牲热数据的访问体验。在 Serverless TiDB 中,很自然的,冷数据将只存储在 S3 上,因为分层的存储设计会很自然地将热数据保留在本地磁盘和内存中,冷数据在最下层也就是 S3 上。这个设计的坏处是牺牲了冷数据缓存预热时的延迟,因为要从 S3 上加载。但是就像上面说到的,在现实的场景中,这个延迟通常是可以被容忍的,想要提升这种情况的用户体验也很容易,再加入一级缓存即可。
在提到冷热分离的时候,有两个隐含的话题没有 cover:
>写请求热点如何处理?
>如何快速感知到热点?
要回答这两个问题,需要讨论:数据分片,请求路由以及数据调度,这三个话题也是支持弹性伸缩的关键。
我们先说数据分片。传统 Shared Nothing 架构的数据分片方式在逻辑上和物理上是一一对应的,例如 TiKV 的一个 region 对应的就是某台 TiKV 节点上的 RocksDB 一段物理数据。另一个更好理解的例子是 MySQL 分库分表,每个分片就是一台具体的 MySQL。
但是在云上,如果有这样一个云原生的存储引擎,那我们其实已经不太需要关心数据的物理分布,但是逻辑分片仍然是重要的,因为涉及到计算资源的分配(以及缓存)。过去 Shared-nothing 架构的分布式数据库的问题是:如果要调整分片就会涉及到数据移动和搬迁,例如添加 / 删除存储节点后的 Rebalance。这个过程是最容易出问题的,因为只要涉及到物理数据的移动,保证一致性和高可用都将是复杂的工作,TiKV 的 Multi-Raft 花了无数的精力在这块。但是对于基于 S3 的存储引擎来说,数据在分片之间的移动其实只是一个元信息修改,所谓的物理的移动也只是在新的节点上的缓存预热问题。
有数据的逻辑分片就意味着需要有对应的请求路由层,需要根据规则将请求定位到具体某台机器上。目前我仍然认为就像 TiKV 一样,KV Range 是一个很好分片方式。像字典一样,当你拿到一个单词,通过字典序就能很精准的定位。在 TiDB Serverless 中我们仍然保留了 KV 的分片和逻辑路由,但是会在原本的 TiDB 之上增加一层 Proxy 用于保持不同租户的 Session ,我们姑且叫它 Gateway。
Gateway 是租户隔离及流量感知的重要一环(后边会提到), Gateway 感知到某个租户的连接被创建后,会在一个缓存的计算资源池中(tidb-server 的 container pool)捞出一个 idle 状态的 tidb-server pod 来接管客户端的请求,然后当连接断开或者超时一段时间后自动归还给 pool。这个设计对于计算资源的快速伸缩是非常有效的,而且 tidb-server 的无状态设计,也让我们在给 TiDB Serverless 实现这部分能力的时候简单了很多,而且在 Gateway 感知到热点出现后可以很快的弹性扩容。计算的扩容很好理解,存储层的扩容也很简单,只需修改一下元信息 + 在新的节点上挂载远端存储并预热缓存即可。
在云上,一切资源都是需要付费的,而且不同的服务定价差别巨大,对比一下 S3 / RDS / DynamoDB 的定价即可看到很大的区别,而且相比服务费用和存储成本,大家很容易忽略流量的费用(有时候你看账单会发现其实比起存储成本,流量会是更大的开销)。节省成本有几个关键:
>利用 S3 进行跨 AZ 的数据移动和共享,节省跨 AZ 流量费用。
>利用 Spot Instances ,进行离线任务。离线任务定义:无状态(持久化在 S3 上)、可重入。典型的离线任务是 LSM-Tree 的 compaction 或者备份恢复什么的。
TiKV 对于存储引擎有着很好的抽象,其实主要修改的地方大概只有原来 RocksDB 的部分。关于 Raft 和分片的逻辑基本是能够复用的。至于 SQL 引擎和事务的部分,因为原本就和存储的耦合比较低,所以主要修改的部分就是添加租户相关的逻辑。这也是为什么我们能使用一个小团队,大概一年时间就能推出产品的原因。
想象一下,如果你需要支撑一个庞大规模的用户数据库,集群中有成千上万的用户,每个用户基本上只访问自己的数据,并且有明显的冷热数据区分。而且就像一开始提到的那样,可能有 90% 的用户是小型用户,但你无法确定这些用户何时会突然增长。你需要设计一个这样的系统,以应对这种情况。
最原始的方法很简单,就是直接将一批机器提供给一个用户,将另一批机器提供给另一个用户。这样的物理隔离方法非常简单,但缺点是资源分配不够灵活,并且有许多共享成本。例如,每个租户都有自己管控系统和日志存储等方面的成本。显然,这是最笨重的物理隔离方法。
第二种方法是在容器平台上进行部署,利用虚拟化,尽可能利用底层云平台的资源。这种方法可能比第一种方案稍好一些,至少可以更充分地利用底层硬件资源。然而,仍然存在前面提到的共享成本问题。
以上两种方案的核心思想是:隔离。其实包括所谓的在 SQL 引擎内部做虚拟机之类的方式本质上都是在强调隔离。但如果要实现用户数量越多、单位成本会越低的效果,光强调隔离是不够的,还需要共享:在共享的基础上进行调度,以实现隔离的效果。
TiDB Serverless 允许多个租户共享一个物理 Cluster 但是从不同用户的视角来看是相互隔离的。这是降低单位成本的关键所在,因为多个用户共享同一资源池。不过,这种基于共享的方案也存在一些问题,例如资源共享不足以满足大租户的需要、租户之间的数据可能会混淆、无法对每个租户的需求进行个性化的定制等。此外,如果一个租户需要定制化的功能,整个系统需要被更改以适应这个租户的需要,这会导致系统的可维护性下降。因此,需要一种更灵活且可定制化的多租户实现方式,以满足不同租户的不同需求。
我们看看在 TiDB Serverless 中是如何解决这些问题的。在介绍设计方案前,我们先看看一些重要的设计目标:
对于第一点,不同租户的数据如何隔离而且互相不可见,要回答这个问题需要从两个方面考虑:物理存储和元信息。
物理存储的隔离反而是容易的。在上面提到了 TiKV 内部是会对数据进行分片的,即使数据存储在 S3 上,我们也可以利用不同的 Key 的编码很好地区分不同租户的数据。TiDB 5.0 中也引入了一套名为 Placement Rules 的机制在语意层提供用户控制数据物理分布的操作介面(目前还没有在 TiDB 的云产品产品线中直接暴露给用户)(https://docs.pingcap.com/tidb/stable/placement-rules-in-sql)。TiDB 在设计之初为了性能考虑尽可能精简存储层对 key 的编码,并没有引入 namespace 的概念也没有对用户的数据进行额外的前缀编码,例如:不同用户的数据加上租户 id,为了实现租户的区分,这些工作现在都需要做,在存储层这部分并不算困难,更多要考虑的是兼容性和迁移方案。
传统的分布式数据库中,元信息存储是系统的一部分,例如在传统的 TiDB 中,PD 组件存储的元信息的主要部分是 TiKV 的 Key 和 Value 与具体的某个存储节点的对应关系。但是在一个 DBaaS 中,元信息远不止如此,更好的设计是将源信息存储单独抽离出来作为一个公共服务为多个集群服务,理由如下:
关于实现租户之间的不可见性,还有另一个重要的模块,就是上面提到的 Gateway。如果没有这个模块,要共享一个底层的 TiDB Cluster 是不现实的。这很好理解,就像你不能拥有多个 root 账号一样。因为 TiDB 使用的是 MySQL 协议,为了实现多租户,总是要在一个地方识别租户的名字,但是 MySQL 的网络协议并没有为多租户系统设计。
为此,我们选择了一个“取巧”的方案:既然要识别租户信息,那就应该在 Session 建立之初识别,只需要在鉴权的时候将租户 id 传进来就好,之后 session 建立起来,我们自然知道这个链接属于哪个租户,至于如何兼容标准的 MySQL 协议?session variable?No,直接在用户名前加个前缀就行了,这就是为什么在 TiDB Serverless 的用户名前面有一些奇怪前缀的原因:
在连接建立之初就知道了租户 id 后,所有的逻辑隔离都可以通过这个 id 实现。
Gateway 会干比起普通 Proxy 更多的事情,事实上在 TiDB Serverless 中,我们直接将原本 TiDB 代码中的 Session 管理模块的代码单独抽出来,用于实现 Gateway。这个设计带来的好处很多:
Gateway 作为常驻模块,本身也是无状态的。增加的延迟和与带来的好处相比,我们认为是可以接受的。
我们通过 Gateway 和元信息的修改实现了多租户在一个物理集群上的逻辑层的隔离,但是如何避免 Noisy Neiberhood 的问题?在上面提到我们引入了 RU 的概念,这里就不得不提一下我们在 TiDB 7.0 中引入的名为 Resource Control 的新框架(https://docs.pingcap.com/tidb/dev/tidb-resource-control)。和 Dynamo 类似,TiDB 的 Resource Control 也是使用了 Token bucket 的算法(https://en.wikipedia.org/wiki/Token_bucket)。将不同类型的物理资源和 RU 对应起来,然后通过 Token bucket 进行全局的资源控制:
比起硬性的物理隔离,这样实现资源隔离方式的好处是很明显的:
其实还有更多好处也在 Dynamo 那篇论文里提到,这和我们实际的感受是一致的。
但是,基于共享大集群的多租户服务实现,会带来一个挑战:爆炸半径的控制。例如当某一个服务出现故障或者发现严重的 Bug,可能影响的范不是一个租户,而是一片。目前我们的解法是简单的 sharding,至少一个 region 故障不会影响到另一个 region。另外对于大租户,我们也提供传统的 dedicated cluster 服务,也算是在业务层对这个问题提供了解决方案。当然,这方面我们也仍然在持续的探索中,有些工作很快会有一些可以看到的效果,到时候再与大家分享。
还有很多很有趣的话题,例如上面提到整个系统都是通过 Kubernetes 串联在一起的,那么为这样一个复杂的 DBaaS 提供 Devops,如何池化云上资源和多 region 支持?另外设计思路的改变不仅影响了上面提到的内容,对于数据库的周边工具的设计也会有重大的影响,例如 CDC、备份恢复什么的。因为篇幅关系就不在这里写了,以后有机会再说。
领取专属 10元无门槛券
私享最新 技术干货