本章内容:
- 执行基于查询的设计
- ScyllaDB 如何在集群中分配数据
- 实施基于查询的设计,为示例应用程序构建架构
存储数据是运行数据库的简单部分。ScyllaDB 的设计目标就是存储数据,通常来说,数据库的构建目的是让数据的存储与检索变得简单。而我发现,最难的部分往往是确定你需要存储哪些数据,以及如何将这些数据存储在数据库中,以便于高效访问。
数据库架构的开发通常始于应用程序的构思。这个应用有需求,而这些需求直接或间接地与数据库相关。你和其他团队成员会反复迭代这些需求,直到最终形成一个明确的数据库需求。接下来,你会将这些需求转化为数据库架构。
在第一章中,你学到 ScyllaDB 是一种不同的数据库——它将数据分布在多个节点之间,以提供更好的可扩展性和容错能力。在第二章中,你学习到,Scylla 通过基于分区键对数据进行分区来实现数据的分布。这一关系意味着你需要对数据进行设计,以便有效地实现 ScyllaDB 的优势:如果你设计的数据能够有效地分布,这种分布将会对数据库产生积极的影响。
在设计架构时,你需要考虑应用程序的需求。你会阅读需求,并进行深思熟虑,直到最终设计出一个数据库架构。在这一章中,我们将引导你进入架构设计的深度思考阶段,帮助你构建一个符合 ScyllaDB 特性的架构,基于上一章你构建的餐厅评论数据库,扩展设计你的架构。
在这一章中,你将学习如何进行 ScyllaDB 的数据建模。为了最大限度地利用 Scylla 的设计并确保数据库性能,你将学习一种称为“基于查询的设计”(query-first design)的方法,在这种方法中,你将围绕应用程序需要执行的查询来设计数据库架构。让我们开始吧!
3.1 架构设计前的应用设计
在设计数据库架构时,你需要创建一个与数据库目标协同工作的架构。如果你考虑 Scylla 的目标作为数据库,它希望将数据分布到集群中,以提供更好的可扩展性和容错能力。数据的分布意味着查询将涉及多个节点,因此,你需要设计应用程序,使查询尽量使用分区键,从而最小化参与请求的节点数量。你的架构需要满足以下约束:
- 它需要将数据分布到集群中。
- 它还应查询最少数量的节点,以满足所需的一致性级别。
这些约束之间存在一定的张力:你的架构希望将数据分布到集群中,以便平衡节点之间的负载,但你希望最小化查询所需的节点数量。在满足这些约束时,可能需要做出一些权衡——你是选择具有较小分区的架构,这可能需要更多的查询来聚合数据,还是选择具有较大分区的架构,这样可以减少查询次数,但可能导致数据在集群中的分布不均匀?
在图 3.1 中,你可以看到利用分区键进行查询的成本与没有使用分区键的查询成本之间的差异。没有使用分区键的查询需要扫描每个节点来查找匹配的数据,而使用分区键的查询可以让协调节点——处理请求的节点——将查询定向到拥有该分区的节点,从而减少集群负担并更快地返回结果。
上述设计约束与查询紧密相关。你希望将数据分布到集群中,以便你的查询能够将负载分散到多个节点上。试想一下,如果你的所有数据都集中在集群中的一小部分节点上,某些节点将会非常繁忙,而其他节点则可能几乎没有任何流量。如果这些高度利用的节点变得过载,你的性能可能会下降:由于负载不均衡,许多查询可能无法完成。
然而,你也希望最小化每个查询所涉及的节点数量,以减少查询所需的工作量;如果一个查询要使用一个非常大的集群中的所有节点,这将是非常低效的。这些以查询为中心的约束要求你采取以查询为中心的设计方法。查询 Scylla 的方式是其性能的关键组成部分,由于你需要考虑查询对多个维度的影响,因此,仔细思考如何查询 Scylla 是至关重要的。
在 Scylla 中设计架构时,最佳的做法是应用一种称为“基于查询的设计”(query-first design)的方法,即首先关注应用程序需要执行的查询,然后围绕这些查询构建数据库架构。在 Scylla 中,你根据希望如何查询数据来结构化数据——基于查询的设计方法将帮助你完成这一目标。
3.1.1 基于查询的设计工具箱
在基于查询的设计中,你需要将一组应用需求转化为一系列问题,逐步引导你将这些需求转化为 ScyllaDB 的架构设计。这些问题相互关联,循序渐进地帮助你构建一个有效的 ScyllaDB 架构。这些问题包括以下内容:
- 我的应用程序有什么需求?
- 我的应用程序需要执行哪些查询来满足这些需求?
- 这些查询查询的是哪些表?
- 如何唯一标识和分区这些表以满足查询需求?
- 这些表中存储哪些数据?
- 这个设计是否符合需求?
- 这个架构可以改进吗?
这个过程如图 3.2 所示,展示了你如何通过一系列问题,从最初的需求开始,到你需要执行的查询,直到你最终得到一个完整设计的 ScyllaDB 架构,从而能有效地存储数据。
你从需求出发,然后利用这些需求来识别需要执行的查询。这些查询是在寻找某些数据——这些“数据”需要存储在表中。你的表需要进行分区以分散数据到集群中,因此你需要在需求和数据库设计约束的范围内确定分区方式。接着,你指定每个表中的字段,完善设计。此时,你可以检查两个方面:
- 设计是否符合需求?
- 这个设计是否可以改进?
在设计初期考虑这些问题非常重要,因为在 ScyllaDB 中,将架构更改为适应新的用例可能是一个高摩擦的操作。尽管 Scylla 支持通过一些功能(你将在第七章学习到)来适应新的查询模式,但这些功能会带来额外的性能开销;如果它们不适合你的需求,可能还需要手动将数据复制到新表中。因此,仔细考虑设计非常重要,不仅要考虑当前的需求,还要考虑未来的需求。
你没有的工具
像在 Scylla 中创建有效设计一样,许多与关系型数据库不同的工具在这里不可用。例如,外键、参照完整性和连接(JOIN)。然而,这些限制并不会阻止你创建出设计良好且高效的数据库架构。
在关系型数据库中,用户可以自由发出 JOIN 查询,基于公共列值从多个表中检索数据。在之前章节中的食物评论示例中,如果使用关系型数据库,你可能会将餐厅信息存储在一个餐厅表中,而不是在食物评论表中存储餐厅名称,而是存储餐厅的 ID,这个 ID 连接到餐厅表中的一行。然后,一个 JOIN 查询将遍历这个连接,从多个表中聚合数据。
但是,这种 JOIN 操作在 ScyllaDB 或许多其他 NoSQL 数据库中是不可用的。如果数据是通过分区分布的,那么在 Scylla 中进行假设的 JOIN 查询可能会涉及额外的分区和节点,从而影响查询性能。
Scylla 的目标是提供更好的可扩展性和容错性,同时确保性能既快速又可预测。允许跨表连接(JOIN)与这一目标相悖:每个查询应该只涉及一个表。实际上,JOIN 关键字甚至不属于 CQL 规范的一部分。
与 JOIN 查询类似,外键和参照完整性也不可用。由于 Scylla 追求可预测的性能,它不会执行额外的查询来验证数据库的正确性和一致性。你可以使用指向其他表数据的 ID,但 Scylla 不会强制验证这些 ID 是否有效。
在你因震惊而退缩并发誓再也不使用没有这些特性的数据库之前,请记住,ScyllaDB 提供了一组不同的属性作为权衡,重点是可预测的分布式性能、可扩展性和容错性。在本章中,当你遇到一些初看起来可能觉得奇怪的技术和设计决策时,记住这一段内容和这些权衡!
你从提取应用程序的查询需求开始,并扩展设计,直到你有一个既适合你的应用程序又适合 ScyllaDB 的架构。为了在 Scylla 中实践查询优先设计,让我们以餐厅评论应用程序为例,来构建一个 ScyllaDB 架构。
3.1.2 示例应用程序需求
在上一章中,你将餐厅评论存储到了 ScyllaDB 中。你在与数据库打交道时感到非常愉快,随着你去的地方越来越多,你意识到自己可以将两个大爱结合起来:餐厅评论和数据库(如果这些不是你最大的爱好,那就请配合一下)。你决定建立一个网站,分享你的餐厅评论。因为你已经有了一个 ScyllaDB 集群,所以选择使用它作为你网站的存储(如果你选择其他方式,那这本书就完全不同了)。
查询优先设计的第一步是识别应用程序的需求,如图 3.3 所示。经过对潜在网站的思考,你确定了它需要的功能,最重要的是,你给它取了个名字:“餐厅评论”。如其名所示!“餐厅评论”有以下初步需求:
- 作者将文章发布到网站。
- 用户查看文章,阅读餐厅评论。
- 文章包含标题、作者、评分、日期、图片库、评论文本和餐厅名称。
- 评论的评分范围是 1 到 10。
- 主页显示按最新排序的文章摘要,展示标题、作者名称、评分和一张图片。
- 主页链接到完整的文章。
- 作者有专门的页面,展示他们的文章摘要。
- 作者有姓名、简介和照片。
- 用户可以查看按最高评分排序的文章摘要列表。
你有一种预感,随着时间的推移,你会想为这个应用程序添加更多功能,并利用这些功能深入学习 ScyllaDB。现在,这些功能为你提供了一个基础,帮助你练习如何拆解需求并构建架构——让我们开始吧!
3.1.3 确定查询
接下来,你问自己:“为了满足这些需求,我的应用程序需要执行哪些查询?”(见图 3.4)。你的查询将决定你如何设计数据库架构;因此,在设计初期理解查询至关重要。
为了确定查询,你可以使用熟悉的 CRUD 操作——创建(Create)、读取(Read)、更新(Update)和删除(Delete)——作为动词。这些查询将作用于需求中的名词,比如作者或文章。你有时还需要对查询进行过滤:你可以通过“by”加上过滤条件来表示这种过滤。例如,如果你的应用需要按日期加载事件,你可能会使用“Read Events by Date”查询。如果查看你的需求,你会发现你需要执行多个查询。
注意 这些并不是你将要执行的实际查询;实际的查询将用 CQL 编写,正如在第 2 章中讨论的那样,看起来更像是 SELECT * FROM your_cool_table WHERE your_awesome_primary_key = 12;。这些只是你需要查询的描述。在后续章节中,当你完成设计后,你将把这些描述转换为实际的 CQL 查询。
第一个需求是“作者发布文章到网站”,这听起来很像一个涉及将文章插入数据库的过程。因为你是通过查询将文章插入数据库,所以你需要一个 Create Article 语句。此时你可能会问:“什么是文章?”尽管其他需求中涉及了这些字段,但现在你可以暂时跳过这个问题。先专注于你需要执行的查询,稍后你再决定每个表中需要哪些字段。
第二个需求是“用户查看文章以阅读餐厅评论”。直接让用户访问数据库是不安全的,所以应用需要加载文章以供用户查看。这一功能暗示了一个 Read Article 查询(这与用户浏览文章不同),你可以用它来检索文章供用户阅读。
接下来的两个需求涉及你需要存储的数据,而不是如何访问这些数据的方式:
- 文章包含标题、作者、评分、日期、图片库、评论文本和餐厅信息。
- 评论的评分在 1 到 10 之间。
文章需要某些字段,每篇文章都有一个符合特定参数的评分。你可以稍后在填写每个表所需的字段时再处理这些需求。
接下来的相关需求是:“主页显示按最新排序的文章摘要,包括标题、作者名、评分和一张图片。”主页显示文章摘要,你需要按日期加载这些摘要,按最新排序:Read Article Summaries by Date。文章摘要在初看时与文章非常相似,因为你查询的是不同的数据,并且你还需要按时间检索摘要,所以你应该将它们视为不同的查询。
查询文章会执行以下操作:
- 加载标题、作者、评分、日期、图片库以及评论文本和餐厅信息
- 检索一篇特定的文章
另一方面,加载最新的文章摘要会执行以下操作:
- 仅加载标题、作者名、评分和一张图片
- 加载多个摘要,按发布时间排序
或许它们可以查询同一个表,但你可以在练习的后续过程中再确定是否如此。在不确定的情况下,最好不要过度合并。设计时要保持扩展性;如果有重复的部分,可以在优化设计时再减少重复。随着设计实施的推进,你可能会发现一些看似不必要的重复步骤,在后续步骤中却是必要的分离。
接下来的需求是:“主页链接到文章”,这明确表示文章摘要链接到文章。你会在确定需要哪些字段时仔细研究这一点。
接下来的两个需求涉及作者。网站将为每个作者(目前大概只有你,但你有一个媒体帝国的梦想)提供一个页面。这个作者页面将包含该作者的文章摘要——这意味着你需要执行 Read Article Summaries by Author 查询。在最后一个需求中,有每个作者的数据。你可以稍后研究这些具体内容,但它意味着你需要读取作者信息,所以你需要一个 Read Author 查询。
最后一个需求是:“用户可以查看按最高评分排序的文章摘要列表”,你需要一种方法来显示按评分排序的文章摘要。这需要执行 Read Article Summaries by Score 查询。
注意 什么是适合按评分排序读取文章的分区键?这是一个棘手的问题;你很快就会学习如何解决它。
在分析完需求后,你确定你的模式需要支持以下六个查询:
- Create Article
- Read Article
- Read Article Summaries by Date
- Read Article Summaries by Author
- Read Article Summaries by Score
- Read Author
你可能会注意到一个问题:文章摘要和作者在哪里被创建?如果没有任何东西让它们存在,怎么读取它们?需求列表中常常会有隐性需求——因为你需要读取文章摘要,所以它们必须以某种方式被创建。现在,添加 Create Article Summary 和 Create Author 查询到你的列表中。现在,你有了来自这些需求的八个查询,列在表 3.1 中。
表 3.1 将需求映射到查询
| 需求 | 查询 |
|---|---|
| 作者发布文章… | Create Article |
| 用户查看文章… | Read Article |
| 文章包含标题… | N/A |
| 评论的评分在… | N/A |
| 主页显示文章摘要… | Create Article Summary, Read Article Summaries by Date |
| 主页链接到文章… | N/A |
| 作者有专门页面… | Create Author, Read Article Summaries by Author |
| 作者有名字… | Read Author |
| 按最高评分排序的文章摘要… | Read Article Summaries by Score |
有一个笑话问:“如何画一只猫头鹰?”答案是:“先画几个圆圈,然后再画其他部分。”查询优先设计有时感觉类似。你需要将查询映射到一个数据库模式中,这个模式不仅能够有效满足应用的需求,还能提高性能,并利用 ScyllaDB 的优势和特性。从需求中提取查询通常很直接,而从这些查询设计模式则需要平衡应用和数据库的考虑。接下来,我们来看一些你可以应用的技术,帮助你设计数据库模式。
3.2 确定表
在确定了查询之后,查询优先设计的下一步(见图 3.5)是问自己:“这些查询是查询哪些表?”在分析需求时,你确定了几个需要实现的查询。你会发现有三个主要的东西需要查询:
- 文章
- 文章摘要
- 作者
3.2.1 去规范化
Scylla 不支持联接操作——你不能在一次查询中跨多个表查询行。如果你想为一篇文章显示作者的名字,你需要将作者的名字与文章一起存储,或者进行多次查询来从数据库中检索该信息。
这些方法看起来可能是不必要的重复:如果数据库已经有数据,为什么还需要再次存储呢?关系型数据库强烈鼓励规范化的概念,即只存储一次数据,通过外键链接到数据,并由数据库验证外键的正确性。
一个常见的例子是表示商店的数据库。销售的商品可能会在名为 items 的表中存储其 ID、名称和描述,但有关价格的信息可能存储在名为 prices 的表中,包含商品 ID 和价格。关系型数据库会验证 prices 表中的商品 ID 是否与 items 表中的 ID 匹配。它还支持通过查询时跟随这些链接轻松地从多个表中检索数据,从而将多个表的数据汇总起来。这是关系型数据库设计的核心组成部分,如果你熟悉它,可能会发现很难不使用这种范式。
然而,在 Scylla 中,如何查询数据决定了如何存储数据。因为你需要以不同的方式访问数据——例如,你计划以多种方式加载文章摘要——所以你必须使用去规范化,意思是将相同的数据存储在多个地方,以便以不同的方式查询它。
如果你想根据作者加载文章摘要,文章摘要必须按作者进行分区,因为数据的存储方式会影响查询的方式。如果你想加载得分最高的文章摘要,而这些摘要假设是按作者分区的,那么查询必须包括分区键——作者。否则,它将检查所有分区,可能会带来性能上的问题。
去规范化是一种有用的技术——它保留了 ScyllaDB 查询的快速写入、可扩展性和容错性,并为你提供了在多种上下文中访问数据的灵活性。其缺点是数据的重复存储和维护的额外工作。你不仅需要在一个表中插入一行数据,还需要将其插入到去规范化的表中,以保持数据的一致性(见图 3.6)。Scylla 提供了帮助处理这一过程的功能,我们将在后面的章节中介绍。
在了解了 Scylla 如何在设计中鼓励去规范化之后,你现在可以扩展你的模式并确定需要哪些表。
3.2.2 提取表
考虑到去规范化,接下来我们需要思考,为了满足这些查询,我们需要哪些表?你已经识别出了几个查询需求:
- 创建文章(Create Article)
- 阅读文章(Read Article)
- 创建作者(Create Author)
- 阅读作者(Read Author)
- 创建文章摘要(Create Article Summary)
- 按日期读取文章摘要(Read Article Summaries by Date)
- 按作者读取文章摘要(Read Article Summaries by Author)
- 按评分读取文章摘要(Read Article Summaries by Score)
前两个查询与文章相关。随着你确定表结构,一个常见的模式将会显现:查询动词(如创建、读取)的对象通常会变成表格。创建文章的查询会将一行数据插入到文章表中;而读取文章的查询会读取该行数据。
让我们来看看作者。和文章一样,你的查询也会创建作者并读取作者。应该采用同样的方式吗?答案是肯定的!你可以创建一个作者表,用来存储并读取作者数据。
现在我们要讨论的是文章摘要。此时,你可能会想,文章和文章摘要有什么区别?仔细看看,文章摘要是文章的一个子集,它只包含文章的一部分字段。到设计的最后,你可以通过这一点来改进你的表结构。
你想要以几种方式查询文章摘要——按日期、按作者和按评分。那么,你需要哪些表来满足这些需求呢?如果你想按这些方式查询数据,你就需要以支持这些查询方式的形式存储数据。记住,在 Scylla 中,你应该通过分区键来查询数据,这意味着你需要为每种查询方式准备一个合适分区键的表。对于这些不同的查询,你肯定需要去规范化存储数据,也就是说,需要用多个表来存储文章摘要,每个表按不同的方式进行分区,以满足查询要求。
是的,这样做看起来可能有些重复。你可能会想,“我真的需要把数据存储三次,以便能按三种不同的方式查询吗?”有可能是的(一些查询可能会共享分区键,取决于你的使用场景),但让我们更深入地分析一下原因。
记住,ScyllaDB 的目标是通过分布式数据库提供可扩展性和容错性。假设有一个文章摘要表,行按作者 ID 进行分区,主键由作者 ID 和文章 ID 组成。文章 ID 唯一标识一篇文章,但由于查询需要包含分区键,你还必须在主键中包含作者 ID。这个表的复制因子是三,意味着每个分区会在集群的三个节点上进行复制。如果你想加载某个作者的文章摘要,你只需要指定作者 ID,查询只会访问存储该分区的三个节点,如图 3.7 所示。
但是,如果你想要找到评分最高的文章——例如 10 分呢?在通过分区键查询时,作者 ID 对你没有帮助,它只能提供某个作者写的文章。为了找到评分最高的文章,你需要进行跨分区查询,即分别扫描每个分区来查找高评分的文章。加载集群中的每个分区来检查特定的行是一个缓慢的操作,并且不具备良好的扩展性,这会导致用户体验不佳。
那如果使用类似于传统数据库的索引呢?索引是一种指定表中行的二级排序方式,并提供对这些行的快速访问的传统数据库构造。即使你能神奇地直接映射到这些特定的行,它们仍然会分散在集群中各个节点的分区中,因此你将支付巨大的性能代价来查询这些数据。正是因为这个限制,ScyllaDB 对索引的实现采用了去规范化的数据——在 Scylla 中,索引存储的是表的副本,只不过分区键不同。你将在后续章节中学习更多关于这些概念的内容。
现在,你需要自己存储去规范化的数据。为了按作者查询文章摘要,你需要一个 article_summaries_by_author 表;为了按日期查询,你需要一个 article_summaries_by_date 表;为了按评分查询,你需要一个 article_summaries_by_score 表。
有了这三个文章摘要表,如何创建一个文章摘要呢?你已经列出了需要支持的查询——创建文章摘要(Create Article Summary),但它是做什么的?你需要将一个文章摘要插入到这三个表中。为了实现这一点,你需要为每个表编写一条 INSERT 语句。然而,你不必执行三次独立的查询;ScyllaDB 支持批量操作,关于这一点你将在第六章中学习。现在,你可以理解为你的 Create Article Summary 查询需要将数据插入到每个文章摘要表中。
通过这些表名,你已经有了 schema 的初步构思。然而,仅仅有表名是不足以满足应用需求的。表需要存储数据,这首先从定义每个表开始。在确定表的列时,最关键的是选择一个好的分区键,以确保表的性能。为了了解这其中的细节,我们来看看 Scylla 是如何分配数据的,以及你如何利用它进行快速高效的查询。
3.3 高效地在哈希环上分配数据
在 Scylla 中,你希望通过分区键进行查询:表中决定数据如何在集群中分布的值。具有相同分区键的数据存储在同一个节点上,如果你通过分区键查询,你就能够最小化参与查询的节点数量,这有助于加快请求的完成速度。你会在 WHERE 子句中使用分区键;数据库可以利用它只查询相关的节点。查询速度快——你高兴,Scylla 高兴——大家都赢了。
在上一章引入分区键时,我以非常高的层次讲解了这个概念——ScyllaDB 使用表的分区键在集群中分配数据。虽然这是真的,但只是冰山一角。数据分配是一个微妙的过程,涉及多个组件和概念,但一旦理解了它,它不仅能提供查询性能的洞察,还能帮助你从应用开发者和数据库管理员的角度理解 ScyllaDB。
理解 Scylla 数据分配的关键在于哈希环(hash ring),它是一个分布式数据结构,决定了数据存储的位置以及哪些节点拥有这些数据。让我们深入了解这个基础概念。
3.3.1 哈希环
一开始你只有一个节点,因为每个集群都从一个节点开始。这个初始的单节点负责此时写入数据库的每一行数据。将数据写入单节点的 Scylla 集群不是一种理想的做法,但它有助于说明问题。随着你添加更多节点,分配数据的过程就开始变得有趣了。最初的节点拥有所有数据,并希望将部分数据交给新加入的节点。那么,第一个节点如何将数据交给第二个节点呢?
想象一个巨大的环。在环的周长上有一些点,这些点类似于圆桌上的座位。每个点都有一个整数值,称为 token。当数据写入集群时,分区键通过哈希函数被分配一个整数值,这个哈希值映射到环上的一个 token。具有相同分区键的行会被分配到环上的同一个位置,而具有不同分区键的行则几乎总是被分配到环上的不同位置。这个概念——将键哈希到环上的 token 上——被称为哈希环(如图 3.8 所示)。ScyllaDB 借用了这个概念来自 Cassandra,但它也是许多分布式系统的基石。
每一行数据在哈希环上都有一个位置,但这与 Scylla 如何分配数据有什么关系呢?当一个节点启动时,它会在哈希环的多个位置上占据随机的位置,形成虚拟节点(虚拟节点简称为 vnode,发音为“vee nodes”),如图 3.9 和 3.10 所示。通过在哈希环上占据这些位置,节点就声明了对该环片段的数据的责任,直到下一个节点的位置。
当一个新节点加入哈希环时,它会占据其他节点已经声明的数据,按比例分割这些环片段。被减轻了一些数据负担的其他节点,将之前拥有的数据流式传输给新加入的节点——即新的所有者节点。
虚拟节点(Vnodes)通过将集群划分为更小的片段,提供了多个好处。与其让每个节点占据哈希环中的一个巨大的数据块,不如将数据划分为更小、更易于管理的块。由于这些块较小,集群在节点加入时更容易并行化流式数据处理。这些小块也能够分摊基于哈希环操作的成本。与其让一个节点交接大量数据,从而影响其延迟,Scylla 可以将负载分散到整个集群,执行多个小规模的交接操作,而不是进行单次巨大的数据同步。
注意 这种分布方式意味着哈希碰撞,即使不太可能发生,也不会造成问题。集群使用令牌来确定哪个节点拥有该令牌——这是一种单向映射。即使两个分区具有相同的令牌,它们都会映射到相同的节点,这是预期的行为,不会引发任何问题。
NODETOOL STATUS 回顾
在上一章中,当你运行
nodetool status时,输出中有一个神秘的Tokens字段:Status=Up/Down |/ State=Normal/Leaving/Joining/Moving -- Address Load Tokens Owns Host ID Rack UN scylla-1 572 KB 256 ? 75ddfa00-9624-4137-b926-429dff20e516 rack1这个
Tokens字段列出了每个节点的虚拟节点数量,标示了该节点在哈希环上加入的位置数。
假设哈希环的令牌范围是 1 到 100,在一个假设的三节点集群中,每个节点会在哈希环上加入多个位置。在 Scylla 中,令牌范围和虚拟节点的数量要大得多(见下文注释),但这里的小范围有助于说明这个概念。节点会在哈希环上加入多个随机位置,以便实现数据的均匀分布:如果所有数据都聚集在一起,某些节点将拥有比其他节点更多的数据。数据在集群中的分布遵循类似的原则。当你的分区较小、大小相对均匀且均匀分布在集群中时,Scylla 的性能最佳。
注释 Scylla 使用了 murmur3 哈希实现,这意味着值会被哈希为介于 -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807 之间的数字——即 64 位有符号整数的范围。默认情况下,每个节点在加入集群时,会加入 256 个虚拟节点——拥有哈希环的 256 个切片。
尽管哈希分区键有助于将分区均匀分布到集群中,但分区的大小由你的设计决定。让我们回到你的应用程序,应用你学到的关于哈希环的知识来设计各种表。
3.3.2 制定良好的分区策略
在分析应用程序所需的查询时,你已经为你的模式确定了几个表(见表 3.2)。
表 3.2 表与查询的映射
| 表名 | 查询 |
|---|---|
| articles | 创建文章、读取文章 |
| authors | 创建作者、读取作者 |
| article_summaries_by_author | 创建文章摘要、按作者读取文章摘要 |
| article_summaries_by_date | 创建文章摘要、按日期读取文章摘要 |
| article_summaries_by_score | 创建文章摘要、按分数读取文章摘要 |
设计模式的下一步是问:“如何唯一标识这些表,并进行分区,以满足查询需求?”主键包含行的分区键——你在第 2 章学到,主键决定了行的唯一性。因此,在确定分区键之前,你需要知道主键是什么。
主键
首先,你应该检查你要存储的内容是否有一个流行的属性,用于标识其唯一性。例如,汽车有一个 VIN(车辆识别码),每辆车的 VIN 都是唯一的;书籍(包括这本书!)有一个 ISBN(国际标准书号),用于唯一标识它们。对于文章或作者来说,没有国际标准来标识它们,因此你需要多思考一下,才能找到主键和分区键。ScyllaDB 确实支持生成唯一标识符,但它们也有一些缺点,你将在下一章中学习到。
接下来,你可以问自己,是否有字段可以组合起来形成一个好的主键。什么能用来确定唯一的文章?标题可能会被重复使用,尤其是当你有一些缺乏创意的作者时。内容理想情况下应该每篇文章都是唯一的,但它的值非常大,不适合作为主键。也许你可以使用多个字段的组合——比如日期、作者和标题?这可能是可行的,但我发现回顾你要查询的内容是很有帮助的。当你的应用程序运行“读取文章”查询时,它只是想读取一篇单独的文章。执行这个查询的可能是一个 Web 服务器,响应一个 URL 的内容,它需要的信息应该是可以存储在 URL 中的。是不是很烦,当你粘贴一个链接时,链接好像有一亿个字符长?为了加载一篇文章,你不想每次都要跟踪作者 ID、标题和日期。因此,使用 ID 作为主键通常是一个强有力的选择。
提供一个唯一标识符满足了主键的唯一性要求,且这些 ID 可能编码其他信息,比如时间,这可以用来进行相对排序。你将在下一章学习更多关于 ID 类型的内容,当你探索数据类型时。
你可能会认为是电子邮件,但人们有时会更改自己的电子邮件地址。为每个作者提供一个唯一标识符也能很好地解决这个问题。也许它是一个数字 ID,或者是用户名;但根据目前的设计,作者需要额外的信息来区分他们与数据库中其他的作者。
与之前的步骤类似,文章摘要稍微有些不同。对于各种文章摘要表,如果你尝试使用与文章表相同的主键——一个 ID——你将会遇到问题。如果 ID 本身足以让文章在文章表中唯一,那么它应该足够满足索引表的需求,但事实证明并非如此。
虽然 ID 仍然能够区分唯一性,但你还希望至少按分区键进行查询,以获得高性能的查询;如果 ID 是分区键,那就无法满足按作者、日期或分数查询的使用场景。因为分区键包含在主键中,所以你需要将这些字段包含在主键中(如图 3.11 所示)。例如,对于 article_summaries_by_author 表,行的主键会是作者 ID 和文章 ID。类似地,其他两个表会将日期和文章 ID 用作 article_summaries_by_date 表的主键,而 article_summaries_by_score 表会使用分数和文章 ID 作为主键。
在确定了主键后,你可以继续确定分区键,并可能需要调整主键。
分区键
你在上一章中学到,主键中的第一个条目作为行的分区键(稍后你将看到如何构建复合主键)。一个好的分区键能够均匀地分布数据到整个集群,并且被查询所使用。它存储的数据量相对较小(一个好的经验法则是 100 MB 是一个较大的分区,但这取决于实际应用场景)。在表 3.3 中,我列出了各个表的主键,了解了主键之后,你可以重新安排它们来指定分区键。每个表的良好分区键应该是什么呢?
表 3.3 每个表都有一个主键,从中提取分区键。
| 表名 | 无序主键 |
|---|---|
| articles | id |
| authors | id |
| article_summaries_by_author | id, author_id |
| article_summaries_by_date | id, date |
| article_summaries_by_score | id, score |
对于 articles 和 authors 表来说,这非常简单。主键只有一列,因此分区键就是唯一的那一列。如果所有事情都能这么简单就好了!
警告: 仅使用 ID 作为主键和分区键时,你只能通过 ID 查找行。如果你想通过多个行进行查询,这种方式就无法工作,因为你将跨多个分区查询,带来额外的节点,影响性能。相反,你应该使用一个包含多行的分区键进行查询,就像你在文章摘要表中所做的那样。
然而,article_summaries 表则给你带来了选择。给定主键为 ID 和 author 的组合,对于 article_summaries_by_author 表,应该选择哪个作为分区键?如果你选择 ID,Scylla 会基于文章的 ID 在集群中分布这些行。这样的分布意味着,如果你想按作者加载所有文章,查询就需要跨集群的多个节点进行,这样的行为是低效的。如果你按作者对数据进行分区,那么查询所有由某个特定作者写的文章时,查询只会命中存储该分区的节点,因为你是在该分区内进行查询(如图 3.12 所示)。你几乎总是希望至少按分区键进行查询——这是实现良好性能的关键。由于这个表的使用场景是根据作者来查找文章,因此作者是分区键的最佳选择。这个分布使得主键为 author_id, id,因为分区键是主键中的第一个条目。
article_summaries_by_date 和 article_summaries_by_score 表,或许是书中最不令人惊讶的部分,提供了一个与 article_summaries_by_author 相似的选择,解决方案也类似。由于你在查询 article_summaries_by_date 时是按文章的日期进行查询,因此你也希望按日期进行数据分区。因此,主键应为 date, id;对于 article_summaries_by_score,主键应为 score, id。
正确分区
如果一个分区太大,数据将在集群中分布不均。相反,如果分区太小,加载多行数据的范围查询可能需要多个查询才能加载所需的数据量。例如,假设你按日期查询文章——如果你每周发布一篇文章,但你的分区键是按天分区,那么查询最近的文章时,七次查询中有六次将返回空结果。
数据分区是一项平衡的工作。有时它很简单。author_id 对于按作者查询的文章来说是一个天然的分区键,而像日期这样的字段可能需要一些微调才能获得最佳的应用性能。另一个需要考虑的问题是某个分区承载了过多的流量——超过了节点的处理能力。在接下来的章节中,当你进一步完善设计时,你将学习如何调整分区键以使数据库的分区布局更加合理。
主键和分区键
通过仔细查看各个表,现在你的主键已经按正确的顺序排列,以便进行数据分区;见表 3.4。
表 3.4 每个表的分区键是主键中列出的第一个值。
| 表名 | 主键 | 分区键 |
|---|---|---|
| articles | id | id |
| authors | id | id |
| article_summaries_by_author | author_id, id | author_id |
| article_summaries_by_date | date, id | date |
| article_summaries_by_score | score, id | score |
对于文章摘要表,主键分为两类:分区键和非分区键。剩下的部分有一个正式的名称,并且在表中执行特定的功能。让我们来看看非分区键——聚集键(Clustering Keys) 。
聚集键(Clustering Keys)
聚集键是表的主键中非分区键部分,定义了表中行的排序方式。在上一章中,当你查看示例表时,你注意到 Scylla 自动填充了表的隐式配置,将非分区键列(即聚集键)按升序排序:
CREATE TABLE initial.food_reviews ( #1
restaurant text,
ordered_at timestamp,
food text,
review text,
PRIMARY KEY (restaurant, ordered_at, food)
) WITH CLUSTERING ORDER BY (ordered_at ASC, food ASC); #2
- #1 你定义了表。
- #2 Scylla 填充了这行。
在每个分区内,每一行按订单时间排序,再按食物名称排序,均为升序(也就是说,先按较早的订单时间排序,再按食物名称字母顺序排序)。如果没有特别指定,ScyllaDB 默认将聚集键按升序自然排序。
当创建表时,如果有聚集键,你需要有意识地指定它们的顺序,确保得到符合要求的查询结果。以 article_summaries_by_author 为例。该表的目的是检索某个作者的文章,但你是希望先看到最旧的文章,还是最新的文章呢?默认情况下,Scylla 会按 id ASC 排序,给你最旧的文章。创建你的摘要表时,要指定排序顺序,确保得到最新的文章:id DESC。
现在你已经定义了表,并且在这些表中,主键和分区键用于指定唯一性、分布数据,并在分区内对数据进行排序。然而,你的查询不仅仅是针对主键的:作者有名字,文章有标题和内容。你不仅需要存储这些字段,还需要指定这些数据的结构。为此,你将使用数据类型。在下一章中,你将详细了解数据类型,并继续实践查询优先的设计方法。
总结
在 Scylla 中,数据的查询方式决定了如何存储数据。一个设计良好的数据库架构能够将数据均匀分布到集群中,并查询最少数量的节点,以满足所需的一致性级别。
查询优先设计(Query-first design)首先关注应用程序需要的查询,并根据这些查询推导出表结构和数据,帮助你设计一个最能利用 ScyllaDB 的架构。
查询优先设计的第一步是分析每个需求,识别是否需要某个数据库查询来支持它。
查询所操作的名词——例如,文章、文章摘要和作者——通常会在设计的下一步转化为表。
由于 Scylla 查询一次只能查询一个表,如果你需要按不同的属性加载数据,就必须对数据进行去规范化,将相同的数据存储在多个位置,以便从不同的角度进行查询。没有去规范化,如果查询不使用主键,就需要扫描集群中的每一个分区。
Scylla 通过哈希环分布数据,节点加入哈希环,管理环的切片。分区根据其分区键的哈希值被分配到环上的某个位置,也就对应到某个节点。
节点作为虚拟节点(vnodes)在哈希环上加入多个位置,这使得集群能够管理更多的小数据块,并更好地平衡集群中的负载。
查询优先设计的下一步是根据表结构分析如何为每个表创建主键,如何通过分区键将数据划分为小且相对均匀的分区。
Scylla 根据聚集键(即主键中的非分区键部分)对表中的数据进行排序。