设计 Spanner 图架构的最佳实践

本文档介绍了如何利用设计 Spanner 图架构的最佳实践来创建高效的查询。您可以迭代架构设计,因此我们建议您先确定关键的查询模式,以指导架构设计。

如需了解 Spanner 架构设计最佳实践的一般信息,请参阅架构设计最佳实践

优化边缘遍历

边遍历是指沿着图的边遍历图的过程,从特定节点开始,沿着连接的边移动到其他节点。边的方向由架构定义。边遍历是 Spanner 图中的基本操作,因此提高边遍历效率对应用性能至关重要。

您可以沿两个方向遍历边:

  • 向前边缘遍历:沿着源节点的出边缘进行遍历。

  • 反向边遍历:沿着目的地节点的入边进行遍历。

给定某个人,以下示例查询会对 Owns 边进行正向边遍历:

GRAPH FinGraph
MATCH (person:Person {id: 1})-[owns:Owns]->(accnt:Account)
RETURN accnt.id;

给定一个账号,以下示例查询会对 Owns 边执行反向边遍历:

GRAPH FinGraph
MATCH (accnt:Account {id: 1})<-[owns:Owns]-(person:Person)
RETURN person.name;

使用交错优化正向边缘遍历

如需提高正向边遍历性能,请将边输入表交错到源节点输入表中,以便将边与源节点共置。交错是 Spanner 中的一种存储优化技术,用于在存储中将子表行与其对应的父行物理放置在一起。如需详细了解交错,请参阅架构概览

以下示例展示了这些最佳实践:

CREATE TABLE Person (
  id               INT64 NOT NULL,
  name             STRING(MAX),
) PRIMARY KEY (id);

CREATE TABLE PersonOwnAccount (
  id               INT64 NOT NULL,
  account_id       INT64 NOT NULL,
  create_time      TIMESTAMP,
) PRIMARY KEY (id, account_id),
  INTERLEAVE IN PARENT Person ON DELETE CASCADE;

使用外键优化反向边遍历

如需高效遍历反向边,请在边和目标节点之间创建外键约束条件。此外键会自动在以目标节点键为键边上创建次级索引。系统会在查询执行期间自动使用二级索引。

以下示例展示了这些最佳实践:

CREATE TABLE Person (
  id               INT64 NOT NULL,
  name             STRING(MAX),
) PRIMARY KEY (id);

CREATE TABLE Account (
  id               INT64 NOT NULL,
  create_time      TIMESTAMP,
) PRIMARY KEY (id);

CREATE TABLE PersonOwnAccount (
  id               INT64 NOT NULL,
  account_id       INT64 NOT NULL,
  create_time      TIMESTAMP,
  CONSTRAINT FK_Account FOREIGN KEY (account_id) REFERENCES Account (id),
) PRIMARY KEY (id, account_id),
  INTERLEAVE IN PARENT Person ON DELETE CASCADE;

使用二级索引优化反向边遍历

如果您不想在边缘上创建外键(例如,由于它会强制执行严格的数据完整性),则可以直接在边缘输入表上创建二级索引,如以下示例所示:

CREATE TABLE PersonOwnAccount (
  id               INT64 NOT NULL,
  account_id       INT64 NOT NULL,
  create_time      TIMESTAMP,
) PRIMARY KEY (id, account_id),
  INTERLEAVE IN PARENT Person ON DELETE CASCADE;

CREATE INDEX Reverse_PersonOwnAccount
ON PersonOwnAccount (account_id);

禁止悬空边缘

悬空边缘是指连接的节点数少于 2 个的边缘。如果删除节点时未移除其关联的边,或者创建边时未将其正确关联到其节点,则可能会出现悬空边。

禁止悬空边具有以下优势:

  • 强制执行图结构完整性。
  • 通过避免过滤掉不存在端点的边来提高查询性能。

使用参照约束条件禁止悬空边

如需禁止悬空边,请对两个端点指定约束条件:

  • 将边缘输入表交错插入到源节点输入表中。这种方法可确保边的源节点始终存在。
  • 对边创建外键约束条件,以确保边的目标节点始终存在。

以下示例使用交错和外键来强制执行引用完整性:

CREATE TABLE PersonOwnAccount (
  id               INT64 NOT NULL,
  account_id       INT64 NOT NULL,
  create_time      TIMESTAMP,
  CONSTRAINT FK_Account FOREIGN KEY (account_id) REFERENCES Account (id) ON DELETE CASCADE,
) PRIMARY KEY (id, account_id),
  INTERLEAVE IN PARENT Person ON DELETE CASCADE;

使用 ON DELETE CASCADE 在删除节点时自动移除边

当您使用交错或外键禁止悬空边时,如果您想删除仍有边缘附加的节点,请使用 ON DELETE 子句来控制行为。如需了解详情,请参阅交错表的删除级联使用外键的删除级联

您可以通过以下方式使用 ON DELETE

  • ON DELETE NO ACTION(或省略 ON DELETE 子句):删除具有边的节点将会失败。
  • ON DELETE CASCADE:删除节点会自动移除同一事务中的关联边。

删除连接不同类型节点的边的级联

  • 在删除源节点时删除边。例如,INTERLEAVE IN PARENT Person ON DELETE CASCADE 会从要删除的 Person 节点中删除所有出站 PersonOwnAccount 边。如需了解详情,请参阅创建交错表

  • 在删除目标节点时删除边。例如,CONSTRAINT FK_Account FOREIGN KEY(account_id) REFERENCES Account(id) ON DELETE CASCADE 会删除指向要删除的 Account 节点的所有入站 PersonOwnAccount 边。如需了解详情,请参阅外键

删除连接同一类型节点的边的级联

当边的源节点和目标节点具有相同的类型且边交错到源节点中时,您只能为源节点或目标节点(但不能同时为这两个节点)定义 ON DELETE CASCADE

如需在上述两种情况下自动移除悬空边,请在边缘源节点引用上创建外键,而不是将边缘输入表交错到源节点输入表中。

我们建议您进行交错,以优化向前边缘遍历。请务必先验证对工作负载的影响,然后再继续。请参阅以下示例,其中使用 AccountTransferAccount 作为边缘输入表:

--Define two Foreign Keys, each on one end Node of Transfer Edge, both with ON DELETE CASCADE action:
CREATE TABLE AccountTransferAccount (
  id               INT64 NOT NULL,
  to_id            INT64 NOT NULL,
  amount           FLOAT64,
  create_time      TIMESTAMP NOT NULL,
  order_number     STRING(MAX),
  CONSTRAINT FK_FromAccount FOREIGN KEY (id) REFERENCES Account (id) ON DELETE CASCADE,
  CONSTRAINT FK_ToAccount FOREIGN KEY (to_id) REFERENCES Account (id) ON DELETE CASCADE,
) PRIMARY KEY (id, to_id);

使用二级索引按节点或边属性过滤

二级索引对于高效的查询处理至关重要。它们支持根据特定属性值快速查找节点和边,而无需遍历整个图结构。在处理大型图时,这一点非常重要,因为遍历所有节点和边会非常低效。

加快按属性过滤节点的速度

如需加快按节点属性进行过滤的速度,请对属性创建二级索引。例如,以下查询会查找指定昵称的账号。如果没有次级索引,系统会扫描所有 Account 节点,以匹配过滤条件。

GRAPH FinGraph
MATCH (acct:Account)
WHERE acct.nick_name = "abcd"
RETURN acct.id;

如需加快查询速度,请针对过滤后的媒体资源创建二级索引,如以下示例所示:

CREATE TABLE Account (
  id               INT64 NOT NULL,
  create_time      TIMESTAMP,
  is_blocked       BOOL,
  nick_name        STRING(MAX),
) PRIMARY KEY (id);

CREATE INDEX AccountByNickName
ON Account (nick_name);

提示:对于稀疏属性,请使用已过滤 NULL 的索引。如需了解详情,请参阅停用对 NULL 值的编入索引

通过对边缘属性进行过滤来加快向前边缘遍历速度

在遍历边时按其属性进行过滤时,您可以通过对边属性创建二级索引并将该索引交错到源节点中来加快查询速度。

例如,以下查询会查找在特定时间之后由指定用户拥有的账号:

GRAPH FinGraph
MATCH (person:Person)-[owns:Owns]->(acct:Account)
WHERE person.id = 1
  AND owns.create_time >= PARSE_TIMESTAMP("%c", "Thu Dec 25 07:30:00 2008")
RETURN acct.id;

默认情况下,此查询会读取指定人物的所有边,然后过滤满足 create_time 条件的边。

以下示例展示了如何通过在边缘来源节点引用 (id) 和边缘属性 (create_time) 上创建二级索引来提高查询效率。将索引交错在来源节点输入表下,以便将索引与来源节点放在一起。

