使用 Gremlin mergeV() 和 mergeE() 步骤进行高效的更新插入 - Amazon Neptune

本文属于机器翻译版本。若本译文内容与英语原文存在差异,则一律以英文原文为准。

使用 Gremlin mergeV()mergeE() 步骤进行高效的更新插入

如果顶点或边缘已经存在,更新插入(或条件插入)会重复使用顶点或边缘,如果不存在,则创建它。高效的更新插入可以显著改变 Gremlin 查询的性能。

更新插入允许您编写幂等插入操作:无论您运行多少次这样的操作,总体结果都是一样的。这在高度并发的写入场景中很有用,在这种情况下,对图形的同一部分进行并发修改可能会强制一个或多个事务回滚并引发 ConcurrentModificationException,从而需要重试。

例如,以下查询使用提供的 Map 来更新插入顶点,以首先尝试查找 T.id"v-1" 的顶点。如果找到该顶点,则将其返回。如果找不到,则通过 onCreate 子句创建具有 id 和属性的顶点。

g.mergeV([(id):'v-1']). option(onCreate, [(label): 'PERSON', 'email': 'person-1@example.org'])

批处理更新插入以提高吞吐量

对于高吞吐量的写入场景,您可以将 mergeV()mergeE() 步骤串联在一起,以批量更新插入顶点和边缘。批处理减少了更新插入大量顶点和边缘的事务开销。然后,您可以通过使用多个客户端并行更新插入批量请求来进一步提高吞吐量。

根据经验,我们建议对于每个批量请求,更新插入大约 200 条记录。记录是单个顶点或边缘标签或属性。例如,具有单个标签和 4 个属性的顶点会创建 5 条记录。带有标签和单个属性的边缘会创建 2 条记录。如果您想更新插入批量顶点,每个顶点都有一个标签和 4 个属性,那么您应该从批量大小为 40 开始,因为 200 / (1 + 4) = 40

您可以试验批量大小。每批 200 条记录是一个不错的起点,但理想的批量大小可能会更高或更低,具体取决于您的工作负载。但请注意,Neptune 可能会限制每个请求的 Gremlin 步骤总数。此限制未记录在案,但为了安全起见,请尽量确保您的请求包含不超过 1500 个 Gremlin 步骤。Neptune 可能会拒绝超过 1500 个步骤的大批量请求。

要提高吞吐量,您可以使用多个客户端并行批量更新插入(请参阅创建高效的多线程 Gremlin 写入)。客户端的数量应与 Neptune 写入器实例上的工作线程数相同,通常是服务器 vCPUs 上工作线程数的 2 倍。例如,一个r5.8xlarge实例有 32 vCPUs 和 64 个工作线程。对于使用 r5.8xlarge 的高吞吐量写入场景,您将使用 64 个客户端并行向 Neptune 写入批量更新插入。

每个客户端都应提交批量请求,等待请求完成后,再提交另一个请求。尽管多个客户端并行运行,但每个客户端都以串行方式提交请求。这样可以确保服务器获得稳定的请求流,这些请求会占用所有工作线程,而不会淹没服务器端的请求队列(请参阅调整 Neptune 数据库集群中数据库实例的大小)。

尽量避免生成多个遍历器的步骤

当 Gremlin 步骤执行时,它会接受一个传入的遍历器,并发出一个或多个输出遍历器。一个步骤发出的遍历器的数量决定了执行下一步的次数。

通常,在执行批量操作时,您希望每个操作(例如更新插入顶点 A)执行一次,以便操作序列如下所示:更新插入顶点 A,接着更新插入顶点 B,然后更新插入顶点 C,依此类推。只要一个步骤只创建或修改一个元素,它就会只发出一个遍历器,而代表下一个操作的步骤只执行一次。另一方面,如果一个操作创建或修改了多个元素,则它会发出多个遍历器,这反过来又会导致后续步骤多次执行,每个发出的遍历器执行一次。这可能会导致数据库执行不必要的额外工作,在某些情况下,还可能导致创建不想要的额外顶点、边缘或属性值。

诸如 g.V().addV() 的查询就是一个例子,说明事情如何出错。这个简单的查询会为在图形中找到的每个顶点添加一个顶点,因为 V() 会为图形中的每个顶点发出一个遍历器,而每个遍历器都会触发对 addV() 的调用。

有关处理可能发出多个遍历器的操作的方法,请参阅混用更新插入和插入

更新插入顶点

mergeV() 步骤是专门为更新插入顶点而设计的。它将 Map 作为参数来表示要匹配图形中现有顶点的元素,如果找不到元素,则使用该 Map 来创建新的顶点。该步骤还允许您在创建或匹配时更改行为,其中 option() 调制器可以与 Merge.onCreateMerge.onMatch 令牌一起应用来控制相应的行为。有关如何使用此步骤的更多信息,请参阅 TinkerPop 参考文档

