借助 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
表包含五个示例数据行,它们分别表示五个执行某种用户操作的不同用户,各操作之间的间隔大约为一毫秒。该图还标注了
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()
函数(GoogleSQL 或 PostgreSQL)作为列默认值,
让 Spanner 自动生成 UUID 值。
使用 UUID 有以下几个缺点:
- 它们略大,需要使用 16 个字节或以上。主键的其他选项不需要使用这么大的存储空间。
- 它们不携带关于该记录的信息。例如,
SingerId
和AlbumId
具有固有含义,而 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
而不是访问时间戳进行订购:
在这里,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 将跨行创建分块, 并优化广告效果。
下图演示了使用哈希技术创建三个逻辑碎片如何能够在各个服务器之间更均匀地分布写入吞吐量:
这里的 UserAccessLog
表按照 ShardId
(被计算为键列的哈希函数)排序。五个 UserAccessLog
行被分成三个逻辑碎片,每个逻辑碎片恰好在不同的分片中。插入内容在分片之间均匀分布,这样可以平衡用来处理分片的三台服务器上的写入吞吐量。
Spanner 还支持在 Cloud Spanner 中 生成的列。
要在 Google SQL 中执行此操作,请使用 <ph type="x-smartling-placeholder"></ph> 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:降序或升序 取决于用户查询,例如“top”表示最新,“top”表示最早。
在其值单调递增或递减的列上使用交错索引
与之前您应该避免的主键示例类似,它也是一个 不建议在值为 单调递增或递减,即使它们不是主键列也是如此。
例如,假设您定义了下表,其中 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`。