架构设计最佳实践

借助 Spanner 的分布式架构,您可以设计架构来避免热点问题。热点是指向同一服务器发送过多请求,这会使服务器资源饱和,并可能导致较长的延迟时间。

本页面介绍了设计架构以避免产生热点的最佳实践。避免热点的一种方法是调整架构设计,以允许 Spanner 将数据拆分和分布到多个服务器。将数据分布到各个服务器有助于 Spanner 数据库高效运行,尤其是在执行批量数据插入时。

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

架构和数据模型中所述,在架构设计中选择主键时应小心谨慎,以免不小心在数据库中产生热点。出现热点的一个原因是将某个列的值单调更改作为第一个键部分,因为这会导致所有插入都发生在键空间的末尾。这种模式是不可取的,因为 Spanner 使用键范围在服务器之间划分数据,这意味着您的所有插入都被定向到单个服务器,而该服务器最终会完成所有工作。

例如,假设您想维护 UserAccessLog 表行上的上次访问时间戳列。下表定义使用基于时间戳的主键作为第一个键部分。如果表中的插入率较高,则不建议这样做:

GoogleSQL


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

PostgreSQL


CREATE TABLE UserAccessLog (
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (LastAccess, UserId)
);

这里的问题在于,行是按照上次访问时间戳的顺序写入此表的,而且由于上次访问时间戳始终增加,因此它们始终写入表的末尾。之所以创建热点,是因为单个 Spanner 服务器会接收所有写入操作,而这会使该服务器过载。

下图演示了此类问题:

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

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

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

当 Spanner 向表附加更多行时,分块会变大,当其达到大约 8 GB 时,Spanner 会创建另一个分块,如基于负载的拆分中所述。Spanner 会将后续新行附加到此新分块,并且分配给该分块的服务器成为新的潜在热点。

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

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

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

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

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

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

对于 STRING(36) 列,您可以使用 Spanner GENERATE_UUID() 函数(GoogleSQLPostgreSQL)作为列默认值,以便让 Spanner 自动生成 UUID 值。

使用 UUID 有以下几个缺点:

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

对顺序值进行位反转

您应该确保数字(GoogleSQL 中的 INT64 或 PostgreSQL 中的 bigint)主键不会依序递增或递减。顺序主键可能会导致大规模出现热点问题。避免此问题的一种方法是对顺序值进行位反转,以确保在键空间中均匀分布主键值。

Spanner 支持位反转序列,该序列可生成唯一的整数位反转值。您可以在主键的第一个(或唯一)组件中使用序列,以避免出现热点问题。如需了解详情,请参阅位反转序列

交换键的顺序

更均匀地分布写入键空间的一种方法是交换键的顺序,使包含单调值的列不是第一个键部分:

GoogleSQL

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

PostgreSQL

CREATE TABLE UserAccessLog (
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (UserId, LastAccess)
);

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

下图说明了 Spanner 使用 UserId(而不是访问时间戳)订购 UserAccessLog 表中的五行:

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

在这里,Spanner 将 UserAccessLog 数据分块为三个分块,每个分块包含大约一千行有序的 UserId 值。假设每行包含大约 1MB 用户数据,并且最大拆分大小约为 8 GB,这是对用户数据拆分方式的合理估算。虽然用户事件相隔约一毫秒,但每个事件都由不同的用户引发,因此与使用时间戳进行排序相比,插入顺序生成热点的可能性要低得多。

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

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

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

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

GoogleSQL

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

PostgreSQL

CREATE TABLE UserAccessLog (
ShardId bigint NOT NULL,
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (ShardId, LastAccess, UserId)
);

如需计算 ShardId,请对主键列的组合进行哈希处理,然后计算哈希值的模 N。例如:

ShardId = hash(LastAccess and UserId) % N

您选择的哈希函数和列组合决定了行在键空间中的分布方式。然后,Spanner 会在各行之间创建分片以优化性能。

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

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

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

Spanner 还允许您在生成的列中创建哈希函数。

如需在 Google SQL 中执行此操作,请在写入时使用 FARM_FINGERPRINT 函数,如以下示例所示:

GoogleSQL

CREATE TABLE UserAccessLog (
ShardId INT64 NOT NULL
AS (MOD(FARM_FINGERPRINT(CAST(LastAccess AS STRING)), 2048)) STORED,
LastAccess TIMESTAMP NOT NULL,
UserId    INT64 NOT NULL,
) PRIMARY KEY (ShardId, LastAccess, UserId);

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

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

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

如果您有一个使用时间戳作为键的历史记录表,请在满足以下任一条件时考虑对键列使用降序:

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

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

GoogleSQL

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

架构设计最佳实践 2:降序或升序取决于用户查询,例如,“顶部”表示最新,“顶部”表示最早。

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

与您应该避免的上一个主键示例类似,在值单调递增或递减的列上创建非交错索引也不是明智之举,即使它们不是主键列也是如此。

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

GoogleSQL

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

PostgreSQL

CREATE TABLE Users (
UserId     bigint NOT NULL,
LastAccess TIMESTAMPTZ,
...
PRIMARY KEY (UserId)
);

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

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

PostgreSQL

CREATE INDEX UsersByLastAccess ON Users(LastAccess)
WHERE LastAccess IS NOT NULL;

但是,这会产生与上述最佳做法所述相同的问题,因为 Spanner 在后台将索引作为表来实现,而生成的索引表使用一个其值单调递增的列作为其第一个键部分。

但是,您可以创建这样的交错索引,因为交错索引的行在相应的父行中交错,并且单个父行不太可能每秒生成数千个事件。

架构设计最佳实践 3:不要在其值单调递增或递减的高写入速率列上创建非交错索引。 不要使用交错索引,而应使用您在设计索引列时用于基表主键设计的技术,例如添加“hardId”。

后续步骤