导读: 百度搜索系统是百度历史最悠久、规模最大并且对其的使用已经植根在大家日常生活中的系统。坊间有一种有趣的做法:很多人通过打开百度搜索来验证自己的网络是不是通畅的。这种做法说明百度搜索系统在大家心目中是“稳定”的代表,且事实确是如此。百度搜索系统为什么具有如此高的可用性?背后使用了哪些技术?以往的技术文章鲜有介绍。本文立足于大家所熟悉的百度搜索系统本身,为大家介绍其可用性治理中关于“稳定性问题分析”方面使用的精细技术,以历史为线索,介绍稳定性问题分析过程中的困厄之境、破局之道、创新之法。希望给读者带来一些启发,更希望能引起志同道合者的共鸣和探讨。

第1章 困境

在大规模微服务系统下,如果故障未发生,应该归功于运气好。但是永远不要指望故障不发生,必须把发生故障当作常态。从故障发生到解除过程遵循的基本模式抽象如下。

可用性治理主要从这3个角度着手提升:1. 加强系统韧性;2. 完善止损手段,提升止损有效性,加速止损效率;3. 加速原因定位和解除效率。

以上3点,每个都是一项专题,限于篇幅,本文仅从【3】展开。

百度搜索系统的故障原因定位和解除,是一件相当困难的事情,也可能是全公司最具有挑战性的一件事情。困难体现在以下几个方面。

极其复杂的系统 VS. 极端严格的可用性要求

百度搜索系统分为在线和离线两部分。离线系统每天从整个互联网抓取资源,建立索引库,形成倒排、正排和摘要三种重要的数据。然后,在线系统基于这些数据,接收用户的query,并以极快的速度为用户找到他想要的内容。如下图所示。

百度搜索系统是极其庞大的。让我们通过几个数字直观感受一下它的规模:

百度搜索系统的资源占用量折合成数十万台机器,系统分布在天南海北的N大地域,搜索微服务系统包含了数百种服务,包含的数据量达到数十PB级别,天级变更次数达到数十万量级,日常的故障种类达到数百种,搜索系统有数百人参与研发,系统每天面临数十亿级的用户搜索请求。

虽然系统是超大规模,但是百度对可用性的要求是极其严格的。百度搜索系统的可用性是在5个9以上的。这是什么概念呢?如果用可提供服务的时间来衡量,在5个9的可用性下,系统一年不可用时间只有5分钟多,而在6个9的可用性下,一年不可用的时间只有半分钟左右。所以,可以说百度搜索是不停服的。

一个query到达百度搜索系统,要经历上万个节点的处理。下图展示了一个query经历的全部节点的一小部分,大概占其经历节点全集的几千分之一。在这种复杂的路径下,所有节点都正常的概率是极其小的,异常是常态。

复杂的系统,意味着故障现场的数据收集和分析是一项浩大的工程。

多样的稳定性问题种类

百度搜索系统向来奉行“全”、“新”、“快”、“准”、“稳”五字诀。日常中的故障主要体现在“快”和“稳”方面,大体可归为三类:

  1. PV损失故障:未按时、正确向用户返回query结果,是最严重的故障。
  2. 搜索效果故障:预期网页未在搜索结果中展现;或未排序在搜索结果的合理位置;搜索结果页面响应速度变慢。
  3. 容量故障:因外部或内部等各种原因,无法保证系统高可用需要的冗余度,甚至容量水位超过临界点造成崩溃宕机等情况,未及时预估、告警、修复。

这些种类繁多、领域各异的问题背后,不变的是对数据采集加工的需求和人工分析经验的自动化抽象。

第2章 引进来、本土化:破局

在2014年以前,故障原因定位和解除都在和数据较劲,当时所能用到的数据,主要有两种。一是搜索服务在线日志(logging);二是一些分布零散的监控(metrics)。这两类数据,一方面不够翔实,利用效率低,问题追查有死角;另一方面,对它们的使用强依赖于人工,自动化程度低。以一个例子说明。

拒绝问题的分析首先通过中控机上部署的脚本定时扫描线上服务抓取单PV各模块日志,展现到一个拒绝分析平台(这个平台在当时已经算是比较强大的拒绝原因分析工具了)页面,如下图所示;然后人工阅读抓取到的日志原文进行分析。这个过程虽然具有一定的自动化能力,但是PV收集量较小,数据量不足,很多拒绝的原因无法准确定位;数据平铺展示需要依赖有经验的同学阅读,分析效率极其低下。

