跳到内容

索引以获得更好的性能

JanusGraph 支持两种不同类型的索引来加速查询处理:图索引以顶点为中心的索引(又名关系索引)。大多数图查询从通过其属性标识的顶点或边列表开始遍历。图索引使这些全局检索操作在大型图上高效。以顶点为中心的索引加速了图的实际遍历,特别是在遍历具有许多关联边的顶点时。

图索引

图索引是整个图的全局索引结构,它允许通过其属性对足够选择性的条件进行高效的顶点或边检索。例如,考虑以下查询

g.V().has('name', 'hercules')
g.E().has('reason', textContains('loves'))

第一个查询要求查找所有名为 hercules 的顶点。第二个查询要求查找所有属性 reason 包含单词 loves 的边。如果没有图索引,回答这些查询将需要对图中所有顶点或边进行全扫描,以查找符合给定条件的顶点或边,这对于大型图来说效率非常低且不可行。

JanusGraph 区分为两种类型的图索引:复合索引混合索引。复合索引非常快速高效,但仅限于对特定、预定义的属性键组合进行相等查找。混合索引可用于对任何索引键组合进行查找,并且除了相等之外,还支持多个条件谓词,具体取决于后端索引存储。

两种类型的索引都是通过 JanusGraph 管理系统和 JanusGraphManagement.buildIndex(String, Class) 返回的索引构建器创建的,其中第一个参数定义索引的名称,第二个参数指定要索引的元素类型(例如 Vertex.class)。图索引的名称必须是唯一的。针对新定义的属性键(即在与索引相同的管理事务中定义的属性键)构建的图索引立即可用。这同样适用于受限于在与索引相同的管理事务中创建的标签的图索引。针对已在使用中但不受限于新创建的标签的属性键构建的图索引需要执行重新索引过程,以确保索引包含所有以前添加的元素。在重新索引过程完成之前,索引将不可用。建议在与初始模式相同的事务中定义图索引。

注意

在没有索引的情况下,JanusGraph 将默认进行全图扫描以检索所需的顶点列表。虽然这会产生正确的结果集,但图扫描效率可能非常低,并导致生产环境中整体系统性能不佳。在 JanusGraph 的生产部署中启用 force-index 配置选项以禁止图扫描。

信息

有关索引状态的更多信息,请参阅索引生命周期文档

复合索引

复合索引存储在一个名为 graphIndex 的单独存储中。例如,如果您的存储后端是 Cassandra,您将在您的命名空间下看到一个名为 graphIndex 的表。复合索引通过一个或多个键的(固定)组合检索顶点或边。考虑以下复合索引定义。

graph.tx().rollback() //Never create new indexes while a transaction is active
mgmt = graph.openManagement()
name = mgmt.getPropertyKey('name')
age = mgmt.getPropertyKey('age')
mgmt.buildIndex('byNameComposite', Vertex.class).addKey(name).buildCompositeIndex()
mgmt.buildIndex('byNameAndAgeComposite', Vertex.class).addKey(name).addKey(age).buildCompositeIndex()
mgmt.commit()
//Wait for the index to become available
ManagementSystem.awaitGraphIndexStatus(graph, 'byNameComposite').call()
ManagementSystem.awaitGraphIndexStatus(graph, 'byNameAndAgeComposite').call()
//Reindex the existing data
mgmt = graph.openManagement()
mgmt.updateIndex(mgmt.getGraphIndex("byNameComposite"), SchemaAction.REINDEX).get()
mgmt.updateIndex(mgmt.getGraphIndex("byNameAndAgeComposite"), SchemaAction.REINDEX).get()
mgmt.commit()

首先,已定义两个属性键 nameage。接下来,构建一个仅针对 name 属性键的简单复合索引。JanusGraph 将使用此索引来回答以下查询。

g.V().has('name', 'hercules')

第二个复合图索引包含两个键。JanusGraph 将使用此索引来回答以下查询。

g.V().has('age', 30).has('name', 'hercules')

