超大规模检索中的索引设计

超大规模检索中的索引设计

一 问题背景

1.1 业务背景

精准广告场景中,人群定向的常用方法是:根据各种不同的规则,将每一个用户(User)打上丰富的标签。与此同时,广告主(Member)在根据规则圈选投放人群时,系统也会将广告(Ad)打上各种的标签。当一个Ad和一个User被打上同一个标签(Tag)时,就表示该Ad圈定了这个User,即该Ad会参与对该User的展现竞价。
本次优化的难点出现在一个特定业务场景下,我们需要明确的知道一个Ad圈选了哪些User,而我们唯一知道的就是这个Ad被打上的一组Tag(Tag List)。此时,我们需要一个存储 + 索引的技术产品,让我们能够快速地通过Tag查询到每个Tag下挂载的所有User(User List)的id。此外,由于广告业务对实时性要求很高,系统还要保证User的实时行为能够快速在投放系统中生效,当一个User的某个行为导致该User身上被追加 或 删除某些Tag时,所有受到影响Tag下挂载的User List都会发生变化。
image.png
业务中的数据规模:

  • User总数大约4亿
  • Tag总数约100w个
  • 平均每个Tag下会挂载100w个User
  • 平均每秒钟会对2000个tag进行查询
  • 平均每秒钟会有5w个User会触发Tag更新,每次更新平均会更新10个Tag

可见,不管是存储规模、每秒检索结果的数据量 还是 每秒需要进行更新的数据量都非常庞大。这里需要特别说明的是,User的id信息是一个uint64数字,后面的优化会利用这个特性。

1.2 技术选型引入的问题

我们需要一个强大的检索引擎来支持数据的检索 + 更新,这个检索引擎需要支持“tag->多个数值型userid”的数据存储结构:

  • 查询时,通过多个tag查询得到tag下挂载的所有userid
  • 更新时,通过变更的tag 以及 需要add 或 delete 的userid就可以完成变更

但是,在梳理技术选型时,我们发现没有任何一个现有的技术选型可以支持这种数据结构。现有的检索引擎大都是基于“文档(doc)搜索”而开发的,往往将数据结构抽象为“doc id->doc”范式的正排查找 和 “关键词(keyword)-> doc id list”范式的倒排链查找。所有的倒排索引都是基于正排数据构建的。
举个例子,必须先有如下文档

doc id doc内容
1 我 在 吃 饭
2 羊 在 吃 草
3 你 在 吃 饭

才可能有如下倒排索引

关键词 doc id list
1
1,2,3
1,2,3
1,3
2
2
3

可以看出,我们期望的是类似于倒排表的这种数据结构,但是为了产出这样一种数据结构,我们不得不建立一张我们根本不需要的正排数据表。

1.3 索引结构设计中的问题

结合业务背景,为了获得一个“tag->多个数值型userid”的链式结构,我们不得不建立一张以userid为docid,以该userid下所有tag为doc内容的正排结构。

userid tag信息
1 女装 男装 宠物
2 男装  电子 影音
3 书籍 影音 女装

然后通过建立倒排的方式得到我们需要的表结构

tag信息 userid
女装 1,3
男装 1,2
宠物 1
电子 2
影音 2,3
书籍 3

这样一来,我们就可以通过tag去检索User List了。
看上去,我们通过一些冗余存储解决了数据结构的问题,但是实际上我们只满足了查询时的要求,还没有考虑更新。这样的索引结构有一个严重的问题:倒排表是正排表的辅助表,因此必须通过更新正排表来触发倒排表的更新;而正排表中的tag信息作为“doc内容”,又必须是整体更新的。
举个例子,假如此时user id为1的User新增了一个“电子”的标签,我们必须通过将正排表中user id为1的一行整体更新为

1 女装 男装 宠物 电子

以此来触发倒排表中“电子”一行变更为

电子 1,2

