使用 fold()/coalesce()/unfold() 进行高效的 Gremlin 更新插入 - Amazon Neptune
Amazon Web Services 文档中描述的 Amazon Web Services 服务或功能可能因区域而异。要查看适用于中国区域的差异,请参阅 中国的 Amazon Web Services 服务入门 (PDF)

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

使用 fold()/coalesce()/unfold() 进行高效的 Gremlin 更新插入

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

本页展示了如何使用 fold()/coalesce()/unfold() Gremlin 模式来进行高效的更新插入。但是,随着Neptune在引擎 TinkerPop 版本 1.2.1.0中引入的3.6.x版本的发布,在大多数情况下,新的mergeV()mergeE()步骤更可取。此处描述的 fold()/coalesce()/unfold() 模式在某些复杂情况下可能仍然有用,但如果可以的话,一般使用 mergeV()mergeE(),如使用 Gremlin mergeV() 和 mergeE() 步骤进行高效的更新插入中所述。

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

例如,以下查询通过首先在数据集中查找指定的顶点,然后将结果折叠到列表中来更新插入顶点。在为 coalesce() 步骤提供的第一次遍历中,查询随后展开此列表。如果展开的列表不为空,将从 coalesce() 中发出结果。但是,如果由于顶点当前不存在而 unfold() 返回一个空集合,则 coalesce() 继续计算向其提供的第二个遍历,在第二次遍历中,查询将创建缺失的顶点。

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

使用 coalesce() 的优化形式执行更新插入

Neptune 可以优化 fold().coalesce(unfold(), ...) 习惯用法以进行高吞吐量更新,但这种优化只有在 coalesce() 的两个部分都返回顶点或边缘而不返回其它部分时才有效。如果您尝试从 coalesce() 的任何部分返回不同的内容(例如属性),则不会进行 Neptune 优化。查询可能会成功,但其性能不如优化的版本,尤其是在处理大型数据集时。

由于未优化的更新插入查询会增加执行时间并降低吞吐量,因此值得使用 Gremlin explain 端点来确定更新插入查询是否经过完全优化。审核 explain 计划时,请查找以 + not converted into Neptune stepsWARNING: >> 开头的行。例如:

