借助 Spanner 的分布式架构,您可以设计自己的架构以避免热点 - 向同一服务器发送的请求过多,导致服务器资源利用率达到饱和状态,并可能导致延迟时间较长。
本页面介绍了一些最佳实践,用于设计您自己的架构以避免创建热点。避免热点的一种方法是调整架构设计,使 Spanner 能够将数据拆分并分布到多个服务器上。将数据分布到多个服务器有助于 Spanner 数据库高效地运行,特别是在执行批量数据插入操作时。
选择一个主键以避免生成热点
正如架构和数据模型中所述,您应在架构设计中仔细选择主键,以免不慎在数据库中生成热点。形成热点的其中一个原因是将值单调变化的列作为首个键部分,这会导致所有插入操作都发生在键空间的末尾。这种模式是不理想的,因为 Spanner 会使用键范围来划分服务器之间的数据,这意味着您的所有插入操作都将集中于单个服务器,并于其上完成所有工作。
例如,假设您想维护 UserAccessLogs
表行上的上次访问时间戳列。下表定义使用基于时间戳的主键作为首个键部分。如果表的插入速率很高,我们不建议这样做:
GoogleSQL
CREATE TABLE UserAccessLogs ( LastAccess TIMESTAMP NOT NULL, UserId STRING(1024), ... ) PRIMARY KEY (LastAccess, UserId);
PostgreSQL
CREATE TABLE useraccesslogs ( lastaccess timestamptz NOT NULL, userid text, ... PRIMARY KEY (lastaccess, userid) );
这里的问题在于,行将按照上次访问时间戳的顺序写入此表,但是因为上次访问时间戳总是不断递增,因此它们总是写入表的末尾。由于单个 Spanner 服务器接收所有写入操作,这将使该服务器超载,从而生成热点。
下图演示了此类问题:
上一个 UserAccessLogs
表包含五个示例数据行,它们分别表示五个执行某种用户操作的不同用户,各操作之间的间隔大约为一毫秒。该图还注释了 Spanner 插入这些行的顺序(带有标签的箭头表示每行的写入顺序)。由于插入按时间戳排序,并且时间戳值始终递增,因此 Spanner 始终会将插入添加到表的末尾并指向相同的分片。(正如在架构和数据模型中所讨论的,分片是来自一个或多个相关表的一系列行,Spanner 会按行键顺序存储这些行。)
这是有问题的,因为 Spanner 以分片为单位将工作分配给不同的服务器,因此分配给此特定分片的服务器最终会处理所有插入请求。随着用户访问事件频率的增加,向相应的服务器插入请求的频率也会增加。然后,服务器便易于成为热点,如上图的红色边框和背景所示。在此简化图示中,每个服务器最多可处理一个分片,但 Spanner 可为每个服务器分配多个分片。
当 Spanner 向表中添加更多行时,分片会随着增大,然后根据需要创建新的分片。如需详细了解如何创建拆分,请参阅基于负载的拆分。 Spanner 会将后续的新行附加到这个新分片中,分配给该分片的服务器也会成为新的潜在热点。
当热点出现时,您可能会发现插入变得缓慢,同一台服务器上的其他工作的速度也会下降。将 LastAccess
列的顺序改为升序并不能解决这个问题,因为这样会使所有写入都插入到表的顶部,所有插入仍然会被传递到单个服务器。
架构设计最佳做法 #1:不要选择其值单调递增或递减的列作为高写入速率表的首个键部分。
使用通用唯一标识符 (UUID)
您可以使用由 RFC 4122 定义的通用唯一标识符 (UUID) 作为主键。 建议使用 UUID 版本 4,因为它使用位序列中的随机值。我们不建议使用版本 1 UUID,因为它们将时间戳存储在高位中。
以下几种方法可以将 UUID 存储为主键:
- 在
STRING(36)
列中。 - 在一对
INT64
列中。 - 在
BYTES(16)
列中。
对于 STRING(36)
列,您可以使用 Spanner GENERATE_UUID()
函数(GoogleSQL 或 PostgreSQL)作为列的默认值,让 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 useraccesslogs (
logentryid VARCHAR(36) NOT NULL,
lastaccess timestamptz NOT NULL,
userid text,
...
PRIMARY KEY (lastaccess, userid)
);
您可以插入 GENERATE_UUID()
来生成 LogEntryId
值。
GENERATE_UUID()
会生成 STRING
值,因此 LogEntryId
列必须使用 GoogleSQL 的 STRING
类型或 PostgreSQL 的 text
类型。
GoogleSQL
INSERT INTO
UserAccessLogs (LogEntryId, LastAccess, UserId)
VALUES
(GENERATE_UUID(), '2016-01-25 10:10:10.555555-05:00', 'TomSmith');
PostgreSQL
INSERT INTO
useraccesslogs (logentryid, lastaccess, userid)
VALUES
(spanner.generate_uuid(),'2016-01-25 10:10:10.555555-05:00', 'TomSmith');
使用 UUID 有以下几个缺点:
- 它们略大,需要使用 16 个字节或以上。主键的其他选项不需要使用这么大的存储空间。
- 它们不携带关于记录的信息。例如,
SingerId
和AlbumId
的主键具有固有含义,而 UUID 则不具有。 - 您会失去相关记录之间的局部性,这也是使用 UUID 会消除热点的原因。
对顺序值进行位反转
您应确保数值(GoogleSQL 中的 INT64
或 PostgreSQL 中的 bigint
)主键不会按顺序递增或递减。顺序主键可能会大规模造成热点。避免此问题的一种方法是对序列值进行位反转,确保主键值在键空间中均匀分布。
Spanner 支持位反转序列,该序列可生成唯一的整数位反转值。您可以在主键的第一个(或唯一)组成部分中使用序列,以避免出现热门问题。如需了解详情,请参阅位反转序列。
交换键的顺序
一种在键空间中更均匀地分布写入的方法是交换键的顺序,以便使包含单调值的列不是首个键部分:
GoogleSQL
CREATE TABLE UserAccessLogs ( UserId INT64 NOT NULL, LastAccess TIMESTAMP NOT NULL, ... ) PRIMARY KEY (UserId, LastAccess);
PostgreSQL
CREATE TABLE useraccesslogs ( userid bigint NOT NULL, lastaccess TIMESTAMPTZ NOT NULL, ... PRIMARY KEY (UserId, LastAccess) );
在此修改后的架构中,插入现在按照 UserId
排序,而非按照上次访问时间戳排序。这种架构可以在不同分片之间分布写入,因为单个用户不太可能每秒产生数千个事件。
下图显示了 Spanner 按 UserId
排序(而非按访问时间戳排序)的 UserAccessLogs
表中的五行:
在这种情况下,Spanner 可能会将 UserAccessLogs
数据分成三个分片,每个分片包含大约一千个按 UserId
值的顺序排列的行。即使用户事件发生的时间间隔约为一毫秒,但每个事件都由不同的用户提出的,因此,与使用时间戳进行排序相比,这样的插入顺序生成热点的可能性要小得多。如需详细了解如何创建分片,请参阅基于负载进行分片
另请参阅为基于时间戳的键排序的相关最佳实践。
对唯一键进行哈希处理并将写入分布到逻辑碎片中
另一种将负载分布到多个服务器上的常见方法是:创建一个包含实际唯一键哈希值的列,然后将哈希列(或者哈希列以及唯一键列一起)用作主键。此模式有助于避免热点,因为新行在键空间中的分布更均匀。
您可以使用哈希值在数据库中创建逻辑碎片或分区。在物理分片数据库中,行分布在多个数据库服务器中。在逻辑分片数据库中,表中的数据定义了分片。例如,要将对 UserAccessLogs
表的写入分布到 N 个逻辑碎片,可以在表中添加一个 ShardId
键列:
GoogleSQL
CREATE TABLE UserAccessLogs ( ShardId INT64 NOT NULL, LastAccess TIMESTAMP NOT NULL, UserId INT64 NOT NULL, ... ) PRIMARY KEY (ShardId, LastAccess, UserId);
PostgreSQL
CREATE TABLE useraccesslogs ( 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 将在各行之间创建分片以优化性能。
下图演示了使用哈希技术创建三个逻辑碎片如何能够在各个服务器之间更均匀地分布写入吞吐量:
这里的 UserAccessLogs
表按照 ShardId
(被计算为键列的哈希函数)排序。五个 UserAccessLogs
行被分成三个逻辑碎片,每个逻辑碎片恰好在不同的分片中。插入内容在分片之间均匀分布,这样可以平衡用来处理分片的三台服务器上的写入吞吐量。
您还可以在 Spanner 中通过生成的列创建哈希函数。
如需在 GoogleSQL 中执行此操作,请在写入时使用 FARM_FINGERPRINT 函数,如以下示例所示:
GoogleSQL
CREATE TABLE UserAccessLogs (
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 UserAccessLogs ( 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 本质上是作为表来实现索引的,并且生成的索引表将使用其值单调递增的列作为其首个键部分。
您可以创建一个交错索引,其中上次访问行交错在相应的用户行下。这是因为单个父行不太可能每秒产生数千个事件。
GoogleSQL
CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(UserId, LastAccess), INTERLEAVE IN Users;
PostgreSQL
CREATE INDEX usersbylastaccess ON users(userid, lastaccess) WHERE lastaccess IS NOT NULL, INTERLEAVE IN Users;
架构设计最佳实践 #3:不要在其值单调递增或递减的高写入速率列上创建非交错索引。 使用交错索引,或者在设计索引列时使用与设计基表主键时类似的技术,例如添加 `shardId`。