系统设计:在搜索系统实现缓存的策略与思考

2024-08-20 09:43:10 浏览数 (3)

世界奇奇怪,人类可可爱。在这个不断变化的复杂世界里,人类每天会在搜索系统中敲下数万乃至上亿个问题,找寻答案,给自己的好奇一个归宿。而搜索系统就像一个输送管道,每日孜孜不倦地进行各种计算,浪里淘金,旨在为各种问题送上致命一击。在庞大的数据流中,答案的搜索往往离不开大量计算资源和时间;并且,随着问题越来越多,搜索系统的计算往往会出现挤兑,大大影响效率。因此,为搜索系统搭建一个有效的缓存机制,将会大幅提升计算效率,节省大量资源。

01

一些有趣的搜索事实

  1. 查询长度:大多数的用户查询是短查询。用户一般不会在搜索时敲下完整的问句或问题,而是倾向于取关键字进行搜索,这样的查询一般包含3~7个字。
  2. 查询特性:用户查询有很大比例的趋同性和重复性,约有30%~40%的用户查询是重复查询。从大量用户的查询统计上看,查询词汇具备时间局部性,即大多数重复的用户查询会在较短的间隔时间内被再次重复访问。
  3. 查询分布:一般来说,100w 个用户查询中,约64%的用户查询只出现一次,这种查询可称为“孤立查询”,即这部分查询不具备时间局部性,未来不久中系统也不会再次出现。“孤立查询”是造成系统长尾的重要原因,这类查询往往需要经历完整的搜索链路。而另一方面,重复查询也非常集中:最热门的25个热点查询大约占总查询的1.2%-1.5%。
  4. 查询习惯:用户一般习惯只查看返回结果的前三个页面(一般是前30个返回结果)。约64%的用户只查看第一个页面(top10),12%的用户会查看第二个页面(top20),只有不到12%的用户会查看第三个页面以后的检索结果(top30后)。

以上一些数据虽然较旧,并且在不同的搜索系统中,由于不同的搜索集、不同的搜索行为训练等原因,可能会略有差异;但其体现的搜索特性是一致的:用户的搜索行为大多遵循幂律分布,即约20%的查询词占了总查询次数的80%,如下图所示:

图 幂律分布

基于以上数据前提,我们可以理解:搜索行为具有随机性,但同时具有很强的重复性。对单个用户而言,搜索行为会随机发生,例如搜索一个问题或一个热点;但在大盘中看,很多用户的搜索行为是会重叠的。这个结论看似矛盾;但事实上,确实符合搜索系统的行为分布。一方面,当用户带着明确问题查询时,往往只输入关键词,并选择系统提示词进行搜索,这种搜索行为的规整为系统缓存提供了极大便利;另一方面,很多搜索行为一般和当天的热点新闻事件相关,这种情况会构成十分多重复的热点查询。以微信搜索为例,在春晚时加入了和搜一搜的口播互动,很多人会在当晚通过微信搜索“春晚”或“春节联欢晚会”等方式进入视频号直播。这些情况下,热词查询词将会在当天占据特别大的比例。因此,基于以上的数据分析支撑,为搜索系统添加缓存具备可行性。

02

从简单的搜索系统框架说起

图 一种简单的搜索架构

讨论搜索系统中的缓存设计,我们需要从搜索框架说起,分析搜索系统中数据的流动和使用情况。如图是一种典型的搜索框架,一般来说,搜索系统可大致分为5个层级:

  1. 搜索入口层。搜索入口一般指 cgi。各类搜索(如发现搜索、相关搜索等)都会发送至 cgi,拉取相关参数后(如用户关系链、用户画像等)组成请求,转发至后台。入口层是连接用户侧和后台服务侧的桥梁,还可承担如流量管理、兜底缓存等功能。
  2. 整合排序层。这一层一般有 qp、proxy、mixer 及 rank 等,对搜索整体流程进行综合调度。qp 模块对 query 进行处理,包括分词、纠错、改写、意图分析等,用以确定检索策略;proxy 模块则承担一些流量均衡、doc 组装、过滤、上报等逻辑,mixer 模块则承担具体的调度逻辑,如召回、打分、排序等,两者可以分别部署,也可以合并为一个模块,具体依赖于系统设计;rank 一般指粗排、精排、混排,将各种类型的召回结果有序组合。
  3. 检索召回层。召回层主要负责从索引集群中进行 doc 的召回和求交计算。索引集群一般分为时新、全量,分别存放新热文章和全量文章。集群间互相配合,分担在线检索和离线刷库的流量压力,同时可根据不同的召回策略提升召回多样性。
  4. 数据层。各类图文、视频等数据,会在数据层进行原始数据导入、向量计算、特征因子接入管理等统一操作,生成可被检索的索引数据,为用户提供高效、准确、多样的检索服务。数据层中还会有各种离线任务和离线模型,不断进行迭代训练及数据写入,以保证索引最新、最广、最全。
  5. 运营层。为了保障搜索质量,还有一系列效率工具和平台协助开发人员对系统进行监控和跟进,如日志分析,现场还原,实验分析、结果干预等。这些工具可以帮助我们追踪每次搜索行为的细节,细化监控系统中的每一个角落,为搜索系统的健康运行提供有力保障。