在问题追查死角和问题追查效率上,前者显得更为迫切。无死角的问题追查呼吁着更多的可观测数据被收集到。如果在非生产环境,获取这些数据是轻而易举的,虽然会有query速度上的损失,但是在非生产环境都能容忍,然而,这个速度损失的代价,在生产环境中是承受不起的。在理论基石《Dapper, a Large-Scale Distributed Systems Tracing Infrastructure》的指导下,我们建设了kepler1.0系统,它基于query抽样,产出调用链和部分annotation(query处理过程中的非调用链的KV数据)。同时,基于业界开源的prometheus方案,我们完善自己的metrics系统。它们上线后立即产生了巨大的应用价值,打开了搜索系统可观测性建设和应用的想象空间。

2.1 kepler1.0简介

系统架构如下图所示。

阶段性使命:kepler1.0在于完善搜索系统的可观测性,基于开源成熟方案结合公司内组件实现从0到1的建设,快速完成可观测性能力空白的补齐,具备根据queryID查询query处理过程的调用链以及途径服务实例日志的能力。

引进来:从kepler1.0的架构不难发现,它从数据通路、存储架构等方面完整的参考zipkin

本土化:引进zipkin时数据采集sdk只支持c++,为了满足对非c++模块的可观测性需求,兼顾sdk的多语言维护成本以及trace的侵入性,采用了常驻进程通过日志采集输出格式和c++ sdk兼容的trace数据,即图中的日志采集模块。

2.2 通用metrics采集方案初步探索

系统架构如下图所示。

阶段性使命:2015年前后搜索开始探索大规模在线服务集群容器化混部技术,此时公司内的监控系统对多维度指标汇聚支持较弱,基于机器维度指标的传统容量管理方式已经难以满足容器化混部场景的需求。

引进来:将开源界成熟的metrics方案引入搜索在线服务混部集群,实现了符合prometheus协议的容器指标exporter,并依托prometheus的灵活多维度指标查询接口以及grafana丰富的可视化能力,建设了搜索在线业务混部集群容量管理依赖的底层数据系统。

本土化:容器指标prometheus-exporter和搜索在线PaaS系统深度对接,将服务元信息输出为prometheus的label,实现了容器元信息的指标索引和汇聚能力,满足容器化混部场景下容量管理的需求。指标和PaaS元信息关联是云原生metrics系统的初步探索主要成果。

2.3 应用效果初显

场景1:拒绝、效果问题

阶段性痛点:人工分析强依赖日志,从海量调用链、日志数据中精确检索出某些特定query,通过ssh扫线上机器日志效率很低,且对线上服务存在home盘io打满导致稳定性风险。

解决情况:对命中常态随机抽样拒绝问题、可复现的效果问题开启强制抽样采集,通过queryID直接从平台查询调用链及日志用于人工分析原因,基本满足了这个阶段的trace需求。

场景2:速度问题

阶段性痛点:仅有日志数据,缺乏调用链的精细时间戳;一个query激发的调用链长、扇出度大,日志散落广泛,难收集。通过日志几乎无法恢复完整的时序过程。这导致速度的优化呈现黑盒状态。

解决情况:补全了调用链的精细时间戳,使query的完整时序恢复成为可能。通过调用链可以查找到程序层面耗时长尾阶段或调度层面热点实例等优化点,基于此,孵化并落地了tcp connect异步化、业务回调阻塞操作解除等改进项目。

场景3:容量问题

阶段性痛点:多维度指标信息不足(缺少容器指标、指标和PaaS系统脱节);缺少有效的汇聚、加工、组合、对比、挖掘以及可视化手段。

解决情况:建设了搜索在线的容器层面多维度指标数据采集系统,为容器化的容量管理应用提供了重要的基础输出来源,迈出了指标系统云原生化探索的一步。下图为项目上线后通过容器指标进行消耗审计功能的截图。

第3章 创新:应用价值的释放

虽然kepler1.0和prometheus打开了可观测性建设的大门,但是受限于能力,已经难以低成本地获取更多的使用价值了。

3.1 源动力

基于开源方案的实现在资源成本、采集延迟、数据覆盖面等方面无法满足搜索服务和流量规模,这影响了稳定性问题解决的彻底性,特别是在搜索效果问题层面表现尤为严重,诸如无法稳定复现搜索结果异常问题、关键结果在索引库层面未预期召回问题等。

