架构概览

本页面讨论架构并介绍了交错表,交错表可以提高查询父子关系中的表时的查询性能。

Spanner 数据库包含一个或多个表。表采用行和列的结构。其中一列或多列被定义为表的主键,用于唯一标识每行。主键始终会编入索引以进行快速行查找,并且您可以在一列或多列上定义二级索引。如果您要更新或删除表中的现有行,则该表必须具有主键。没有主键列的表只能有一行。只有 GoogleSQL 方言数据库可以具有没有主键的表。

Spanner 中的数据是强类型的。您必须为每个数据库定义一个架构,并且该架构必须指定每个表的每一列的数据类型。数据类型包括标量和复杂类型,如需了解这些类型,请参阅 GoogleSQL 中的数据类型PostgreSQL 数据类型

父子表关系

在 Spanner 中定义父子关系的方法有两种:表交错外键

Spanner 的表交错非常适合许多父子关系。通过交错,Spanner 能够以物理方式将子行与存储空间中的父行存储在一起。托管同一位置可以显著提高性能。例如,如果您有一个 Customers 表和一个 Invoices 表,并且您的应用经常为客户提取所有账单,那么您可以将 Invoices 定义为 Customers 的交错子表。这样做可声明两个独立表之间的数据存储区域关系。您告知 Spanner 使用一个 Customers 行存储一行或多行 Invoices

如需将子表与父表相关联,您可以使用将子表声明为在父表中交错的 DDL,并将父表主键添加为子表复合主键的第一部分,从而将子表与父表相关联。如需详细了解交错,请参阅本主题后面的创建交错表

外键是一种较通用的父子解决方案,并解决了其他用例。外键不限于主键列,而表可以具有多个外键关系,二者在某些关系中可以作为父键,而在其他关系中可以作为子键。但是,外键关系不隐含表在存储层中的共用位置关系。

Google 建议您选择将父子关系表示为交错表或外键,但不能同时表示为这两者。如需详细了解外键及其与交错表的比较,请参阅外键概览

选择主键

通常,您的应用已经有一个本身就适合用作主键的字段。例如,对于 Customers 表,可能会有一个应用提供的 CustomerId 充当主键。在其他情况下,您可能需要在插入行时生成主键。这通常是没有业务意义的唯一整数值(代理主键)。

无论哪种情况,都请务必小心谨慎,千万不要在选择主键时形成热点。例如,如果您插入一些记录,而这些记录将单调递增的整数用作键,那么您将始终在键空间末尾进插入记录。这种情况是不可取的,因为 Spanner 会按照键范围划分服务器之间的数据,这意味着您的插入将指向单个服务器,从而形成热点。可利用一些方法将负载分散到多个服务器上,从而避免热点:

根据主键添加二级索引

在某些情况下,基于主键添加二级索引会对数据库的使用带来好处。如果您经常运行需要对表的主键进行反向顺序扫描的查询,则尤其如此。

交错表中的主键

对于交错,每个表都必须有一个主键。如果您将某个表声明为另一个表的交错子表,则该表必须具有一个复合主键,且该复合主键包含父键的所有组成部分(按相同顺序排列),并且通常包含一个或多个额外的子表列。

Spanner 按主键值的排序顺序存储行,并在父行之间插入子行。如需查看交错行的图示,请参阅本主题后面的创建交错表

总而言之,Spanner 能够以物理方式将相关表的行存储在一起。架构示例展示了此物理布局的外观。

数据库分片

您可以定义最多七层的交错父子关系的层次结构,这意味着您可以共置 7 个独立表的行。如果表中数据的大小很小,则单个 Spanner 服务器或许可以处理您的数据库。然而,当相关表不断增长,开始达到单个服务器的资源限制时,会发生什么情况呢?Spanner 是一个分布式数据库,这意味着随着数据库不断增长,Spanner 会将数据划分为称为“分块”的区块。各个分块可以彼此独立移动并被分配给不同的服务器,这些服务器可以位于不同的物理位置。分片包含一系列连续的行。这一范围的开始和结束键称为“分片边界”。Spanner 会根据大小和负载自动添加和移除分块边界,这会改变数据库中的分片数量。

