设计 Spanner Graph 架构的最佳实践

本文档介绍了设计 Spanner Graph 架构的最佳实践,重点介绍了高效查询、优化的边遍历和有效的数据管理技术。

如需了解 Spanner 架构(而非 Spanner Graph 架构)的设计,请参阅架构设计最佳实践

选择架构设计

架构设计会影响图的性能。以下主题可帮助您选择有效的策略。

架构化设计与无架构设计

  • 架构化设计将图表定义存储在 Spanner Graph 架构中,适用于定义更改不频繁的稳定图表。架构会强制执行图定义,并且属性支持所有 Spanner 数据类型。

  • 无架构设计可从数据中推断出图表定义,从而提供更高的灵活性,而无需更改架构。默认情况下,系统不会强制执行动态标签和属性。 属性必须是有效的 JSON 值。

下文总结了有架构数据管理与无架构数据管理之间的主要区别。此外,考虑图查询有助于确定要使用的架构类型。

功能 架构化数据管理 无架构数据管理
存储图表定义 图定义存储在 Spanner Graph 架构中。 从数据中可以明显看出图的定义。不过,Spanner Graph 不会检查数据来推断定义。
更新图表定义 需要更改 Spanner Graph 架构。适用于定义明确且很少发生变化的情况。 无需更改 Spanner Graph 架构。
强制执行图表定义 属性图表架构会强制执行边的允许节点类型。 它还会强制执行图节点或边类型的允许属性和属性类型。 默认情况下不强制执行。您可以使用检查限制条件来强制执行标签和属性数据完整性。
属性数据类型 支持任何 Spanner 数据类型,例如 timestamp 动态属性必须是有效的 JSON 值。

根据图查询选择架构设计

架构化和无架构设计通常可提供相当的性能。不过,当查询使用跨多个节点或边类型的量化路径模式时,无架构设计可提供更好的性能。

根本原因在于底层数据模型。无架构设计将所有数据存储在单个节点表和边缘表中,DYNAMIC LABEL强制执行。遍历多种类型的查询以最少的表扫描次数执行。

相比之下,架构化设计通常为每种节点和边类型使用单独的表,因此跨多种类型的查询必须扫描并合并来自所有相应表的数据。

以下是一些适用于无架构设计的示例查询,以及一个适用于两种设计的示例查询:

无架构设计

以下查询采用无架构设计时效果更好,因为它们使用量化路径模式,可以匹配多种类型的节点和边:

  • 此查询的量化路径模式使用多种边类型(TransferWithdraw),并且未为超过一个跃点的路径指定中间节点类型。

    GRAPH FinGraph
    MATCH p = (:Account {id:1})-[:Transfer|Withdraw]->{1,3}(:Account)
    RETURN TO_JSON(p) AS p;
    
  • 此查询的量化路径模式用于查找 Person 节点和 Account 节点之间的一到三个跃点路径,使用多种边类型(OwnsTransfers),而无需为更长的路径指定中间节点类型。这样,路径就可以遍历各种类型的中间节点。例如 (:Person)-[:Owns]->(:Account)-[:Transfers]->(:Account)

    GRAPH FinGraph
    MATCH p = (:Person {id:1})-[:Owns|Transfers]->{1,3}(:Account)
    RETURN TO_JSON(p) AS p;
    
  • 此查询的量化路径模式用于查找 Person 节点和 Account 节点之间的一到三个跃点路径,而不指定任何边标签。与之前的查询类似,它允许路径遍历各种类型的中间节点。

    GRAPH FinGraph
    MATCH p = (:Person {id:1})-[]->{1,3}(:Account)
    RETURN TO_JSON(p) AS p;
    
  • 此查询会查找 Account 节点之间使用任意方向 (-[:Owns]-) 的 Owns 类型边的一到三个跃点路径。由于路径可以沿任一方向遍历边,并且未指定中间节点,因此两跃点路径可能会经过不同类型的节点。例如 (:Account)-[:Owns]-(:Person)-[:Owns]-(:Account)

    GRAPH FinGraph
    MATCH p = (:Account {id:1})-[:Owns]-{1,3}(:Account)
    RETURN TO_JSON(p) AS p;
    