请注意,要使用此索引,复合图索引的所有键都必须在查询的相等条件中找到。例如,以下查询无法使用任一索引回答,因为它只包含 age 上的约束,而不包含 name

g.V().has('age', 30)

另请注意,复合图索引只能用于上述查询中的相等约束。以下查询将仅使用定义在 name 键上的简单复合索引来回答,因为 age 约束不是相等约束。

g.V().has('name', 'hercules').has('age', inside(20, 50))

复合索引不需要配置外部索引后端,并通过主存储后端支持。因此,复合索引修改通过与图修改相同的事务持久化,这意味着如果底层存储后端支持原子性和/或一致性,这些更改是原子和/或一致的。

注意

复合索引可以只包含一个或多个键。只包含一个键的复合索引有时被称为键索引。

索引唯一性

复合索引还可以用于强制图中的属性唯一性。如果复合图索引定义为 unique(),则对于与该索引的键关联的任何给定属性值串联,最多只能有一个顶点或边。例如,要强制整个图中名称唯一,将定义以下复合图索引。

graph.tx().rollback()  //Never create new indexes while a transaction is active
mgmt = graph.openManagement()
name = mgmt.getPropertyKey('name')
mgmt.buildIndex('byNameUnique', Vertex.class).addKey(name).unique().buildCompositeIndex()
mgmt.commit()
//Wait for the index to become available
ManagementSystem.awaitGraphIndexStatus(graph, 'byNameUnique').call()
//Reindex the existing data
mgmt = graph.openManagement()
mgmt.updateIndex(mgmt.getGraphIndex("byNameUnique"), SchemaAction.REINDEX).get()
mgmt.commit()

注意

要强制对最终一致的存储后端实现唯一性,必须将索引的一致性显式设置为启用锁定。

混合索引

混合索引通过任何先前添加的属性键组合检索顶点或边。混合索引比复合索引提供更大的灵活性,并支持除相等之外的附加条件谓词。另一方面,对于大多数相等查询,混合索引比复合索引慢。

与复合索引不同,混合索引需要配置一个索引后端,并使用该索引后端执行查找操作。JanusGraph 可以在单个安装中支持多个索引后端。每个索引后端都必须在 JanusGraph 配置中通过名称唯一标识,这被称为索引后端名称

graph.tx().rollback()  //Never create new indexes while a transaction is active
mgmt = graph.openManagement()
name = mgmt.getPropertyKey('name')
age = mgmt.getPropertyKey('age')
mgmt.buildIndex('nameAndAge', Vertex.class).addKey(name).addKey(age).buildMixedIndex("search")
mgmt.commit()
//Wait for the index to become available
ManagementSystem.awaitGraphIndexStatus(graph, 'nameAndAge').call()
//Reindex the existing data
mgmt = graph.openManagement()
mgmt.updateIndex(mgmt.getGraphIndex("nameAndAge"), SchemaAction.REINDEX).get()
mgmt.commit()

上面的示例定义了一个混合索引,其中包含属性键 nameage。定义引用了索引后端名称 search,以便 JanusGraph 知道它应该用于此特定索引的哪个配置的索引后端。在 buildMixedIndex 调用中指定的 search 参数必须与 JanusGraph 配置定义中的第二个子句匹配,如下所示:index.search.backend。如果索引名为 solrsearch,则配置定义将如下所示:index.solrsearch.backend。

上面指定的 mgmt.buildIndex 示例使用文本搜索作为其默认行为。一个明确将索引定义为文本索引的索引语句可以这样写

mgmt.buildIndex('nameAndAge',Vertex.class).addKey(name,Mapping.TEXT.asParameter()).addKey(age,Mapping.TEXT.asParameter()).buildMixedIndex("search")

有关文本和字符串搜索选项的更多信息,请参阅索引参数和全文搜索,有关每个后端如何处理文本与字符串搜索的更多详细信息,请参阅所使用的索引后端特定的文档部分。

虽然索引定义示例看起来与上面的复合索引类似,但它提供了更强大的查询支持,并且可以回答以下任何查询。

g.V().has('name', textContains('hercules')).has('age', inside(20, 50))
g.V().has('name', textContains('hercules'))
g.V().has('age', lt(50))
g.V().has('age', outside(20, 50))
g.V().has('age', lt(50).or(gte(60)))
g.V().or(__.has('name', textContains('hercules')), __.has('age', inside(20, 50)))

混合索引支持全文搜索、范围搜索、地理搜索等。有关特定索引后端支持的谓词列表,请参阅搜索谓词和数据类型

注意

与复合索引不同,混合索引不支持唯一性。

添加属性键

属性键可以添加到现有混合索引中,这允许后续查询在查询条件中包含此键。

graph.tx().rollback()  //Never create new indexes while a transaction is active
mgmt = graph.openManagement()
location = mgmt.makePropertyKey('location').dataType(Geoshape.class).make()
nameAndAge = mgmt.getGraphIndex('nameAndAge')
mgmt.addIndexKey(nameAndAge, location)
mgmt.commit()
//Previously created property keys already have the status ENABLED, but
//our newly created property key "location" needs to REGISTER so we wait for both statuses
ManagementSystem.awaitGraphIndexStatus(graph, 'nameAndAge').status(SchemaStatus.REGISTERED, SchemaStatus.ENABLED).call()
//Reindex the existing data
mgmt = graph.openManagement()
mgmt.updateIndex(mgmt.getGraphIndex("nameAndAge"), SchemaAction.REINDEX).get()
mgmt.commit()

要添加新定义的键,我们首先通过其名称从管理事务中检索现有索引,然后调用 addIndexKey 方法将键添加到此索引。

如果添加的键在同一管理事务中定义,它将立即可用于查询。如果属性键已在使用中,添加该键需要执行重新索引过程以确保索引包含所有以前添加的元素。在重新索引过程完成之前,该键将不可用于混合索引。

映射参数

当将属性键添加到混合索引时——无论是通过索引构建器还是 addIndexKey 方法——可以选择指定参数列表,以调整属性值如何映射到索引后端。有关每个索引后端支持的参数类型的完整列表,请参阅映射参数概述

排序

图查询结果的返回顺序可以使用 order().by() 指令定义。order().by() 方法需要两个参数

  • 用于排序结果的属性键的名称。结果将根据此属性键的顶点或边的值进行排序。

  • 排序顺序:升序 asc 或降序 desc

例如,查询 g.V().has('name', textContains('hercules')).order().by('age', desc).limit(10) 检索姓名中包含 hercules 的十个最年长个体。

使用 order().by() 时,需要注意以下几点

  • 复合图索引本身不支持对搜索结果排序。所有结果都将被检索,然后在内存中排序。对于大型结果集,这可能非常昂贵。

  • 混合索引原生且高效地支持排序。但是,order().by() 方法中使用的属性键必须已添加到混合索引中,以支持原生结果排序。这在 order().by() 键与查询键不同的情况下很重要。如果属性键不是索引的一部分,则排序需要将所有结果加载到内存中。

标签约束

在许多情况下,只索引具有特定标签的顶点或边是可取的。例如,人们可能只想通过名称索引神,而不是每个具有名称属性的顶点。在定义索引时,可以使用索引构建器的 indexOnly 方法将索引限制为特定的顶点或边标签。以下为属性键 name 创建一个复合索引,该索引仅索引标记为 god 的顶点。

graph.tx().rollback()  //Never create new indexes while a transaction is active
mgmt = graph.openManagement()
name = mgmt.getPropertyKey('name')
god = mgmt.getVertexLabel('god')
mgmt.buildIndex('byNameAndLabel', Vertex.class).addKey(name).indexOnly(god).buildCompositeIndex()
mgmt.commit()
//Wait for the index to become available
ManagementSystem.awaitGraphIndexStatus(graph, 'byNameAndLabel').call()
//You can check the indexOnly constraint built just now
mgmt = graph.openManagement()
mgmt.getIndexOnlyConstraint("byNameAndLabel")
//Reindex the existing data
mgmt.updateIndex(mgmt.getGraphIndex("byNameAndLabel"), SchemaAction.REINDEX).get()
mgmt.commit()

标签限制同样适用于混合索引。当具有标签限制的复合索引被定义为唯一时,唯一性约束仅适用于指定标签的顶点或边上的属性。

将顶点属性内联到复合索引中

将顶点属性内联到复合索引结构中可以显著提升性能和效率。

  1. 性能提升更快的查询:将顶点属性直接内联到索引中允许搜索引擎从索引本身检索所有相关数据。这意味着,查询不需要向数据存储发出额外的调用来获取完整的顶点信息,从而显著减少查找时间。

  2. 数据局部性在分布式存储中,内联属性可确保在单个分区或分片中存在更完整的数据。这减少了跨节点网络调用,并通过确保数据更接近正在处理的请求来提高整体查询性能。

  3. 索引成本与存储权衡虽然内联属性会增加索引的大小(可能导致更广泛的索引存储需求),但对于性能而言,这通常是值得的权衡,尤其是在查询速度至关重要时。这是为读密集型工作负载优化的系统中常见的模式。

用法

为了利用内联属性功能,JanusGraph 事务应设置为使用 .propertyPrefetching(false)

示例

//Build index
mgmt.buildIndex("composite", Vertex.class)
    .addKey(idKey)
    .addInlinePropertyKey(nameKey)
    .buildCompositeIndex()
mgmt.commit()

//Query
tx = graph.buildTransaction()
    .propertyPrefetching(false) //this is important
    .start()

tx.traversal().V().has("id", 100).next().value("name")

复合索引与混合索引

  1. 使用复合索引进行精确匹配索引检索。复合索引不需要配置或操作外部索引系统,并且通常比混合索引快得多。

    1. 作为例外,当查询约束的唯一值数量相对较少,或者预期一个值与图中的许多元素相关联(即选择性较低)时,请使用混合索引进行精确匹配。
  2. 使用混合索引进行数值范围、全文或地理空间索引。此外,使用混合索引可以加速 order().by() 查询。

  3. 复合索引要求所有字段都存在,而混合索引只需要至少一个字段存在。例如,假设您有一个包含 key1key2 的复合索引,以及一个包含 key1key3 的混合索引。如果您添加一个只包含属性 key1 的顶点,那么 JanusGraph 将创建一个新的混合索引条目,但不会创建复合索引条目。

以顶点为中心的索引

以顶点为中心的索引,也称为关系索引,是为每个顶点单独构建的本地索引结构。它们与边和属性一起存储在 edgeStore 中。以顶点为中心的索引有两种类型:边索引和属性索引。

边索引

在大型图中,顶点可能具有数千条关联边。遍历这些顶点可能会非常慢,因为必须检索大量的关联边,然后在内存中进行过滤以匹配遍历的条件。以顶点为中心的索引可以通过使用本地化索引结构仅检索需要遍历的边来加速此类遍历。

假设赫拉克勒斯除了在入门级众神之图中捕获的三个怪物之外,还与数百个怪物搏斗过。如果没有以顶点为中心的索引,查询那些在时间点 1020 之间搏斗过的怪物将需要检索所有 battled 边,尽管只有少数匹配的边。

h = g.V().has('name', 'hercules').next()
g.V(h).outE('battled').has('time', inside(10, 20)).inV()

按时间构建以顶点为中心的索引可以加快此类遍历查询。请注意,这个初始索引示例已经存在于“众神之图”中,名称为 edges。因此,运行以下步骤将导致唯一性约束错误。

graph.tx().rollback()  //Never create new indexes while a transaction is active
mgmt = graph.openManagement()
time = mgmt.getPropertyKey('time')
battled = mgmt.getEdgeLabel('battled')
mgmt.buildEdgeIndex(battled, 'battlesByTime', Direction.BOTH, Order.desc, time)
mgmt.commit()
//Wait for the index to become available
ManagementSystem.awaitRelationIndexStatus(graph, 'battlesByTime', 'battled').call()
//Reindex the existing data
mgmt = graph.openManagement()
mgmt.updateIndex(mgmt.getRelationIndex(battled, "battlesByTime"), SchemaAction.REINDEX).get()
mgmt.commit()

