如何让 768 台服务器看起来像 1 台?Neki 和 Vitess 给出答案!
让 768 台服务器看起来像 1 台
这里有 768 台服务器。对一些人来说,这是一大堆计算机。但对管理拥有数百万客户、每秒执行数百万次查询的应用程序基础设施的人而言,这很正常。这种规模的产品通常需数千台服务器协同工作。
几乎在所有情况下,最难扩展的基础设施组件是数据库。单台数据库服务器无法应对高需求,因此需通过数据库分片技术,将查询和数据分散到多台服务器上。
对于处理几 TB 以上数据的 Postgres 或 MySQL 数据库,数据库分片是最佳扩展方式。下面看看如何从小型单节点数据库,逐步扩展到将几 TB 数据分散存储在四个分片中,最终扩展到在 768 台服务器上进行分片,存储 PB 级数据。
成长的烦恼
要理解为何分片是扩展关系型数据库的必要手段,需先了解可扩展性较差的方法存在的瓶颈。
先看一个简单的应用程序架构。大多数应用程序至少在发展初期是这样运行的:客户端设备上的软件通过互联网连接到位于数据中心的应用服务器,该服务器负责处理身份验证、页面加载以及应用程序的所有服务器端逻辑。所有持久化数据,如用户账户、帖子、设置和消息等,都存储在数据库服务器中并从中读取(这里的 "数据库服务器" 通常指 Postgres 或 MySQL,本文主要关注 Postgres)。
即便使用大型数据库服务器(拥有数十个 CPU 核心、数百 GB 的 RAM),瓶颈也会很快出现,通常是由于高查询量导致 CPU 受限,或者是由于大量读写操作导致 I/O 受限(IOPS)。
通用可扩展性定律很好地总结了这一现象:该定律指出资源 "争用" 会使可扩展性随着资源增加呈非线性增长,并且在某个临界点,"不一致性" 会导致性能下降。对于 Postgres 以及任何试图在大型服务器上通过多线程或多进程进行扩展的软件系统来说,都是如此。
至少在短期内,一种解决方法是利用只读副本。在这种配置中,将原始服务器作为 "主服务器",并添加额外的 "副本"。主服务器会持续向每个副本发送消息流,以确保它们与主服务器上的数据更改保持同步。写入操作(`INSERT`、`UPDATE`、`DELETE`)只能发送到主服务器,若允许向任何服务器进行写入操作,可能会出现数据冲突,解决这个问题需要复杂且缓慢的共识算法,虽可行,但在大多数情况下,为实现最佳性能,这种做法并不理想。不过,应用服务器可以将读取(`SELECT`)查询发送到副本。由于大多数应用程序的读取请求比例远高于写入请求,因此这大大提升了可扩展性。(即使查询流量不需要副本,为实现高可用性和数据持久性,副本也是必不可少的。)
通过添加副本,数据库可以扩展以处理更多流量,如 OpenAI 在单个主服务器上使用 50 个副本。但事实证明,垂直扩展服务器(增加 CPU/RAM)和添加副本的方法只能解决部分问题,有些瓶颈无法通过这些方法解决:
1) 写入操作仅限于一台服务器:当写入量足够大时,再多的只读副本也无法解决问题。在 Postgres 确认写入操作已提交之前,它必须将更改记录到预写日志(WAL)中,并将该日志刷新到持久存储中。WAL 是主服务器上所有连接共享的资源,这实际上就是整个数据库的单一写入瓶颈,即使有数十个副本也无济于事。
2) 副本不会增加数据容量:副本是主服务器数据的完整副本,包括所有索引。添加副本可以增加读取数据的位置,但并不能分散数据。
3) 备份:备份是数据持久性以及恢复点目标(RPO)/恢复时间目标(RTO)保障的重要组成部分。由于节点与存储之间的通信带宽有限,将大型单体数据库备份到对象存储可能需要数小时甚至数天时间,对于许多依赖频繁且有效的备份的组织来说,这是无法接受的。
解决这些问题最有效的方法就是分片。
分片的作用
分片通过将数据和查询分散到多个不同的主服务器,解决了上述三个瓶颈。对于数据而言,由于单个节点的存储容量和写入吞吐量有限,分片尤为重要;对于查询来说,由于网络互连和 CPU 一次能处理的查询数量有限,分片也能发挥作用。
当数据规模超过几 TB 时,分片就会很有用。例如,有 2 TB 的数据,可设置四个分片,每个分片存储 500 GB 数据,并处理总查询流量的 1/4。当需要存储 1 PB(即 100 万 GB)的数据时,就需要更多的分片,可使用 256 个分片,每个分片配备一个主服务器和两个副本,每个分片负责存储约 4 TB 数据,这样一来,总共就需要 256 * 3 = 768 台服务器!
如果没有一个完善的系统,这将大大增加应用程序后端的复杂性。在如此复杂的情况下,系统该如何:
- 决定哪些数据存储到哪台服务器?
- 决定哪些查询发送到哪台服务器?
- 处理需要同时与多个分片通信的查询?
- 对分散的数据库进行备份?
- 监控整个系统的健康状况?
- 应对服务器故障?
要解决上述每一个问题,都有很多内容可以探讨。但本文要解决的问题是:如何让这 768 台服务器对我们的应用程序来说看起来就像一个统一的数据库?我们希望应用服务器从与一个复杂的系统交互,转变为通过单个连接字符串与之交互,就好像它在与一个大型的、可扩展的数据库进行交互一样,而实际上,它利用了数十或数百个分片。用于 Postgres 的 Neki 和用于 MySQL 的 Vitess 可以解决这个问题,下面看看它们是如何做到的。
代理层
在几个关键组件中,最重要的是代理层。代理是位于两个服务之间的中间件服务器,在我们的场景中,这两个服务分别是应用服务器和数据库服务器。
代理在 Postgres 数据库中经常被使用,即使不进行分片,它们也可用于连接池管理和请求排队。对于普通(未分片)的 Postgres,PgBouncer 是一个常用的代理,人们用它将数千个应用程序连接多路复用到较少的直接 Postgres 连接上。
PgBouncer 的目标很简单,它旨在接受来自多个客户端的大量连接,并将这些连接通过一个较小的连接池进行路由,该连接池会与 Postgres 持续保持连接。查询排队功能在流量高峰和数据库故障转移期间非常有用,这样当新的主服务器上线时,请求可以继续执行。
对 Postgres 进行分片需要一个更复杂的代理。最大的区别在于,除了多路复用和缓冲之外,代理还必须了解数据在服务器之间的分布方式,并将 SQL 查询路由到正确的分片,因此,将其称为 "路由器"。
在插入数据时,路由器必须知道数据的分布方式,这就是所谓的 [分片策略](/blog/database-sharding#sharding-strategy)。一种常见的方法是根据 ID 列的哈希值对传入的行进行分片。例如,当向数据库中插入如下行时:
INSERT INTO users (id, username, email) VALUES(1, 'ada', 'ada@example.com'),(2, 'grace', 'grace@example.com'),(3, 'linus', 'linus@example.com'),(4, 'margaret', 'margaret@example.com'),(5, 'dennis', 'dennis@example.com'),(6, 'barbara', 'barbara@example.com'),(7, 'donald', 'donald@example.com'),(8, 'james', 'james@example.com');
四个分片中的每个分片都会被分配一个负责存储的 ID 范围,路由器会将插入操作发送到正确的分片。插入操作首先会发送到路由器,路由器会计算每个 ID 的哈希值,然后将其转发到正确的分片。
对于读取操作,有些查询比较简单,路由器只需将其转发到单个分片即可。例如:
SELECT email from user where id = 4;
在这种情况下,路由器只需在内部记录每个用户 ID 所在的服务器,并将查询转发到相应位置。根据上述示例,这个查询会被发送到第一个(顶部)分片。但有些情况会更复杂,例如:
SELECT email FROM userWHERE id BETWEEN 3 AND 5;
具有该 ID 范围的用户分散在多个分片中。路由器必须了解数据拓扑结构,制定一个将查询分发到所有可能包含匹配结果的分片的计划,在路由器端聚合结果,然后将完整的结果集发送给客户端。这意味着路由器本身必须内置一个完整的查询解析器和路由规划器。路由器必须能够在一个系统内完成查询解析、规划、连接池管理和缓冲等任务,开发这样复杂的软件并非易事。
它如何知晓?
每个数据库都是独一无二的,有自己的模式、表和查询模式。那么,路由器如何通用地知道哪些数据和查询应该发送到哪里呢?在 Neki 和 Vitess 中,这些信息都是通过表示系统数据拓扑的 JSON 文件来指定的。Vitess 的 VSchema 和 Neki 的数据拓扑为工程师提供了极大的灵活性,让他们能够精确描述表和查询的分布方式。以下是一个为 `user` 表指定分片方案的简化示例:
{ "shard_indexes": { "user_hash": { "type": "hash" } }, "tables": { "user": { "shard_by": "user_hash", "column": "id" } }}这些元数据存储在路由器中,它会告诉路由器 `user` 表是根据 `id` 列使用 `user_hash` 分片索引进行分片的。`user_hash` 分片索引使用路由器内置的值哈希算法。对于每个传入的行,它会对 ID 进行哈希处理,并根据哈希结果将其发送到正确的分片进行存储。由于这些信息都是通过文本和 JSON 传递给路由器的,因此 AI 代理非常适合进行配置和优化。
多个代理,一个数据库
当有 256 个分片,分布在 768 台服务器上,并且每秒要处理数百万次查询时,无法通过单个代理来路由所有流量,需要多个代理,具体数量可能是 10 个,也可能是 100 个,这取决于流量情况。
我们仍然希望应用程序将其视为一台单一的服务器,这时,网络负载均衡器(NLB)就能发挥作用。NLB 的工作很简单:允许通过单个主机/IP 进行连接,并将每个连接分配到多个目标之一,这就是流量在路由器之间分配的方式。一旦分配完成,连接在其生命周期内将始终与同一个代理保持连接。
在某些情况下,NLB 并不是必需的。去掉 NLB 会使应用服务器的连接逻辑稍微复杂一些,因为它需要知道每个路由器的主机信息,但这样可以减少一次网络跳数,将往返延迟降至最低。
全貌
现在,所有组件都已就位,可以让存储 1000 TB 数据的 768 台服务器对我们的应用程序来说看起来就像一个单一的、整体的数据库。
- 应用服务器收到 "连接到 `mydb.pscale.com` 上的数据库" 的指令。
- 进行 DNS 查找,返回 NLB 的 IP 地址:`123.152.100.4`。
- 应用程序请求连接到 `123.152.100.4` 上的数据库。
- 该连接首先通过 NLB 进行路由,然后到达 N 个代理之一。
- 应用程序开始发送数据库查询,查询路径为:应用程序 -> NLB(可选) -> 代理 -> 分片。复杂的路由逻辑对应用程序是透明的。(为简单起见,下面的示例中未显示 NLB)。
这个示例展示了如何扩展到 1 PB 的规模,但分片应该在达到这个规模之前就开始进行。具体的建议取决于每个数据库的大小、模式和每秒查询量(QPS),但我们建议对于几 TB 以上的数据,对 Postgres 和 MySQL 进行分片。通常在这个阶段,会开始遇到前面提到的瓶颈:长时间的备份、写入瓶颈等。如果你在扩展关系型数据库方面遇到挑战,Neki 和 Vitess 是很好的解决方案。
用于 MySQL 的 Vitess 已经在扩展全球大型关系型数据库方面使用了十多年。我们在为客户运营大型分片数据库方面拥有多年经验,并且是 Vitess 项目的核心维护者。Neki 是由 Vitess 的同一批专家维护者开发的,它为 Postgres 带来了更强大的分片系统。
其他方面呢?
我们只是触及了像 Neki 和 Vitess 这样的分片系统所提供功能的皮毛,还有很多其他有趣的细节值得探索。例如,分片数据的最佳方法是什么?分片数据库如何处理故障?如何更改分片数量?如何同时对 256 个分片进行备份?请继续关注我们的更多内容。
公司信息
关于我们 品牌介绍 博客 更新日志 招聘信息 活动信息
产品相关
案例研究 企业版 定价 性能基准测试
资源中心
文档 迁移指南 技术支持 服务状态 信任中心
课程学习
面向开发者的 MySQL 数据库扩展 学习 Vitess
开源项目
Vitess Vitess 社区 GitHub
隐私政策 | 使用条款 | Cookie 政策 | 专利信息 | 请勿分享我的个人信息
