教程:重构图数据模型

重构是更改数据模型和图的过程。您需要重构数据模型的主要原因包括:

  • 已建模的图无法覆盖所有的用例。

  • 出现了新的用例。

  • 用于这些用例的 Cypher® 执行效率不高,尤其是在图规模扩大时。

为了满足这些需求,本教程将引导您完成重构数据模型的设计、实现和测试,并更新相应的 Cypher。

先决条件

本教程是教程:创建图数据模型的后续内容。在继续之前,您需要先创建好该教程中的数据模型。

或者,您现在也可以从零开始创建它。选择您首选的部署方法,并使用此代码添加数据

CREATE (Apollo13:Movie {title: 'Apollo 13', tmdbID: 568, released: '1995-06-30', imdbRating: 7.6, genres: ['Drama', 'Adventure', 'IMAX']})
CREATE (TomH:Person {name: 'Tom Hanks', tmdbID: 31, born: '1956-07-09'})
CREATE (MegR:Person {name: 'Meg Ryan', tmdbID: 5344, born: '1961-11-19'})
CREATE (DannyD:Person {name: 'Danny DeVito', tmdbID: 518, born: '1944-11-17'})
CREATE (JackN:Person {name: 'Jack Nicholson', tmdbID: 514, born: '1937-04-22'})
CREATE (SleeplessInSeattle:Movie {title: 'Sleepless in Seattle', tmdbID: 858, released: '1993-06-25', imdbRating: 6.8, genres: ['Comedy', 'Drama', 'Romance']})
CREATE (Hoffa:Movie {title: 'Hoffa', tmdbID: 10410, released: '1992-12-25', imdbRating: 6.6, genres: ['Crime', 'Drama']})

MERGE (TomH)-[:ACTED_IN {roles:'Jim Lovell'}]->(Apollo13)
MERGE (TomH)-[:ACTED_IN {roles:'Sam Baldwin'}]->(SleeplessInSeattle)
MERGE (MegR)-[:ACTED_IN {roles:'Annie Reed'}]->(SleeplessInSeattle)
MERGE (DannyD)-[:DIRECTED]->(Hoffa)
MERGE (DannyD)-[:ACTED_IN {roles:'Robert "Bobby" Ciaro'}]->(Hoffa)
MERGE (JackN)-[:ACTED_IN {roles:'Hoffa'}]->(Hoffa)

CREATE (Sandy:User {name: 'Sandy Jones', userID: 1})
CREATE (Clinton:User {name: 'Clinton Spencer', userID: 2})

MERGE (Sandy)-[:RATED {rating:5}]->(Apollo13)
MERGE (Sandy)-[:RATED {rating:4}]->(SleeplessInSeattle)
MERGE (Clinton)-[:RATED {rating:3}]->(Apollo13)
MERGE (Clinton)-[:RATED {rating:3}]->(SleeplessInSeattle)
MERGE (Clinton)-[:RATED {rating:3}]->(Hoffa)

现有或新的用例

假设您想知道某种特定语言有哪些电影可用。

要回答这个问题,您需要先按照教程:创建图数据模型中添加用户信息的方式,将此信息添加到图中。但是,添加新数据会带来重复的风险,这进而会影响图的性能

为了说明这种情况,请将新属性 languages 添加到 'Movie' 节点及其对应的值

MATCH (Apollo13:Movie {title:'Apollo 13'})
MATCH (SleeplessInSeattle:Movie {title:'Sleepless in Seattle'})
MATCH (Hoffa:Movie {title:'Hoffa'})
SET Apollo13.languages = ['English']
SET SleeplessInSeattle.languages = ['English']
SET Hoffa.languages = ['English', 'Italian', 'Latin']

更新后的图应该如下所示

如果您想检索所有英语电影,请执行此查询

MATCH (m:Movie)
WHERE 'English' IN m.languages
RETURN m.title

此查询的结果回答了问题,并返回了电影“Apollo 13”、“Sleepless in Seattle”和“Hoffa”

表 1. 结果
m.title

"Apollo 13"

"Sleepless in Seattle"

"Hoffa"

