理解大规模读写操作

阅读本文档,学习如何做出关于应用架构设计的明智决策,以使其具有极高性能和可靠性。本文档包含高级 Firestore 主题。如果您刚刚开始使用 Firestore,请参阅快速入门指南

Firestore 是一种灵活且可伸缩的数据库,适用于在 Firebase 和 Google Cloud 上进行移动端、Web 和服务器开发。Firestore 易于上手,并可以用于编写功能丰富且强大的应用。

为了确保在数据库大小和流量增加时应用仍能正常运行,了解 Firestore 后端中的读写机制很有帮助。您还必须了解读写操作与存储层之间的交互,以及可能影响性能的底层限制。

在设计应用架构之前,请参阅以下部分了解最佳实践。

了解概要组件

下图显示了 Firestore API 请求涉及的概要组件。

概要组件

Firestore SDK 和客户端库

Firestore 支持适用于不同平台的 SDK 和客户端库。虽然应用可以直接对 Firestore API 进行 HTTP 和 RPC 调用,但客户端库提供了一个抽象层,以简化 API 使用并实现最佳实践。它们可能还提供离线访问、缓存等附加功能。

Google Front End (GFE)

这是所有 Google Cloud 服务通用的基础架构服务。GFE 接受传入的请求,并将其转发到相关的 Google 服务(在本文中是指 Firestore 服务)。它还提供了其他重要功能,包括防范拒绝服务攻击。

Firestore 服务

Firestore 服务会对 API 请求执行检查(包括身份验证、授权、配额检查和安全规则),并管理事务。此 Firestore 服务包含一个存储客户端,该存储客户端与存储层进行交互以执行数据读写操作。

Firestore 存储层

Firestore 存储层负责存储数据和元数据,以及 Firestore 提供的关联数据库功能。以下部分介绍 Firestore 存储层中数据的组织方式以及系统扩缩方式。了解数据的组织方式有助于您设计出可伸缩的数据模型,并更好地理解 Firestore 中的最佳实践。

键范围和分块

Firestore 是一种基于文档的 NoSQL 数据库。数据存储在文档中,文档以集合的层次结构进行整理。对于每个文档,集合层次结构和文档 ID 会转换为其单个键。系统利用此单个键按逻辑关系存储文档并按字典顺序对其排序。我们使用术语“键范围”来指代按字典顺序的连续键范围。

典型的 Firestore 数据库太大,不适合在单台实体计算机上运行。此外还有一些数据工作负载过重的情况,导致一台机器无法处理。为了处理大型工作负载,Firestore 会将数据划分为多个单独的部分,这些部分可以存储在多台机器(或称“存储服务器”)上,并由它们进行传送。这些分区是在数据库表中以键范围块(称为分块)的形式进行的。

同步复制

请务必注意,数据库始终自动且同步复制。数据分块在不同的可用区中都具有副本,即使某个可用区变得无法访问,数据分块也会保持可用。分块的不同副本的一致复制由 Paxos 算法管理,从而确保数据的一致性。每个分块都会有一个副本被选择作为 Paxos 主副本,负责处理对该分块的写入。同步复制功能使您始终能够能够从 Firestore 中读取最新版本的数据。

以上特点最终成就了一个可伸缩且高度可用的系统,无论是面对繁重的工作负载还是超大规模的数据,都可以保证低延迟的读写操作。

数据布局

Firestore 是一种无架构文档数据库。但在内部,它主要将数据存储在其存储层的两个关系型数据库样式的表中,如下所示:

  • “Documents”(文档)表:文档存储在此表中。
  • “Indexes”(索引)表:索引条目存储在此表中。索引有助于高效获取结果并按索引值对结果进行排序。

下图显示了 Firestore 数据库表可能的形式以及分块。这些分块复制到三个不同的可用区中,每个分块都有指定的 Paxos 主副本。

数据布局

单区域与多区域

创建数据库时,您必须选择一个区域多区域

单区域位置是一个特定的地理位置,例如 us-west1。如前所述,Firestore 数据库的数据分块在所选区域内的不同可用区中都有副本。

多区域位置由规定的一组区域(其中存储了数据库的副本)组成。在 Firestore 的多区域部署中,其中两个区域拥有数据库中所有数据的完整副本。第三个区域中有一个“见证者副本”(witness replica),该副本不保留全部数据,但参与复制。通过在多个区域之间复制数据,即使某个区域不可用,您也可以读取和写入数据。

如需详细了解某个区域的位置,请参阅 Firestore 位置

单区域与多区域

了解 Firestore 中写入操作的生命周期

Firestore 客户端可以通过创建、更新或删除单个文档来写入数据。对单个文档执行写入操作需要在存储层中以原子方式对文档及其关联的索引条目进行更新。Firestore 还支持由针对一个或多个文档进行的多次读取和/或写入组成的原子操作。

对于所有写入操作,Firestore 都提供关系型数据库的 ACID 特性(原子性、一致性、隔离性和持久性)。Firestore 还提供可序列化特性,这意味着所有事务都看似按顺序执行。