综上流程,当一个 query 从 cgi 进入后,会连同一些必要的用户参数,组成请求包传送至整合排序层;整合排序层再拉取必要的 qp 信息(如纠错、改写等),旋即发送至检索层,对数据层中离线计算好的数据进行求交召回;检索获取的结果会返回至整合排序层,再进行一次或多次排序(粗排、精排、混排);最后将组装好的结果返回至入口层,由入口层进行数据格式转换返回至客户端。在这个搜索过程中,我们借用编程语言中常量和变量的定义,提炼流程中产生的三类数据:

  1. 局部变量数据。由于不同用户或 query 等,必定要重新计算的数据。如用户实验命中、社交标签、新热文章标签等。
  2. 全局变量数据。由于不同用户或 query 等,在较短时间周期内会发生变化的数据。如一些特征计算结果、集群召回结果等。
  3. 常量数据。无论用户或 query 如何变化,在较长时间周期内都不会产生变化的数据。如用户画像、用户关系链等。

局部变量数据属于时变数据,每次搜索都需要重新计算;而其他两种数据在一定时间周期内,都是时不变数据。因此对于搜索系统而言,针对时间周期内的时不变数据进行缓存,将会带来不错的收益。在增加缓存后,我们不仅可以重用缓存中计算好的数据,较少执行实际的查询计算,大大提高查询效率;同时,有很多查询请求可以在较早阶段获取缓存返回,减轻下游系统负载,节省算力开支。

有趣的是,这里的数据划分,不是统一的强标准,而是会根据不同的请求维度、曝光指标、召回策略等因素发生变化。例如,用户前后搜索行为无法预估,用户可能会先搜索“方便面”,浏览了若干篇文章结果后,而后搜索“易烊千玺”再次进行浏览。对于单个用户来说,由于搜索间隔短,用户画像、用户关系链等不会产生太大变化,前后两次搜索行为可以直接使用同一份用户数据传递计算;但如果是不同用户,那么这份用户数据就无法通过缓存共享了。针对单次query而言,两次搜索词大相径庭,搜索结果自然也会风马牛不相及;但如果放到大盘中看,可能会有多名用户同时搜索“方便面”或“易烊千玺”,在不考虑个性化的前提下,不同用户的搜索结果便可以通过缓存进行共享。因此,在搜索系统中,缓存什么类型数据,通过何种粒度缓存数据,以及如何使用缓存数据,是十分值得探讨和分析的问题。

03

不同的缓存类型

人类的时间太宝贵了。从“奥特曼有多少个”到“核污水有哪些危害”,我们每敲下一个问题,都希望可以马上得到答案,并足够准确,对搜索系统来说是一个极大的挑战。搜索系统往往需要同时与“效率”和“效果”做抗争:“效率”保证尽快返回查询结果;“效果”保证将最匹配的结果放在最前面。假如我们有无限资源,自然可以部署最优质的链路和模型,通过持续的计算为用户带来最好的搜索体验;然而,系统资源往往是有限的,cpu、gpu 等资源应该花费在更值得的计算上。因此,我们需要在“效率”和“效果”中进行博弈,让两者的天秤达到一个平衡。而缓存作为一种优化技术,正是调节两者平衡的一个重要砝码。如图,是一个简单的缓存架构设计:

图 一种简单的缓存架构设计