这不满足我们在本小节开始时提到的“更新时,通过变更的tag 以及 需要add 或 delete 的userid就可以完成变更”。在这里,若我们想更新tag下的User List,我们必须提供该tag下的所有user id。由于tag下有百万量级的user id,这种更新方式带来的网络带宽开销、cpu开销是巨大的,以至于无法接受。

二 技术难点分析

2.1 索引结构初步设计

本节将介绍一种巧妙的索引结构设计方式,可以在技术选型受限的情况下,通过牺牲部分存储来实现“tag->多个数值型userid”的链式结构。虽然这种设计方式没有真正的解决如此大规模数据下的检索问题,但对一些中小型场景仍有借鉴意义。
当我们遇到1.3节中提到的问题时,一种可行的思路是“拆分正排表doc粒度”。尝试将1.3节中的正排表拆分成如下结构

主键(userid_tag) userid tag
1_女装 1 女装
1_男装 1 男装
1_宠物 1 宠物
2_男装 2 男装
2_电子 2 电子
2_影音 2 影音
3_书籍 3 书籍
3_影音 3 影音
3_女装 3 女装

然后对tag建立倒排索引(忽略主键)

tag userid
女装 1,3
男装 1,2
宠物 1
电子 2
影音 2,3
书籍 3

可以发现,建立得到倒排索引与1.3节中的一致,满足查询需求。
再来看更新,当我们需要给user id为1的User新增了一个“电子”标签,只需要在正排表中插入一行

1_电子 1 电子

倒排表中“电子”一行就会变为

电子 1,2

这样一来,就同时解决了检索和更新的问题。

2.2 数据规模的难题

2.1节中描述的拆表的方式,本质上是将“tag->多个数值型userid”拆成了多个“user_tag插入记录”,然后再建立倒排索引。前面1.1节中提到:有100w个不同的tag,平均每个tag下有100w个userid,计算下来,user_tag插入记录的记录数量在1万亿左右。虽然每一个记录占用的存储空间非常小(64B左右),但是由于检索内核出于自身设计的一些考虑,往往会设置单机存储doc数量的上限阈值。如此庞大的记录数量超出了上限阈值,即使是采用集群式存储,由于单阈值的存在,也会导致大量的机器资源开销与浪费。
举个例子,假设单机最多存20亿个doc,采用水平分列的方式,需要500列才能存下1万亿条记录,若考虑到主备容灾,则需要至少1000台机器。而实际上,每台机器的磁盘都没有占满,仅使用了128GB(20亿 * 64B = 128GB)。
为了解决这个问题,先要梳理下究竟是什么原因导致了doc数量如此之大。抛开我们无法决定的业务背景,导致doc数量膨胀的主因是大量的tag下有大量的userid,拆分之后出现了惊人数量的tag_userid主键。但是,不同的下挂载的userid之间是有大量重复的,如果我们能消除这种重复现象,就能在很大程度上减轻膨胀。
image.png
一个很容易地想到的方法是分组。由于业务上要求我们用tag进行检索,因此我们只能将userid分组。分组之后,每个tag下挂载的是userGroupId,一个UserGroup下可以再次挂载很多userid。这样,doc主键就变成了tag_userGroupId,预期数量可以显著减少(注意“预期”这个词,将在2.4节中说明)。
image.png
但还有一个问题需要解决,那就是虽然Tag0和Tag1下面都挂了userGroup1,但是Tag0下挂载的是user1、user9,Tag1下挂载的是user5、user7。所以,不同tag下挂载同一个userGroup时,group中包含的user是不一样的。由于我们的主键是tag_userGroupId,这个问题可以很好的得到解决。
还是接2.1节中的例子,假设user1和user2属于group1,user3属于group2,那么如下建立正排表。

主键(groupid_tag) userid tag
group1_女装 1 女装
group1_男装 1,2 男装
group1_宠物 1 宠物
group1_电子 2 电子
group1_影音 2 影音
group2_书籍 3 书籍
group2_影音 3 影音
group2_女装 3 女装

倒排表对应的是如下结构:

tag 主键(groupid_tag) userid(合并后)
女装 group1_女装,group2_女装 1,3
男装 group1_男装 1,2
宠物 group1_宠物 1
电子 group1_电子 2
影音 group1_影音,group2_影音 2,3
书籍 group2_书籍 3

可以看出,检索出的userid结果和2.1节中是一样的。

截止到目前为止,检索上的功能已经打通。这种方法的本质上将一张复合表拆分成了多张简单表,中间通过一个虚拟外键进行关联。从工程上来看,这种方式的本质是用时间去换取空间:牺牲每次检索时进行关联的计算开销,去换取存储上的压力减轻。
接下来在看更新链路中的问题。在这个场景中,索引设计要考虑的最关键的问题就是更新时的doc粒度。如1.3节中提到的,由于更新是以doc为粒度更新的,若doc中的内容过大,则无法进行有效更新。拆表后,doc中的内容是“当前tag_userGroupId下包含的userid list”,其doc规模介于1.3节和2.1节中提出的两种方案之间,且具有一定的随机性。若tag_userGroupId下包含的userid较多,则更新时压力较大,效率较低。

2.3 数值型数据的存储优化

为了解决tag_userGroupId下包含的userid较多导致更新效率较低的问题,我们需要找到一种优化的存储方式,对多值userid进行存储压缩。这里介绍的一种方式是bitmap压缩。
当数据是数值型,且数值分布比较集中时,可以建立bitmap存储,每个bit表示一个userid是否存在(0或1),userid则作为寻址bit位置的索引。这种方法仍然是时间换空间,用bitmap反解的计算开销去换取存储空间。
image.png
这里影响压缩效率的关键点是映射计算后,bit是否集中分布,若分布过于离散,则会出现很多bit空洞。当出现很多bit空洞时,一般有两种办法:

  • 优化映射计算的方法
    在进行映射的时候,要保证映射后数据尽可能的集中
  • 对空洞进行再次压缩
    由于一般不一定能找到一种理想的映射计算方法,比较常见的是对bitmap空间进行再次压缩,压缩方法有很多种,这部分读者可以自行设计,这里简述两种压缩方法。
    (1)自适应存储
    当空洞较多时,bitmap存储的效果有可能劣于暴力存储,可以根据业务特点计算出两种方案的临界值,自适应判断使用哪种存储
    (2)bitmap再压缩
    当出现较多字节空洞的时候,可以采用<字节id,字节值>的方式存储非空洞字节

image.png

2.4 数据分组规则设计

2.2节中提到,将userid分组变为UserGroup的核心目的在于减少doc数量,这里需要设计分组的规则。一个比较极端的案例就是4亿user分组后,变成了4亿个UserGroup,每个UserGroup下一个User。为了达成尽可能减少doc数量的目的,在设计规则时,应关注userid的bit分布特性,将公共部分提取出来当作userGroupId,并将这些user划入该组下。
这本质上是一次“特征提取”的过程,将数值样本中的共性尽可能丰富的提取出来。
image.png

三 总结

至此,超大规模检索中的索引设计告一段落,现在总结下问题分析过程中的一些关键点。
1、在进行索引设计时,不能只考虑检索上的功能与性能是否满足要求,还要考虑更新时的功能与性能,给出综合的解决方案
2、当遇到数据表过大的场景时,第一时间要反思是否自己对表的设计不合理,能否对表进行拆分
3、当需要基于规则对数值型数据进行分组时,并不是只有取整和取模两种简单的分组方式,要结合数据特征,给出效果较好的分组方式
4、当暴力存储占用存储空间较大时,可以考虑能否用bitmap的方式压缩存储;若bitmap空洞较多,可以考虑进行进一步的压缩

如果感兴趣,欢迎关注微信技术公众号
clipboard.png

阅读 287发布于 7月31日
推荐阅读
目录