为什么规划数据库容量如此困难?那么怎么简化?可以使用开源NoSQL数据库ScyllaDB来演示示例。
调整数据库的大小看起来很简单:用数据集的大小和所需吞吐量除以节点的容量。很容易,不是吗?
如果有人曾经尝试规划数据库容量,就会知道这有多难。即使是做出粗略的估计也很具挑战性。那么为什么这么难?
以下是估算集群大小的步骤:
(1) 对使用模式做出假设。
(2) 估计所需工作量。
(3) 决定数据库的高级配置。
(4) 将工作负载、配置和使用模式提供给数据库的性能模型。
(5) 收入。
这个流程虽然容易理解,但在实践工作中却不那么容易。
例如,在对数据库配置(例如复制因子和一致性级别)做出决策时,做出的决策会受到预先设想答案的影响。而当成本变得非常昂贵时,而进行一些复制似乎有点过分。
将数据库的规模调整看作一个设计过程,需要意识它是迭代的,并且支持需求和使用的发现和研究。与任何设计过程一样,最佳规模在经济上和操作上都不理想,其投入的时间和资源也很有限。
在设计过程的简单性和成本与准确性之间存在内在的权衡。毕竟,复杂的模型可以更好地预测数据库性能,但其成本可能与构建数据库本身一样高,而且需要太多的输入,以至于其应用不切实际。
带来哪些问题?
数据库的工作负载通常被描述为查询的吞吐量和它必须支持的数据集大小。这将会引发出一些问题:
•这个数字是最大吞吐量还是平均值?(工作量通常有周期性变化和峰值)
•是否应该分离某些类型的查询?(例如读取和写入)
•如果还没有使用这个数据库,那么如何估计查询的数量?数据集多大?
•热门数据集是什么?数据库存储的数据比它们在任何给定时间点所能提供的数据多得多。
•数据模型呢?从经验中知道,数据模型对查询数量、性能和存储大小有很大的影响。
•预期增长是多少?希望构建一个可以随工作负载扩展的数据库。
•查询的服务等级目标(SLO)是什么?设计的延迟目标是什么?
有些人很幸运,拥有一个可以正常工作的系统,也许还有运行正常的数据库,他们可以很容易地从中提取或推断出这些问题的答案。但在通常情况下,一些被迫使用猜测方法和粗略计算。这并不像听起来那么糟糕。例如,可以了解这个使用蒙特卡罗工具的模型,如下图所示。
估计工作量很有趣。但是出于简单的分级目的,人们感兴趣的是吞吐量和数据集的最大值,并将只区分两种查询:读取和写入,其原因将在后面解释。
还可以假设对数据模型的控制程度很高。对于任何NoSQL数据库,其目标是通过一个查询来处理所有需要的数据--如果用户想优化读取或写入,需要做出决定。
通常的基本步骤是:
(1) 估计峰值数据集大小和工作负载。
(2) 初步绘制数据模型,对优化目标进行高层决策。
(3) 根据数据模型估计读/写比率。
构建数据库的性能模型
数据库的性能模型必须在一些有时相互冲突的需求之间取得平衡,它必须考虑足够的性能和容量安全裕度,但需要在成本、可靠性与性能、持续和峰值负载之间实现平衡,但仍然可以简化,即使在没有特定应用程序的情况下使用,同时提供明确的结果。
这确实是一项具有挑战性的任务。但它可以简单得多。例如,以下是它如何与Scylla一起工作,Scylla是一个兼容Apache Cassandra的开源NoSQL数据库。
查询vs操作
工作负载是根据查询(通常是CQL)指定的。CQL查询可能非常复杂,并生成数量不一的基本操作,这些操作的性能相对可预测。以下面的CQL查询为例:
- SELECT * FROM user_stats WHERE id=UUID
- SELECT * FROM user_stats WHERE username=USERNAME
- SELECT * FROM user_stats WHERE city=”New York” ALLOW FILTERING
查询#1将使用主键定位记录,因此会立即在正确的分区上执行--根据一致性级别,它仍然可能分解为几个操作,因为将查询多个副本(稍后详细介绍)。
尽管查询#2看起来非常相似,但它会基于二级索引查找记录,分解为两个子查询:一个子查询到全局二级索引以查找主键,另一个子查询从分区检索行。此外,根据一致性级别,这可能会生成多个操作。
查询#3甚至更极端,因为它跨分区扫描;它的表现将是糟糕的和不可预测的。
另一个例子是UPDATE查询:
- UPDATE user_stats SET username=USERNAME, rank=231, score=3432 WHERE ID=UUID
- UPDATE user_stats SET username=USERNAME, rand=231, score=3432 WHERE ID=UUID IF EXISTS
查询#1可能会直观地分解为读取和写入这两个操作--但在CQL UPDATE查询中是UPSERT查询,只会生成1个写入操作。
然而,查询#2尽管看起来很相似,但它不仅要求在所有副本上先读取后写入,而且还要求进行编排的轻量级事务(LWT)。
类似地,查询生成的磁盘操作数量可能会有很大的不同。大多数数据库在排序字符串表(SSTable)存储格式中使用日志结构的合并树(LSM)结构。他们从不修改磁盘上的SSTable文件,它们是不变的。写入被持久化到只追加提交日志以进行恢复,并写入内存缓冲区(memtable)。当memtable变得太大时,它会被写入磁盘上的一个新的SSTable文件,并从内存中刷新。这使得写路径非常高效,但在读取时引入了一个问题:数据库必须在多个SSTables中搜索一个值。
为了防止这种读取失控放大,数据库定期将多个SSTables压缩到数量更少的文件中,只保留最新的数据。这减少了完成查询所需的读取操作数量。
这意味着将磁盘操作归因于单个查询实际上是不可能的。磁盘操作的确切数量取决于SSTables的数量、它们的排列、压缩策略等。开源的NoSQL数据库旨在最大限度地利用存储空间,所以只要磁盘速度足够快并且不是瓶颈,就可以忽略这个维度。这就是推荐快速本地NVMe磁盘的原因。
虽然这个示例主要关注CQL,但预测查询成本并不是唯一的问题。事实上,查询语言越丰富、功能越强大,就越难以预测其性能。例如,由于SQL的强大功能,它的性能可能难以预测。因此,复杂的查询优化器是RDBMS的重要组成部分,它确实提高了性能,但其代价是使性能更加难以预测。这是NoSQL采取的另一种折衷方法:优先考虑可预测的性能和可扩展性,而不是功能丰富的查询语言。
一致性的难题
分布式可用数据库需要可靠地将数据复制到多个节点。每个键空间可以配置副本的数量,称之为复制因子。从客户端的角度来看,这可以同步发生,也可以异步发生,这取决于写入的一致性级别。
例如,当一致性级别为1时,数据最终会写入所有副本,但客户端只会等待一个副本确认写入。即使有些节点暂时不可用,数据库也应该在这些节点可用时缓存写入和复制(这称为暗示切换)。在实践中,可以假设每个写查询都会在每个副本上生成至少一个写入操作。
然而,对于读取来说,情况有些不同。一致性级别为ALL的查询必须从所有副本读取数据,从而生成与集群的复制因子一样多的读取操作,但一致性级别为1的查询只从单个节点读取数据,从而实现更便宜、更快的读取。这允许用户以牺牲一致性为代价从集群中挤出更多的读吞吐量,因为一个节点在被读取时可能还没有获得最近的更新。
最后,还有轻量级事务(LWT)需要考虑。如上所述,轻量级事务(LWT)需要利用Paxos算法对所有副本进行编排。轻量级事务(LWT)不仅强制每个副本读取然后写入该值,而且还需要维护事务的状态,直到Paxos轮结束。由于轻量级事务(LWT)的行为方式不同,需要将其视为每个核心能够支持的吞吐量的独立性能模型。
所有操作都是平等的,但有些操作比其他操作更平等
现在已经将CQL查询分解为基本操作,那么可以询问一些问题:每个操作需要多少时间(和资源)?CPU核心能支持的容量是多少?同样,其答案有点复杂。作为一个例子,可以考虑一个简单的读取并遵循节点中的执行步骤:
(1) 在内存表中查找值。
(2) 在缓存中查找值。
(3) 在SSTables中逐层查找值并合并值。
(4) 响应协调者。
显然,如果它们在基于内存的memtable或行级缓存中,读取将更快地完成,这没什么好奇怪的。此外,步骤#3和#4可能会产生更高的成本,这取决于从磁盘读取、处理和通过网络传输的数据的大小。如果需要读取10MB的数据,这可能需要相当长的时间。这可能是因为存储在单个单元格中的值很大,也可能是因为范围扫描返回许多结果。但是,在值很大的情况下,Scylla不能将它们分成更小的块,必须将整个单元格加载到内存中。
一般来说,建议对数据进行建模,使分区、行和单元不要增长得太大,以确保减少性能的差异。然而在现实中,总会有一些差异。
当涉及到分区访问模式时,这种差异尤其显著。许多数据库用于跨节点扩展和传播数据的策略是将数据分块到彼此独立的分区中。但独立也意味着分区可能会经历不均匀的负载,导致所谓的“热分区”问题,即单个分区会遇到节点容量限制,尽管集群的其余部分有足够的资源。这个问题的发生在很大程度上取决于数据模型、数据集中分区键的分布以及工作负载中键的分布。由于预测热分区通常需要分析整个数据集和工作负载,因此在设计阶段是不切实际的,因此可以提供某些已知的分布作为模型的输入,或者对分区的相对热进行一些假设。另外,nodetool toppartitions命令可以帮助定位热分区。
物化视图、二级索引和其他
数据库具有自动二级索引和物化视图以及变更数据捕获(CDC)功能。这些表本质上是由数据库本身维护的辅助表,只允许使用一个写操作以多种形式写入数据。
而在幕后,Scylla根据用户提供的模式派生要写入的新值,并将这些新值写入不同的表。在这方面,Scylla和RDBMS之间的主要区别在于,派生数据是异步写入的,并且作为索引和物化视图跨网络写入,而不局限于单个分区。这是另一个需要考虑的写入的来源。在Scylla中,它是可以预测的,并且在性能上与用户生成的操作相似。对于写入的每一项,CDC和从写入单元格派生的每个物化视图或二级索引都将触发一次写入。在某些情况下,物化视图和CDC可能需要额外的读取,例如,如果启用了CDC的“预映像”功能,就会发生这种情况。另外需要记住的是,一个CQL查询可以触发多个写操作。
CDC、二级索引和物化视图被实现为由Scylla本身管理的常规表,但这也意味着它们消耗的磁盘空间与用户表相当,因此必须在容量计划中考虑到这一点。
高峰和数据库维护
所有数据库都需要执行各种维护操作。RDBMS需要清理重做日志(例如Postgre SQL VACUUM),转储快照到磁盘(查看Redis),或执行内存垃圾收集(Cassandra、Elastic、HBase)。使用LSM存储的数据库(如Cassandra、HBase、Scylla)也需要定期压缩SSTables,并将memtable刷新到新的SSTables中。
如果数据库足够智能,可以将压缩和修复等维护操作推迟到稍后、负载更少的时间,那么可以在短时间内获得最佳性能。然而,最终将不得不为这些维护操作预留资源。这对于大多数系统来说非常有用,因为一天内的负载的分配并不是均匀的。但是,企业的计划应该为数据库的持续长期操作提供足够的容量。
此外,仅根据吞吐量进行规划是不够的。在某种程度上,可以使数据库过载以获得更高的吞吐量,但其代价是更高的延迟。
在这个意义上,基准往往具有误导性,通常时间太短而无法达到有意义的持续运行。延迟/吞吐量的权衡通常更容易度量,甚至在较短的基准测试中也可以观察到。
另一个重要但经常被忽视的问题是降级操作。作为一个本地冗余和高可用的数据库,Scylla被设计为平滑地处理节点故障(根据用户设置的一致性约束)。但是,虽然故障在语义上是一致的,但这并不意味着它们是动态透明的,而其容量的显著损失将影响集群的性能,以及故障节点的恢复或替换。在调整集群规模时也需要考虑这些因素。
选择适当规模的节点
由于Scylla的容量可以通过增加节点或选择更大的节点来增加,一个有趣的问题出现了:应该选择哪种扩展策略?一方面,更大的节点效率更高,因为可以独立于服务Scylla的内核分配CPU核来处理网络和其他任务,并减少节点协调的相对开销。另一方面,拥有的节点越多,当其中一个节点出现故障时,损失的部分容量就越少--尽管丢失节点的概率稍微高一些。
对于非常大的工作负载,解决这个问题是没有意义的,因为大型节点是不够的。但是对于许多较小的工作负载来说,3个中大型节点就足够了。这个决定与工作量相关。但是,对于可靠性降级操作,建议至少运行6~9个节点(假设复制因子为3)。
结论
容量规划和调整集群规模可能非常复杂和具有挑战性。本文已经讨论了如何考虑安全裕度、维护操作和使用模式。重要的是要记住,任何猜测都只是迭代的初始估计。一旦投入生产并有了真实的数据,可以让它指导实施容量计划。