稳定性问题是否得到解决永远是可观测性建设的出发点和落脚点,毫不妥协的数据建设一直是重中之重。从2016年起,搜索开始引领可观测性的创新并将它们做到了极致,使各类问题得以切实解决。

3.2 全量采集

因为搜索系统规模太庞大,所以kepler1.0只能支持最高10%的采样率,在实际使用中,资源成本和问题解决彻底性之间存在矛盾。

(1)搜索系统大部分故障都是query粒度的。很多case无法稳定复现,但又需要分析出历史上某个特定query的搜索结果异常的原因。让人无奈的是,当时只有备份下来的日志才能满足任一历史query的数据回溯需求,但它面临收集成本高的难题;另外,很多query没有命中kepler1.0的抽样,其详细的tracing数据并未有被激发出来,分析无从下手。能看到任一历史特定query的tracing和logging信息是几乎所有同学的愿望。

(2)公司内部存储服务性价比较低、可维护性不高,通过扩大采样率对上述问题进行覆盖需要的资源成本巨大,实际中无法满足。

对于这个矛盾,业界当时并没有很好的解决方案。于是,我们通过技术创新实现了kepler2.0系统。系统从实现上将tracing和logging两种数据解耦,通过单一职责设计实现了针对每种数据特点极致优化,以极低的资源开销和极少的耗时增长为成本,换取了全量query的tracing和logging能力,天级别数十PB的日志和数十万亿量级的调用链可实现秒查。让大多数故障追查面临的问题迎刃而解。

3.2.1 全量日志索引

首先,我们介绍全量日志索引,对应于上图中日志索引模块。

搜索服务的日志都会在线上机器备份相当长一段时间,以往的解决方案都着眼于将日志原文输出到旁路系统,然而,忽略了在线集群天然就是一个日志原文的现成的零成本存储场所。于是,我们创新的提出了一套方案,核心设计理念概括成一句话:原地建索引。

北斗中通过一个四元组定义一条日志的索引,我们叫做location,它由4个字段组成:ip(日志所在机器)+inode(日志所在文件)+offset(日志所在偏移量)+length(日志长度)。这四个字段共计20字节,且只和日志条数有关,和日志长度无关,由此实现对海量日志的低成本索引。location由log-indexer模块(部署在搜索在线服务机器上)采集后对原始日志建立索引,索引保存在日志所在容器的磁盘。

北斗本地存储的日志索引逻辑格式如下图所示。

查询时,将inode、offset、length发送给索引ip所在的机器(即原始日志所在机器),通过机器上日志读取模块,可根据inode、offset、length以O(1)的时间复杂度定点查询返回日志原文,避免了对文件的scan过程,减少了不必要的cpu和io消耗,减小了日志查询对生产环境服务稳定性的影响。

同时,除了支持location索引以外,我们还支持了灵活索引,例如将检索词、用户标识等有业务含义的字段为二级索引,方便问题追查时拿不到queryID的场景,可支持根据其他灵活索引中的信息进行查询;在索引的使用方式上,除了用于日志查询以外,我们还通过索引推送方式构建了流式处理架构,用于支持对日志流式分析的应用需求。

这里还有一个问题:查询某一query的日志时,是不是仍然需要向所有实例广播查询请求?答案是:不会。我们对查询过程做了优化,方法是:通过下文介绍的callgraph全量调用链辅助,来确定query的日志位于哪些实例上,实现定点发送,避免广播。

3.2.2 全量调用链

在dapper论文提供的方案中,同时存在调用链和annotation两种类型的数据。经过重新审视,我们发现,annotation的本质是logging,可以通过logging来表达;而调用链既可以满足分析问题的需要,又因为它具有整齐一致的数据格式而极易创建和压缩,达到资源的高性价比利用。所以,callgraph系统(kepler2.0架构图中红色部分)就带着数据最简、最纯洁的特点应运而生。全量调用链的核心使命在于将搜索全部query的调用链数据在合理的资源开销下存储下来并高效查询。

在tracing的数据逻辑模型中,调用链的核心元素为span,一个span由4部分组成:父节点span_id、本节点span_id、本节点访问的子节点ip&port、开始&结束时间戳。