写入事务概要步骤

当 Firestore 客户端使用前面提到的任何方法发出写入操作或提交事务时,相应操作会在内部作为存储层中的数据库读写事务来执行。此事务让 Firestore 能够保证前述 ACID 特性。

作为事务的第一步,Firestore 会读取现有文档,并确定要对“Documents”表中的数据进行的更改。

这也包括对“Indexes”表进行必要的更新,如下所示:

  • 要添加到文档中的字段需要在“Indexes”表中相应地插入。
  • 要从文档中移除的字段需要在“Indexes”表中相应地删除。
  • 文档中要修改的字段需要同时在“Indexes”表中进行删除(对于旧值)和插入(对于新值)。

为了计算之前提到的更改,Firestore 会读取项目的索引配置。索引配置中存储与项目索引有关的信息。Firestore 使用两种索引类型:单字段索引和复合索引。如需详细了解 Firestore 中创建的索引,请参阅 Firestore 中的索引类型

计算出所需更改之后,Firestore 会在事务中收集这些更改,然后提交。

了解存储层中的写入事务

如前所述,Firestore 中的写入涉及存储层中的读写事务。根据数据布局,写入可能涉及一个或多个分块,如数据布局中所示。

在下图中,Firestore 数据库在单个可用区内的三个不同存储服务器中托管了八个分块(标记为 1-8),每个分块复制到 3 个(或更多)不同的可用区中。每个分块都有一个 Paxos 主副本,不同分块的主副本可能位于不同的可用区中。

Firestore 数据库分块

假设某个 Firestore 数据库中包含 Restaurants 集合,如下所示:

Restaurant 集合

Firestore 客户端会请求通过更新 priceCategory 字段的值,对 Restaurant 集合中的文档进行以下更改。

更改集合中的文档

下面的概要步骤介绍了写入过程中会发生的情况:

  1. 创建一个读写事务。
  2. 在存储层中,从“Documents”表的 Restaurants 集合中读取 restaurant1 文档
  3. 从“Indexes”表中读取该文档的索引。
  4. 计算要对数据进行的更改。在本示例中,需要进行五项更改:
    • M1:更新“Documents”表中 restaurant1 的行,以反映 priceCategory 字段值的变化。
    • M2 和 M3:针对降序和升序索引,删除“Indexes”表中 priceCategory 的旧值对应的行。
    • M4 和 M5:针对降序和升序索引,在“Indexes”表中插入 priceCategory 的新值行。
  5. 提交这些更改。

Firestore 服务中的存储客户端会查找包含要更改的行键的分块。我们假设分块 3 处理 M1,分块 6 处理 M2-M5。有一个分布式事务,所有这些分块都以“参与者”的身份有所涉及。参与者分块中还可能包括此前在该读写事务中从中读取数据的任何其他分块。

以下步骤介绍了提交过程中会发生的情况:

  1. 存储客户端发出提交。提交中包含更改 M1-M5。
  2. 分块 3 和 6 是此事务的参与者。其中一个参与者被选为“协调者”,例如分块 3。协调者的任务是确保事务在所有参与者之间以原子方式提交或中止。
    • 这些分块的主副本负责由参与者和协调者执行的工作。
  3. 每个参与者和协调者都针对各自的副本运行 Paxos 算法。
    • 主副本针对其余副本运行 Paxos 算法。如果大多数副本向主副本返回 ok to commit 响应,则达成共识 (Quorum)。
    • 然后,每个参与者都会在准备就绪(提交的第一阶段,共两个阶段)时通知协调者。如果任意参与者无法提交事务,则整个事务执行 aborts
  4. 协调者知道所有参与者(包括其自身)已准备就绪后,就会将 accept 事务结果传达给所有参与者(两阶段提交的第二阶段)。在此阶段,每个参与者都会记录对稳定存储空间的提交决策,并且事务会提交。
  5. 协调者对 Firestore 中已提交事务的存储空间客户端进行响应。同时,协调者和所有参与者将更改应用于数据。

提交生命周期

当 Firestore 数据库很小时,可能会出现单一分块拥有更改 M1-M5 中的所有键的情况。在这种情况下,事务中只有一个参与者,前面提到的两阶段提交不是必需的,因此可以提高写入速度。

多区域中的写入

在多区域部署中,副本分布在多个区域,这会提高可用性,但会降低性能。不同区域中副本之间的通信需要更长的往返时间。因此,与单区域部署相比,Firestore 操作的基准延迟时间略长。

我们配置副本的方式确保占主导地位的分块始终位于主区域中。流量从哪个区域传入 Firestore 服务器,哪个区域就是主区域。这种主导地位决策可减少 Firestore 中的存储客户端与主副本(或多分块事务的协调者)之间的往返延迟。

Firestore 中的每次写入都需要与 Firestore 中的实时引擎进行一些互动。如需详细了解实时查询,请参阅理解大规模实时查询