当用户查询到达搜索引擎时,会首先在该层级的缓存系统中查找,如果从缓存中发现了相同查询的搜索结果,则中断后续计算,从缓存内读出结果返回至上一层;如果缓存中没有找到相同查询,则会将本次请求交由下一层继续处理,待数据返回后根据一定的策略将本次计算结果写入该层级缓存中。根据所处层级的不同,会产生几种缓存类型:

  1. 结果型缓存。直接缓存搜索结果,当用户命中查询词后获取缓存的搜索结果,直接返回。这种缓存类型效率极高,因为基本不再依赖任何计算,在入口层/整合排序层获取搜索结果后即可直接返回。
  2. 中间值型缓存。每一次搜索行为中,为了召回、排序等目标,通常需要拉取很多其他信息辅助计算,例如用户画像、qp 查询串信息、倒排索引、2个或多个词的求交记录等,这些额外的中间信息也可以缓存起来加快计算。以拉取倒排索引为例,在没有命中缓存的情况下,一般至少需要一次 rpc 来回 读取磁盘索引 求交计算的时间;而在命中缓存的情况下,这些步骤会被简化至读缓存即可,大大提升效率。
  3. 多层混合缓存。一般来说,上述两种缓存不会单独存在,在搜索系统中会混合使用。例如在入口层/整合排序层,会优先查找被缓存的结果类缓存直接返回;在没有找到时,下层结构在进行计算时也会优先拉取一些中间值的缓存结果,比如索引的 doc 倒排、排序时需要使用的各种特征、一些文章摘要计算结果等。多层缓存的意义在于,当上一层的缓存没有命中时,依然可以依托下一层的缓存命中达到一定的优化效果。

搜索系统中大多采取的是第三种形式,因为如果单用前两种方式,都有比较明显的缺点。结果型缓存,虽然查询和管理效率很高,但往往会用查询原串做缓存 key,粒度很粗,当一名用户搜索“秋天的第一杯奶茶”,而另一名用户搜索“秋天第一杯奶茶”,这两个搜索会被认为存在差异,所以前一名用户的结果即使缓存了也不能为下一名用户的查询带来收益;而中间值型缓存,一般会通过 term 或者 docid 做缓存 key,粒度比较细,在上述关于“秋天”、“第一杯”、“奶茶”这种查询下依然可以命中,不过其仍需要进行后续计算;并且以 term 或 docid 做缓存 key 时的缓存容量较难估计,缓存的中间值类型过多也会带来管理不便的问题。因此,结果型缓存和中间值型缓存会相互补充,起到一个相辅相成的效果。

图 一种简单的2层混合缓存结构

04

缓存策略

为了提高cache命中率,会结合不同的策略来调整缓存命中情况。缓存策略可以帮助我们关注更常用、更多被访问的数据,并淘汰不需要的数据以维持缓存系统的高效运转。一般来说,缓存策略包含以下方面:

  1. 缓存的更新和淘汰策略。缓存的更新和淘汰一般分为动态策略和静态策略。动态策略包括 FIFO(First In First Out)、LFU(Less Frequently Uses)、LRU(Least Recently Used)及一些变种(LRU/2、FBR、SLRU等)、PDC(Probability Driven Cache)、LandLord、TTL(Time To Live)等,还包括 Cache-Aside、Read-Through、Write-Through、Write-Behind 等缓存模式(篇幅原因,本文不逐一展开);动态策略和在线请求相关,会根据在线命中情况不断调整,写入新缓存并淘汰旧缓存,典型的如每天的上升热搜、突发新闻等;静态策略则是通过分析搜索日志等手段,将一些可能发生的热点事件提前运营好,将对应结果刷入缓存,为线上可能爆发的流量做好预防,典型的如“端午节”、“春节联欢晚会”等搜索。两者相比,动态缓存处于持续变化的状态,更关注时效性问题,在应对线上的突发小流量及部分重复搜索有良好收益;而静态缓存则可以持续更长时间以提升缓存命中率,并且一般不需要考虑结果时效性问题,在应对节假日等一些大型活动的流量时会有良好收益。大多数搜索系统中都会将动态缓存和静态缓存混用;有的搜索系统还会通过统计查询长度、查询频次等特征来细化缓存策略,以获取更高的缓存收益。
  2. 预取策略。系统可以预测用户行为或请求行为发起预拉取,例如当用户在搜索输入框输入时即提前拉取用户参数、获取了部分文章结果后提前拉取摘要、在用户浏览了半页首刷结果后即发起第二页搜索请求等,这些数据都可以通过提前拉取写入缓存,当用户请求真正到达后,可以直接获取缓存数据,提升查询效率。
  3. 本地缓存/分布式缓存。如何根据业务场景确定使用本地缓存还是分布式缓存,是一个有趣的议题。本地缓存的优点是业务逻辑和 cache 在同一个进程内部,请求缓存非常迅速,不需产生额外的网络调用,在缓存数据不需要集群支持时非常合适;而它的缺点也正是因为和进程强耦合,数据只能单机独享,服务间无法直接共享缓存而造成一定程度的内存浪费。一般来说,如果决定使用本地缓存,应开发一套统一的缓存组件供业务侧快速接入,减少开发成本。而分布式缓存则和业务分离,作为一个独立服务单独部署,并提供相应的读写 api 接口及监控,供业务侧存储、管理、共享数据,典型的如 redis、memcached、微信 kv 等服务。分布式缓存的优点在于数据的统一管理和分享,并几乎不消耗业务的资源;但是会带来一定的网络开销,同时在高并发的情况下,分布式缓存的高效读写及稳定性也是一个巨大的挑战。

如今,各式各样的缓存技术在无数应用服务中发挥着重要作用,然而尚无一种通用的缓存策略可以满足所有业务场景的需求。因此,我们需要根据具体的背景来选择合适的缓存策略,达到“效率”和“效果”的平衡。

05

缓存可能带来的问题

使用缓存后的系统并不是一个最优系统,反而会带来一些问题。如上所说,我们追求“效率”和“效果”的平衡;但缓存本质是用空间换时间的艺术,本质在于“简化、减少计算”,但并不是“优化计算”。换言之,缓存只是帮助我们“偷懒”实现加速加载,但实际上并未优化任何计算。当缓存hit时,计算量会减少;但当缓存miss后,所有计算流程依然需要跑一遍,而未经充分优化的计算流程依然会带来较高的耗时。因此,我们不能忽视缓存掩盖的一些问题:

  1. 无法解决长尾问题。使用缓存后,热点搜索耗时会达到一个比较低的水位,但对于一些查询长度较长的孤立查询依然会消耗较多时间,在耗时曲线上形成右偏分布或双峰分布。这说明虽然可以有效降低平均耗时,但是对于长尾查询而言没有任何增益,甚至会因为缓存所带来的平均收益掩盖长尾问题。搜索不仅仅关注大部分的用户体验,也关注长尾体验;因此长尾问题在缓存设计下,需要额外关注。
  2. 时效性问题和一致性问题。每天、每小时、每分钟,都会有创作者写下新的文章并发表,也会有越来越多的读者阅读并参与互动;一篇文章,如何可以快速地被搜索系统检索并正确打分,一直是一个难题。而在使用缓存后,数据将会存储在缓存和数据库两处地方。当数据库中的数据已经变化时,缓存中的数据可能还未更新,这将导致用户只能检索到旧的文章;或者已检索到最新的文章,但尚未更新特征导致排序靠后、标签错误等情况。因此需要有一定的机制保障数据的同步和更新,维持线上搜索结果的稳定。
  3. 穿透、击穿、雪崩、预热。这四个问题是缓存机制中的经典问题。穿透指一个实际并不存在的数据,例如一些恶意请求构造的输入,会导致每次都必须要去数据库查询;击穿指某个热点数据过期,导致大量请求直接访问数据库;雪崩指大量缓存数据同时过期,导致大量请求直接访问数据库;预热指当系统启动或缓存失效时,缓存中没有数据,需要从数据库中读取加载数据,此时可能会出现大量请求直接访问数据库,影响性能。
  4. 资源消耗和缓存维护。系统加入缓存后,虽然可以带来时间方面的收益,但是会增加空间的消耗。同时,引入缓存后的系统复杂度会增加,也需要投入更多人力进行开发和维护。
  5. 缓存粒度和命中率问题。我们在设计缓存系统时,往往希望最大化命中率。但命中率和 cachekey 相关,因此构造优秀的 cachekey 将会有效提升命中率,带来性能提升。例如 Elasticsearch 引擎中会设计 Request Cache、Query Cache、Fielddata Cache 以提高检索速度;简单的搜索系统中也可以通过以查询串缓存搜索结果,以 term 缓存召回结果,这样2~3层的 cachekey 组合来保证请求不用走遍完整链路,跳过部分计算带来整体命中率的提升。
  6. 缓存对象问题。我们需要关注缓存对象的大小,因为这可能影响到整体缓存的使用资源大小,并可能会带来一些序列化、反序列化、慢查询及网络传输上的性能问题。

06

后记

不同于以往写的文章,这是第一次尝试写深一个话题。写深的过程有点像算法中的 dfs,不断下钻挖掘相关知识,一点点丰满骨肉,在磕磕碰碰中终于写完了。我不断复盘自己做的事情,也不断推敲如何做的更好,锤炼自身。如果本文有什么不合理的地方,欢迎大家纠错和指正。希望我们都可以和乔老爷子一样:stay hungry,stay foolish。在庞大的二进制世界里,不断迈出探寻的脚步。

-End-

原创作者|李嘉鹏

开发中常见的缓存机制有哪些?欢迎评论分享。我们将选取点赞本文并且留言评论的一位读者,送出腾讯云开发者定制发财按键1个(见下图)。8月27日中午12点开奖。

0 人点赞