架构设计最佳做法

本页面介绍设计 Cloud Spanner 架构以避免热点的最佳做法以及将数据加载到 Cloud Spanner 中的最佳做法。

选择一个主键以避免生成热点

正如架构和数据模型中所述,您应仔细选择主键,以免不慎在数据库中生成热点。形成热点的其中一个原因是将值单调递增的列作为首个键部分,这会导致所有插入操作都发生在键空间的末尾。这种模式是不理想的,因为 Cloud Spanner 会按照键范围划分服务器之间的数据,这意味着,您的所有插入操作都将集中于单个服务器,并于其上完成所有工作。

例如,假设您想维护 UserAccessLog 表行上的上次访问时间戳列。以下表定义使用基于时间戳的主键作为首个键部分的表,如果表具有高速率插入,这是一种反模式:

-- ANTI-PATTERN: USING A COLUMN WHOSE VALUE MONOTONICALLY INCREASES OR
-- DECREASES AS THE FIRST KEY PART OF A HIGH WRITE RATE TABLE

CREATE TABLE UserAccessLog (
  LastAccess TIMESTAMP NOT NULL,
  UserId     INT64 NOT NULL,
  ...
) PRIMARY KEY (LastAccess, UserId);

这里的问题在于,行将按照上次访问时间戳的顺序写入此表,但是因为上次访问时间戳总是不断递增,因此它们总是写入表的末尾。由于单个 Cloud Spanner 服务器将接收所有写入操作,这将使该服务器超载,从而生成热点。

下图演示了此类问题:

按时间戳排序且带有相应热点的 UserAccessLog 表

上面的 UserAccessLog 表包含五个示例数据行,它们分别表示五个执行某种用户操作的不同用户,各操作之间的间隔大约为一毫秒。该图还注释了插入这些行的顺序(带有标签的箭头表示每行的写入顺序)。由于插入按时间戳排序,并且时间戳值始终递增,因此插入始终会被添加到表的末尾并指向相同的分片 (split)。(正如在架构和数据模型中所讨论的,分片是来自一个或多个相关表的一系列行,这些行按行键顺序存储。)

这是有问题的,由于 Cloud Spanner 以分片为单位将工作分配给不同的服务器,因此分配给此特定分片的服务器最终会处理所有插入请求。随着用户访问事件频率的增加,向相应的服务器插入请求的频率也会增加。然后,服务器便易于成为热点,如上图的红色边框和背景所示。请注意,在此简化图示中,每个服务器最多可处理一个分片,但实际上,每个 Cloud Spanner 服务器可被分配多个分片。

基于负载的拆分所述,当更多的行被添加到表中时,分片也会随着增大,当其大小达到大约 8 GB 时,Cloud Spanner 会创建另一个分片。随后的新行会被添加到这个新的分片中,分配给它的服务器也会成为新的潜在热点。

当热点出现时,您可能会发现插入变得缓慢,同一台服务器上的其他工作的速度也会下降。将 LastAccess 列的顺序改为升序并不能解决这个问题,因为这样会使所有写入都插入到表的顶部,所有插入仍然会被传递到单个服务器。

架构设计最佳做法 #1:不要选择其值单调递增或递减的列作为高写入速率表的首个键部分。

交换键的顺序

一种在键空间中分布写入的方法是交换键的顺序,以便使包含单调递增或递减值的列不是首个键部分:

CREATE TABLE UserAccessLog (
  UserId     INT64 NOT NULL,
  LastAccess TIMESTAMP NOT NULL,
  ...
) PRIMARY KEY (UserId, LastAccess);

在此修改后的架构中,插入现在按照 UserId 排序,而非按照上次访问时间戳排序。这种架构可以在不同分片之间分布写入,因为单个用户不太可能每秒产生数千个事件。

下图演示了按 UserId 排序(而非按访问时间戳排序)的 UserAccessLog 表中的五行:

按 UserId 排序且写入吞吐量平衡的 UserAccessLog 表

在这种情况下,UserAccessLog 的数据被分成三个分片,每个分片包含一千个按 UserId 值的顺序排列的行。这是基于如下假设对用户数据拆分方法作出的合理估计:假设每行包含大约 1MB 的用户数据,并且最大分块大小约为 8 GB。即使用户事件发生的时间间隔约为一毫秒,但每个事件都由不同的用户提出的,因此,与按照时间戳排序相比,这样的插入顺序生成热点的可能性要小得多。

另请参阅为基于时间戳的键排序的相关最佳做法。

对唯一键进行哈希处理并将写入分布到逻辑碎片中

另一种将负载分布到多个服务器上的常见方法是:创建一个包含实际唯一键哈希值的列,然后将哈希列(或者哈希列以及唯一键列一起)用作主键。此模式有助于避免热点,因为新行在键空间中的分布更均匀。

您可以使用哈希值在数据库中创建逻辑碎片或分区。(在物理碎片数据库中,行分布在多个数据库中。在逻辑碎片数据库中,碎片由表中的数据定义。)例如,要将对 UserAccessLog 表的写入分布到 N 个逻辑碎片,可以在表中添加一个 ShardId 键列:

CREATE TABLE UserAccessLog (
  ShardId     INT64 NOT NULL,
  LastAccess  TIMESTAMP NOT NULL,
  UserId      INT64 NOT NULL,
  ...
) PRIMARY KEY (ShardId, LastAccess, UserId);

要计算 ShardId,可以对主键列的组合进行哈希处理并计算哈希值的模 N - ShardId = hash(LastAccess and UserId) % N。您选择的哈希函数和列组合决定了行在键空间中的分布方式。然后,Cloud Spanner 将在各行之间创建分片以优化性能。请注意,分片可能与逻辑碎片不一致。

下图演示了使用哈希技术创建三个逻辑碎片如何能够在各个服务器之间更均匀地分布写入吞吐量:

按 ShardId 排序且写入吞吐量平衡的 UserAccessLog 表

这里的 UserAccessLog 表按照 ShardId(被计算为键列的哈希函数)排序。五个 UserAccessLog 行被分成三个逻辑碎片,每个逻辑碎片恰好在不同的分片中。插入内容在分片之间均匀分布,这样可以平衡用来处理分片的三台服务器上的写入吞吐量。

您选择的哈希函数将决定您的插入在键范围内的分布情况。尽管加密哈希可能是一个不错的选择,但您不需要加密哈希。选择哈希函数时,需要考虑以下几个因素:

  • 避免热点。产生更多哈希值的函数往往会减少热点。
  • 读取效率。如果要扫描的哈希值较少,读取所有哈希值的速度会更快。
  • 节点数。

使用通用唯一标识符 (UUID)

您可以使用由 RFC 4122 定义的通用唯一标识符 (UUID) 作为主键。建议使用版本 4 UUID,因为它使用位序列中的随机值。版本 1 UUID 将时间戳存储在高序位中,不推荐使用。

以下几种方法可以将 UUID 存储为主键:

  • STRING(36) 列中。
  • 在一对 INT64 列中。
  • BYTES(16) 列中。

使用 UUID 有以下几个缺点:

  • 它们略大,需要使用 16 个字节或以上。主键的其他选项不需要使用这么大的存储空间。
  • 它们不携带关于记录的信息。例如,SingerId 和 AlbumId 的主键具有固有含义,而 UUID 则不具有。
  • 您会失去相关记录之间的局部性,这也是使用 UUID 会消除热点的原因。

对顺序值进行位反转

当您生成唯一的数值主键时,后续数值的高序位应该大致均匀地分布在整个数字空间中。一种方法是通过常规方法生成序列号,然后对它们进行位反转以获得最终值。

位反转保持主键上的唯一值。您只需存储反转值,因为您可以重新计算应用代码中的原始值。

为基于时间戳的键使用降序

如果您有一个以时间戳为键的历史记录表,请在符合以下任一情况时考虑对键列进行降序排序:

  • 使用交错表存储历史记录,并且也将读取父行。在这种情况下,对于 DESC 时间戳列,最新的历史记录条目将存储在与父行相邻的位置。否则,读取父行及其最近的历史记录时将需要在中间搜索以跳过较早的历史记录。
  • 按反向时间顺序读取顺序条目,并且不确定要读取多少条目。例如,您可以使用带有 LIMIT 的 SQL 查询来获取最新的 N 个事件,或者您可以计划在读取一定数量的行后取消读取。在这种情况下,您希望从最新的条目开始并按顺序读取较早的条目,直至满足条件。Cloud Spanner 能够更高效地处理按降序存储的时间戳键。

添加 DESC 关键字以使时间戳键按降序排列。例如:

CREATE TABLE UserAccessLog (
  UserId     INT64 NOT NULL,
  LastAccess TIMESTAMP NOT NULL,
  ...
) PRIMARY KEY (UserId, LastAccess DESC);

架构设计最佳做法 #2:为基于时间戳的键使用降序。

在其值单调递增或递减的列上使用交错索引

与之前的主键反模式相似,在值单调递增或递减的列上创建非交错索引也是一个坏主意,即使它们不是主键列,也是如此。

例如,假设您定义了下表,其中 LastAccess 是非主键列:

CREATE TABLE Users (
  UserId     INT64 NOT NULL,
  LastAccess TIMESTAMP,
  ...
) PRIMARY KEY (UserId);

为了快速查询数据库中“自 X 时间起”的用户访问信息,在 LastAccess 列上定义一个索引似乎很方便,如下所示:

-- ANTI-PATTERN: CREATING A NON-INTERLEAVED INDEX ON A COLUMN WHOSE VALUE
-- MONOTONICALLY INCREASES OR DECREASES ON A HIGH WRITE RATE COLUMN

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess)

但是,这会导致出现与之前最佳做法中所述的相同的问题,因为索引本质上是作为表实现的,并且生成的索引表将使用其值单调递增的列作为其首个键部分。

尽管如此,您仍然可以创建一个这样的交错索引,因为交错索引的行在相应的父行中进行交错,并且单个父行不太可能每秒产生数千个事件。

架构设计最佳做法#3:不要在其值单调递增或递减的高写入速率列上创建非交错索引。

后续步骤