github https://github.com/chitucao/summer-trie.git
gitee https://gitee.com/chitucao/summer-trie.git
这是一个节点支持任意数据类型的前缀树,适用于大量列表数据的索引和压缩,不同于有限字符集前缀树实现(每个节点表达的状态是同一类型),主要是设计思想是将数据中多个不同类型的字段作为节点,组合成一颗前缀树,提高这些字段的检索性能;
目前是用于同程旅行盲盒机票、火车票的本地资源预筛选、数据分析以及校验场景下的结果缓存;
如果使用的过程中发现有 bug ,或者希望添加额外的功能,欢迎提交 PR;
比如一个对象叫 TrainSourceDO ,是一个火车票资源地实体,希望为出发城市、抵达城市、价格、坐席类型建立索引,并且能够查询到数据本身,可以按照以下配置;
这里的 setPropertyMapper 是指定希望哪个字段作为要建立索引的字段,setDictKeyMapper 指定了索引字段和字典 key (树上实际存储的数据)映射关系;
这里的价格我们希望是能够支持范围和比较查询的,并且为了提高查询性能,所以指定了用 treeMap ;
最终前缀树节点的顺序是按照添加顺序来的,也就是出发城市作为第一个节点,原始数据作为尾部节点,所以是可以查询原始数据的;
Configuration configuration = new Configuration();
// 出发城市
CustomizedProperty<TrainSourceDO, Integer> depCityIdProperty = new CustomizedProperty<>("depCityId");
depCityIdProperty.setPropertyMapper(TrainSourceDO::getDepartureCityId);
depCityIdProperty.setDictKeyMapper(r -> r);
configuration.addProperty(depCityIdProperty);
// 抵达城市
CustomizedProperty<TrainSourceDO, Integer> arrCityIdProperty = new CustomizedProperty<>("arrCityId");
arrCityIdProperty.setPropertyMapper(TrainSourceDO::getArrivalCityId);
arrCityIdProperty.setDictKeyMapper(r -> r);
configuration.addProperty(arrCityIdProperty);
// 价格
CustomizedProperty<TrainSourceDO, Integer> arrDistrictIdProperty = new CustomizedProperty<>("price", NodeType.TREE_MAP);
arrDistrictIdProperty.setPropertyMapper(t -> ((Double) t.getMinRealPrice()).intValue());
arrDistrictIdProperty.setDictKeyMapper(r -> r);
configuration.addProperty(arrDistrictIdProperty);
// 坐席类型
SimpleProperty<TrainSourceDO, String> seatClassProperty = new SimpleProperty<>("seatClass", DictKeyType.BYTE);
seatClassProperty.setPropertyMapper(TrainSourceDO::getSeatClass);
configuration.addProperty(seatClassProperty);
// 数据
CustomizedProperty<TrainSourceDO, TrainSourceDO> dataProperty = new CustomizedProperty<>("data");
dataProperty.setPropertyMapper(Function.identity());
dataProperty.setDictKeyMapper(TrainSourceDO::getId);
configuration.addProperty(dataProperty);
// 新建前缀树
MapTrie<TrainSourceDO> trie = new MapTrie<>(configuration);
同上,只是尾部节点不再是数据了;
Configuration configuration = new Configuration();
// 出发城市
CustomizedProperty<TrainSourceDO, Integer> depCityIdProperty = new CustomizedProperty<>("depCityId");
depCityIdProperty.setPropertyMapper(TrainSourceDO::getDepartureCityId);
depCityIdProperty.setDictKeyMapper(r -> r);
configuration.addProperty(depCityIdProperty);
// 抵达城市
CustomizedProperty<TrainSourceDO, Integer> arrCityIdProperty = new CustomizedProperty<>("arrCityId");
arrCityIdProperty.setPropertyMapper(TrainSourceDO::getArrivalCityId);
arrCityIdProperty.setDictKeyMapper(r -> r);
configuration.addProperty(arrCityIdProperty);
// 新建前缀树
MapTrie<TrainSourceDO> trie = new MapTrie<>(configuration);
将所有字段都作为前缀树的节点,反射构建;
Configuration configuration = new Configuration();
Field[] fields = ReflectUtil.getFields(TrainSourceDO.class);
for (Field field : fields) {
if (Number.class.isAssignableFrom(field.getType())) {
CustomizedProperty customizedProperty = new CustomizedProperty<>(field.getName());
customizedProperty.setPropertyMapper(e -> ReflectUtil.getFieldValue(e, field));
customizedProperty.setDictKeyMapper(r -> r);
configuration.addProperty(customizedProperty);
} else {
SimpleProperty simpleProperty = new SimpleProperty<>(field.getName());
simpleProperty.setPropertyMapper(e -> ReflectUtil.getFieldValue(e, field));
configuration.addProperty(simpleProperty);
}
}
// 新建前缀树
MapTrie<TrainSourceDO> trie = new MapTrie<>(configuration);
再利用 resultBuilder 可以将数据完整的查询出来,反射构建;
ResultBuilder<TrainSourceDO> resultBuilder = new ResultBuilder<>(TrainSourceDO::new);
Field[] fields = ReflectUtil.getFields(TrainSourceDO.class);
for (Field field : fields) {
resultBuilder.addSetter(field.getName(), (t, r) -> ReflectUtil.setFieldValue(t, field, r));
}
return resultBuilder;
不带虚拟头节点的话,前缀树就是一个梯形结构,所以将选择性比较高的字段排在后面能够更好的压缩数据,可以先建立一次前缀树,拿到所有字段的字典值大小排序后再按照大小重新构建一次;
// 拿到每个字段的字典值大小
Map<String, Integer> dictSizes = trie1.dictSizes();
// 按照字段的大小排序
CollectionUtil.sort(configuration2.getProperties(), Comparator.comparing(e -> dictSizes.get(e.name())));
// 重新构建
MapTrie<TrainSourceDO> trie2 = new MapTrie<>(configuration2);
for (TrainSourceDO data : dataList) {
trie2.insert(data);
}
直接 insert 就行,时间复杂度是 O(h),h 是前缀树高度,也就是建立索引的字段数量
List<TrainSourceDO> dataList = getDataList("train_resource_3000.json");
for (TrainSourceDO data : dataList) {
trie.insert(data);
}
1.根据数据本身删除;
数据本身包含了所有建立索引的字段,所以删除效率较高,复杂度 O(h)
List<TrainSourceDO> dataToErase = RandomUtil.randomEles(dataList, 10);
for (TrainSourceDO data : dataToErase) {
trie.erase(data);
}
2.根据条件删除
尽量给出尽可能多的字段,可以提高删除的效率,给出首部的字段删除效率高一点,直接给出尾部的字段需要循环查找,删除效率低;
Criteria criteria = new Criteria().addCriterion(Condition.BETWEEN, 0, 1005, "depCityId");
trie.erase(criteria);
有一种特殊情况,如果希望只根据 id 删除,然后尽量提高删除的效率的话,可以先根据尾部节点的字典拿到该数据,然后再删除,时间复杂度 O(1)+O(h);
// 数据
CustomizedProperty<TrainSourceDO, TrainSourceDO> dataProperty = new CustomizedProperty<>("data", NodeType.TREE_MAP);
dataProperty.setPropertyMapper(Function.identity());
dataProperty.setDictKeyMapper(TrainSourceDO::getId);
configuration.addProperty(dataProperty);
// 先根据字典拿到数据,然后再删除
long id = 143859138L;
TrainSourceDO eraseData = (TrainSourceDO) trie.dictValues("data", id).iterator().next();
trie.erase(eraseData);
List<Integer> queryDepCityList = Lists.newArrayList(144, 145, 146, 900);
List<Integer> indexList1 = dataList.stream().filter(e -> queryDepCityList.contains(e.getDepartureCityId())).map(TrainSourceDO::getArrivalCityId).distinct().sorted()
.collect(Collectors.toList());
Criteria criteria = new Criteria();
criteria.addCriterion(Condition.IN, queryDepCityList, "depCityId");
List<Integer> indexList2 = trie.<Integer> propertySearch(criteria, "arrCityId").stream().sorted().collect(Collectors.toList());
Criteria criteria = new Criteria();
criteria.addCriterion(Condition.IN, queryDepCityList, "depCityId");
List<TrainSourceDO> dataList2 = trie.dataSearch(criteria).stream().sorted(Comparator.comparing(TrainSourceDO::getId)).collect(Collectors.toList());
树结构看起来比较直观,也可以指定要查询的多个字段组合成一颗树返回;
Criteria criteria = new Criteria();
criteria.addCriterion(Condition.IN, queryDepCityList, "depCityId");
Aggregations aggregations = new Aggregations();
Object result = trie.treeSearch(criteria, aggregations, "depCityId", "arrCityId", "id");
同树结构查询,不过平铺成了列表,列表是最常用的数据返回方式;
是支持聚合的,比如例如出发城市、抵达城市、出发日期、价格构建一颗树,然后对价格进行聚合,就用字典树实现了低价日历,以下是伪代码;
Trie<FlightUnitVO> trie = inlandFlightTrieIndexManager.getTrie();
Criteria criteria = buildCriteriaByQueryCondition(request.getQueryCondition());
Aggregations aggregations = new Aggregations();
aggregations.addAggregation(Aggregation.MIN, FlightTrieIndexNames.INDEX_PRICE);
ResultBuilder<ListSearchResponse> resultBuilder = new ResultBuilder<>(ListSearchResponse::new);
resultBuilder.addSetter(FlightTrieIndexNames.INDEX_DEP_CITY_CODE, ListSearchResponse::setDepCityCode);
resultBuilder.addSetter(FlightTrieIndexNames.INDEX_ARR_CITY_CODE, ListSearchResponse::setArrCityCode);
resultBuilder.addSetter(FlightTrieIndexNames.INDEX_DEP_DATE, ListSearchResponse::setDate);
resultBuilder.addSetter(FlightTrieIndexNames.INDEX_PRICE, ListSearchResponse::setMinPrice);
List<ListSearchResponse> result = trie.listSearch(criteria, aggregations, resultBuilder);
return R.ok(result);
常见的情况是作为下拉框的 options ;
List<Integer> dataList2 = trie.<Integer>dictValues("depCityId").stream().sorted().collect(Collectors.toList());
或者希望通过 id 拿到原始数据的特殊情况,比直接从树上拿更快,O(1)的复杂度;
long id = 143859138L;
TrainSourceDO eraseData = (TrainSourceDO) trie.dictValues("data", id).iterator().next();
使用的是 protobuf 序列化,对比原始 json 数据,序列化后的大小为原始 json 的 1/5 ,也适用于数据 dump 分析;
// 序列化
MapTrie<TrainSourceDO> trie1 = new MapTrie<>(buildConfiguration3());
for (TrainSourceDO data : dataList) {
trie1.insert(data);
}
File dumpFile = new File(RESOUCE_FOLDER + "train_resource_dump.dat");
if (dumpFile.exists()) {
dumpFile.delete();
}
FileUtil.writeBytes(trie1.serialize(), dumpFile);
// 反序列化
MapTrie<TrainSourceDO> trie2 = new MapTrie<>(buildConfiguration3());
trie2.deserialize(FileUtil.readBytes(dumpFile));
项目
在盲盒的开盒流程中,会对本地资源预筛后再去请求实时的搜索接口,为了提高对这份资源的检索速度,用到了位图索引;
资源筛选的流程中,用户的出发地是确定的,日期,价格区间这些是随机变量,先随机日期,然后随机价格区间;
日期随机可以有两种做法,一种是从配置上指定的固定日期区间随机,另一种是拿到这个出发地下有效的出发日期然后随机。早期一直是用的第一种做法,直到目的地盲盒的出现,资源的数量太少了,对应的有效日期也变少了,所以固定日期随机会导致开盒成功率降低很多。这个时候想到了有效日期随机,先拿到这个出发地下所有有效日期,再从这些有效日期中随机,可以提高开盒成功率;
如果用位图实现的话,为了得到指定出发地下的所有有效日期,需要过滤出所有原始数据,拿到日期去重后再随机,后面的随机价格区间同理,需要根据出发地和日期再过滤一次,这样查下来效率还是很低的;
所以想到了使用前缀树这种结构,如果按照出发地,日期,价格区间建立节点并依次查询,可以有效减少查询范围;
有些活动的库存数量是有限的,为了尽量提高开盒成功率,所以在用户实际开盒前会做一次资源预校验,根据用户的出发地和业务的一些策略配置,判断用户有没有有效的抵达地,如果用户没有有效抵达地资源,就提前拦截掉,避免无效的库存消耗;
如果每次请求方每次都过来查显然是不行的,所以限制请求方在场次开始前只查询一次,结果包含所有的有效出发地和抵达地,然后由请求方缓存起来。这个结果是和活动场次相关的,毕竟每个活动场次里面配置的产品和策略都不一样,用这个配置去全量的资源池中过滤出有效的出发抵达地返回;
随着活动场次越来越多和策略配置的精细化,即使是每个场次只查询一次,也会带来很大的查询压力以及可能的超时问题,所以想到了由服务提供方这边也建立一份缓存,定时刷新;
这份缓存就很适合用前缀树实现,按照活动、场次、产品、出发城市、出发日期、抵达城市构建;
有两个场景可以使用这份缓存:
1.查询条件指定场次,查询结果指定出发城市+抵达城市,就是资源预校验的结果;
2.场次确定了,那么从这个场次相关的预校验缓存里面去拿到的有效出发日期、有效价格区间、有效抵达地,会进一步减少数据范围,提高查询效率;
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.