一、背景
在视频推荐场景中,一方面我们需要让新启用的视频尽可能快的触达用户,这一点对于新闻类的内容尤为关键;另一方面我们需要快速识别新物品的好坏,通过分发的流量,以及对应的后验数据,来判断新物品是否值得继续分发流量。
而这两点对于索引先验数据和后验数据的延迟都有很高的要求。下文将为大家介绍看点视频推荐的索引构建方案,希望和大家一同交流。文章作者:纪文忠,腾讯 QQ 端推荐研发工程师。
注:这里我们把视频创建时就带有的数据称为先验数据,如 tag,作者账号 id 等,而把用户行为反馈的数据称为后验数据,如曝光、点击、播放等。
二、看点视频推荐整体架构
从数据链路来看此架构图,从下往上来看,首先视频内容由内容中心通过消息队列给到我们,经过一定的处理入库、建索引、生成正排/倒排数据,这时候在存储层可召回的内容约有 1 千万条。
然后经过召回层,通过用户画像、点击历史等特征召回出数千条视频,给到粗排层;粗排将这数千条视频打分,取数百条给到精排层;精排再一次打分,给到重排;重排根据一定规则和策略进行打散和干预,最终取 10+条给到用户;
视频在用户侧曝光后,从上之下,是另一条数据链路:用户对视频的行为,如曝光、点击、播放、点赞、评论等经过上报至日志服务,然后通过实时/离线处理产生特征回到存储层,由此形成一个循环。
基于此架构,我们需要设计一套召回/倒排索引,能够以实时/近实时的延迟来处理所有数据。
三、方案设计
在旧方案中,索引是每半小时定时构建的,无法满足近实时的要求。在分析这个索引构建的方案时,我们遇到的主要挑战有:
数据虽不要求强一致性,但需要保证最终一致性;
后验数据写入量极大,看点用户行为每日达到百亿+;
召回系统要求高并发、低延迟、高可用。
1. 业界主流方案调研
通过对比业界主流方案,我们可以看到,基于 Redis 的方案灵活性较差,直接使用比较困难,需要进行较多定制化开发,可以首先排除。
因此我们可选择的方案主要在自研或者选择开源成熟方案。经过研究,我们发现如果自研索引开发成本较高,而简单的自研方案可能无法满足业务需求,完善的自研索引方案所需要的开发成本往往较高,往往需要多人的团队来开发维护,最终我们选择了基于 ES 的索引服务。
至于为什么选择基于 ES,而不是选择基于 Solr,主要是因为 ES 有更成熟的社区,以及有腾讯云 PaaS 服务支持,使用起来更加灵活方便。
2. 数据链路图
(1)方案介绍
如下图所示:
这个方案从数据链路上分为两大块。
第一块,先验数据链路,就是上半部分,我们的数据源主要来自内容中心,通过解析服务写入到 CDB 中。其中这个链路又分为全量链路和增量链路。
全量链路主要是在重建索引时才需要的,触发次数少但也重要。它从 DB 这里 dump 数据,写入 kafka,然后通过写入服务写入 ES。
增量链路是确保其实时性的链路,通过监听 binlog,发送消息至 kafka,写入服务消费 kafka 然后写入 ES。
第二块,是后验数据链路。看点的用户行为流水每天有上百亿,这个量级直接打入 ES 是绝对扛不住的。所以我们需要对此进行聚合计算。
这里使用 Flink 做了 1 分钟滚动窗口的聚合,然后把结果输出到写模块,得到 1 分钟增量的后验数据。在这里,Redis 存储近 7 天的后验数据,写模块消费到增量数据后,需要读出当天的数据,并于增量数据累加后写回 Redis,并发送对应的 rowkey 和后验数据消息给到 Kafka,再经由 ES 写入服务消费、写入 ES 索引。
(2)一致性问题分析
这个数据链路存在 3 个一致性问题需要小心处理:
第一,Redis 写模块这里,需要先读出数据,累加之后再写入。先读后写,需要保证原子性,而这里可能存在同时有其他线程在同一时间写入,造成数据不一致。
解决方案 1 是通过 redis 加锁来完成;解决方案 2 如下图所示,在 kafka 队列中,使用 rowkey 作为分区 key,确保同一 rowkey 分配至同一分区,而同一只能由同一消费者消费,也就是同一 rowkey 由一个进程处理,再接着以 rowkey 作为分线程 key,使用 hash 算法分线程,这样同一 rowkey 就在同一线程内处理,因此解决了此处的一致性问题。另外,通过这种方案,同一流内的一致性问题都可以解决。
第二,还是 Redis 写模块这里,我们知道 Redis 写入是需要先消费 kafka 的消息的,那么这里就要求 kafka 消息 commit 和 redis 写入需要在一个事务内完成,或者说需要保证原子性。
如果这里先 commit 再进行 redis 写入,那么如果系统在 commit 完且写入 redis 前宕机了,那么这条消息将丢失掉;如果先写入,在 commit,那么这里就可能会重复消费。
如何解决这个一致性问题呢?我们通过先写入 redis,并且写入的信息里带上时间戳作为版本号,然后再 commit 消息;写入前会比较消息版本号和 redis 的版本号,若小于,则直接丢弃;这样这个问题也解决了。
第三,我们观察到写入 ES 有 3 个独立的进程写入,不同流写入同一个索引也会引入一致性问题。这里我们可以分析出,主要是先验数据的写入可能会存在一致性问题,因为后验数据写入的是不同字段,而且只有 update 操作,不会删除或者插入。
举一个例子,上游的 MySQL 这里删除一条数据,全量链路和增量链路同时执行,而刚好全量 Dump 时刚好取到这条数据,随后 binlog 写入 delete 记录,那么 ES 写入模块分别会消费到插入和写入两条消息,而他自己无法区分先后顺序,最终可能导致先删除后插入,而 DB 里这条消息是已删除的,这就造成了不一致。
那么这里如何解决该问题呢?其实分析到问题之后就比较好办,常用的办法就是利用 Kfaka 的回溯能力:在 Dump 全量数据前记录下当前时间戳 t1,Dump 完成之后,将增量链路回溯至 t1 即可。而这段可能不一致的时间窗口,也就是 1 分钟左右,业务上是完全可以忍受的。
线上 0 停机高可用的在线索引升级流程如下图所示:
(3)写入平滑
由于 Flink 聚合后的数据有很大的毛刺,导入写入 ES 时服务不稳定,cpu 和 rt 都有较大毛刺,写入情况如下图所示:
此处监控间隔是 10 秒,可以看到,由于聚合窗口是 1min,每分钟前 10 秒写入达到峰值,后面逐渐减少,然后新的一分钟开始时又周期性重复这种情况。
对此我们需要研究出合适的平滑写入方案,这里直接使用固定阈值来平滑写入不合适,因为业务不同时间写入量不同,无法给出固定阈值。
最终我们使用以下方案来平滑写入:
我们使用自适应的限流器来平滑写,通过统计前 1 分钟接收的消息总量,来计算当前每秒可发送的消息总量。具体实现如下图所示,将该模块拆分为读线程和写线程,读线程统计接收消息数,并把消息存入队列;令牌桶数据每秒更新;写线程获取令牌桶,获取不到则等待,获取到了就写入。最终我们平滑写入后的效果如图所示:
在不同时间段,均能达到平滑的效果。
四、召回性能调优
1. 高并发场景优化
由于存在多路召回,所以召回系统有读放大的问题,我们 ES 相关的召回,总 qps 是 50W。这么大的请求量如果直接打入 ES,一定是扛不住的,那么如何来进行优化呢?
由于大量请求的参数是相同的,并且存在大量的热门 key,因此我们引入了多级缓存来提高召回的吞吐量和延迟时间。
我们多级缓存方案如下图所示:
这个方案架构清晰,简单明了,整个链路: 本地缓存(BigCache)<->分布式缓存(Redis)<->ES。
经过计算,整体缓存命中率为 95+%,其中本地缓存命中率 75+%,分布式缓存命中率 20%,打入 ES 的请求量大约为 5%。这就大大提高了召回的吞吐量并降低了 RT。
该方案还考虑缓了存穿透和雪崩的问题,在线上上线之后,不久就发生了一次雪崩,ES 全部请求失败,并且缓存全部未命中。起初我们还在分析,究竟是缓存失效导致 ES 失败,还是 ES 失败导致设置请求失效,实际上这就是经典的缓存雪崩的问题。
我们分析一下,这个方案解决了 4 点问题:
本地缓存定时dump到磁盘中,服务重启时将磁盘中的缓存文件加载至本地缓存。
巧妙设计缓存Value,包含请求结果和过期时间,由业务自行判断是否过期;当下游请求失败时,直接延长过期时间,并将老结果返回上游。
热点key失效后,请求下游资源前进行加锁,限制单key并发请求量,保护下游不会被瞬间流量打崩。
最后使用限流器兜底,如果系统整体超时或者失败率增加,会触发限流器限制总请求量。
2. ES 性能调优
(1)设置合理的 primary_shards
primary_shards 即主分片数,是 ES 索引拆分的分片数,对应底层 Lucene 的索引数。这个值越大,单请求的并发度就越高,但给到上层 MergeResult 的数量也会增加,因此这个数字不是越大越好。
根据我们的经验结合官方建议,通常单个 shard 为 1~50G 比较合理,由于整个索引大小 10G,我们计算出合理取值范围为 1~10 个,接下里我们通过压测来取最合适业务的值。压测结果如下图所示:
根据压测数据,我们选择 6 作为主分片数,此时 es 的平均 rt13ms,99 分位的 rt 为 39ms。
(2)请求结果过滤不需要的字段
ES 返回结果都是 json,而且默认会带上 source 和_id,_version 等字段,我们把不必要的正排字段过滤掉,再使用 filter_path 把其他不需要的字段过滤掉,这样总共能减少 80%的包大小,过滤结果如下图所示:
包大小由 26k 减小到 5k,带来的收益是提升了 30%的吞吐性能和降低 3ms 左右的 rt。
(3)设置合理 routing 字段
ES 支持使用 routing 字段来对索引进行路由,即在建立索引时,可以将制定字段作为路由依据,通过哈希算法直接算出其对应的分片位置。
这样查询时也可以根据指定字段来路由,到指定的分片查询而不需要到所有分片查询。根据业务特点,我们将作者账号 id puin 作为路由字段,路由过程如下图所示:
这样一来,我们对带有作者账号 id 的召回的查询吞吐量可以提高 6 倍,整体来看,给 ES 带来了 30%的吞吐性能提升。
(4)关闭不需要索引或排序的字段
通过索引模板,我们将可以将不需要索引的字段指定为"index":false,将不需要排序的字段指定为"doc_values":false。这里经测试,给 ES 整体带来了 10%左右的吞吐性能提升。
五、结语
本文介绍了看点视频推荐索引的构建方案,服务于看点视频的 CB 类型召回。其特点是,开发成本低,使用灵活方便,功能丰富,性能较高,符合线上要求。
上线以来服务于关注召回、冷启动召回、tag 画像召回、账号画像召回等许多路召回,为看点视频带来较大业务增长。未来随着业务进一步增长,我们会进一步优化该方案,目前来看,该技术方案还领先于业务一段时间。最后欢迎各位同学交流,欢迎在评论区留言。
本文来自 DataFunTalk
原文链接:
评论