前缀树后续层级的节点中,同一个字段值可能会出现多次,如果直接将重复的值存储在树节点上,会比较浪费空间,所以想到为每个字段建立一个全局唯一的映射关系,也就是字典,字典的 key 对应一个 number 类型的 id ,字典的 value 对应实际的值,树节点上只需要存储字典 key 就可以了,可以节省空间;
这个映射关系主要分为两类,一种是类数字类型,比如字段是日期,金额,数字编号等,可以唯一转换成一个 number 类型的字典 key ,这样做的好处是可以做范围查询,另一种是数据本身无法对应这种数字类型的 id ,是由字典去分配一个自增 id ,这种情况下这个字段是不支持范围查询的;
所以树节点上存储的实际上就是字典 key ,是 number 类型的,只要是 number 的子类就行,如果字典范围比较小,可以尽量映射成范围较小的数据结构洁身空间;
位图更新的成本比较高,因为涉及到多个索引,直接更新还可能会引入一致性问题,所以一般不会直接更新原始的数据。拿 es 来举例,删除或者更新会将原始的文档 id 标记为删除,然后新增,最后在段合并的时候将这些无效的数据清理掉。所以更新的次数多了,位图中就会出现很多无效的占用空间的数据,导致空间占用越来越高,所以还需要在业务低峰期定期重建索引;
前缀树和 B+树
都是多路查找树;
查询效率都比较稳定;
数据都是存在叶子节点上;
非叶子结点相当于是叶子结点的索引;
B+Tree 更适合磁盘存储,可以使用磁盘的 Block (空间局部性和磁盘预读);
Trie 不适用于存储在随机访问比较慢的介质上,当数据非常庞大并且存储在磁盘上时,数据结构的效率更多地取决于磁盘块访问的数量,而不是所有操作的总量。B+ Tree 在一个节点(可视为“数据块”)中包含许多记录,因此所需的块访问次数比 Trie 少得多。
summer-trie
项目地址
github https://github.com/chitucao/summer-trie.git
gitee https://gitee.com/chitucao/summer-trie.git
介绍
这是一个节点支持任意数据类型的前缀树,适用于大量列表数据的索引和压缩,不同于有限字符集前缀树实现(每个节点表达的状态是同一类型),主要是设计思想是将数据中多个不同类型的字段作为节点,组合成一颗前缀树,提高这些字段的检索性能;
目前是用于同程旅行盲盒机票、火车票的本地资源预筛选、数据分析以及校验场景下的结果缓存;
如果使用的过程中发现有 bug ,或者希望添加额外的功能,欢迎提交 PR;
适用于以下场景
关键功能和特性
几种核心的查询方式
核心概念
节点( Node )
字典( Dict )
属性( Property )
配置( Configuration )
快速开始
新建前缀树
1.作为索引,并查询原始数据
比如一个对象叫 TrainSourceDO ,是一个火车票资源地实体,希望为出发城市、抵达城市、价格、坐席类型建立索引,并且能够查询到数据本身,可以按照以下配置;
这里的 setPropertyMapper 是指定希望哪个字段作为要建立索引的字段,setDictKeyMapper 指定了索引字段和字典 key (树上实际存储的数据)映射关系;
这里的价格我们希望是能够支持范围和比较查询的,并且为了提高查询性能,所以指定了用 treeMap ;
最终前缀树节点的顺序是按照添加顺序来的,也就是出发城市作为第一个节点,原始数据作为尾部节点,所以是可以查询原始数据的;
2.用作索引,只查询索引
同上,只是尾部节点不再是数据了;
3.用于压缩
将所有字段都作为前缀树的节点,反射构建;
再利用 resultBuilder 可以将数据完整的查询出来,反射构建;
不带虚拟头节点的话,前缀树就是一个梯形结构,所以将选择性比较高的字段排在后面能够更好的压缩数据,可以先建立一次前缀树,拿到所有字段的字典值大小排序后再按照大小重新构建一次;
添加数据
直接 insert 就行,时间复杂度是 O(h),h 是前缀树高度,也就是建立索引的字段数量
删除数据
1.根据数据本身删除;
数据本身包含了所有建立索引的字段,所以删除效率较高,复杂度 O(h)
2.根据条件删除
尽量给出尽可能多的字段,可以提高删除的效率,给出首部的字段删除效率高一点,直接给出尾部的字段需要循环查找,删除效率低;
有一种特殊情况,如果希望只根据 id 删除,然后尽量提高删除的效率的话,可以先根据尾部节点的字典拿到该数据,然后再删除,时间复杂度 O(1)+O(h);
查询数据
1.按层查询
2.原始数据查询
3.树结构查询
树结构看起来比较直观,也可以指定要查询的多个字段组合成一颗树返回;
4.列表结构查询
同树结构查询,不过平铺成了列表,列表是最常用的数据返回方式;
是支持聚合的,比如例如出发城市、抵达城市、出发日期、价格构建一颗树,然后对价格进行聚合,就用字典树实现了低价日历,以下是伪代码;
5.字典值查询
常见的情况是作为下拉框的 options ;
或者希望通过 id 拿到原始数据的特殊情况,比直接从树上拿更快,O(1)的复杂度;
序列化和反序列化
使用的是 protobuf 序列化,对比原始 json 数据,序列化后的大小为原始 json 的 1/5 ,也适用于数据 dump 分析;
项目
性能分析和对比
树和位图
1.树
2.位图
前缀树和 B+树
前缀树的种类和变种
生产和个人实践
盲盒项目的背景和痛点
在盲盒的开盒流程中,会对本地资源预筛后再去请求实时的搜索接口,为了提高对这份资源的检索速度,用到了位图索引;
资源筛选的流程中,用户的出发地是确定的,日期,价格区间这些是随机变量,先随机日期,然后随机价格区间;
问题 1:开盒成功率低
日期随机可以有两种做法,一种是从配置上指定的固定日期区间随机,另一种是拿到这个出发地下有效的出发日期然后随机。早期一直是用的第一种做法,直到目的地盲盒的出现,资源的数量太少了,对应的有效日期也变少了,所以固定日期随机会导致开盒成功率降低很多。这个时候想到了有效日期随机,先拿到这个出发地下所有有效日期,再从这些有效日期中随机,可以提高开盒成功率;
如果用位图实现的话,为了得到指定出发地下的所有有效日期,需要过滤出所有原始数据,拿到日期去重后再随机,后面的随机价格区间同理,需要根据出发地和日期再过滤一次,这样查下来效率还是很低的;
所以想到了使用前缀树这种结构,如果按照出发地,日期,价格区间建立节点并依次查询,可以有效减少查询范围;
问题 2:资源预校验效率低
有些活动的库存数量是有限的,为了尽量提高开盒成功率,所以在用户实际开盒前会做一次资源预校验,根据用户的出发地和业务的一些策略配置,判断用户有没有有效的抵达地,如果用户没有有效抵达地资源,就提前拦截掉,避免无效的库存消耗;
如果每次请求方每次都过来查显然是不行的,所以限制请求方在场次开始前只查询一次,结果包含所有的有效出发地和抵达地,然后由请求方缓存起来。这个结果是和活动场次相关的,毕竟每个活动场次里面配置的产品和策略都不一样,用这个配置去全量的资源池中过滤出有效的出发抵达地返回;
随着活动场次越来越多和策略配置的精细化,即使是每个场次只查询一次,也会带来很大的查询压力以及可能的超时问题,所以想到了由服务提供方这边也建立一份缓存,定时刷新;
这份缓存就很适合用前缀树实现,按照活动、场次、产品、出发城市、出发日期、抵达城市构建;
有两个场景可以使用这份缓存:
1.查询条件指定场次,查询结果指定出发城市+抵达城市,就是资源预校验的结果;
2.场次确定了,那么从这个场次相关的预校验缓存里面去拿到的有效出发日期、有效价格区间、有效抵达地,会进一步减少数据范围,提高查询效率;
用于资源分析
实现低价日历