基于负载进行分片

作为 Spanner 如何执行基于负载的拆分以缓解读取热点的示例,假设您的数据库包含一个有 10 行的表,这些行的读取频率比表中的所有其他行高。Spanner 可以在这 10 行的每一行之间添加分块边界,使它们分别由不同的服务器处理,而不是允许对这些行的所有读取操作使用单个服务器的资源。

一般来说,如果您遵循架构设计的最佳实践,Spanner 可以缓解热点问题,以便读取吞吐量应该每隔几分钟就提高一次,直到您使实例中的资源达到饱和,或者遇到无法添加新的分块边界的情况(因为您的分块仅覆盖没有交错子项的单个行)。

架构示例

下面的架构示例展示了如何创建使用交错和不使用交错的父表和子表,并说明了数据的相应物理布局。

创建父表

假设您正在创建一个音乐应用,并且您需要一个简单表来存储歌手数据行:

包含 5 行 4 列的 Singers 表。SingerID 是第一列。

请注意,该表包含一个主键列 SingerId,它显示在粗线的左侧,并且该表按行和列进行整理。

您可以使用 Spanner 架构定义表,如下所示:

GoogleSQL

CREATE TABLE Singers (
SingerId   INT64 NOT NULL,
FirstName  STRING(1024),
LastName   STRING(1024),
SingerInfo BYTES(MAX),
) PRIMARY KEY (SingerId);

PostgreSQL

CREATE TABLE singers (
singer_id   BIGINT PRIMARY KEY,
first_name  VARCHAR(1024),
last_name   VARCHAR(1024),
singer_info BYTEA
);

请注意有关示例架构的以下事项:

  • Singers 是位于数据库层次结构根目录处的表(因为它未被定义为另一个表的交错子表)。
  • 对于 GoogleSQL 方言数据库,主键列通常带有 NOT NULL 注解(但如果您希望允许在键列中包含 NULL 值,则可以忽略此注解)。如需了解详情,请参阅键列
  • 未包含在主键中的列称为非键列,它们可以具有可选的 NOT NULL 注解。
  • 必须为 GoogleSQL 中使用 STRINGBYTES 类型的列定义一个长度,该长度表示可以在字段中存储的 Unicode 字符数上限。对于 PostgreSQL varcharcharacter varying 类型,长度规范是可选的。如需了解详情,请参阅 GoogleSQL 方言数据库的标量数据类型和 PostgreSQL 方言数据库的 PostgreSQL 数据类型

Singers 表中行的物理布局是什么样的?下图显示了主键(“Singers(1)”)和“Singers(2)”依次存储的 Singers 表行,其中括号中的数字是主键值。

按主键顺序存储的表行示例。有一条虚线,表示按键 3 和 4 之间的分屏边界。

上图展示了由 Singers(3)Singers(4) 键控的行之间的示例拆分边界,并将生成的拆分中的数据分配给不同的服务器。随着此表不断扩大,Singers 数据的行可能会存储在不同位置。

创建父表和子表

假设您现在想要将有关每位歌手专辑的一些基本数据添加到音乐应用。

包含 5 行 3 列的“Album”表格。主键列位于左侧。

请注意,Albums 的主键由两列组成:SingerIdAlbumId,它们将每个专辑与其歌手相关联。以下示例架构在数据库层次结构的根目录下定义了 AlbumsSingers 表,这使其成为同级表。

-- Schema hierarchy:
-- + Singers (sibling table of Albums)
-- + Albums (sibling table of Singers)

GoogleSQL

CREATE TABLE Singers (
 SingerId   INT64 NOT NULL,
 FirstName  STRING(1024),
 LastName   STRING(1024),
 SingerInfo BYTES(MAX),
) PRIMARY KEY (SingerId);