了解读取操作在 Firestore 中的生命周期

本部分深入介绍 Firestore 中的独立非实时读取。在内部,Firestore 服务器会分两个主要阶段来处理大多数查询:

  1. 对“Indexes”表进行单次范围扫描
  2. 根据之前扫描的结果,在“Documents”表中执行点查询
在 Firestore 中,某些查询需要的处理可能较少(例如,适用于 Datastore 模式的仅键查询),某些可能需要较多的处理(例如 IN 查询)。

存储层中的数据读取是在内部使用数据库事务完成的,以确保读取一致性。不过,这些事务与用于写入的事务不同,它们不会获取锁,而是选择时间戳,然后执行该时间戳的所有读取。由于它们不会获取锁,因此不会阻止并发读写事务。为了执行此事务,Firestore 中的存储客户端会指定时间戳边界,以告知存储层如何选择读取时间戳。Firestore 中存储客户端选择的时间戳边界类型由读取请求的读取选项决定。

了解存储层中的读取事务

本部分介绍读取类型以及 Firestore 的存储层中如何处理这些读取。

强一致性读取

默认情况下,Firestore 读取具有强一致性。这种强一致性意味着 Firestore 读取会返回数据的最新版本,其中包含了截至读取开始时已提交的所有写入。

单个分块读取

Firestore 中的存储客户端会查找拥有要读取的行键的分块。假设它需要读取前述部分中的分块 3 中的数据。客户端将读取请求发送到距离最近的副本,以缩短往返延迟时间。

此时,根据所选的副本,可能会出现以下情况:

  • 读取请求进入主副本(可用区 A)。
    • 由于主副本始终是最新的,因此读取可以直接进行。
  • 读取请求进入非主副本(例如可用区 B)
    • 分块 3 可能根据其内部状态知道它有足够的信息来处理此读取,并进行处理。
    • 分块 3 不确定自身是否已获得最新的数据。它向主副本发送消息,要求提供为了处理此读取所需的最后一个事务的时间戳。在该事务被应用后,读取便可以继续。

然后,Firestore 会将响应返回到其客户端。

多分块读取

当必须从多个分块中执行读取时,所有分块中都会使用同一机制。从所有分块返回数据后,Firestore 中的存储客户端会合并结果。Firestore 随后会使用此数据响应其客户端。

过时读取 (Stale read)

强一致性读取是 Firestore 中的默认模式。不过,由于可能需要与主副本进行通信,因此可能会造成延迟时间较长。很多情况下,您的 Firestore 应用不需要读取最新版本的数据,使用过时几秒钟的数据也不会影响其功能性。

在这种情况下,客户端可以使用 read_time 读取选项选择接收历史数据。在这种情况下,执行的读取如同采用了 read_time 时的数据,并且最近的副本很有可能已验证其拥有在指定的 read_time 时的数据。如需明显获得更好的性能,15 秒是一个合理的过时值。即使是过时读取,生成的行也会彼此保持一致。

避开热点

Firestore 中的分块会自动拆分为更小的片段,以便在需要时或键空间扩大时将处理流量的工作分配到更多的存储服务器。即使这些流量消失,为处理多余流量而创建的分块也会保留大约 24 小时。因此,如果周期性流量高峰出现,我们将保持原本的分块数量,并在必要时引入更多分块。这些机制可帮助 Firestore 数据库在增加流量负载或扩大数据库规模时实现自动扩缩。不过,需要注意一些限制(如下所述)。

拆分存储空间和负载需要时间,而流量迅速增加可能会导致在服务调整时出现较长的延迟时间或超出期限的错误(通常称为热点。最佳实践是在键范围中分配操作,同时对数据库中的一个集合以每秒 500 次操作的速度增加流量。在此渐增阶段之后,每 5 分钟最多可增加 50% 的流量。此过程称为 500/50/5 规则,使数据库以最佳方式扩缩,以满足您的工作负载要求。

虽然分块会随着负载的增加而自动创建,但仅当 Firestore 使用专门的一组复制存储服务器来处理单个文档时,它才能拆分键范围。因此,单个文档上大量持续的并发操作可能会导致该文档上出现热点。如果您在单个文档上遇到持续的高延迟时间,应该考虑修改数据模型,以将数据拆分或复制到多个文档中。

当多个操作同时尝试读取和/或写入同一文档时,就会发生争用错误。

另一种导致出现热点的特殊情况是,在 Firestore 中将顺序递增/递减键用作文档 ID,并且每秒存在大量操作。此时创建更多分块无法缓解问题,因为流量的激增只会移动到新创建的分块。默认情况下,Firestore 会自动将文档中的所有字段编入索引,因此,对于包含顺序递增/递减值(如时间戳)的文档字段,也可能在索引空间上形成此类移动热点。

请注意,按照上述做法,Firestore 可以扩容以处理任意大的工作负载,而无需调整任何配置。

问题排查

Firestore 提供 Key Visualizer,作为用于分析使用模式和排查热点问题的诊断工具。

后续步骤