使用 Gremlin 制作高效的 upsert - Amazon Neptune
Amazon Web Services 文档中描述的 Amazon Web Services 服务或功能可能因区域而异。要查看适用于中国区域的差异,请参阅 中国的 Amazon Web Services 服务入门 (PDF)

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

使用 Gremlin 制作高效的 upsert

高效的 upsert 可以显著提高 Gremlin 查询的性能。

如果顶点或边已经存在,upsert(或条件插入)会重用顶点或边,但如果不存在,则创建它。

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

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

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

使用经过优化的 for upcoalesce() serts 形式

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

由于未优化的 upsert 查询会增加执行时间并降低吞吐量,因此值得使用 Gremlinexplain 端点来确定 upsert 查询是否已完全优化。查看explain计划时,查找以+ not converted into Neptune steps和开头的行WARNING: >>。例如:

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

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

有时无法完全优化查询。在这些情况下,你应该尝试将无法优化的步骤放在查询的末尾,从而允许引擎优化尽可能多的步骤。这种技术在一些批量上移示例中使用,在这些示例中,对一组顶点或边进行所有优化的顶点或边都是在对相同的顶点或边应用任何额外的、可能未经过优化的修改之前执行的。

批处理 upsert 以提高吞吐量

对于高吞吐量写入场景,您可以将 upsert 步骤链接在一起,批量上置顶点和边。批处理减少了大量顶点和边的事务开销。然后,您可以通过使用多个客户端parallel 处理批处理请求来进一步提高吞吐量。

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

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

为了提高吞吐量,您可以使用多个客户端parallel 更新批处理。客户端数量应与 Neptune 写入器实例上的工作线程数相同,通常是服务器上 vCPUs 数量的 2 倍。例如,一个r5.8xlarge实例有 32 个 vCPUs 和 64 个工作线程。对于使用的高吞吐量写入场景r5.8xlarge,您将使用 64 个客户端将批量上载并parallel 写入 Neptune。

每个客户都应提交批量请求并等待请求完成,然后再提交其他请求。尽管多个客户机parallel 运行,但每个单独的客户机都以串行方式提交请求。这样可以确保向服务器提供稳定的请求流,这些请求占用所有工作线程,而不会淹没服务器端的请求队列。

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

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

通常,在执行批处理操作时,您希望每个操作(例如 upsert 顶点 A)执行一次,这样操作序列如下所示:向上置顶点 A,然后向上置顶点 B,然后向上置顶点 C,依此类推。只要一个步骤仅创建或修改一个元素,它就会只发出一个遍历器,并且表示下一个操作的步骤仅执行一次。另一方面,如果一个操作创建或修改了多个元素,则它会发出多个遍历器,这反过来会导致后续步骤多次执行,每个发射的遍历器执行一次。这可能导致数据库执行不必要的额外工作,在某些情况下,还可能导致创建不需要的额外顶点、边或属性值。

混合顶部和插页有关处理可能发出多个遍历器的操作的方法,请参见。

颠倒顶点

您可以使用顶点 ID 来确定是否存在相应的顶点。这是首选方法,因为 Neptune 针对围绕 ID 的高度并发用例优化 upserts。例如,以下查询使用给定顶点 ID 创建顶点(如果尚不存在),或者如果存在,则重用该顶点:

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

请注意,此查询以id()步骤结束。虽然严格来说并不是翻转顶点所必需的,但在 upsert 查询的末尾添加一个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 来确定在 upsert 操作期间是否存在顶点。这使Neptune 可以围绕 ID 优化突发事件。在高度并发的修改场景中,基于 ID 的 upsert 可能比基于属性的上升要有效得多。

串联顶点

你可以将顶点连接在一起以批量插入:

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()(或者outV()如果你曾经inE()找到边缘),而不是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的边缘上置点,使用边缘ID或fromto顶点 ID,而不是基于属性的上置点,这样 Neptune 就可以完全优化上升点。

检查tofrom点是否存在

注意创建新优势的步骤的构造: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顶点不存在,则以下 upsert 在不翻边的情况下完成:

// 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()

串联

如果您想将 edge upsert 链接在一起以创建批处理请求,则即使您已经知道边缘 ID,也必须以顶点查找开始每个 upsert。

如果您已经知道要向上置的边的 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 条边:

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

但是,有时候你可能想把插件和插件混在一起。例如,如果您使用边缘来表示动作或事件的实例,则可能就是这种情况。请求可能会使用 upserts 来确保所有必需的顶点都存在,然后使用插入来添加边。对于此类请求,请注意每次操作发出的潜在遍历器数量。

考虑以下示例,该示例将上移和插入混合在一起,将表示事件的边添加到图形中:

// 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 条已关注边和 3 条已访问边缘。但是,写入的查询会插入 8 个 EDGE:2 个已关注,6 个已访问。其原因是插入 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()

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

这种方法的缺点是查询现在没有得到完全优化,因为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属性的值过滤凸起的顶点。然后,查询将任何小于 3version 的顶点的值更新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()