CREATE TABLE Albums (
SingerId     INT64 NOT NULL,
AlbumId      INT64 NOT NULL,
AlbumTitle   STRING(MAX),
) PRIMARY KEY (SingerId, AlbumId);

PostgreSQL

CREATE TABLE singers (
singer_id   BIGINT PRIMARY KEY,
first_name  VARCHAR(1024),
last_name   VARCHAR(1024),
singer_info BYTEA
);

CREATE TABLE albums (
singer_id     BIGINT,
album_id      BIGINT,
album_title   VARCHAR,
PRIMARY KEY (singer_id, album_id)
);

SingersAlbums 的行的物理布局如下图所示:Albums 表的行按连续的主键存储,Singers 的行按连续的主键进行存储:

行的物理布局。主键显示在最左边的列中。
    例如 Album(2,1)、Album(2,2) 等。

关于此架构的一个重要说明是,Spanner 假定 SingersAlbums 表之间没有数据存放区域关系,因为它们是顶级表。随着数据库不断扩大,Spanner 可以在任何行之间添加拆分边界。这意味着,Albums 表行的分片结束位置可能不同于 Singers 表行,并且两个分片可以彼此独立移动。

根据您的应用的具体需求,可以让 Albums 数据位于不同于 Singers 数据的分片上。但是,由于需要跨不同资源协调读取和更新,因此可能会导致性能下降。如果您的应用经常需要检索有关特定歌手的所有专辑的信息,则应创建 Albums 作为 Singers 的交错子表,它可以沿主键维度协同定位两个表中的行。下一个示例将对此进行更详细的说明。

创建交错表

交错表是指您声明为另一个表的交错子的表,因为您希望以物理方式将子表的行与关联的父行一起存储。如前所述,父表主键必须是子表复合主键的第一部分。

在设计音乐应用时,假设您意识到应用在访问 Singers 行时需要频繁访问 Albums 表中的行。例如,当您访问行 Singers(1) 时,还需要访问行 Albums(1, 1) 和行 Albums(1, 2)。在这种情况下,SingersAlbums 需要建立强大的数据存放区域关系。您可以通过将 Albums 创建为 Singers 的交错子表来声明此数据存放区域关系。

-- Schema hierarchy:
-- + Singers
--   + Albums (interleaved table, child table of Singers)

以下架构中的粗体行展示了如何将 Albums 创建为 Singers 的交错表。

GoogleSQL

CREATE TABLE Singers (
 SingerId   INT64 NOT NULL,
 FirstName  STRING(1024),
 LastName   STRING(1024),
 SingerInfo BYTES(MAX),
 ) PRIMARY KEY (SingerId);

CREATE TABLE Albums (
 SingerId     INT64 NOT NULL,
 AlbumId      INT64 NOT NULL,
 AlbumTitle   STRING(MAX),
 ) PRIMARY KEY (SingerId, AlbumId),
INTERLEAVE IN PARENT Singers ON DELETE CASCADE;

PostgreSQL

CREATE TABLE singers (
 singer_id   BIGINT PRIMARY KEY,
 first_name  VARCHAR(1024),
 last_name   VARCHAR(1024),
 singer_info BYTEA
 );

CREATE TABLE albums (
 singer_id     BIGINT,
 album_id      BIGINT,
 album_title   VARCHAR,
 PRIMARY KEY (singer_id, album_id)
 )
 INTERLEAVE IN PARENT singers ON DELETE CASCADE;

