架构设计最佳做法

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

本页介绍了一些最佳实践,用于设计您自己的架构以避免出现热点。避免热点的一种方法是调整架构设计,以允许 Spanner 将数据拆分并分布到多个服务器上。将数据分布在多个服务器上有助于 Spanner 数据库高效运行,特别是在执行批量数据插入操作时。

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

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

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

GoogleSQL

CREATE TABLE UserAccessLogs (
  LastAccess TIMESTAMP NOT NULL,
  UserId STRING(1024),
  ...
) PRIMARY KEY (LastAccess, UserId);

PostgreSQL

CREATE TABLE useraccesslog (
  lastaccess timestamptz NOT NULL,
  userid text,
  ...
PRIMARY KEY (lastaccess, userid)
);

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

下图演示了此类问题:

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

上文中的 UserAccessLog 表包含五个示例数据行,它们分别表示五个执行某种用户操作的不同用户,各操作之间的间隔大约为一毫秒。该图还注释了 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 值。

例如,对于以下表格:

GoogleSQL

CREATE TABLE UserAccessLogs (
  LogEntryId STRING(36) NOT NULL,
  LastAccess TIMESTAMP NOT NULL,
  UserId STRING(1024),
  ...
) PRIMARY KEY (LogEntryId, LastAccess, UserId);

PostgreSQL

CREATE TABLE useraccesslog (
  logentryid VARCHAR(36) NOT NULL,
  lastaccess timestamptz NOT NULL,
  userid text,
  ...
PRIMARY KEY (lastaccess, userid)
);

您可以插入 GENERATE_UUID() 来生成 LogEntryId 值。GENERATE_UUID() 会生成 STRING 值,因此 LogEntryId 列必须使用 STRING 类型(对于 GoogleSQL)或 text 类型(对于 PostgreSQL)。

GoogleSQL

INSERT INTO
  UserAccessLog (LogEntryId, LastAccess, UserId)
VALUES
  (GENERATE_UUID(), '2016-01-25 10:10:10.555555-05:00', 'TomSmith');

PostgreSQL

INSERT INTO
  useraccesslog (logentryid, lastaccess, userid)
VALUES
  (spanner.generate_uuid(),'2016-01-25 10:10:10.555555-05:00', 'TomSmith');

使用 UUID 有以下几个缺点:

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

对顺序值进行位反转

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

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

交换键的顺序

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

GoogleSQL

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

PostgreSQL

CREATE TABLE useraccesslog (
userid bigint NOT NULL,
lastaccess TIMESTAMPTZ 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。例如:

GoogleSQL

ShardId = hash(LastAccess and UserId) % N

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

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

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

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

借助 Spanner,您还可以在生成的列中创建哈希函数。

如需在 GoogleSQL 中执行此操作,请在写入时使用 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 能够更高效地处理按降序存储的时间戳键。

添加 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:不要在其值单调递增或递减的高写入速率列上创建非交错索引。 在设计索引列时,请使用与设计基表主键时类似的技术(例如添加 `shardId`),而不是使用交错索引。

后续步骤