今日头条搜索品质优化 - 端到端篇
叶航 字节跳动技术质量 稿
导览
为了提升头条端上的搜索性能体验,我们从19年3/4双月开始投入专门的人力做品质优化,通过这段时间的持续投入,搜索整体的性能体验有了明显的提升,基本上与竞品对齐。中间踩过很多坑,也收获了一些经验,这里做一个总结分享,欢迎大家拍砖交流~
为了方便阅读,整体的介绍会拆分为2个部分,本篇介绍一下业务背景和整体的技术挑战,以及端到端场景的品质优化,后续会再对落地页做单独的优化介绍。
业务背景
搜索的业务场景其实比较简单,核心场景是两个:
- 搜索结果页。也就是展示用户搜索结果的页面,也就是本篇介绍所说的“端到端”场景
- 落地页。也就是从结果页里点击打开的新页面,可以是站内页面,也可以是站外H5、小程序等。站内页和小程序等体裁性能相对较好,我们的优化主要针对的是站外落地页
端到端场景的技术挑战
看起来很简单的一个场景,为什么会有性能瓶颈,需要专门做优化呢?
基于H5+webview的架构
搜索结果页是基于webview来实现的。相比于纯Native实现,基于webview的架构在性能体验上会有天然的瓶颈,主要是在速度和稳定性上:
- 速度方面,webview的首次初始化、页面加载流程、webview原生网络模块、web&Ntive通信等环节都存在一定的耗时瓶颈。
- 稳定性方面,webview的白屏、黑屏等异常也给我们持续带来很多困扰。
聊一聊技术选型背景:
选择H5是由业务形态和业务发展共同决定的。头条搜索是综搜,有大量字节如意的需求(简单说就是不同query可以出不同的结构化样式),卡片样式非常多,而且需要很高的动态性,兼具跨端(Android+iOS+M站)优势的webview+h5是不错的选择(也是早期业务刚启动时唯一的选择)。
经过2年的快速迭代,头条搜索的pv已经近2亿,前端卡片样式达到数百种,业务复杂度已经到达一个相当高的地步。在这个背景下,虽然webview架构带来很大的性能挑战,但相比较做整体技术栈的迁移(比如切Native/Lynx/Flutter)带来的巨大成本(主要是),还是继续基于webview架构来优化有更高的roi。也得益于字节有了TTWebview自研内核,为webview优化提供了非常强有力支撑。
复杂的业务&有限的资源
前面提到过搜索的业务已经比较复杂,对性能方面的影响主要在页面体积和请求耗时方面。完整主文档+核心JS xxk,在网络传输跟js执行阶段耗时都很长,除此之外,影响性能的业务复杂度更多来自头条app本身。
作为一个平台型app,头条承载了大量业务,并且由于快速迭代遗留的历史包袱,头条整体的性能压力已经很大,比如启动速度、cpu、内存等,这些压力也会直接影响到搜索业务上。比如冷启动发起搜索路径会比较长,并且刚启动时由于整体资源紧张,搜索成功率也会显著低,再比如虚拟内存紧张,会直接影响搜索页面出现黑屏的概率。
另一方面,业务众多容易产生相互影响,比如webview的使用:头条里有10个以上的业务会用到webview,其他业务使用webview不当很容易影响到搜索。同样的,搜索做webview预加载的策略,也会很容易使详情页白屏升高。也正因此,相比于其他主打搜索功能的app(百度、浏览器等),头条搜索能使用的资源是相对有限的,没办法做激进的、独占式的webveiw资源使用。
进展总结
- 搜索成功率提高
- 搜索80分位耗时降低
优化过程
整体思路
-
多端合作共建(前端、服务端、推荐后端、网络、内核)
-
问题发现:建立用户视角的全链路数据统计和监控
-
数据驱动,识别关键瓶颈,分而治之,极致优化
- 网页渲染架构优化
- 网络链路优化
- 后端优化
- 内核加载流程优化
- 端上耗时压缩
- 极致策略优化
-
监控完善,防劣化
-
组件化建设,头条Lite双端复用
指标统计和分析能力建设
核心是**建立用户视角的全链路数据统计和监控,每次搜索统计以用户点击搜索词为起点,结果页渲染出首屏为终点,中途取消均不视为成功。(**原有的统计口径是利用前端performance api统计的首屏耗时)
主要遇到的挑战是:
- 整个项目团队已经沿用旧口径一段时间了,口径迁移有较大成本,包括开发和信息对齐的成本;
- 另外由于系统webview内部实现纯黑盒,很多环节是没办法进一步分析的;
通过沟通协调,最终整个项目团队达成一致:以用户视角口径为目标、以全流程性能分析为驱动来做优化,并且与内核团队共建完成webview内核全流程打点,为后续更细致的性能分析打下基础。
指标的口径迁移和建设从最终的效果来看是非常值得的,新的口径帮助我们发现了很多被忽略的问题,而这些原本被忽略的点也是我们后续的优化空间所在:
- 耗时方面。比如端上逻辑耗时、webview初始化耗时、js模版加载耗时、jsb通信耗时等在以往是被忽略的,但其实存在很大的优化空间;
- 稳定性方面。旧的成功率以网络请求为口径,遗漏了用户主动取消的场景以及webview异常等;
渲染架构迭代
主要围绕前端架构和端上加载流程来做优化。通过前端页面做SSR服务端渲染改造,再结合端上预创建webview、预链接、内核PLZ优化等,在速度和成功率上取得大幅提升。
【整体的演进过程】
离线化,由模版请求数据
离线化,native代替前端请求
离线化,单卡ssr
完整ssr
基于ssr的模版预加载
【核心优化点】
- 减少js执行
- 并行/预执行
- 服务端渲染,减轻端上工作
- 流式传输,加快首屏渲染
- 分机型策略
搜索网络链路优化
网络是整个链路的重要一环,搜索经SSR服务端渲染改造后会走两种网络
- Webview网络:通过预链接&链接保活,请求的链接复用率提升到90%+,80分位DNS+TCP建链耗时降为0;
- TTNet:前期和Feed共用域名,已经有较高的复用率,后期切独立域名后通过链接保活做优化,保持90%+链接复用率;
另外经过对长尾badcase分析,挖掘出了dns过长、H2链接黑洞等问题,提出并推动网络库实现兜底IP、链接保活、旁路重试等策略,对搜索网络性能/可用性有明显提升。另外在网络协议层面也推进了quic、br压缩等优化策略。
异常case治理
异常case是整个优化过程里的一大难点,也是痛点。由于webview本身的复杂性,结合业务复杂性,webview版本兼容性,厂商兼容性等,衍生出很多异常。这类问题也很难用系统性的方案来解决,各自原因不同,更多是一个不断踩坑填坑的经验积累~下面挑选2个印象深的case具体介绍下:
-
Vivo9系统异常cancel
在搜索做完SSR+内核Plz优化之后,虽然大盘性能提升了,但是仍然有不少搜不出的用户反馈。经过初步分析发现,用户反馈和异常统计在vivo9系统上都有明显的聚集性,这个现象一度让我们非常费解,因为ttwebview的存在按理说已经能规避webview厂商兼容问题。
怀着深深的疑惑逐步排查定位,最终发现是:
vivo9系统为了适配深色模式,会在webview的onPageStart/ onPageFinished/ onLoadResource/ doUpdateVisitedHistory几个时机,从framework层直接调用webview.loadUrl执行一段js来实现深色模式。而在KITKAT以上的版本,loadUrl里面执行js会导致当前正在加载的页面被cancel掉,从而导致搜索搜不出,plz特性会明显加剧这个问题。
这个故事告诉我们在Android开发里不管是任何场景,都不要忽略出现厂商兼容问题的可能性...
-
onRenderProcessGone自动恢复
原生webview在8.0系统后默认是多进程,Render进程和主进程隔离,而ttwebview在低版本也能开启多进程。在多进程webview下一个常见问题是主进程正常运行但Render进程被杀了,此时webview会通过webViewClient.onRenderProcessGone回调通知应用(所有存活的webview)做相应处理。
override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail?): Boolean { }
此时如果webvew在onRenderProcessGone返回false,则app进程也会发生崩溃,但是如果返回true,则当前的WebView会不可用,再次使用该实例加载网页会出现白屏。为避免主进程崩溃显然需要返回true,但在搜索的业务里由于二次搜索、预创建等场景的存在,白屏的问题也很影响用户。
因此需要增加恢复的措施,在render进程发生崩溃时,使用新的WebView来代替有问题的WebView,不过由于SearchActivity内部维护着一些相关的状态,如果直接替换掉当前的Activity里的WebView的话,还需要处理这些状态,比较麻烦。因此解决方案是,启动一个新的Activity,发起一次新的搜索,这样就可以使用新的WebView实例。
极致策略优化
整个搜索链路上,经过SSR服务端渲染+网络优化,网络侧和前端侧很难进一步优化了,客户端上也经过一些常规手段优化(无用逻辑精简、异步、预加载),正常流程中耗时在10几ms内。但受限于服务端耗时较长(80分位近1s),整体搜索速度还是落后于竞品。针对于此我们提出了一些更极致的优化策略:
【请求提前】
打破常规加载流程,把搜索请求的发起时机尽可能提前,拿到了不错的收益。具体的两个场景:
- 被动搜索场景(含页面跳转),把请求发起时机提前到Intent构造时,节省Activity生命周期执行的系统耗时~100ms;
- 主动搜索场景(无页面跳转),把请求时机提前到手指Touch Down的时机,节省点击判定的~30ms;
【搜索预取】
针对服务端处理高耗时的问题,设计和推动实现了预取的方案,可以大幅减少后端+网络耗时。方案最大的难点在于推荐侧流量压力过大,很难支撑大批量的预取请求,因此需要通过各种策略提升预取的命中率,在命中率和覆盖率间取一个平衡,另外在流控方面也需要做好详细设计。
目前方案已经上线,小量实验收益符合预期,但受限于推荐侧压力未能扩量。目前推动推荐侧在做Cache能力,完成后预期能大幅减轻流量压力,支持预取扩量。
后续规划
经过一段时间的治理整个搜索链路已经逐步接近理想态,再往后的主要空间在于后端&推荐侧的处理耗时上(后端Cache+预取),而预取的命中率调优是一个复杂的课题,需要投入更多精力做用户的行为分析与预测,以及结合网络环境、机型性能等因素做精细化的策略;另一方面是在做更细致的性能分析(机型、系统、设备性能)等,把现有策略做的更加深入和精细,拿到更大的收益,比如独立Render进程在低内存机型上完全有可能因内存不足而负向。