+ not converted into Neptune steps: [FoldStep, CoalesceStep([[UnfoldStep], [AddEdgeSte... WARNING: >> FoldStep << is not supported natively yet

这些警告可以帮助您识别查询中阻碍其完全优化的部分。

有时无法完全优化查询。在这些情况下,您应该尝试将无法优化的步骤放在查询的末尾,从而允许引擎优化尽可能多的步骤。在一些批量更新插入示例中使用了这种技术,其中对一组顶点或边缘执行了所有优化的更新插入,然后再对相同的顶点或边缘进行任何额外的、可能未优化的修改。

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

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

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

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

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

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

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

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

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

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

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

更新插入顶点

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

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

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

或者,您可以使用顶点属性来确定顶点是否存在:

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

如果可能,请使用您自己的用户提供的 ID 来创建顶点,并使用这些 ID 来确定在更新插入操作期间是否存在顶点。这使得 Neptune 可以围绕 ID 优化更新插入。在高度并发的修改场景中,基于 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()

更新插入边缘

您可以使用边缘 ID 来更新插入边缘,就像使用自定义顶点 ID 更新插入顶点一样。同样,这是首选方法,因为它允许 Neptune 优化查询。例如,如果边缘尚不存在,则以下查询会根据其边缘 ID 创建该边缘,如果存在,则重用该边缘。如果该查询需要创建新边缘,它还会使用 fromto 顶点的 ID。

g.E('e-1') .fold() .coalesce(unfold(), addE('KNOWS').from(V('v-1')) .to(V('v-2')) .property(id, 'e-1')) .id()

许多应用程序使用自定义顶点 ID,但让 Neptune 来生成边缘 ID。如果您不知道边缘的 ID,但您知道 fromto 顶点 ID,您可以使用此公式来更新插入边缘:

g.V('v-1') .outE('KNOWS') .where(inV().hasId('v-2')) .fold() .coalesce(unfold(), addE('KNOWS').from(V('v-1')) .to(V('v-2'))) .id()

请注意,where() 子句中的顶点步骤应该是 inV()(或者,如果您曾经使用 inE() 来查找边缘,则为 outV()),而不是 otherV()。请勿在此处使用 otherV(),否则查询将无法优化,性能会受到影响。例如,Neptune 不会优化以下查询:

// Unoptimized upsert, because of otherV() g.V('v-1') .outE('KNOWS') .where(otherV().hasId('v-2')) .fold() .coalesce(unfold(), addE('KNOWS').from(V('v-1')) .to(V('v-2'))) .id()

如果您不知道前面的边缘或顶点 ID,可以使用顶点属性更新插入:

g.V() .hasLabel('Person') .has('name', 'person-1') .outE('LIVES_IN') .where(inV().hasLabel('City').has('name', 'city-1')) .fold() .coalesce(unfold(), addE('LIVES_IN').from(V().hasLabel('Person') .has('name', 'person-1')) .to(V().hasLabel('City') .has('name', 'city-1'))) .id()

与顶点更新插入一样,最好通过边缘 ID 或 fromto 顶点 ID 使用基于 ID 的边缘更新插入,而不是基于属性的更新插入,这样 Neptune 就可以完全优化更新插入。

检查 fromto 顶点是否存在

请注意创建新边缘的步骤的构造:addE().from().to()。这种构造可确保查询检查 fromto 顶点是否存在。如果其中任何一个顶点不存在,则查询将返回如下错误:

{ "detailedMessage": "Encountered a traverser that does not map to a value for child... "code": "IllegalArgumentException", "requestId": "..." }

如果 fromto 顶点可能不存在,则应尝试在它们之间更新插入边缘之前,更新插入它们。请参阅 组合使用顶点和边缘更新插入

还有另一种构造可用来创建您不应使用的边缘:V().addE().to()。只有当 from 顶点存在时,它才会添加边缘。如果 to 顶点不存在,查询会生成错误,如前所述,但如果 from 顶点不存在,则它将无法插入边缘且不发出提示,而不会生成任何错误。例如,如果 from 顶点不存在,则以下更新插入会在不更新插入边缘的情况下完成:

// Will not insert edge if from vertex does not exist g.V('v-1') .outE('KNOWS') .where(inV().hasId('v-2')) .fold() .coalesce(unfold(), V('v-1').addE('KNOWS') .to(V('v-2'))) .id()

串联边缘更新插入

如果要将边缘更新插入串联在一起以创建批量请求,则即使您已经知道边缘 ID,也必须通过顶点查找来开始每个更新插入。

如果您已经知道要更新插入的边缘的 ID 以及 fromto 顶点的 ID,则可以使用以下公式:

g.V('v-1') .outE('KNOWS') .hasId('e-1') .fold() .coalesce(unfold(), V('v-1').addE('KNOWS') .to(V('v-2')) .property(id, 'e-1')) .V('v-3') .outE('KNOWS') .hasId('e-2').fold() .coalesce(unfold(), V('v-3').addE('KNOWS') .to(V('v-4')) .property(id, 'e-2')) .V('v-5') .outE('KNOWS') .hasId('e-3') .fold() .coalesce(unfold(), V('v-5').addE('KNOWS') .to(V('v-6')) .property(id, 'e-3')) .id()

也许最常见的批量边缘更新插入场景是您知道 fromto 顶点 ID,但不知道要更新插入的边缘的 ID。在这种情况下,请使用以下公式:

g.V('v-1') .outE('KNOWS') .where(inV().hasId('v-2')) .fold() .coalesce(unfold(), V('v-1').addE('KNOWS') .to(V('v-2'))) .V('v-3') .outE('KNOWS') .where(inV().hasId('v-4')) .fold() .coalesce(unfold(), V('v-3').addE('KNOWS') .to(V('v-4'))) .V('v-5') .outE('KNOWS') .where(inV().hasId('v-6')) .fold() .coalesce(unfold(), V('v-5').addE('KNOWS').to(V('v-6'))) .id()

如果您知道要更新插入的边缘的 ID,但不知道 fromto 顶点的 ID(这很少见),您可以使用以下公式:

g.V() .hasLabel('Person') .has('email', 'person-1@example.org') .outE('KNOWS') .hasId('e-1') .fold() .coalesce(unfold(), V().hasLabel('Person') .has('email', 'person-1@example.org') .addE('KNOWS') .to(V().hasLabel('Person') .has('email', 'person-2@example.org')) .property(id, 'e-1')) .V() .hasLabel('Person') .has('email', 'person-3@example.org') .outE('KNOWS') .hasId('e-2') .fold() .coalesce(unfold(), V().hasLabel('Person') .has('email', 'person-3@example.org') .addE('KNOWS') .to(V().hasLabel('Person') .has('email', 'person-4@example.org')) .property(id, 'e-2')) .V() .hasLabel('Person') .has('email', 'person-5@example.org') .outE('KNOWS') .hasId('e-1') .fold() .coalesce(unfold(), V().hasLabel('Person') .has('email', 'person-5@example.org') .addE('KNOWS') .to(V().hasLabel('Person') .has('email', 'person-6@example.org')) .property(id, 'e-3')) .id()

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

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

g.V('p-1') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-1') .property('email', 'person-1@example.org')) .V('p-2') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-2') .property('name', 'person-2@example.org')) .V('c-1') .fold() .coalesce(unfold(), addV('City').property(id, 'c-1') .property('name', 'city-1')) .V('p-1') .outE('LIVES_IN') .where(inV().hasId('c-1')) .fold() .coalesce(unfold(), V('p-1').addE('LIVES_IN') .to(V('c-1'))) .V('p-2') .outE('LIVES_IN') .where(inV().hasId('c-1')) .fold() .coalesce(unfold(), V('p-2').addE('LIVES_IN') .to(V('c-1'))) .id()

混用更新插入和插入

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

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

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

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

// Fully optimized, but inserts too many edges g.V('p-1') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-1') .property('email', 'person-1@example.org')) .V('p-2') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-2') .property('name', 'person-2@example.org')) .V('p-3') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-3') .property('name', 'person-3@example.org')) .V('c-1') .fold() .coalesce(unfold(), addV('City').property(id, 'c-1') .property('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.V('p-1') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-1') .property('email', 'person-1@example.org')) .V('p-2') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-2'). .property('name', 'person-2@example.org')) .V('p-3') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-3'). .property('name', 'person-3@example.org')) .V('c-1') .fold(). .coalesce(unfold(), addV('City').property(id, 'c-1'). .property('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() 来代表后续步骤减少遍历器的数量,请尝试对操作进行排序,以便成本最低的操作占据查询中未优化的部分。

修改现有顶点和边缘的更新插入

有时,如果顶点或边缘不存在,则需要创建顶点或边缘,然后向其添加或更新属性,无论它是新的还是现有的顶点或边缘。

要添加或修改属性,请使用 property() 步骤。在 coalesce() 步骤之外使用此步骤。如果您尝试修改 coalesce() 步骤内现有顶点或边缘的属性,Neptune 查询引擎可能不会对查询进行优化。

以下查询在每个更新插入的顶点上添加或更新计数器属性。每个 property() 步骤都具有单个基数,以确保新值替换任何现有值,而不是添加到一组现有值中。

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

如果您有一个适用于所有已更新插入的元素的属性值(例如 lastUpdated 时间戳值),则可以在查询结尾添加或更新它:

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')) .V('v-1', 'v-2', 'v-3') .property(single, 'lastUpdated', datetime('2020-02-08')) .id()

如果还有其它条件可以确定是否应进一步修改顶点或边缘,则可以使用 has() 步骤来筛选要对其应用修改的元素。以下示例使用 has() 步骤根据其 version 属性的值筛选已更新插入的顶点。然后,对于 version 小于 3 的任何顶点,该查询将该顶点的 version 更新为 3:

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