两种设计

以下查询在采用架构化设计和无架构设计时性能相当。其量化路径 (:Account)-[:Transfer]->{1,3}(:Account) 涉及一种节点类型 Account 和一种边类型 Transfer。由于路径仅涉及一种节点类型和一种边类型,因此这两种设计的性能相当。尽管中间节点未明确标记,但该模式将其限制为 Account 节点。Person 节点显示在此量化路径之外。

GRAPH FinGraph
MATCH p = (:Person {id:1})-[:Owns]->(:Account)-[:Transfer]->{1,3}(:Account)
RETURN TO_JSON(p) AS p;

优化 Spanner Graph 架构性能

选择使用架构化或无架构 Spanner Graph 架构后,您可以通过以下方式优化其性能:

优化边缘遍历

边缘遍历是指沿着图表的边缘来遍历图表的过程,具体方法是从特定节点开始,沿着其连接的边缘依次访问其他节点。架构定义了边缘的方向。边缘遍历是 Spanner Graph 中的一项基本操作,因此提高边缘遍历效率可以显著提升应用的性能。

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

  • 正向边缘遍历会沿着来源节点的传出边缘进行遍历。
  • 反向边缘遍历会沿着目标节点的传入边缘进行遍历。

正向和反向边缘遍历查询示例

以下示例查询会针对指定人员执行 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;

优化正向边缘遍历

如需提高正向边缘遍历性能,请优化从来源到边缘以及从边缘到目的地的遍历。

  • 如需优化从来源到边缘的遍历,请使用 INTERLEAVE IN PARENT 子句将边缘输入表交织到来源节点输入表中。交织是 Spanner 中的一种存储优化技术,可在存储中将子表行与其对应的父行放在同一位置。如需详细了解交织,请参阅架构概览

  • 如需优化从边缘到目标的遍历,请在边缘和目标
    节点之间创建外键约束条件。这会强制执行从边缘到目的地的约束,从而通过消除目的地表扫描来提高性能。如果强制执行外键导致写入性能瓶颈(例如,在更新中心节点时),请改用信息性外键

以下示例展示了如何将交织与强制执行的外键限制条件和信息性外键限制条件搭配使用。

强制执行外键

在此边缘表示例中,PersonOwnAccount 执行以下操作:

  • 交织到源节点表 Person 中。

  • 为目标节点表 Account 创建强制执行的外键。

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

CREATE TABLE Account (
  id               INT64 NOT NULL,
  create_time      TIMESTAMP,
  close_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;

信息性外键

在此边缘表示例中,PersonOwnAccount 执行以下操作:

  • 交织到源节点表 Person 中。

  • 为目标节点表 Account 创建信息性外键。

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

CREATE TABLE Account (
  id               INT64 NOT NULL,
  create_time      TIMESTAMP,
  close_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) NOT ENFORCED
) PRIMARY KEY (id, account_id),
  INTERLEAVE IN PARENT Person ON DELETE CASCADE;

优化反向边缘遍历

优化反向边缘遍历,除非您的查询仅使用正向遍历,因为涉及反向或任意方向遍历的查询很常见。

如需优化反向边缘遍历,您可以执行以下操作:

  • 在边表中创建二级索引。

  • 将索引交织到目标节点输入表中,以将边缘与目标节点放在同一位置。

  • 将边属性存储在索引中。

此示例展示了如何使用二级索引来优化边表 PersonOwnAccount 的反向边缘遍历:

  • INTERLEAVE IN 子句将索引数据与目标节点表 Account 并置。

  • STORING 子句用于在索引中存储边属性。

如需详细了解交织索引,请参阅索引和交织

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 AccountOwnedByPerson
ON PersonOwnAccount (account_id)
STORING (create_time),
INTERLEAVE IN Account;

使用二级索引过滤属性

借助二级索引,您可以根据特定的属性值高效查找节点和边。使用索引有助于避免全表扫描,对于大型图表尤其有用。

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

以下查询会查找具有指定昵称的账号。由于它不使用二级索引,因此必须扫描所有 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);