您可以使用顶点 ID 来确定特定顶点是否存在。这是首选方法,因为 Neptune 针对高度并发的用例优化了 upsert。IDs例如,以下查询会创建一个具有给定顶点 ID 的顶点(如果该顶点尚不存在),如果已存在,则重用它:

g.mergeV([(T.id): 'v-1']). option(onCreate, [(T.label): 'PERSON', email: 'person-1@example.org', age: 21]). option(onMatch, [age: 22]). id()

请注意,此查询以 id() 步骤结尾。虽然对于更新插入顶点来说并不是绝对必要的,但在更新插入查询结尾采取 id() 步骤,可以确保服务器不会将所有顶点属性序列化回客户端,这有助于降低查询的锁定成本。

或者,您可以使用顶点属性来标识顶点:

g.mergeV([email: 'person-1@example.org']). option(onCreate, [(T.label): 'PERSON', age: 21]). option(onMatch, [age: 22]). id()

如果可能,请使用您自己的用户提供的顶点IDs来创建顶点,并使用这些顶点IDs来确定在上置操作期间是否存在顶点。这让 Neptune 可以优化更新插入。当并发修改很常见时,基于 ID 的更新插入可能比基于属性的更新插入效率要高得多。

串联顶点更新插入

您可以将顶点更新插入串联在一起以批量插入它们:

g.V('v-1') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-1') .property('email', 'person-1@example.org')) .V('v-2') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-2') .property('email', 'person-2@example.org')) .V('v-3') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-3') .property('email', 'person-3@example.org')) .id()

或者,您也可以使用以下 mergeV() 语法:

g.mergeV([(T.id): 'v-1', (T.label): 'PERSON', email: 'person-1@example.org']). mergeV([(T.id): 'v-2', (T.label): 'PERSON', email: 'person-2@example.org']). mergeV([(T.id): 'v-3', (T.label): 'PERSON', email: 'person-3@example.org'])

然而,由于这种形式的查询在搜索条件中包含的元素对于按 id 执行的基本查找来说是多余的,所以它不如以前的查询效率高。

更新插入边缘

mergeE() 步骤是专门为更新插入边缘而设计的。它将 Map 作为参数来表示要匹配图形中现有边缘的元素,如果找不到元素,则使用该 Map 来创建新的边缘。该步骤还允许您在创建或匹配时更改行为,其中 option() 调制器可以与 Merge.onCreateMerge.onMatch 令牌一起应用来控制相应的行为。有关如何使用此步骤的更多信息,请参阅 TinkerPop 参考文档

你可以使用 edge IDs 向上插入边,就像使用自定义顶点向上插入顶点一样。IDs同样,这是首选方法,因为它允许 Neptune 优化查询。例如,如果边缘尚不存在,则以下查询会根据其边缘 ID 创建该边缘,如果存在,则重用该边缘。如果需要创建新边,该查询还会使用Direction.fromDirection.to顶点中的:IDs

g.mergeE([(T.id): 'e-1']). option(onCreate, [(from): 'v-1', (to): 'v-2', weight: 1.0]). option(onMatch, [weight: 0.5]). id()

请注意,此查询以 id() 步骤结尾。虽然对于更新插入边缘来说并不是绝对必要的,但在更新插入查询结尾添加 id() 步骤,可以确保服务器不会将所有边缘属性序列化回客户端,这有助于降低查询的锁定成本。

许多应用程序使用自定义顶点IDs,但留下 Neptune 来生成边缘。IDs如果你不知道边的 ID,但你知道fromto顶点IDs,你可以使用这种查询来插入一条边:

g.mergeE([(from): 'v-1', (to): 'v-2', (T.label): 'KNOWS']). id()

对于创建边缘的步骤,mergeE() 引用的所有顶点都必须存在。

串联边缘更新插入

与顶点更新插入一样,将批量请求的 mergeE() 步骤串联在一起很简单:

g.mergeE([(from): 'v-1', (to): 'v-2', (T.label): 'KNOWS']). mergeE([(from): 'v-2', (to): 'v-3', (T.label): 'KNOWS']). mergeE([(from): 'v-3', (to): 'v-4', (T.label): 'KNOWS']). id()

组合使用顶点和边缘更新插入

有时,您可能想要同时更新插入两个顶点和连接它们的边缘。您可以混用此处介绍的批量示例。以下示例更新插入 3 个顶点和 2 个边缘:

g.mergeV([(id):'v-1']). option(onCreate, [(label): 'PERSON', 'email': 'person-1@example.org']). mergeV([(id):'v-2']). option(onCreate, [(label): 'PERSON', 'email': 'person-2@example.org']). mergeV([(id):'v-3']). option(onCreate, [(label): 'PERSON', 'email': 'person-3@example.org']). mergeE([(from): 'v-1', (to): 'v-2', (T.label): 'KNOWS']). mergeE([(from): 'v-2', (to): 'v-3', (T.label): 'KNOWS']). id()

混用更新插入和插入

有时,您可能想要同时更新插入两个顶点和连接它们的边缘。您可以混用此处介绍的批量示例。以下示例更新插入 3 个顶点和 2 个边缘:

更新插入通常一次处理一个元素。如果您坚持此处介绍的更新插入模式,则每个更新插入操作都会发出单个遍历器,这会导致后续操作仅执行一次。

但是,有时您可能想混用更新插入和插入。例如,如果您使用边缘来表示操作或事件的实例,则可能出现这种情况。请求可能会使用更新插入来确保所有必要的顶点都存在,然后使用插入来添加边缘。对于此类请求,请注意可能从每个操作中发出的遍历器数量。

考虑以下示例,它混用了更新插入和插入,以将代表事件的边缘添加到图形中:

// Fully optimized, but inserts too many edges g.mergeV([(id):'v-1']). option(onCreate, [(label): 'PERSON', 'email': 'person-1@example.org']). mergeV([(id):'v-2']). option(onCreate, [(label): 'PERSON', 'email': 'person-2@example.org']). mergeV([(id):'v-3']). option(onCreate, [(label): 'PERSON', 'email': 'person-3@example.org']). mergeV([(T.id): 'c-1', (T.label): 'CITY', name: 'city-1']). V('p-1', 'p-2'). addE('FOLLOWED').to(V('p-1')). V('p-1', 'p-2', 'p-3'). addE('VISITED').to(V('c-1')). id()

该查询应插入 5 条边:2 FOLLOWED 条边和 3 VISITED 条边。但是,写入的查询会插入 8 个边:2 FOLLOWED 和 6 VISITED。其原因是插入 2 FOLLOWED 条边的操作会发出 2 个遍历器,从而导致随后的插入操作(插入 3 条边)执行两次。

修复方法是在每个可能发出多个遍历器的操作之后添加一个 fold() 步骤:

g.mergeV([(T.id): 'v-1', (T.label): 'PERSON', email: 'person-1@example.org']). mergeV([(T.id): 'v-2', (T.label): 'PERSON', email: 'person-2@example.org']). mergeV([(T.id): 'v-3', (T.label): 'PERSON', email: 'person-3@example.org']). mergeV([(T.id): 'c-1', (T.label): 'CITY', name: 'city-1']). V('p-1', 'p-2'). addE('FOLLOWED'). to(V('p-1')). fold(). V('p-1', 'p-2', 'p-3'). addE('VISITED'). to(V('c-1')). id()

在这里,我们在插入FOLLOWED边缘的操作之后插入了一个fold()步骤。这将生成单个遍历器,从而导致后续操作仅执行一次。

这种方法的缺点是,由于 fold() 未进行优化,因此查询现在尚未完全优化。fold() 后面的插入操作现在也不会得到优化。

如果您需要使用 fold() 来代表后续步骤减少遍历器的数量,请尝试对操作进行排序,以便成本最低的操作占据查询中未优化的部分。

设置基数

Neptune 中顶点属性的默认基数已设置,这意味着在使用 mergeV () 时,地图中提供的值都将赋予该基数。要使用单基数,必须明确其用法。从 TinkerPop 3.7.0 开始,有一种新的语法允许将基数作为映射的一部分提供,如以下示例所示:

g.mergeV([(T.id): 1234]). option(onMatch, ['age': single(20), 'name': single('alice'), 'city': set('miami')])

或者,你可以将基数设置为默认值,如下option所示:

// age and name are set to single cardinality by default g.mergeV([(T.id): 1234]). option(onMatch, ['age': 22, 'name': 'alice', 'city': set('boston')], single)

在 3.7.0 mergeV() 之前的版本中,设置基数的选项较少。一般的方法是回退到该property()步骤,如下所示:

g.mergeV([(T.id): '1234']). option(onMatch, sideEffect(property(single,'age', 20). property(set,'city','miami')).constant([:]))
注意

这种方法只有mergeV()在与起始步骤一起使用时才起作用。因此,您将无法mergeV()在单个遍历中进行链接,因为如果传入的遍历器是图形元素,则在开始步骤mergeV()之后使用此语法的第一个遍历会产生错误。在这种情况下,你需要将mergeV()呼叫分成多个请求,每个请求都可以作为起始步骤。