全量调用链核心技术创新点在于两点:(1)自研span_id推导式生成算法,(2)结合数据特征定制压缩算法。相比kepler1.0,在存储开销上实现了60%的优化。下面分别介绍这两种技术。

3.2.2.1 span_id推导式生成算法

说明:下图中共有两个0和1两个span,每个span由client端和server端两部分构成,每个方框为向trace系统的存储中真实写入的数据。

左图:kepler1.0随机数算法。为了使得一个span的client和server能拼接起来并且还原出多个span之间的父子关系,所有span的server端必须保存parent_span_id。因此两个span实际需要向存储中写入4条数据。

右图:kepler2.0推导式算法,span_id自根节点从0开始,每调用一次下游就累加该下游实例的ip作为其span_id并将其传给下游,下游实例递归在此span_id上继续累加,这样可以保证一个query所有调用的span_id是唯一性。实例只需要保存自己的span_id和下游的ip,即可根据算法还原出一个span的client端和server端。由此可见,只需要写入2条数据且数据中不需要保存parent_span_id,因此存储空间得到了节省,从而实现了全量调用链的采集能力。

右图中ip1:port1对ip2:port的调用链模拟了对同一个实例ip2:port2访问多次的场景,该场景在搜索业务中广泛存在(例如:一个query在融合层服务会请求同一个排序服务实例两次;调度层面上游请求下游异常重试到同一个实例等),推导式算法均可以保证生成span_id在query内的唯一性,从而保证了调用链数据的完整性。

3.2.2.2 数据压缩

结合数据特征综合采用多种压缩算法。

(1) 业务层面:结合业务数据特征进行了定制化压缩,而非采用通用算法无脑压缩。

(a) timestamp:使用相对于base的差值和pfordelta算法。对扇出型服务多子节点时间戳进行了压缩,只需保存第一个开始时间戳以及相对该时间戳的偏移。以搜索在线服务常见高扇出、短时延场景为例,存储偏移比直接存储两个时间戳节省70%。

(b) ip:搜索内网服务ip均为10.0.0.0/24网段,故只保存ip的后3字节,省去第1字节的10,每个ip节省25%。

(2) protobuf层面:业务层面的数据最终持久化存储时采用了protobuf,灵活运用protobuf的序列化特性节省存储。

(a) varint:变长代替原来定长64位对所有的整数进行压缩保存,对于ip、port、时间戳偏移这种不足64位的数据实现了无存储浪费。

(b) packed repeated:ip和timestamp均为repeated类型,只需要保存一次field number。packed默认是不开启的,导致每个repeated字段都保存一次field number,造成了极大浪费。以平均扇出比为40的扇出链路为例,开启packed可节省了25%的存储空间(40字节的field number)。

最终,一个span的逻辑格式(上图)和物理格式(下图)如下:

3.2.3 应用场景的受益

3.2.3.1 时光穿越:历史上任一特定query的关键结果在索引库层面未预期召回问题

因为召回层索引库是搜索最大规模的服务集群,kepler1.0在索引库服务上只支持0.1%抽样率,使得由于索引库的某个库种和分片故障导致的效果问题追查捉襟见肘。全量调用链采集较好的解决了这一困境。

真实案例:PC搜索 query=杭州 未展现百度百科结果,首先通过工具查询到该结果的url所在数据库A的9号分片,进一步通过全量调用链调用链查看该query对数据库A所有请求中丢失了9号分片(该分片因重试后仍超时被调度策略丢弃),进一步定位该分片所有副本均无法提供服务导致失败,修复服务后预期结果正常召回。

3.2.3.2 链式分析:有状态服务导致“误中副车”型效果问题

有状态服务效果问题分析复杂性:以最常见的cache服务为例。如果没有cache只需通过效果异常的queryID通过调用链和日志即可定位异常原因。但显然搜索在线系统不可能没有cache,且通常cache数据会辅以异步更新机制,此时对于命中了脏cache的query只是“受害者”,它的调用链和日志无法用于问题最终定位,需要时序上前一个写cache的query的调用链和日志进行分析,我们称其为“捣乱者”。

kepler1.0的局限性:kepler1.0采样算法是随机比例抽样,“捣乱者”和“受害者”两个query是否命中抽样是独立事件,由于“捣乱者”在先,当“受害者”在受到效果影响时,已无法倒流时间触发前者抽样了,导致两个query在“时序”维度够成的trace链条中断,追查也随之陷入了困境。

kepler2.0的破解之法: