Hazelcast 无法在可选字段上使用 SqlPredicate 和 Index 正常工作



我们将复杂对象存储在Hazelcast地图中,并且需要不仅根据键而且根据这些复杂对象的内容搜索对象的可能性。为了不对性能造成太大影响,我们在这些搜索字词上使用索引。

我们还使用 spring-data-hazelcast,它提供了允许我们使用 findByAbcXyz() 类型语义查询的存储库。对于一些更复杂的查询,我们使用@Query注释(spring-data-hazelcast 在内部转换为 SqlPredicates)。

我们现在遇到了一个问题,在某些情况下,这些基于@Query的搜索方法没有返回任何值,即使我们可以验证搜索的对象确实存在于地图中。

我已经设法用核心榛子广播重现了这个问题(即不使用弹簧数据榛子)。

这是我们的对象结构:

BetriebspunktKey.java

public class BetriebspunktKey implements Serializable {
private Integer uicLand;
private Integer nummer;
public BetriebspunktKey(final Integer uicLand, final Integer nummer) {
this.uicLand = uicLand;
this.nummer = nummer;
}
public Integer getUicLand() {
return uicLand;
}
public Integer getNummer() {
return nummer;
}
}

Betriebspunkt.java

public class Betriebspunkt implements Serializable {
private BetriebspunktKey key;
private List<BetriebspunktVersion> versionen;
public Betriebspunkt(final BetriebspunktKey key, final List<BetriebspunktVersion> versionen) {
this.key = key;
this.versionen = versionen;
}
public BetriebspunktKey getKey() {
return key;
}
}

BetriebspunktVersion.java

public class BetriebspunktVersion implements Serializable {
private List<BetriebspunktKey> zusatzbetriebspunkte;
public BetriebspunktVersion(final List<BetriebspunktKey> zusatzbetriebspunkte) {
this.zusatzbetriebspunkte = zusatzbetriebspunkte;
}
}

在我的主文件中,我现在正在设置 hazelcast:

Config config = new Config();
final MapConfig mapConfig = config.getMapConfig("points");
mapConfig.addMapIndexConfig(new MapIndexConfig("versionen[any].zusatzbetriebspunkte[any].nummer", false));
HazelcastInstance instance = Hazelcast.newHazelcastInstance(config);
IMap<BetriebspunktKey, Betriebspunkt> map = instance.getMap("points");

我还在为以后的搜索条件做准备:

Predicate equalPredicate = Predicates.equal("versionen[any].zusatzbetriebspunkte[any].nummer", 53090);
Predicate sqlPredicate = new SqlPredicate("versionen[any].zusatzbetriebspunkte[any].nummer=53090");

接下来,我将创建两个对象,一个具有"完整深度"的信息,另一个不包含任何"zusatzbetriebspunkte":

final Betriebspunkt abc = new Betriebspunkt(
new BetriebspunktKey(80, 166),
Collections.singletonList(new BetriebspunktVersion(
Collections.singletonList(new BetriebspunktKey(80, 53090))
))
);
final Betriebspunkt def = new Betriebspunkt(
new BetriebspunktKey(83, 141),
Collections.singletonList(new BetriebspunktVersion(
Collections.emptyList()
))
);

在这里,事情变得有趣。如果我首先将"full"对象插入到映射中,则使用EqualPredicate和SqlPredicate进行搜索有效:

map.put(abc.getKey(), abc);
map.put(def.getKey(), def);
Collection<Betriebspunkt> equalResults = map.values(equalPredicate);
Collection<Betriebspunkt> sqlResults = map.values(sqlPredicate);
assertEquals(1, equalResults.size()); // contains "abc"
assertEquals(1, sqlResults.size());   // contains "abc"

但是,如果我以相反的顺序将对象插入到我的地图中(即首先是"部分"对象,然后是"完整"对象),只有 EqualPredicate 正常工作,无论映射的内容或搜索条件如何,SqlPredicate 都会返回一个空列表。

map.put(abc.getKey(), abc);
map.put(def.getKey(), def);
Collection<Betriebspunkt> equalResults = map.values(equalPredicate);
Collection<Betriebspunkt> sqlResults = map.values(sqlPredicate);
assertEquals(1, equalResults.size()); // contains "abc"
assertEquals(1, sqlResults.size());   // --> this fails, it returns en empty list

这种行为的原因是什么?它看起来像榛子代码中的一个错误。

失败的原因

经过大量调试,我找到了此问题的原因。原因确实可以在榛子代码中找到。

将值放入榛播地图时,调用DefaultRecordStore.putInternal。在此方法结束时调用DefaultRecordStore.saveIndex,它查找相应的索引,然后调用Indexes.saveEntryIndex。此方法遍历每个索引并调用InternalIndex.saveEntryIndex(或者更确切地说是其实现IndexImpl.saveEntryIndex。该方法的有趣部分是以下几行:

if (this.converter == null || this.converter == TypeConverters.NULL_CONVERTER) {
this.converter = entry.getConverter(this.attributeName);
}

当第一个元素放入映射时,每个索引都存储一个转换器类。查看QueryableEntry.getConverter可以解释会发生什么:

TypeConverter getConverter(String attributeName) {
Object attribute = this.getAttributeValue(attributeName);
if (attribute == null) {
return TypeConverters.NULL_CONVERTER;
} else {
AttributeType attributeType = this.extractAttributeType(attributeName, attribute);
return attributeType == null ? TypeConverters.IDENTITY_CONVERTER : attributeType.getConverter();
}
}

当第一次插入"full"对象时,extractAttributeType()将遵循我们的索引定义"versionen[any].zusatzbetriebspunkte[any].nummer"的"路径",并发现nummer是一个整数类型,因此TypeConverters.IntegerConverter将被返回并存储。

当第一次插入"部分"对象时,"zusatzbetriebspunkte[any]"是emtpy,extractAttributeType无法找出nummer的类型,因此它返回null,这意味着使用TypeConverters.IdentityConverter。

此外,每当插入"full"元素时,都会使用nummer作为键将条目写入索引映射,即索引映射的类型为 Map。

写到地图就这么多。现在让我们看看如何从地图中读取数据。当调用map.values(predicate)时,我们最终将得到包含一行QueryRunner.runUsingGlobalIndexSafely

Collection<QueryableEntry> entries = indexes.query(predicate);

这将在一些样板代码调用之后依次进行

Set<QueryableEntry> result = indexAwarePredicate.filter(queryContext);

对于我们的两个谓词,我们最终将得到如下所示IndexImpl.getRecords()

public Set<QueryableEntry> getRecords(Comparable attributeValue) {
long timestamp = this.stats.makeTimestamp();
if (this.converter == null) {
this.stats.onIndexHit(timestamp, 0L);
return new SingleResultSet((Map)null);
} else {
Set<QueryableEntry> result = this.indexStore.getRecords(this.convert(attributeValue));
this.stats.onIndexHit(timestamp, (long)result.size());
return result;
}
}

关键的调用是this.convert(attributeValue)attributeValue是谓词的value

如果我们比较两个谓词,我们可以看到 EqualPredicate 有两个成员:

attributeName = "versionen[any].zusatzbetriebspunkte[any].nummer"
value = {Integer} 53090

SqlPredicate 包含初始字符串(我们传递给它的构造函数),但在构造中也被解析并映射到内部 EqualPredicate(在计算谓词时最终使用并传递给上面的 getRecords():

sql = "versionen[any].zusatzbetriebspunkte[any].nummer=53090"
predicate = {EqualPredicate}
attributeName = "versionen[any].zusatzbetriebspunkte[any].nummer"
value = {String} "53090"

这就解释了为什么手动创建的 EqualPredicate 在这两种情况下都有效:它的值是一个整数。当传递给转换器时,无论是 IntegerConverter 还是 IdentityConverter 都没有关系,因为两者都会返回整数,然后可以将其用作索引映射中的键(使用整数作为键)。

但是,对于 SqlPredicate,该值是一个字符串。如果将其传递给 IntegerConverter,则会将其转换为相应的整数值,并且访问索引映射有效。如果将其传递给 IdentityConverter,则字符串由转换返回,并且尝试使用字符串访问索引映射将永远不会找到任何结果。

可能的解决方案

我们如何解决这个问题?我看到了几种可能性:

  • 在启动期间将"完全构建"的虚拟值插入到我们的映射中,以确保转换器正确初始化。虽然这有效,但它很丑陋且不易于维护
  • 避免使用 SqlPredicate,并使用基于整数的 EqualPredicate。在使用 spring-data-hazelcast 时,这不是一个选项,因为它总是将基于@Query的搜索转换为 SqlPredicates。我们当然可以直接使用 hazelcast 并绕过 spring-data wrapper,但虽然这可以工作,但这意味着有两种访问 hazelcast 的方法,这也不太易于维护。
  • 使用 Hazelcast 的 ValueExtractor 类。这是一个优雅的解决方案,既可以本地工作,也可以使用spring-data-hazelcast。我将概述它的样子:

首先,我们需要实现一个值提取器,它以适合我们的形式返回我们的 Betriebspunkt 的所有 zusatzbetriebspunkte

public class BetriebspunktExtractor extends ValueExtractor<Betriebspunkt, String> implements Serializable {
@Override
public void extract(final Betriebspunkt betriebspunkt, final String argument, final ValueCollector valueCollector) {
betriebspunkt.getVersionen().stream()
.map(BetriebspunktVersion::getZusatzbetriebspunkte)
.flatMap(List::stream)
.map(zbp -> zbp.getUicLand() + "_" + zbp.getNummer())
.forEach(valueCollector::addObject);
}
}

您会注意到,我不仅返回了nummer字段,而且还包括了uicLand字段,这是我们真正想要的,但无法使用"...[任何]..."表示法。当然,如果我们想要与上面概述的完全相同的行为,我们只能返回数字。

现在我们需要稍微修改一下我们的 hazelcast 配置:

Config config = new Config();
final MapConfig mapConfig = config.getMapConfig("points");
//mapConfig.addMapIndexConfig(new MapIndexConfig("versionen[any].zusatzbetriebspunkte[any].nummer", false));
mapConfig.addMapIndexConfig(new MapIndexConfig("zusatzbetriebspunkt", false));
mapConfig.addMapAttributeConfig(new MapAttributeConfig("zusatzbetriebspunkt", BetriebspunktExtractor.class.getName()));

您会注意到,"长"索引定义使用"...[任何]..."不再需要符号。

现在我们可以使用此"伪属性"来查询我们的值,并且对象以什么顺序添加到映射中并不重要:

Predicate keyPredicate = Predicates.equal("zusatzbetriebspunkt", "80_53090");
Collection<Betriebspunkt> keyResults = map.values(keyPredicate);
assertEquals(1, keyResults.size()); // always contains "abc"

在我们的 spring-data-hazelcast 存储库中,我们现在可以这样做:

@Query("zusatzbetriebspunkt=%d_%d")
List<StammdatenBetriebspunkt> findByZusatzbetriebspunkt(Integer uicLand, Integer nummer);

如果你不需要使用 spring-data-hazelcast,而不是将字符串返回给 ValueCollector,你可以直接返回 BetriebspunktKey,然后在谓词中使用它。这将是最干净的解决方案:

public class BetriebspunktExtractor extends ValueExtractor<Betriebspunkt, String> implements Serializable {
@Override
public void extract(final Betriebspunkt betriebspunkt, final String argument, final ValueCollector valueCollector) {
betriebspunkt.getVersionen().stream()
.map(BetriebspunktVersion::getZusatzbetriebspunkte)
.flatMap(List::stream)
//.map(zbp -> zbp.getUicLand() + "_" + zbp.getNummer())
.forEach(valueCollector::addObject);
}
}

然后

Predicate keyPredicate = Predicates.equal("zusatzbetriebspunkt", new BetriebspunktKey(80, 53090));

但是,要使其正常工作,BetriebspunktKey需要实现Comparable并且还必须提供自己的equalshashCode方法。

最新更新