有关此架构的注意事项:

  • SingerId 是子表 Albums 的主键的第一部分,也是其父表 Singers 的主键。
  • ON DELETE CASCADE 注解表示,当父表中的行被删除时,其子行也会自动删除。如果子表没有此注解,或注解为 ON DELETE NO ACTION,则您必须先删除子行,然后才能删除父行。
  • 交错行将先按父表的行排序,然后按共享父表主键的子表的连续行排序。例如,依次输入“Singers(1)”、“Albums(1, 1)”和“Albums(1, 2)”,依此类推。
  • 如果此数据库进行拆分,每个歌手与其专辑数据的数据存放区域关系都将保留,前提是 Singers 行及其所有 Albums 行的大小都保持在拆分大小以下,并且任何 Albums 行不存在热点。
  • 在插入子行之前,父行必须已经存在。 父行可以已经存在于数据库中,也可以在将子行插入到同一事务中之前插入。

行的物理布局:Albums 行在 Singers 行之间交错

创建交错表的层次结构

SingersAlbums 之间的父子关系可以扩展到更多的后代表。例如,您可以创建一个名为 Songs 的交错表作为 Albums 的子表,用于存储每个专辑的曲目清单:

包含 6 行 4 列的 Songs 表。最左边的三列构成主键。

Songs 必须具有一个主键,其中包含层次结构中与其之上的表的所有主键,即 SingerIdAlbumId

-- Schema hierarchy:
-- + Singers
--   + Albums (interleaved table, child table of Singers)
--     + Songs (interleaved table, child table of Albums)

GoogleSQL

CREATE TABLE Singers (
 SingerId   INT64 NOT NULL,
 FirstName  STRING(1024),
 LastName   STRING(1024),
 SingerInfo BYTES(MAX),
) PRIMARY KEY (SingerId);

CREATE TABLE Albums (
 SingerId     INT64 NOT NULL,
 AlbumId      INT64 NOT NULL,
 AlbumTitle   STRING(MAX),
) PRIMARY KEY (SingerId, AlbumId),
 INTERLEAVE IN PARENT Singers ON DELETE CASCADE;

CREATE TABLE Songs (
 SingerId     INT64 NOT NULL,
 AlbumId      INT64 NOT NULL,
 TrackId      INT64 NOT NULL,
 SongName     STRING(MAX),
) PRIMARY KEY (SingerId, AlbumId, TrackId),
 INTERLEAVE IN PARENT Albums ON DELETE CASCADE;

PostgreSQL

CREATE TABLE singers (
 singer_id   BIGINT PRIMARY KEY,
 first_name  VARCHAR(1024),
 last_name   VARCHAR(1024),
 singer_info BYTEA
 );

CREATE TABLE albums (
 singer_id     BIGINT,
 album_id      BIGINT,
 album_title   VARCHAR,
 PRIMARY KEY (singer_id, album_id)
 )
 INTERLEAVE IN PARENT singers ON DELETE CASCADE;

CREATE TABLE songs (
 singer_id     BIGINT,
 album_id      BIGINT,
 track_id      BIGINT,
 song_name     VARCHAR,
 PRIMARY KEY (singer_id, album_id, track_id)
 )
 INTERLEAVE IN PARENT albums ON DELETE CASCADE;

下图显示了交错行的物理视图。

行的物理视图:Songs 在 Albums 中交错,后者在 Singers 之间交错

在此示例中,随着歌手数量的增加,Spanner 会在歌手之间添加分块边界,以保留歌手与其专辑和歌曲数据之间的数据局部性。但是,如果歌手行及其子行的大小超过拆分大小限制,或者在子行中检测到热点,Spanner 会尝试添加分块边界以隔离该热点行及其下面的所有子行。

总之,父表及其所有子表和后代表形成架构中的表层次结构。虽然层次结构中的每个表在逻辑上都是独立的,但以这种方式进行物理交错可以提高性能,从而有效地预联接表,并使您能够同时访问相关行,同时最大限度地减少对存储空间的访问。

使用交错表执行联接

如果可能,请通过主键联接交错表中的数据。由于每个交错行通常都存储在与其父行相同的分片中,因此 Spanner 可以在本地通过主键执行联接,从而最大限度地减少存储空间访问和网络流量。在以下示例中,SingersAlbums 通过主键 SingerId 联接。

GoogleSQL

SELECT s.FirstName, a.AlbumTitle
FROM Singers AS s JOIN Albums AS a ON s.SingerId = a.SingerId;

PostgreSQL

SELECT s.first_name, a.album_title
FROM singers AS s JOIN albums AS a ON s.singer_id = a.singer_id;

键列

本部分包含一些与关键列相关的说明。

更改表键

表的键不可更改;您无法在现有表中添加键列,也不能从现有表中移除键列。

在主键中存储 NULL

在 GoogleSQL 中,如果要将 NULL 存储在主键列中,请在架构中省略该列的 NOT NULL 子句。(PostgreSQL 方言数据库不支持主键列中的 NULL)。

以下示例在主键列 SingerId 中省略了 NOT NULL 子句。请注意,由于 SingerId 是主键,因此该列中只能有一行存储 NULL

CREATE TABLE Singers (
  SingerId   INT64,
  FirstName  STRING(1024),
  LastName   STRING(1024),
) PRIMARY KEY (SingerId);

主键列可为 null 的属性必须在父表和子表声明之间匹配。在此示例中,不允许对 Albums.SingerId 列使用 NOT NULL,因为 Singers.SingerId 省略了此列。

CREATE TABLE Singers (
  SingerId   INT64,
  FirstName  STRING(1024),
  LastName   STRING(1024),
) PRIMARY KEY (SingerId);

CREATE TABLE Albums (
  SingerId     INT64 NOT NULL,
  AlbumId      INT64 NOT NULL,
  AlbumTitle   STRING(MAX),
) PRIMARY KEY (SingerId, AlbumId),
  INTERLEAVE IN PARENT Singers ON DELETE CASCADE;

不允许的类型

以下列的类型不能是 ARRAY

  • 表的键列。
  • 索引的键列。

多租户设计

如果您要存储属于不同客户的数据,则可能需要实现多租户。例如,音乐服务可能希望单独存储每个唱片公司的内容。

经典多租户架构

设计多租户架构的经典方法是为每个客户创建一个单独的数据库。在此示例中,每个数据库都有自己的 Singers 表:

数据库 1:Ackworth Records
SingerId FirstName LastName
1MarcRichards
2CatalinaSmith
数据库 2:Cama Records
SingerId FirstName LastName
1小艾Trentor
2GabrielWright
数据库 3:Eagan 记录
SingerId FirstName LastName
1BenjaminMartinez
2HannahHarris

架构管理的多租户

在 Spanner 中实现多租户设计的另一种方法是,将所有客户置于单个数据库的单个表中,并为每个客户使用不同的主键值。例如,您可以在表中包含一个 CustomerId 键列。如果您将 CustomerId 作为第一个键列,那么每位客户的数据都能具有良好的存放区域。然后,Spanner 可以有效地使用数据库分片,根据数据大小和加载模式最大限度地提高性能。在以下示例中,所有客户都位于一个 Singers 表中:

Spanner 多租户数据库
CustomerId SingerId FirstName LastName
11MarcRichards
12CatalinaSmith
21小艾Trentor
22GabrielWright
31BenjaminMartinez
32HannahHarris

如果每个租户必须拥有单独的数据库,请注意以下限制:

  • 每个实例的数据库数量以及每个数据库的表和索引数量都有相应限制。根据客户数量,可能无法拥有单独的数据库或表。
  • 添加新表和非交错索引可能需要很长时间。 如果您的架构设计依赖于添加新表和索引,那么您可能无法获得所需的性能。

如果想要创建单独的数据库,那么当您将表分布到不同数据库时,您成功的几率更大,因为采用这种方式,每个数据库每周的架构更改量较少

如果为应用的每个客户创建单独的表和索引,请不要将所有表和索引放在同一个数据库中。而是应该将它们拆分到多个数据库中,以缓解创建大量索引带来的性能问题

如需详细了解多租户的其他数据管理模式和应用设计,请参阅在 Spanner 中实现多租户