近年来爱奇艺快速发展,优质内容层出不穷,爱奇艺广告也随之发展和壮大,广告在线服务同时服务于品牌、中小、DSP 等不同客户,形成了可以满足不同需求类型的较为完善的商业广告变现布局,广告库存涵盖视频、信息流、泡泡社交(爱奇艺的社交平台)和开机屏等多种场景。爱奇艺效果广告是 2015 年开始全新搭建的一个广告投放平台,随着信息流业务的增长,整个投放平台也经历了一次大的架构调整和多次重要的升级优化。
爱奇艺广告投放平台的概要架构如下图所示。本文主要介绍在线服务相关的内容,在线投放服务即图中虚线所框出的部分,主要包括在线的投放和计费服务。
架构背后的业务需求
架构肯定是为业务需求而生的,先来看看我们面对的业务需求及其特点。
爱奇艺效果广告投放平台目前采用代理商模式,平台主要满足两大类业务需求:面向代理商(广告主)的和面向产品及运营团队的需求。具体来看看。
1、面向代理商的需求: 本质上是要帮助代理商降低转化成本
支持多种广告位:贴片、暂停、浮层、信息流、视频关联位和推荐位等
支持多种结算类型:支持 CPC、CPM 和 CPV 等广告结算类型,oCPC 结算方式在规划中
丰富的定向功能:常用定向维度(平台、地域等)及人群精准定向(地域定向 - 支持区县级别、人群属性定向和 DMP 人群定向),关键词定向
灵活的排期及预算设置:支持分钟粒度的排期设置,支持日预算的任意增减
特殊的业务功能:广告去重功能、动态创意、创意优选和平滑消耗等,都是为了提升广告的转化效果
频次控制:避免对相同用户短时间的大量曝光
2、面向产品及运营团队:主要是提升产品控制能力,促进整体系统的良好运转
流量控制:通过黑白名单控制某些流量上不可以 / 可以投放哪些广告
AB 测试功能:影响较大的功能全量发布之前需要进行 AB 测试以确认效果符合预期
计费相关:延迟曝光不计费,曝光、点击异常检测及过滤
负反馈:根据用户反馈自动调整广告投放策略优化用户体验,同时也是对广告主的一种制约
从上面描述的业务需求可以看出,业务的特点有:
业务逻辑复杂:流程包括很多环节(场景信息获取,广告召回,预算控制,频次控制,点击率预估,创意优选,平滑消耗,广告去重,结果排序,结果筛选,概率投放,AB 测试);下图中绿框的部分仅展示投放服务的主要流程:
业务变更非常快:平均每周 5 次的系统功能变更;
广告主数量多,订单量大,订单平均预算较小,并且订单设置会频繁变化。
系统架构
爱奇艺效果广告于 2016 年正式上线。起步伊始,业务逻辑简单,广告和订单数量较少,整体架构相对比较简单。为了快速完成系统的搭建和上线应用,复用了品牌广告投放平台的架构,并做了剪裁,系统架构图如下:
接入层包括 QLB(iQiYi Load Balance)、Nginx 前端机,主要做流量的反向代理和整体的限流与降级功能。
流量分发层:包括策略服务和流量平台服务;策略服务支持公司层面的策略控制和日常的运营需求;流量平台服务主要控制流量在各投放平台上的分配和请求逻辑,投放平台包括品牌广告投放平台,效果广告投放平台和外部 DSP。
投放服务:前文介绍的业务逻辑都包含在这里,由单一的模块来实现。
日志收集:接收曝光点击等日志,主要完成计费、频控和去重等业务逻辑,也是由单一的模块来实现。
计费系统:利用 Redis 主从同步机制把订单的实时消耗数据同步到投放服务。
频次系统:使用 Couchbase 机群来做用户数据存储。
数据同步层:这一层涉及的数据种类很多,其中相对较重要的有两种:业务数据和日志数据,业务数据主要包括广告的定向、排期和预算等内容。
我们利用业务数据做了两方面的优化工作:
通过业务数据分发一些对时效性要求不高的数据给到投放服务,避免了一些网络 IO;
在业务数据中进行空间换时间的优化,包括生成索引及一些投放服务所需要的数据的预计算,譬如提前计算计费系统中的 key 值。
随着业务增长,架构也遇到了一些挑战。
流量增长:系统上线之后很好地满足了广告主对转化效果的要求,这个正向的效果激发了广告主对流量的需求,为此产品和运营团队不断地开辟新的广告位,同时爱奇艺的用户数和流量也在持续增长,这些原因共同为效果广告平台带来了巨大的流量。
广告主数量和订单数量增长:这个增长包括两方面,一方面与流量增长相辅相成,相互促进;爱奇艺的优质流量和良好的转化效果吸引了更多的广告主;另一方面,由于商务政策上的原因,广告主和订单量在季度末会有阶段性的增长。
性能问题:流量和订单量的增长使得系统的负载快速增加,因为订单是全量召回的,当订单量增长到一定数量之后,会使得长尾请求增多,影响整体服务性能,无法通过水平扩容解决。
超投问题:由于曝光和点击的延迟,以及投放计费环路的延迟,不可避免的存在超投问题,这也是广告系统的固有问题;品牌广告是先签订合同,投放量达到即可按照合同收款,超出部分不会对广告主收费,品牌广告预定量都很大,超投比率较小;和品牌广告不同,效果广告实时扣费,如果沿用品牌思路的话,超投部分会造成多余的扣费,而中小广告主对此非常敏感,也会增加技术团队问题分析排查工作,同时因为效果广告的预算少,预算调整变化很快,使得超投比率要比品牌广告大;针对效果广告的超投问题,技术团队要做的事情分成两个层面,一是保证超投的部分不会计费,不给广告主带来损失,二是从根本上减少超投,即减少我们自己的收入损失;分别称为超投不计费和减少超投;
针对上面的几个情况,我们的架构做了调整:
对比上线伊始的架构,此阶段架构调整体现在以下几个方面:
投放服务性能优化 – 包括索引分片和增加粗排序模块,主要解决了上述流量增长、广告主数量订单增长等方面带来的性能问题
索引分片是把原来的一份索引拆分成多份,对应的一个请求会被拆分成多个子请求并行处理,这样每个请求的处理时间会减少,从而有效减少长尾请求数量。
粗排序:全量召回的好处是收益最大化,缺点是性能会随着订单量增加而线性下降;粗排序在召回阶段过滤掉没有竞争力的低价值的(ECPM 较低的)广告,低价值广告被投放的概率和产生转化的概率很低,因此粗排序的过滤对整体收入影响很小,同时能有效减少进入后续核心计算逻辑(包括精排序及其他的业务逻辑)的订单数量,使得服务压力不随订单量而线性增长。
计费服务架构优化 - 主要是提升系统的可扩展性和解决超投问题
可扩展性通过服务拆分来解决,把单一模块的计费服务拆分成三个模块,拆分之后日志收集模块对外提供服务,主要职责是接收日志请求并立即返回,保证极低的响应时间;然后对计费日志和非计费日志进行不同的处理;检测过滤模块主要职责是进行定向检查和异常日志识别。计费服务把有效计费数据更新到计费系统。拆分成三个模块之后,每个模块都很简单,符合微服务基本原则之一:单一职责。
关于超投, 先看第一个问题:超投不计费。
主要难点在于:
同一个广告的计费请求是并发的;
计费系统是分布式的,出于性能考虑,请求的处理流程需要是无锁的。
我们在计费系统中解决这个问题的思路如下:
首先,要严格准确地计费,就要对并行的请求进行串行处理,Redis 的单线程模型天然满足串行计费的需求,我们决定基于 Redis 来实现这个架构,把计费的逻辑以脚本的形式在 Redis 线程中执行,避免了先读后写的逻辑,这样两个根本原因都消除了。
接下来的任务就是设计一个基于 Redis 的高可用高性能的架构。我们考虑了两种可选方案。
方案 1:数据分片,架构中有多个主 Redis,每个主 Redis 存储一个分数分片,日志收集服务处理有效计费请求时要更新主 Redis;每个主 Redis 都有对应的只读从 Redis,投放服务根据分片算法到对应的从 Redis 上获取广告的实时消耗数据。
该方案的优点是可扩展性强,可以通过扩容来解决性能问题;缺点是运维复杂,要满足高可用系统架构还要更复杂;
方案 2:数据不分片,所有的计费请求都汇聚到唯一的主 Redis,同时只读从 Redis 可以下沉到投放服务节点上,可以减少网络 IO,架构更加简洁;但主 Redis 很容易成为性能的瓶颈;
在实践中我们采用了第二种不分片的方案。主要基于以下考虑:
在业务层面,效果广告中有很大比率的是 CPC 广告,而点击日志的数量相对较少,基本不会对系统带来性能压力;对于剩下的 CPM 计费的广告,系统会对计费日志进行聚合以降低主 Redis 的压力;因为从 Redis 是下沉到投放上的,可以不做特殊的高可用设计;主 Redis 的高可用采用 Redis Sentinel 的方案可以实现自动的主从切换,日志收集服务通过 Sentinel 接口获取最新的主 Redis 节点。
在串行计费的情形下,最后一个计费请求累加之后还是可能会超出预算,这里有一个小的优化技巧,调整最后一个计费请求的实际计费值使得消耗与预算刚好吻合。
关于超投的第二个问题减少超投,这个问题不能彻底解决,但可以得到缓解,即降低超投不计费的比率,把库存损失降到最低;我们的解决方案是在广告的计费消耗接近广告预算时执行按概率投放,消耗越接近预算投放的概率越小;该方法有一个弊端,就是没有考虑到广告的差异性,有些广告的 ECPM 较低,本身的投放概率就很小,曝光(或点击)延迟的影响也就很小;针对这一点,我们又做了一次优化:基于历史数据估算广告的预算消耗速度和计费延迟的情况,再利用这两个数据来修正投放概率值。
这个方案的最大特点是实现简单,在现有的系统中做简单的开发即可实现,不需要增加额外的系统支持,不依赖于准确的业务场景预测(譬如曝光率,点击率等),而且效果也还不错;我们还在尝试不同的方式继续进行优化超投比率,因为随着收入的日渐增长,超投引起的收入损失还是很可观的。
关于微服务架构改造的思考
微服务架构现在已经被业界广泛接受和推广实践,我们从最初就对这个架构思想有很强的认同感; 广告在线服务在 2014 年完成了第一版主要架构的搭建,那时的微观架构(虚框表示一台服务器)是这样的:
在同一台机器上部署多个服务,上游服务只请求本机的下游服务,服务之间使用 http 协议传输 protobuf 数据,每个机器都是一个完备的投放系统。
这个架构有很多的优点:结构清晰,运维简单,网络延迟最小化等。
当然也有一些缺点,同一台机器上可部署的服务数量是有限的,因而会限制架构的增长,多个模块混合部署不利于整体的性能优化,一个服务的异常会影响整个机器的服务质量;这个架构在微观上满足了单一服务的原则,但在宏观上还不是真正的微服务化,为了解决上面的一些问题,按照自然的演进我们必然走上微服务化这条路;我们从 16 年中开始进行微服务化的实践。
微服务化过程中我们也遇到了很多问题,分享一下我们的解决方法及效果:
1. 技术选型问题
RPC 选型,必须满足的条件是要支持 C++、protobuf 协议和异步编程模型。最初的可选项有 sofa-pbrpc、pbrpc 和 grpc,这三者中我们选中了 grpc,主要看中了它通用(多语言、多平台和支持代理)、流控、取消与超时等特性;在我们选定 grpc 之后不久百度开源了它的高性能 rpc 框架 brpc,相比之下 brpc 更具有优势:健全的文档,高性能,内置检测服务等非常多的特性;为此我们果断地抛弃了 grpc 和已经在上面投入的一些开发成本,快速地展开了 brpc 相关的基础功能开发和各服务的改造。
名字服务选型,排除了 zookeeper,etcd 等,最终选定的是 consul+consul template 这个组合,它很完美地支持了我们的业务需求;除服务注册与发现外,还有健康检查,服务列表本地备份,支持权重设置等功能,这些功能可以有效地减少团队成员的运维工作量,增强系统的可用性,成为服务的标准配置。
2. 运维成本增加
这是微服务化带来的问题之一,微服务化要做服务拆分,服务节点的类型和数量会增多,同时还要额外运维一些基础服务(譬如,名字服务的 Agency)。考虑到大部分运维工作都是同一个任务在多个机器上重复执行,这样的问题最适合交由机器来完成,所以我们的解决方案就是自动化运维。我们基于 Ansible 自研了一个可视化的自动运维系统。其实研发这个系统最初目的并不是为了支持微服务化,而是为了消除人工运维事故,因为人的状态是不稳定的(有时甚至是不靠谱的),所以希望由机器来替代人来完成重复的标准动作;后来随着微服务化的推进,这个系统很自然地就接管了相关的运维工作。现在这个系统完成了整个团队 90% 以上的运维工作量。
自动运维系统架构
1. 问题发现和分析定位
业界通用的方式是全链路追踪系统(dapper & zipkip)和智能运维,我们也在正在进行这方面的工作;除此之外,我们还做了另外两件事情:异常检测和 Staging 环境建设;
异常检测:主要是从业务层面发现各种宏观指标的异常,对于广告投放系统、库存量、曝光量、点击率和计费率等都是非常受关注的业务指标;异常检测系统可以预测业务指标在当前时刻的合理范围值,然后跟实时数据作对比;如果实时数据超出预测范围就会发出报警并附带分析数据辅助进行问题分析;这部分工作由在线服务和数据团队共同完成,这个系统有效地提高了问题发现的效率。
Staging 环境建设:系统变更(包括运维和新功能发布)是引起线上故障的主要原因,所以我们需要一个系统帮助我们以很小的代价快速发现变更异常。
在功能发布时大家都会采用梯度发布的方法,譬如先升级 5% 的服务,然后观察核心指标的变化,没有明显异常就继续推进直到全量;这个方法并不是总能有效发现问题,假如一个新功能中的 bug 会导致 1% 的订单曝光下降 50%,那么在全量发布之后系统的整体曝光量也只有 0.5% 的变化,也可能因为其他订单的填充使得整体曝光量没有变化,所以仅通过整体曝光量很难发现这个问题。只有对所有订单的曝光量进行对比分析才能准确地发现这个问题。
我们在实践中利用向量余弦相似度来发现系统变更引起异常,即把一段时间内(5min)曝光的广告数量转换成向量并计算余弦相似度。那么如何得到两个向量呢?可以按照梯度发布的时间进行分割前后各生成一个向量,这个方法不够健壮,不同时间的向量本身就有一定的差异。
我们是这样来解决的:部署一个独立的投放环境(我们称为 Staging 环境,相对的原本的投放环境称为 Base 环境)承载线上的小流量(譬如 3%),所有的系统变更都先在这里进行;然后用 Staging 环境的向量与 Base 环境的向量进行相似度计算。
因为对差异非常敏感,使用余弦相似度做监控会有误报发生;不过这个并不难解决,通过一些 bad case 的分析,我们定位并消除了两个环境之间的差异(非 bug)因素;在正常情况下两个环境的相似度会保持在 95% 左右,并在遇到真正的异常时会有明显的下降触发报警。Staging 环境及相似度检测功能在实践中多次帮助我们发现系统异常。
架构设计过程中积累的经验
最后分享几点我在架构设计过程中总结的经验。
深入理解业务。在架构设计方面,业务和架构是要互相配合的,架构在满足业务需求的同时,也可以反过来给业务提需求甚至要求改变业务逻辑已达到系统的最优,这里的关键就是充分理解业务。架构上很难解决的问题,可能在业务上做个微小的调整就搞定了,能有这样的效果,何乐而不为呢。在系统或者架构优化方面,优化理论和策略已经研究的非常充分,剩下的只是如何跟业务场景进行结合和利用。
设计阶段要追求完美,实践阶段要考虑性价比,采用分阶段递进的方式演进到完美的架构。** 在设计阶段可以暂时抛开实现成本或者其他一些客观条件的束缚,按照理想的情况去做架构设计,这样得到的一个结果是我们所追求的一个理想目标,这个目标暂时达不到没关系,因为它的作用就是指明架构将来的发展或者演化的大方向;然后在结合实际的限制条件逐步调整这个完美的架构到一个可实际落地的程度,这个过程中还可以保留多个中间版本,作为架构演进升级过程的 Milestone。也可以这样理解,从现实出发,着眼于未来,随着技术发展的速度越来越快,在设计之初遇到的限制和障碍很快就会被解决,避免被这些暂时的限制和障碍遮住了对未来的想象。
监控先行。监控信息是了解系统运行状态的重要信息,大部分监控信息都要持久化用来做数据分析使用,它可以做异常检测也可以辅助进行问题的分析和定位;做好监控工作是改善 TTA(Time To Detection)和 TTM(Time To Mitigation)指标的方法之一;这里还要强调的是要在设计阶段就考虑到相关的各种监控指标、统计粒度等细节内容,在开发阶段就在系统中进行相关指标的计算和统计,在服务部署阶段将这些指标同步到监控系统中;确保服务上线之初就有相应的监控“保驾护航”,避免裸奔。
容错能力。这个世界是不完美的,不完美世界中的系统要面对各种各样的问题;在一个系统的整个生命周期中,研发运维人员要花费大量的时间来应对和解决各种错误甚至是灾难;两个方面去考虑,即 Design By Failure 和灾难演练(Netflex 已经开源了他们的相关工具)。我想谈谈自己的实际体会:
首先,在设计之初可以先划定系统的边界,分出系统内部和系统外部;从成本的角度考虑,系统内部因为可控性强,可以设定一些假设以减少相关的考量和系统容错设计;其余的系统内部问题以及系统外部的问题,优先解决影响较大的问题(譬如,外部服务不可用,对外接口访问量突增)和高频发生的问题(硬盘故障,网络割接),这样的问题大部分都有可借鉴的方案,如果因业务场景特殊而不能复用已有方案,那就要考虑自己来实现;应对外部服务不可用进行熔断并增加保底策略,访问量突增做限流,专线故障时走外网,硬盘做 Raid;其他的未考虑到问题在问题首次发生时要评估损失和应对成本来决定要否立即解决;
其次,灾难演练的这个想法跟消防演练是一样的,消防演练一方面可以发现逃生流程上的缺陷,更重要的是培养参与者的逃生常识和实操经验,在问题真正发生时能正确应对;灾难演练同理,在做自动化的同时,也要安排专人(尤其是新人)进行故障处理,要有老司机陪同进行 review 在必要时进行指导或者接管处理动作。这样才会使得团队整体的容错应急处理能力不断地提升。这个世界注定是不完美的,因此才会有也更需要完美主义者来让这个世界变得完美,哪怕是只有一点点。
在此我向大家推荐一个架构学习交流群。交流学习群号: 744642380, 里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良