摘要:所有的倒排索引都是基于正排数据构建的。数据规模的难题节中描述的拆表的方式,本质上是将多个数值型拆成了多个插入记录,然后再建立倒排索引。
超大规模检索中的索引设计 一 问题背景
精准广告场景中,人群定向的常用方法是:根据各种不同的规则,将每一个用户(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都会发生变化。
业务中的数据规模:
User总数大约4亿
Tag总数约100w个
平均每个Tag下会挂载100w个User
平均每秒钟会对2000个tag进行查询
平均每秒钟会有5w个User会触发Tag更新,每次更新平均会更新10个Tag
可见,不管是存储规模、每秒检索结果的数据量 还是 每秒需要进行更新的数据量都非常庞大。这里需要特别说明的是,User的id信息是一个uint64数字,后面的优化会利用这个特性。
我们需要一个强大的检索引擎来支持数据的检索 + 更新,这个检索引擎需要支持“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 |
可以看出,我们期望的是类似于倒排表的这种数据结构,但是为了产出这样一种数据结构,我们不得不建立一张我们根本不需要的正排数据表。
结合业务背景,为了获得一个“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开销是巨大的,以至于无法接受。
二 技术难点分析本节将介绍一种巧妙的索引结构设计方式,可以在技术选型受限的情况下,通过牺牲部分存储来实现“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.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之间是有大量重复的,如果我们能消除这种重复现象,就能在很大程度上减轻膨胀。
一个很容易地想到的方法是分组。由于业务上要求我们用tag进行检索,因此我们只能将userid分组。分组之后,每个tag下挂载的是userGroupId,一个UserGroup下可以再次挂载很多userid。这样,doc主键就变成了tag_userGroupId,预期数量可以显著减少(注意“预期”这个词,将在2.4节中说明)。
但还有一个问题需要解决,那就是虽然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较多,则更新时压力较大,效率较低。
为了解决tag_userGroupId下包含的userid较多导致更新效率较低的问题,我们需要找到一种优化的存储方式,对多值userid进行存储压缩。这里介绍的一种方式是bitmap压缩。
当数据是数值型,且数值分布比较集中时,可以建立bitmap存储,每个bit表示一个userid是否存在(0或1),userid则作为寻址bit位置的索引。这种方法仍然是时间换空间,用bitmap反解的计算开销去换取存储空间。
这里影响压缩效率的关键点是映射计算后,bit是否集中分布,若分布过于离散,则会出现很多bit空洞。当出现很多bit空洞时,一般有两种办法:
优化映射计算的方法
在进行映射的时候,要保证映射后数据尽可能的集中
对空洞进行再次压缩
由于一般不一定能找到一种理想的映射计算方法,比较常见的是对bitmap空间进行再次压缩,压缩方法有很多种,这部分读者可以自行设计,这里简述两种压缩方法。
(1)自适应存储
当空洞较多时,bitmap存储的效果有可能劣于暴力存储,可以根据业务特点计算出两种方案的临界值,自适应判断使用哪种存储
(2)bitmap再压缩
当出现较多字节空洞的时候,可以采用<字节id,字节值>的方式存储非空洞字节
2.2节中提到,将userid分组变为UserGroup的核心目的在于减少doc数量,这里需要设计分组的规则。一个比较极端的案例就是4亿user分组后,变成了4亿个UserGroup,每个UserGroup下一个User。为了达成尽可能减少doc数量的目的,在设计规则时,应关注userid的bit分布特性,将公共部分提取出来当作userGroupId,并将这些user划入该组下。
这本质上是一次“特征提取”的过程,将数值样本中的共性尽可能丰富的提取出来。
至此,超大规模检索中的索引设计告一段落,现在总结下问题分析过程中的一些关键点。
1、在进行索引设计时,不能只考虑检索上的功能与性能是否满足要求,还要考虑更新时的功能与性能,给出综合的解决方案
2、当遇到数据表过大的场景时,第一时间要反思是否自己对表的设计不合理,能否对表进行拆分
3、当需要基于规则对数值型数据进行分组时,并不是只有取整和取模两种简单的分组方式,要结合数据特征,给出效果较好的分组方式
4、当暴力存储占用存储空间较大时,可以考虑能否用bitmap的方式压缩存储;若bitmap空洞较多,可以考虑进行进一步的压缩
如果感兴趣,欢迎关注微信技术公众号
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/61739.html
摘要:摘要导语近日,阿里云发布了智能媒体管理服务,通过离线处理能力关联授权的云存储,提供便捷的海量多媒体数据一键分析,并通过该分析过程构建价值元数据,更好支撑内容检索。标准统一,访问接口统一为阿里云的标准。场景化一键式处理,提高易用性。 摘要: 导语 近日,阿里云发布了智能媒体管理(Intelligent Media Management)服务, 通过离线处理能力关联授权的云存储,提供便捷的...
摘要:摘要第九届中国数据库技术大会,阿里云高级技术专家架构师封神曹龙带来题为大数据时代数据库云架构生态实践的演讲。主要内容有三个方面首先介绍了业务挑战带来的架构演进,其次分析了及生态,最后分享了大数据数据库的实际案例。数据备份及恢复。 摘要: 2018第九届中国数据库技术大会,阿里云高级技术专家、架构师封神(曹龙)带来题为大数据时代数据库-云HBase架构&生态&实践的演讲。主要内容有三个方...
阅读 2114·2023-04-26 03:06
阅读 3525·2023-04-26 01:51
阅读 2050·2021-11-24 09:38
阅读 2423·2021-11-17 17:00
阅读 2283·2021-09-28 09:36
阅读 920·2021-09-24 09:47
阅读 2566·2019-08-30 15:54
阅读 1531·2019-08-30 15:44