架构设计最佳做法

借助 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 向表中附加更多行时,分块会随之增大,当其大小达到大约 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 排序,而非按照上次访问时间戳排序。这种架构可以在不同分块之间分布写入,因为单个用户不太可能每秒产生数千个事件。

下图展示了 UserAccessLog 表中的五行 Spanner 使用 UserId 而不是访问时间戳进行订购:

按 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 还支持在 Cloud 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:不要创建 高写入速率列上的非交错索引,其值单调 增加或减少 不要使用交错索引,请使用与 与设计索引列时的基表主键设计有关, 请添加 `shardId`。

后续步骤