马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
媒介
搜索是B站的紧张根本功能,须要对包罗视频、品评、图文等海量的站内优质资源创建索引,处置处罚来自用户逐日数亿的检索哀求。离线索引数据的准确、高效产出是搜索业务的根本。我们在这里分享搜索离线架构团体的改造实践:从周期长,流程复杂的手工构建流程,改造为高容量、高性能、易迭代的分布式建库架构的过程。
业务配景
B站是一个范例的多资源搜索场景,除了视频外,还接入了包罗UP主、番剧影视(PGC)、直播等几十种差异范例的资源。除了资源范例多以外,各种资源的数据源的情势也多种多样,包罗数据库、上游业务接口、Hive表等等。这些数据通过离线近线的聚合和构建,以全量和增量及时流两种方式生产出索引,在线上的服务中见效。
现实业务中,除了搜索业务本身维护的视频MySQL数据库外,还接入了差异情势的数据泉源,一并添加到全量/增量索引数据中构建。这些数据有的仅T+1更新全量;有的则会提供增量数据流和接口,接入时也盼望数据变动能在搜索索引中及时见效。
全量(base)索引:以文件情势提供,包罗某一特定时间点之前的全部数据,通常天天产出、更新一次。
增量(delta)索引:以数据流(消息队列)的情势提供,每条消息对应一份稿件的完备信息。增量索引能及时同步更新,实用于时效性要求高的场景,满意用户对新稿件的检索需求。
这些第三方数据由于各种缘故起因(MySQL容量或性能限定,数据维护团队差异等),不能直接集成到搜索的MySQL数据库中。这些数据的引入在当前的离线和近线建库流程中是独立的,随着数据的种类增长,搜索离线建库流程也徐徐复杂起来。
索引数据产出流程
以视频索引为例,构建视频索引时除了搜索本身维护的MySQL中视频信息外,还须要接入多种第三方数据到索引中,如接入以下三类数据:
- 数据A:第三方数据库+binlog
- 数据B:不开放DB直接访问,以导出的全量snapshot+接口+数据流情势间接提供
- 数据C:Hive表
全量数据产出
为了获取稿件信息,全量建库流程须要先后将这些差异的数据源获取到当地,归并为新的全量数据集,再构建出二进制全量索引。
增量数据产出
对于增量索引,我们盼望将每条稿件的完备字段(即包罗搜索MySQL+数据源A/B/C的全部字段信息)聚合为一条增量索引消息下发。
我们监听binlog A和数据流B,但只关心此中有变革的稿件id,通过变动搜索MySQL中相应稿件的一个标记位,触发该稿件的一条binlog及后续的增量建库流程。
在后续的建库流程中,处置处罚binlog时增长根据ID查询MySQL A和接口B的逻辑,如许就能从MySQL A的查询效果,接口B的相应和当地的文件C中拿到稿件的对应数据,写入到增量索引数据流中。
注意这里对MySQL A和接口B有着严格的时效性要求,即查询得到的数据不能比数据流下发的数据版本更旧,否则无法包管数据的终极同等性。
举个例子,如果MySQL A的binlog来自主库,而按ID查询时访问了MySQL A的从库。
当A中某个稿件的字段发生变革时,在处置处罚主库的对应binlog时会从从库中查询当前值。由于主库从库之间的同步有肯定延伸,这时有大概查询到旧值而不是主库中的新值,导致新值不会在索引中见效,除非该稿件有其他变动再次触发binlog处置处罚。
将MySQL A的binlog调解为由对应从库导出则可制止该标题发生。
索引见效流程
B站的检索引擎和正排服务加载索引数据的过程如下:
- 更新全量索引:服务周期性地获取新的全量索引文件,分析元数据,将索引加载到内存中。
- 消耗增量数据流:加载了全量索引后,服务会从元数据指定的时间点开始消耗增量索引数据流,直到消耗进度靠近当前的最新产出。消耗到的数据会在服务侧及时地创建增量索引,以支持查询。当一份稿件同时出现在增量索引和全量索引时,全量索引中的数据被覆盖;稿件在数据流中出现多次时,增量索引中的旧值也会被新值覆盖。
- 提供服务:当增量数据流的产出进度被消耗追平后,服务进入停当状态,开始处置处罚哀求。
标题与挑衅
随着搜索业务复杂度的增长和数据规模的增长,现有建库逻辑在服从和资源层面徐徐难以为继。
性能
新投稿的增多和汗青投稿的积聚,让搜索MySQL在建库过程中的负载越来越大,开始出现性能瓶颈。
造成MySQL高负载的紧张场景有:
1. 表结构变动:须要复制全表数据。数据复制期间,数据库负载显着增长。
2. 新增字段并批量导入:将新字段导入数据库时须要大量写入,增长数据库压力。
3. 全量索引构建时的扫库利用:索引构建流程每次须要先查询搜索MySQL数据库获取全量稿件信息,dump到文件中,再举行索引构建。当在基线外另行构建索引以支持AB实验时,数据库的查询负载也会相应地倍增。
- 缓解步伐:错开差异构建使命的实行时间;将扫库效果和元数据生存下来,供多个建库使命复用。
当MySQL负载高时,主从数据库同步会出现延伸,导致索引数据更新不及时。用户不能及时看到最新的投稿和变动,体验受陵犯。
我们在一样寻常实验和迭代时,都必须时候思量对数据库的性能影响,而缓解数据库压力的步伐通常导致迭代周期变长,差异的需求上的索引迭代也不能并行推进。性能瓶颈和稳固性风险成为了索引迭代的紧张停滞。
维护本钱
- 搜索索引原始数据没有同一的存储承载,数据大概来自多个数据库、文件、以致接口。离线和近线须要各自维护复杂的拼接逻辑,导致迭代困难,开辟周期长。数据不符合预期时,难以定位缘故起因。
- 全量数据和增量数据的产出逻辑差异大,没有机制能包管两套链路上终极的产出效果同等。数据的不同等大概影响业务效果。
- 全量索引是单机构建,构建周期,实例摆设和数据分片都须要人工维护,每次迭代都斲丧大量人力本钱。
- 增量数据接入新的及时数据时,数据提供方须要同时提供全量、增量数据和查询接口。搜索业务和数据提供方的对接、开辟本钱都较高。
资源
每次构建索引时,都须要对原始数据重新切词分析,构建正排、倒排索引。纵然数据和战略没有变革也须要重复盘算。这些盘算会斲丧大量资源,而且增长索引构建所需的时间。
筹划目标
反思考引构建流程中的标题,不管是复杂的多数据源归并流程、还是MySQL的性能压力延伸风险,归根结底都是存储办法本领不敷,不能直接承载全部索引数据导致的标题复杂化。我们盼望将索引依赖的全部数据聚合到同一的存储办法中,作为基准数据源,直接基于该数据源导出全量数据和增量数据流,并将后续的建库使命彻底分布式化,到达数据同一、可扩展性大幅增强的目标。
预期新的架构筹划可办理当前的痛点:
- 用分布式的存储办法承接搜索数据,后续的构建使命也举行分布式化改造,让建库链路中不再有单点的性能瓶颈。
- 全量和增量都从同一的存储办法中获取,完全屏蔽原有差异数据泉源对建库过程的影响,可以让建库的流程简单化,低沉开辟维护的本钱。
- 有了充足的存储本领之后,我们可以将稿件切词这类较为稳固的中心盘算效果生存下来,只在有数据或战略有变革时重新盘算。节省盘算资源的同时进一步紧缩全量构建周期。
方案筹划
既然MySQL不能满意我们的需求,我们的人力也不答应从零开始造一个轮子来维护索引数据,主要的标题是找到一个符合的存储办法来作为根本,在其之上开辟数据处置处罚和建库逻辑。
存储选型
我们盼望新的存储方案可以具有以下特性:
- 高容量:可以存入现在全部的索引数据。易于水平拓展。
- 高吞吐量:答应大批量的数据写入和导出。
- 低延伸:可以快速随机读写单个稿件数据。
- 易于迭代:数据的结构可以机动迭代,无需Online DDL利用(https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html)。
我们从现有的数据库体系中寻求符合的选型:对于数据机动迭代的需求扫除了MySQL,TiDB等关系型数据库;批量更新稿件部门字段的需求对文档存储性的NoSQL数据库不友好;HBase,Cassandra等列族存储的数据模子比力符合,但简单写入查询延伸较高;KV(键值)存储延伸低,但数据模子不太匹配。
终极我们选择在KV存储的根本上封装行式(Row-Oriented)数据库的情势,以到达分身吞吐与延伸的效果,并选择了b站内部存储组件Taishan作为底层KV存储。
Taishan是B站内部的高性能分布式KV存储,具备多分片水平拓展本领,可应对大规模存储需求,简单读写的延伸也较低,别的还具有以下特性:
- 有序映射:key是有序的,支持scan(范围查询)。
- 支持 CompareAndSwap 利用:基于CAS原子利用可实现乐观锁,在并发的数据更新时防止辩说。
- 高效的数据导出:Taishan 支持快速将数据全量导出到对象存储中,而不须要全表scan。
架构筹划
我们以Taishan为根本存储办法,在其上创建了强大的数据存储层(基于表格存储模子)和同一的数据接入和导出层。
引入存储层后,我们将原始数据源跟详细的建库逻辑隔离,并抽象出可高度复用的数据导入导出逻辑,显着低沉了维护本钱和后续开辟本钱。
离线和近线利用类似的数据泉源并复用导出逻辑,从根本上消除了全量索引和增量索引数据不同等的大概。
高容量的存储层答应我们以增量盘算情势泉源空间调换时间:将一些中心盘算效果(如切词和Embedding等)也一并生存,仅在干系稿件属性有变革时触发盘算并更新;建库时直接利用生存下来的效果,盘算量大幅镌汰。
在全部数据都经Taishan聚合后,离线近线的流程变得清楚直观起来,同时在根本上消除了全量和增量数据不同等的大概性。增量盘算的引入也镌汰了大量重复的盘算量,节省了资源。新的数据流程使得迭代和维护都大为简化。
数据存储层
表格模子
基于KV存储之上封装出表格存储模子:
- 行:每一行代表一个稿件,通过稿件ID标识。
- 列:每一列存储稿件的多少干系字段,通过字段族名(CF,column family)标识。
- 单位格:行和列的组合确定一个单位格,对应KV存储中的一条纪录。
如上表所示,稿件ID和行逐一对应。而稿件的详细字段和列(CF)是多对多的关系:
- 同一稿件的多少干系字段可以打包生存在一列(如title和uname),如许会大幅镌汰KV的访问次数,进步服从。
- 表格中差异的列可以生存类似的字段。比方:
- 这里eb,eb1两列分别对应doc_embedding字段的差异版本。指定差异的列组合(fs,seg,eb)/(fs,seg,eb1),可以构建出两版包罗类似(title,uname,title_term,uname_term),以及差异版本doc_embedding的索引数据。
支持并发写入
Taishan的CAS支持答应我们在差异写入方之间通过乐观锁来同步,制止多个写入方同时更新一个单位格时发生写入丢失。
如果一列的写入方唯一,也可以不利用CAS直接写入。
支持并发写入消除了将多个字段放进同一CF的后续维护风险。纵然同一列后续要增长新的写入方,也无需对该枚举行拆分改造。
Key筹划
Key由ID和字段族名构成。下图是稿件ID1234567890的seg列对应的现实Key内容。ID和列名间用 “:”来分隔。
Data Orientation
Key的筹划决定了数据分列方式。底层Taishan存储中,数据按Key的字符串序次连续分列。
| ID1:CF1 | ID1:CF2 | | ID2:CF1 | ID2:CF2 |
我们将稿件ID放在列名称前,如许表格数据在Taishan中现实存放情势如下:
ID1:CF1
| ID1:CF2
| ID2:CF1
| ID2:CF2
|
现实利用场景中我们紧张按行扫描(获取类似ID的全部字段值),较少按列扫描(获取某一字段下全部取值)。同一ID下全部列连续分布的分列使得按ID(行)读取时有更好的访问局部性和Cache掷中率。
同时,ID利用大端序int64生存。大端序的特点是作为字符串对待时的序次和数字的升序同等,让遍历过程总是按ID升序实行,符合人的直觉与风俗。
Value筹划
Value总是以一个varint N作为header,该varint标识随后的N个字节生存的是元数据,别的的字节则用于存储现实数据。
下图是一个包罗header、元数据(巨细为8字节)和数据(巨细为5字节)的value。
通过这种筹划,我们得到了一个高效、机动且近似于传统表格存储的体系,可以为索引建库提供强大的支持。
序列化
扬弃JSON
在最初的建库流程中,我们利用JSON Lines文件格式生存原始的稿件数据,比方:
video.jsonl- {"id": 81403056, "title": "高燃舞台演绎B站最美的夜", "uname": "哔哩哔哩晚会", "doc_embedding": [0.168322, 0.015824, 0.091791, -0.2059]}{"id": 613621262, "title": "【触手猴】「強風オールバック」を弾いてみた【Piano】", "uname": "marasy_触手猴", "doc_embedding": [0.007262, 0.040466, 0.028768, 0.161083]}
复制代码 JSON格式具有精良的可读性,但服从和性能不敷,紧张体现在:
- 存储服从低:每条纪录中都会重复生存字段名及符号(如 {} 和 ""),浪费大量存储空间。
- 序列化性能低:文本格式的分析性能较差。
如果将完备的稿件字段拆分到多个列中分别生存,导出和查询时的序列化斲丧会更加严肃。
转向protobuf
为了办理上述标题,我们选择了Protocol Buffers(protobuf)格式来序列化稿件字段。以下是简化的Video消息的protobuf界说:
video.proto
- message Video { int64 id = 1; string title = 2; string uname = 3; repeated float doc_embedding = 4;}
复制代码 protobuf的上风紧张有:
- 存储空间优化:protobuf利用field number(以varint情势存储)来区分字段,相比于JSON中存储完备字段名,大大节省了存储空间。
- 性能提升:protobuf的反序列化速率优于JSON。
- 范例安全:和JSON相比,protobuf为数据字段提供了强范例包管,增强了我们对数据的信心。严格的数据范例也从源头上消除了JSON 固有的最大安全整数(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER)标题。
高效地归并
对于protobuf消息,多个序列化后的数据块(buffer)可以直接拼接来到达归并的效果:- Video v;
- v.ParseFromString(buf1 + buf2);
复制代码
这与将其分布反序列化后再实行归并是等效的:- Video v1, v2;
- v1.ParseFromString(buf1);
- v2.ParseFromString(buf2);
- v1.MergeFrom(v2);
复制代码
假设稿件的标题生存在列1,稿件Embedding信息生存在列2,buf1/buf2分别是对应单位格查询的数据。我们只须要直接concate未经反序列化的两段buffer即可拼接出稿件完备数据。
在利用Cord(https://protobuf.dev/reference/cpp/cpp-generated/#cord)或zero_copy_stream(https://protobuf.dev/reference/cpp/api-docs/google.protobuf.io.zero_copy_stream/)的环境下,连这一次拼接也可以省去。
变动数据流
Taishan原生支持binlog导出,可以将变动的Key和Value导出到数据流。
但直接利用Taishan的binlog会有以下标题:
- 大批量写入数据时,会产出大量消息,对后续的整个近线链路以致线上服务造成压力。
- Value只包罗变动的列,获取完备的数据仍需按ID扫描Taishan表。
为了机动控制变处置处罚,我们封装了写入层,在数据写入Taishan完成后别的输出一条变动消息,示比方下:- {"id": 613621262, "cf_changed": ["eb"]} // 稿件av613621262的eb列发生了变更
复制代码 数据变动的场所是否输出对应变动消息到流中是可指定的,批量写入数据时我们选择不触发。
我们也省略了变动后的值,须要的消耗者可以直接查询Taishan表的主节点获取最新的字段。
查询从节点大概得到更新前的旧值,破环数据的终极同等性。
数据接入层
存储方案和序列化方式简直定后,我们开始将现有的数据同一接入到Taishan中。详细而言是将原有的数据库全量增量以及T+1更新的数据全部写入上文所说的Taishan表格,并按需同步到变动数据流。
T+1数据接入
T+1更新的数据没有及时的增量更新,只须要定时写入全量。
写入的逻辑是高度复用的,同过指定设置将hive表/TSV/CSV/JSON Lines文件映射到Taishan中的对应列。
重新版全量中被删除的数据须要额外的处置处罚:我们须要跟上一版数据对比,找出这些被删除的数据,将这些数据同步从Taishan中删除。
及时数据接入
及时数据接入须要将数据及时写入Taishan,并同步到变动数据流。写入的序次必须为先写Taishan再写变动数据流,包管后续处置处罚变动流时能从Taishan主节点查到最新取值。
由于我们不能对上游提供的变动数据流的字段界说做任何的限定和假设,处置处罚增量的Worker须要开辟少量的分析逻辑。
增量写入只写入了迩来有变动的数据,为了让Taishan生存全部的汗青数据,在及时数目接入后,我们还须要再获取一份全量数据(产出时间在增量接入后),将这些数据也写入Taishan中。
这的全量写入过程和T+1数据接入类似,区别在于只须要初始化时实行一次,后续的变动都可以通过增量来获取。别的T+1全量写入时须要思量和增量写入辩说的环境,详细在后文先容。
写入辩说处置处罚
全量vs增量
包罗及时数据的数据在导入时通常也须要刷入一份全量数据做初始化。
一样寻常来说,全量中的数据会比来自数据流的旧,直接写入会未来自数据流的新值覆盖。
因此写入时须要利用CAS,仅当满意Precondition:值不存在时,才会写入。
增量vs增量
同一列可以有多个写入方,比如两个worker消耗两条差异的数据流写入同一列中的两个差异字段。此时须要先读取稿件的该列的旧值,更新此中的新字段后,将完备的新值写入。
当两个worker同时运行时,大概发生写写辩说(Write-Write Conflict),导致一个worker写入的新值,被别的的worker覆盖而丢失。
在这种场景下,我们同样利用CAS,在盘算出新值后,仅当满意Precondition:值==旧值时,才会写入新值,当Precondition不满意时,必须重试。
增量盘算
我们将一些中心盘算效果也生存在Taishan中。范例的场景如稿件标题的切词和向量化的效果。详细而言,如果利用的盘算战略和稿件标题没有变革,切词和向量化的效果可以以为是稳固的。因此我们可以生存这些效果来制止重复盘算,只在稿件的属性有变革时更新盘算效果。
增量更新切词效果的工作流如下图所示:
和及时数据接入类似,在初次接入时也须要对全部已有数据举行一次切词,让汗青稿件也有切词效果。初始化过程同样须要利用CAS来规避写写辩说。
数据导出层
数据进入Taishan后,我们须要从中导出须要的数据来构建全量和增量索引。
对于一份索引,其全量和增量构建使命的设置是共用的,获取到现实数据后的处置处罚逻辑也是同等的。
Taishan中差异的列可以生存字段的多个版本,详细构建全量和增量索引时选用哪些列中的字段,须要在设置中指定。设置中以白名单的情势指定列,无需担心新增列对已有构建使命产生影响。
差异版本的索引大部门环境下只须要调解设置再另行摆设即可产出。
全量数据导出
Taishan会逐日定期将全量数据备份到公司内部的对象存储。我们通过备份数据好的数据,遍历此中的全部KV,也就是按行遍历表格,取出指定的列来获取全量索引数据。Taishan备份服从优劣常高的,通常在分钟级,这也大幅地镌汰了扫库的时间开销。
增量数据导出
增量数据的导出通过消耗变动数据流实现。消耗后对每条消息(ID+变动的CF)反查Taishan,获取所需的完备字段。
数据迭代
新的存储机制简化了索引的迭代流程,只须要将新数据写入Taishan,然后调解构建使命关注的列即可。迭代的开辟量大幅降落,根本只需复用现有流程,调解设置后摆设新使命。
随着新存储方案和增量盘算等优化的落地,索引构建的周期紧缩了一半以上。迭代周期紧缩的同时,消除了链路上的性能瓶颈和延伸风险。
分布式构建
搜索索引最早都是通过物理机crontab定时实行脚本实现。脚本实行各种扫库利用,将数据加载到内存中,并产出一份完备的JSON Lines文本文件作为原始索引数据集,然后调用indexer举行全量切词和构建正排/倒排索引。物理机摆设稳固性没有包管且难以维护,我们起首把构建迁移到K8S集群上,实验以服务情势摆设。如许固然包管了建库使命的稳固性,但是建库使命资源利用率很低。建库服务构建时须要大量资源,但在大多数时间里是不斲丧任何资源的,容器依然须要占据相应的CPU/内存资源。固然通过超配(低软限高硬限)并错开使命触发时间可以来镌汰资源空置的环境,但维护较为繁琐。终极我们选择将建库迁移到业界广泛利用的分布式盘算框架Spark上,利用Spark潮汐资源举行索引构建,一方面可以加大构建并发,另一方面也可以对资源举行更充实的利用。
为了可以或许尽大概的复用及低沉维护本钱,从流程上举行抽象,将索引构建流程分以下紧张步调:
- 读取数据:从设置中加载并举行数据读取,Taishan源在对象存储的导出文件为sst格式,利用官方开源的JNI库举行二次封装。
- 解码 :Taishan非Spark原生支持的数据源,须要额外开辟分析逻辑。
- 再分片:由于导出的单一文件分片数据量较大,一次性读取将占用大量内存,以致OOM。为办理上述标题,参考Spark的cache机制,在读取并解码文件数据流的过程时先将文件载入到指定内存buffer中,若buffer装满则天生一个分片(partition)并写入hdfs中。以此将较少的文件分片(如128个)拆分为较多的hdfs文件分片(如5000个),便于Spark的后续处置处罚。
- 稿件处置处罚:通过设置对稿件数据举行一系列的处置处罚利用,如过滤、字段映射等。
- 编码 & 索引构建:将稿件内容转化为索引须要的特定编码情势,如Flatbuffer、Protobuffer。并根据索引范例和Meta信息,产出索引。
- 压缩打包:对构建出的索引及Meta数据举行打包,产出终极索引文件。
除更省资源外,Spark构建的使命并发度也更高,进一步紧缩了构建时间,终极到达小时级别。
总结与猜测
通过这一系列的技能更新和流程优化,我们的索引构建架构终极从早期的单机构建发展到分布式的数据存储和建库使命,本领更强的同时也更易维护和迭代,索引构建周期实现了从天级到小时级别的飞跃式进步,为业务的未来发展奠定了坚固根本。
参考
- https://protobuf.dev/programming-guides/encoding/#varints
- https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html
- https://research.google/pubs/large-scale-incremental-processing-using-distributed-transactions-and-notifications/
- https://protobuf.dev/programming-guides/encoding/#last-one-wins
-End-
作者丨网管、HevLfreis、瑚太朗、穅泊
|