此示例构建了一个以顶点为中心的索引,该索引以时间降序索引 battled 边的两个方向。以顶点为中心的索引是针对特定的边标签构建的,它是索引构建方法 JanusGraphManagement.buildEdgeIndex() 的第一个参数。该索引仅适用于此标签的边——在上面的示例中为 battled。第二个参数是索引的唯一名称。第三个参数是构建索引的边方向。该索引仅适用于沿此方向的边遍历。在此示例中,以顶点为中心的索引在两个方向上构建,这意味着沿 battled 边的受时间限制的遍历可以由该索引在 INOUT 两个方向上提供服务。JanusGraph 将在 battled 边的入顶点和出顶点上都维护一个以顶点为中心的索引。或者,可以将索引定义为仅适用于 OUT 方向,这将加速从赫拉克勒斯到怪物的遍历,但不能反向。这只需要维护一个索引,从而将索引维护和存储成本减半。最后两个参数是索引的排序顺序和要索引的属性键列表。排序顺序是可选的,默认为升序(即 Order.ASC)。属性键列表必须非空,并定义用于索引给定标签的边的键。以顶点为中心的索引可以定义为具有多个键。

graph.tx().rollback()  //Never create new indexes while a transaction is active
mgmt = graph.openManagement()
time = mgmt.getPropertyKey('time')
rating = mgmt.makePropertyKey('rating').dataType(Double.class).make()
battled = mgmt.getEdgeLabel('battled')
mgmt.buildEdgeIndex(battled, 'battlesByRatingAndTime', Direction.OUT, Order.desc, rating, time)
mgmt.commit()
//Wait for the index to become available
ManagementSystem.awaitRelationIndexStatus(graph, 'battlesByRatingAndTime', 'battled').call()
//Reindex the existing data
mgmt = graph.openManagement()
mgmt.updateIndex(mgmt.getRelationIndex(battled, 'battlesByRatingAndTime'), SchemaAction.REINDEX).get()
mgmt.commit()

此示例通过在 battled 边上添加 rating 属性来扩展模式,并构建一个以顶点为中心的索引,该索引以评分和时间降序索引 battled 边的出站方向。请注意,指定属性键的顺序很重要,因为以顶点为中心的索引是前缀索引。这意味着,battled 边首先按 rating 索引,然后按 time 索引。

h = g.V().has('name', 'hercules').next()
g.V(h).outE('battled').property('rating', 5.0) //Add some rating properties
g.V(h).outE('battled').has('rating', gt(3.0)).inV()
g.V(h).outE('battled').has('rating', 5.0).has('time', inside(10, 50)).inV()
g.V(h).outE('battled').has('time', inside(10, 50)).inV()

因此,battlesByRatingAndTime 索引可以加速前两个查询,但不能加速第三个查询。

属性索引

类似于边索引,可以构建属性索引以基于关联的元属性高效地遍历属性。以下玩具示例说明了属性索引的用法。

graph = JanusGraphFactory.open("inmemory")
mgmt = graph.openManagement()
timestamp = mgmt.makePropertyKey("timestamp").dataType(Integer.class).make()
amount = mgmt.makePropertyKey("amount").dataType(Integer.class).cardinality(Cardinality.LIST).make()
mgmt.buildPropertyIndex(amount, 'amountByTime', Order.desc, timestamp)
mgmt.commit()

bob = graph.addVertex()
bob.property("amount", 100, "timestamp", 1600000000)
bob.property("amount", 200, "timestamp", 1500000000)
bob.property("amount", -150, "timestamp", 1550000000)
graph.tx().commit()

g = graph.traversal()
g.V(bob).properties("amount").has("timestamp", P.gt(1500000000))

在上面的示例中,我们建模了 Bob 进行的许多交易。amount 是一个顶点属性,记录每次交易涉及的金额,而 timestampamount 的元属性,记录特定记录发生的时间。通过创建属性索引,我们可以根据时间戳高效地检索交易金额。或者,我们也可以将交易建模为边,那么 amounttimestamp 都将是边属性,我们可以使用边索引来加速查询。

可以为相同的边标签构建多个以顶点为中心的索引,以支持不同的约束遍历。JanusGraph 的查询优化器尝试为任何给定遍历选择最有效的索引。以顶点为中心的索引只支持相等和范围/区间约束。

注意

以顶点为中心的索引中使用的属性键必须具有明确定义的数据类型(即不是Object.class),并且支持原生排序顺序。这意味着它们不仅必须实现 Comparable,而且它们的序列化器必须实现 OrderPreservingSerializer。目前支持的类型是 BooleanUUIDByteFloatLongStringIntegerDateDoubleCharacterShort

如果以顶点为中心的索引是针对在同一管理事务中定义的边标签或至少一个属性键构建的,则该索引将立即可用于查询。如果边标签和所有索引属性键都已在使用中,则针对其构建以顶点为中心的索引需要执行重新索引过程,以确保索引包含所有以前添加的边。在重新索引过程完成之前,该索引将不可用。

注意

JanusGraph 自动为每个边标签和属性键构建以顶点为中心的索引。这意味着,即使有数千条关联的 battled 边,诸如 g.V(h).out('mother')g.V(h).values('age') 等查询也能通过本地索引高效回答。

以顶点为中心的索引无法加速不受约束的遍历,这些遍历需要遍历特定标签的所有关联边。随着关联边数量的增加,这些遍历将变得更慢。通常,此类遍历可以重写为受约束的遍历,利用以顶点为中心的索引来确保在大规模情况下的可接受性能。

在相邻顶点 ID 上使用以顶点为中心的索引

在某些情况下,根据相邻顶点的属性查找边是很重要的。假设我们想知道赫拉克勒斯是否与刻耳柏洛斯搏斗过。

h = g.V().has('name', 'hercules').next()
g.V(h).out('battled').has('name', 'cerberus').hasNext()

像这样的查询无法使用以顶点为中心的索引,因为它过滤的是顶点属性而不是边属性。但是,通过重构查询,我们可以实现这一点。由于两个顶点都是已知的,因此可以使用顶点 ID 来选择边。

h = g.V().has('name', 'hercules').next()
c = g.V().has('name', 'cerberus').next()

与名称“Cebereus”(相邻顶点的属性)不同,该顶点的 ID 已经保存在连接边本身中。因此,如果赫拉克勒斯与许多对手搏斗过,此查询运行速度会快得多。

g.V(h).outE('battled').where(inV().is(c)).hasNext()

... 或者更简洁地说

g.V(h).out('battled').is(c).limit(1).hasNext()

假设 name 属性上有一个全局索引,这大大提高了性能,因为不再需要获取每个相邻顶点。此外,不需要显式构建和维护相邻顶点 ID 上的以顶点为中心的索引。由于数据模型,存储后端已经提供了通过其相邻顶点 ID 高效访问边的功能。

有序遍历

以下查询指定了遍历关联边的顺序。使用 localLimit 命令检索每个被遍历顶点的一个子集边(按给定顺序)。

h = g.V().has('name', 'hercules').next()
g.V(h).local(outE('battled').order().by('time', desc).limit(10)).inV().values('name')
g.V(h).local(outE('battled').has('rating', 5.0).order().by('time', desc).limit(10)).values('place')

第一个查询要求赫拉克勒斯最近搏斗过的 10 个怪物的名字。第二个查询要求赫拉克勒斯最近评分为 5 星的 10 次搏斗的地点。在这两种情况下,查询都受限于按属性键的顺序和返回元素数量的限制。

如果排序键与索引的键匹配,并且请求的顺序(即升序或降序)与索引定义的顺序相同,则以顶点为中心的索引也可以高效地回答此类查询。battlesByTime 索引将用于回答第一个查询,而 battlesByRatingAndTime 适用于第二个查询。请注意,battlesByRatingAndTime 索引不能用于回答第一个查询,因为索引中的第二个键要生效,必须存在 rating 上的相等约束。