CREATE TABLE PersonOwnAccount (
  id               INT64 NOT NULL,
  account_id       INT64 NOT NULL,
  create_time      TIMESTAMP,
) PRIMARY KEY (id, account_id),
  INTERLEAVE IN PARENT Person ON DELETE CASCADE;

CREATE INDEX PersonOwnAccountByCreateTime
ON PersonOwnAccount (id, create_time)
INTERLEAVE IN Person;

使用这种方法,查询可以高效地查找满足 create_time 条件的所有边。

通过对边缘属性进行过滤来加快反向边缘遍历速度

在遍历反向边时,如果要按其属性进行过滤,您可以使用目标节点和边属性创建次级索引,以加快查询速度。

以下示例查询会对边属性执行反向边遍历并进行过滤:

GRAPH FinGraph
MATCH (acct:Account)<-[owns:Owns]-(person:Person)
WHERE acct.id = 1
  AND owns.create_time >= PARSE_TIMESTAMP("%c", "Thu Dec 25 07:30:00 2008")
RETURN person.id;

如需使用二级索引加快此查询的速度,请使用以下选项之一:

  • 在边缘目的地节点引用 (account_id) 和边缘属性 (create_time) 上创建次级索引,如以下示例所示:

    CREATE TABLE PersonOwnAccount (
      id               INT64 NOT NULL,
      account_id       INT64 NOT NULL,
      create_time      TIMESTAMP,
    ) PRIMARY KEY (id, account_id),
      INTERLEAVE IN PARENT Person ON DELETE CASCADE;
    
    CREATE INDEX PersonOwnAccountByCreateTime
    ON PersonOwnAccount (account_id, create_time);
    

    这种方法可提供更好的性能,因为反向边会按 account_idcreate_time 进行排序,这让查询引擎能够高效地找到满足 create_time 条件的 account_id 边。不过,如果不同的查询模式过滤不同的属性,则每个属性都可能需要单独的索引,这可能会增加开销。

  • 在边缘目标节点引用 (account_id) 上创建二级索引,并将边缘属性 (create_time) 存储在存储列中,如以下示例所示:

    CREATE TABLE PersonOwnAccount (
      id               INT64 NOT NULL,
      account_id       INT64 NOT NULL,
      create_time      TIMESTAMP,
    ) PRIMARY KEY (id, account_id),
      INTERLEAVE IN PARENT Person ON DELETE CASCADE;
    
    CREATE INDEX PersonOwnAccountByCreateTime
    ON PersonOwnAccount (account_id) STORING (create_time);
    

    这种方法可以存储多个属性;不过,查询必须读取目标节点的所有边,然后按边属性进行过滤。

您可以遵循以下准则,将这些方法组合使用:

  • 如果边缘属性在对性能至关重要的查询中使用,请在索引列中使用这些属性。
  • 对于在对性能不太敏感的查询中使用的属性,请将其添加到存储列中。

使用标签和属性对节点和边类型进行建模

节点和边类型通常使用标签进行建模。不过,您也可以使用属性来对模型类型进行建模。假设有这样一个示例,其中有许多不同类型的账号,例如 BankAccountInvestmentAccountRetirementAccount。您可以将这些账号存储在单独的输入表中,并将其建模为单独的标签;也可以将这些账号存储在单个输入表中,并使用属性来区分类型。

通过使用标签对类型进行建模,开始建模流程。在以下情况下,考虑使用属性。

改进架构管理

如果您的图表包含许多不同的节点和边类型,则可能很难为每种类型分别管理单独的输入表。为了简化架构管理,请将类型建模为属性。

用于管理频繁更改的类型的媒体资源中的模型类型

如果您将类型建模为标签,则添加或移除类型需要更改架构。如果您在短时间内执行的架构更新过多,Spanner 可能会限制对队列中架构更新的处理。如需了解详情,请参阅限制架构更新频率

如果您需要频繁更改架构,我们建议您在媒体资源中对类型进行建模,以规避架构更新频率限制。

加快查询速度

当节点或边模式引用多个标签时,使用属性对类型进行建模可能会加快查询速度。以下示例查询会查找 Person 拥有的所有 SavingsAccountInvestmentAccount 实例,假设账号类型是使用标签建模的:

GRAPH FinGraph
MATCH (:Person {id: 1})-[:Owns]->(acct:SavingsAccount|InvestmentAccount)
RETURN acct.id;

acct 节点模式引用两个标签。如果这是对性能至关重要的查询,请考虑使用属性对 Account 进行建模。这种方法可能会提供更好的查询性能,如以下查询示例所示。我们建议您对这两种查询进行基准测试。

GRAPH FinGraph
MATCH (:Person {id: 1})-[:Owns]->(acct:Account)
WHERE acct.type IN ("Savings", "Investment")
RETURN acct.id;

在节点元素键中存储类型以加快查询速度

若要加快按节点类型过滤的查询速度,请按以下步骤操作:如果节点类型是使用属性进行建模的,并且在节点生命周期内不会发生变化,

  1. 将该属性作为节点元素键的一部分添加。
  2. 在边输入表中添加节点类型。
  3. 在边引用键中添加节点类型。

以下示例将此优化应用于 Account 节点和 AccountTransferAccount 边。

CREATE TABLE Account (
  type             STRING(MAX) NOT NULL,
  id               INT64 NOT NULL,
  create_time      TIMESTAMP,
) PRIMARY KEY (type, id);

CREATE TABLE AccountTransferAccount (
  type             STRING(MAX) NOT NULL,
  id               INT64 NOT NULL,
  to_type          STRING(MAX) NOT NULL,
  to_id            INT64 NOT NULL,
  amount           FLOAT64,
  create_time      TIMESTAMP NOT NULL,
  order_number     STRING(MAX),
) PRIMARY KEY (type, id, to_type, to_id),
  INTERLEAVE IN PARENT Account ON DELETE CASCADE;

CREATE PROPERTY GRAPH FinGraph
  NODE TABLES (
    Account
  )
  EDGE TABLES (
    AccountTransferAccount
      SOURCE KEY (type, id) REFERENCES Account
      DESTINATION KEY (to_type, to_id) REFERENCES Account
  );

在节点和边上配置 TTL

Spanner 存留时间 (TTL) 是一种机制,支持在指定时间段后自动到期和移除数据。这通常适用于生命周期或相关性有限的数据,例如会话信息、临时缓存或事件日志。在这些情况下,TTL 有助于维护数据库大小和性能。

以下示例使用 TTL 在账号关闭 90 天后删除账号:

CREATE TABLE Account (
  id               INT64 NOT NULL,
  create_time      TIMESTAMP,
  close_time       TIMESTAMP,
) PRIMARY KEY (id),
  ROW DELETION POLICY (OLDER_THAN(close_time, INTERVAL 90 DAY));

如果节点表中交错了 TTL 和边表,则必须使用 ON DELETE CASCADE 定义交错。同样,如果节点表具有 TTL,并且由边表通过外键引用,则必须使用 ON DELETE CASCADE 定义外键。

在以下示例中,AccountTransferAccount 会在账号保持活跃状态时存储最长 10 年。删除账号后,转移记录也会一并删除。

CREATE TABLE AccountTransferAccount (
  id               INT64 NOT NULL,
  to_id            INT64 NOT NULL,
  amount           FLOAT64,
  create_time      TIMESTAMP NOT NULL,
  order_number     STRING(MAX),
) PRIMARY KEY (id, to_id),
  INTERLEAVE IN PARENT Account ON DELETE CASCADE,
  ROW DELETION POLICY (OLDER_THAN(create_time, INTERVAL 3650 DAY));

合并节点和边输入表

您可以使用同一输入表在架构中定义多个节点和边。

在以下示例表中,Account 节点具有复合键 (owner_id, account_id)。存在隐式边定义,当 id 等于 owner_id 时,具有键 (id) 的 Person 节点拥有具有复合键 (owner_id, account_id)Account 节点。

CREATE TABLE Person (
  id INT64 NOT NULL,
) PRIMARY KEY (id);

-- Assume each account has exactly one owner.
CREATE TABLE Account (
  owner_id INT64 NOT NULL,
  account_id INT64 NOT NULL,
) PRIMARY KEY (owner_id, account_id);

在这种情况下,您可以使用 Account 输入表来定义 Account 节点和 PersonOwnAccount 边,如以下架构示例所示。为了确保所有元素表名称都是唯一的,该示例为边表定义提供了别名 Owns

CREATE PROPERTY GRAPH FinGraph
  NODE TABLES (
    Person,
    Account
  )
  EDGE TABLES (
    Account AS Owns
      SOURCE KEY (owner_id) REFERENCES Person
      DESTINATION KEY (owner_id, account_id) REFERENCES Account
  );

后续步骤