加快按属性过滤边的速度

您可以使用二级索引来提高根据属性值过滤边的性能。

正向边缘遍历

如果没有二级索引,此查询必须扫描某个人的所有边,才能找到与 create_time 过滤条件匹配的边:

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;

以下代码通过对边缘来源节点引用 (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 AccountOwnedByPersonByCreateTime
ON PersonOwnAccount (account_id, create_time),
INTERLEAVE IN Account;

防止出现悬空边缘

连接零个或一个节点的边(即悬空边)可能会影响 Spanner Graph 查询效率和图表结构完整性。如果您删除节点时未删除其关联的边,则可能会出现悬空边。如果您创建的边缘的源节点或目标节点不存在,也可能会出现悬空边缘。为防止出现悬空边,请在 Spanner Graph 架构中纳入以下内容:

使用参照限制条件

您可以在两个端点上使用交织和强制执行的外键来防止悬空边,方法是按照以下步骤操作:

  1. 将边缘输入表交织到来源节点输入表中,以确保边缘的来源节点始终存在。

  2. 对边缘创建强制执行的外键限制条件,以确保边缘的目标节点始终存在。 虽然强制执行的外键可防止悬空边缘,但会增加插入和删除边缘的开销。

以下示例使用强制执行的外键,并使用 INTERLEAVE IN PARENT 子句将边缘输入表交织到来源节点输入表中。同时使用强制执行外键和交织还可以帮助优化正向边缘遍历

  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 删除边

当您使用交织或强制执行的外键来防止悬空边缘时,可以在 Spanner Graph 架构中使用 ON DELETE CASCADE 子句,以便在删除节点的同一事务中删除节点的相关联边缘。如需了解详情,请参阅为交织表删除级联外键操作

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

以下示例展示了如何在 Spanner Graph 架构中使用 ON DELETE CASCADE,以便在删除源节点或目标节点时删除悬空边。在这两种情况下,被删除节点的类型与通过边连接到该节点的节点的类型都不同。

来源节点

使用交织在删除来源节点时删除悬空边缘。以下示例展示了如何使用交织在删除源节点 (Person) 时删除出向边。如需了解详情,请参阅创建交织表

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 结合使用,以便在删除目标节点 (Account) 时删除传入边:

CONSTRAINT FK_Account FOREIGN KEY(account_id)
  REFERENCES Account(id) ON DELETE CASCADE

为连接相同类型节点的边缘删除级联

如果边缘的来源节点和目标节点的类型相同,并且边缘交织到来源节点中,则您可以为来源节点或目标节点定义 ON DELETE CASCADE,但不能同时为这两个节点定义。

为避免在这些情况下出现悬空边缘,请勿交织到来源节点输入表中。而是针对源节点引用和目标节点引用创建两个强制执行的外键。

以下示例使用 AccountTransferAccount 作为边缘输入表。它定义了两个外键,分别位于转移边的每个端节点上,都具有 ON DELETE CASCADE 操作。

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);

对节点和边配置存留时间 (TTL)

借助 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 删除节点时,其关联的交织边缘也会被删除。

  • 对于具有外键的边表:如果边表通过外键引用节点表,则您有以下两种选择:

    • 如需在 TTL 删除所引用节点时自动删除边缘,请在外键上使用 ON DELETE CASCADE。这样可以保持参照完整性。
    • 如需在删除被引用的节点后允许边缘保留(从而创建悬空边缘),请将外键定义为信息性外键

在以下示例中,AccountTransferAccount 边表受两项数据删除政策的约束:

  • TTL 政策会删除超过 10 年的转移记录。
  • ON DELETE CASCADE 子句会在删除来源账号时删除与该来源账号关联的所有转移记录。
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),则 owner_id 部分可以是引用 Person 表的外键。此结构允许 Account 表同时表示 Account 节点和来自 Person 节点的入向边。

  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 节点及其入向 Owns 边。具体可见以下CREATE PROPERTY GRAPH语句。在 EDGE TABLES 子句中,您为 Account 表指定了别名 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
    );

后续步骤