此查询检索所有 Movie 节点,然后测试 languages 属性是否包含 English 值。这并没有错,但随着图的规模扩大,您可能会遇到两个问题

  • 为了执行查询,必须检索所有 Movie 节点 → 随着图的规模扩大,以这种方式建模数据会降低此类查询的性能。避免此问题的替代方法是创建索引

  • language 属性的相同属性值在许多 Movie 节点中重复(在本例中,是所有节点) → 如果许多节点共享同一个属性值,则表明该属性值可以转化为一个新的实体,例如节点或关系。

解决这些问题的方案是将 languages 属性重构为一个节点,并用一个新的关系将其连接到 Movie 节点。

现在 重构后
Movie node with a language property
Movie node connected to a language node via an in language property.

消除重复数据

为了将节点属性 languages 重构为节点,您可以使用以下查询

MATCH (m:Movie)
WITH m, m.languages AS languages
UNWIND languages AS language
MERGE (l:Language {name: language})
MERGE (m)-[:IN_LANGUAGE]->(l)
REMOVE m.languages

分解查询后,您应该执行以下操作:

  1. UNWIND Movie 节点中的 languages 属性,并将它们的条目转换为新的 Language 节点。

  2. 创建 IN_LANGUAGE 关系,将 Movie 节点连接到它们各自的 Language 节点。

  3. Movie 节点中删除 languages 属性。

您的图现在应该如下所示

重构后,您应该只有一个值为 "English" 的 Language 节点,并且与之连接了对应的电影。这消除了图中大量的重复数据,并在图规模扩大时提高了性能。

处理复杂数据

假设出现了一个新用例,需要每部电影的制片人信息。关于制片人的部分数据包括他们的实际地址,这可以被视为复杂数据。

您可以通过创建 ProductionCompany 节点和 address 属性将此信息添加到图中

CREATE (p:ProductionCompany {name:'Imagine Entertainment', country:'US', postalCode:90212, state:'CA', city:'Beverly Hills', address1:'10351 Santa Monica Blvd'})
MERGE (Apollo13:Movie {title:'Apollo 13'})
CREATE (p)-[:PRODUCED]->(Apollo13)
CREATE (jerseyFilms:ProductionCompany {name:'Jersey Films', country:'US', postalCode:90049, state:'CA', city:'Los Angeles', address1:'10351 Santa Monica Blvd'})
MERGE (hoffa:Movie {title:'Hoffa'})
CREATE (jerseyFilms)-[:PRODUCED]->(hoffa)

然而,以这种方式在节点上存储复杂数据可能并不利,原因包括:

  • 数据重复:可能存在多家位于同一地点的制作公司,相同的信息会被重复保存在多个节点上。

    • 示例:在上一步中,您将 'languages' 属性重构为节点,以避免 "English" 条目在所有 Movie 节点上重复。

  • 过度获取:与节点信息相关的查询需要不必要地检索类别中更多的节点。

    • 示例:如果您想返回位于加利福尼亚州的制作公司,则需要扫描 ProductionCompany 节点的所有属性,以从 state 键中检索 California 属性值。相反,一个 California 节点可能是获取此信息的更短路径,您就不需要检索比实际所需更多的信息了。

    • 或者,您也可以创建索引

数据建模的目标是减少查询所触及的图的大小。 如果图包含大量重复数据,或者您的查询仍然存在过度获取数据的情况,您可能需要再次重构模型。

在当前模型中,您以新的节点标签 ProductionCompany 和若干地址属性的形式添加了更多信息。属性值包含大量重复数据,这是不可取的。为了使模型更高效,请检查重复的键值,并查看是否可以将它们转换为另一个实体,例如节点或关系。

在这种情况下,两家制作公司都位于加利福尼亚州,因此可以将州转化为一个 State 节点,并通过新的 LOCATED_AT 关系连接到制作公司。

重构后,按州检索制作公司的查询现在可以基于 State.name 值进行过滤,而不是评估所有 ProductionCompany 节点的 ProductionCompany.state 属性。

如何重构图以处理复杂数据,取决于您想要回答的问题以及图规模扩大时查询的性能。下一步是进行测试,以衡量图的性能

使用特定的关系

当您的项目有一个需要不断检索某条信息的重复用例时,使用特定关系是一种重构策略。使用它们的好处包括:

  • 减少需要检索的节点数量。

  • 提高查询性能。

假设您经常需要检索有关 1995 年演员的信息。该查询可能是:

MATCH (p:Person)-[:ACTED_IN]-(m:Movie)
WHERE p.name = 'Tom Hanks' AND m.released STARTS WITH '1995'
RETURN DISTINCT m.title AS Movie

但如果您创建一个特定的关系,例如 ACTED_IN_1995,当您查询相同信息时,您将改写代码如下:

MATCH (p:Person)-[:ACTED_IN_1995]-(m:Movie)
WHERE p.name = 'Tom Hanks'
RETURN m.title AS Movie

这样,查询就不需要检索所有与汤姆·汉克斯 (Tom Hanks) 连接的 Movie 节点并读取它们所有的 m.released 属性,而只需检索那些通过特定关系 ACTED_IN_1995 与汤姆·汉克斯相连的电影标题。因此,您可以避免过度获取数据并提高查询性能。

重新测试图

重构图之后,您应该重新审视所有用例的查询,并确定是否可以重写其中任何一个以利用重构优势。以下是列表:

用例 查询示例

哪些人参演了电影?

MATCH (p:Person)-[:ACTED_IN]->(m:Movie {title:'Hoffa'})
RETURN p

哪些人执导了电影?

MATCH (p:Person)-[:DIRECTED]->(m:Movie {title:'Hoffa'})
RETURN p

某人参演了哪些电影?

MATCH (p:Person {name:'Tom Hanks'})-[:ACTED_IN]->(m:Movie)
RETURN m

有多少用户给电影打分?

MATCH (m:Movie {title: 'Apollo 13'})
RETURN COUNT {(:User)-[:RATED]->(m)} AS `Number of reviewers`

参演电影的最年轻的人是谁?

MATCH (p:Person)-[:ACTED_IN]-(m:Movie)
WHERE m.title = 'Hoffa'
RETURN  p.name AS Actor, p.born as `Year Born` ORDER BY p.born DESC LIMIT 1

某人在电影中扮演了什么角色?

MATCH (p:Person {name:'Tom Hanks'})-[a:ACTED_IN]->(m:Movie {title: 'Apollo 13'})
RETURN a.roles

根据 IMDb,某一年评分最高的电影是哪一部?

MATCH (m:Movie)
WHERE m.released STARTS WITH '1995'
RETURN  m.title as Movie, m.imdbRating as Rating ORDER BY m.imdbRating DESC LIMIT 1

某位演员参演了哪些剧情片?

MATCH (p:Person)-[:ACTED_IN]-(m:Movie)
WHERE p.name = 'Tom Hanks' AND
'Drama' IN m.genres
RETURN m.title AS Movie

哪些用户给电影打了 5 分?

MATCH (u:User)-[r:RATED]-(m:Movie)
WHERE m.title = 'Apollo 13' AND
r.rating = 5
RETURN u.name as Reviewer

哪些电影是英语的?

MATCH (m:Movie)
WHERE m.languages = 'English'
RETURN m.title as Movie in English

考虑到这一点,您现在应该确定是否需要重写任何查询以利用重构优势,并在适用时进行重写。例如,对于“哪些电影是英语的?”这一用例:

旧查询 重构后的查询
MATCH (m:Movie)
WHERE m.languages = 'English'
RETURN m.title as Movie in English
MATCH (m:Movie)-[:IN_LANGUAGE]->(l:Language)
WHERE l.name = 'English'
RETURN m.title as Movie in English

性能检查

在实际应用程序中进行测试时,特别是使用完全规模化的图时,您还可以对新查询进行分析,看看是否提高了性能。在本教程的示例这类小型实例模型上,您不会看到显著的改进,但可能会看到检索行数的差异。

例如,如果您想查看检索所有 Person 节点的查询的数据库命中次数,您需要在其前添加 PROFILE 子句

PROFILE MATCH (n:Person)
RETURN n

结果应如下所示

您可以在 Cypher 手册 → 执行计划和查询调优 中找到关于查询调优和规划的更多详细信息。

持续学习

您可以在模型上持续进行的大部分重构都是关于重新调整用途或向图中添加更多信息。

您可以关注 GraphAcademy 上的互动课程 图数据建模基础 (Graph Data Modeling Fundamentals),查看更多关于如何将 Person 节点拆分为 ActorDirector 节点、如何将 Movie 节点属性 genre 转换为节点以及其他重构策略的示例。