引言
性能优化是个恒久的话题,随着产品的演进,业务的增长,系统能力总有达到瓶颈的一天,它不可或缺的陪伴着我们走向壮大再走向衰败,是我们面临的不可回避的问题。下图1展示了风控系统近半年来承载流量的增长趋势,可见最近半年来流量高速增长,且对于可预见的未来而言,接入流量还会持续高增。伴随着流量的增长,系统各方面--存储、计算、IO等都表现出一定的瓶颈,通过原始简单的水平扩容并不能解决所有的问题,而且还会带来成本的上升。因此,我们近期对系统进行了一系列优化改造, 目的是满足未来一段时间内业务的增长使用,降低接口的耗时满足某些延时敏感型业务的需要,同时也伴随着一定的IT成本优化。本文结合常见的性能优化手段(预取、批量、异步、压缩、缓存),及在风控系统中的实践进行总结,希望能给读者对于性能优化实践带来一些参考。
图1:风控引擎流量增长趋势
预取——特征预计算
预取作为一种提速手段,通常与缓存搭配使用,在缓存空间换时间的基础上更进一步,以时间换时间,通过预加载来提升性能。常见的使用有,数据库从磁盘加载页时的预读多个页减少磁盘IO、CPU缓存加载一片连续的内存空间提高计算的速度,也就是我们常说的CPU对数组友好对链表不友好的原因。
在Gaia风控引擎中,一次业务请求在引擎内部的执行流程如下图2所示,会经历场景因子(特征)->规则->决策的计算过程, 而计算因子是整个链路最耗时的部分,通常占请求响应时间的70%以上,包括对账号信息、名单库、模型数据、用户画像、设备指纹、三方特征等多个下游的读扩散–特征因子获取,加上场景的上百条规则执行,请求耗时常规在250ms左右,这也是22年中以前我们承诺给主站大部分业务的响应时间,直到电商业务的接入,对我们引擎的响应时间提出了100ms以内响应的要求,迫使我们对引擎进行了一些优化,其中之一就是近线引擎带来的特征预取优化,其演进流程如下图3示:
图2:风控引擎一次请求执行过程
图3:风控特征获取流程
基于一个前提:对同一个主体,按照其行为时序数据消费,slb数据源消费处理完成大多数时候都要比业务请求风控早。因此,我们通过对slb实时流数据消费预读取下游特征,并将结果缓存在redis中,当业务请求风控时,直接获取redis的数据,避免或减少rpc回源特征,以此来降低风控接口的耗时。
通过这套机制,我们按照特征变动频率对特征分层设置不同的缓存时间,同时在一些对数据一致性要求较低的场景对特征开启缓存读,其缓存命中率能达到90%以上,核心业务场景如电商交易,接口响应耗时从80ms下降到25ms。
图4:特征缓存命中率
图5:电商交易场景请求风控接口TP99耗时曲线图
批量——特征批量获取
批量一般是对I/O操作的优化,同样可看作是时间换时间,通过合并、批量进行读取或写入以减少对I/O的操作来提升吞吐和性能。常见的使用有,kafka消费数据的时候批量拉取指定的数据条数,mysql写入redolog/binlog时的组提交(group commit)机制,都是通过批量的优化来减少I/O提升性能的。
在Gaia引擎中最常用的特征为对账号管控/风控名单值的获取,一次业务风险判断请求会涉及到对请求主体(mid、buvid、ip、ua)的各种黑/白名单值获取,以主体mid为例,往往会并发调用下游名单接口多次,判断mid是否在同设备多账号黑名单、虚假账号黑名单、通用白名单等名单中,从而形成不同的因子供规则使用,这种方式就会造成对同下游的同接口的读扩散流量放大浪费资源,且要保持下游接口低延迟往往需要下游进行扩容保证CPU等资源使用率在一定的水位以下。因此,为了降低自身及下游的服务资源和I/O,优化的手段就是将多次请求合并为一次请求,其优化流程如下图6所示:
图6:名单类因子读取优化流程
通过将多次独立下游请求获取给定黑/白名单转化为一次批量请求获取主体所有有效名单,同时结合本地内存判断因子请求名单与所有名单是否有交集来实现原有相同的功能,并通过local cache及singleflight优化,降低对下游接口调用量69%。
图7:实验环境名单库接口批量优化效果
异步——累积因子同步改异步计算优化
异步通常和同步一起对比,其区别在于发起请求之后是立即返回还是等待结果,常用于在系统内部有大量I/O操作时,通过异步提升吞吐。常见的使用有,基于write-back模式向缓存写入数据,mysql异步传输binlog进行主从复制等。
在Gaia引擎中,一次请求会涉及对很多特征因子的计算,其中,最常用的是基于redis实现的累积因子,其包含如下几种类型(见表1),以count(distinct)类型为例,一次计算过程包含1写zadd,1读zcount,概率触发zrem清除不在有效时间窗口内的过期数据,其最多对redis请求3次,最少/均摊2次,且zset这几个操作的时间复杂度都在O(log n)以上,加上一次请求往往会对多个累积因子进行计算(读写扩散),这给redis集群带来了较大的计算压力,由于overload对集群实例扩容的限制,我们对redis集群的水平扩容也遇到了瓶颈。考虑当前引擎流量的情况:爬虫类业务与常规业务流量占比为1.5:1,且爬虫类业务流量还在持续高涨,鉴于爬虫类业务风控的特性(非资产安全,容忍一定的漏过),我们以牺牲数据一致性为代价,对爬虫类业务的累积因子进行了异步计算优化,以减少对redis的调用,其计算演进流程如下图8所示:
表1:风控引擎支持累积因子类型
图8:累积因子计算(优化前/后)流程
基于railgun(关于B站自研异步事件处理平台,可参阅:从1到亿,如何玩好异步消息?CQRS架构下的异步事件治理实践)提供的内存队列聚合功能,我们对累积因子写操作进行了异步化改造,并结合聚合功能,在设置的时间/数量阈值条件下,对相同累积key进行聚合并在内存中去重后批量写入redis,将多次同步redis I/O减少为异步写1次。而优化的效果从三个角度评估,从成本角度看:其对redis的调用qps减少了35%以上(如图9);从接口耗时看:TP99有一定的下降; 从对风控规则的召回影响看,前后召回趋势基本一致,且打击总量差距不大,在可接受的范围内。
图9:单场景累积因子计算优化前/后对redis的调用量情况
图10:累积因子计算优化前/后接口耗时情况
图11:累积因子计算优化前/后规则召回的情况
压缩——日志存储优化
压缩是常见的用于数据传输、持久化等过程中减少带宽、存储占用的方法,本质是通过编码的方式提高数据密度,减少数据的冗余度,一般以数据压缩速度和压缩率两个指标来衡量压缩过程的效能。常用的无损压缩方式有:gzip、xz、lz4、zlib、zstd等。
对于风控业务来说,每一次风控请求会产生包含输入参数、计算详情(计算规则、特征因子等中间结果的快照)、打击决策等多个维度的日志数据。我们需要提供准实时的查询能力,用于辅助人工判定风控决策的召回和误伤等情况。由于一次风控分析可能经历了上百条规则、上千个特征的计算,单条日志数据的平均大小达到了11KB左右,最大的高达几十KB。基于风控日志的特点,我们把常用特征值(mid、buvid、ip等)和日志主体精简出来,使用ES存储并提供关键字查询。其他详情(参数、中间结果等)则依托于B站自研的KV存储taishan KV(*关于B站自研分布式KV存储的介绍可以参阅:B站分布式KV存储实践),以请求traceId为key进行gzip压缩后存储。查询时先基于ES查询日志主体,再对分页记录点查日志详情并合并展示,其流程如图12所示。这些优化帮助风控度过了22年之前接入场景大量增长的阶段,但随着持续接入反爬虫等大流量读场景与降本增效带来的双重压力下,风控日志存储陷入了瓶颈。
图12:旧风控日志详情存储与查询过程
以taishan KV存储的日志详情为例,总存储达到了16TB左右。因此,我们期望能够利用压缩率更高的编码方式和压缩算法替代json+gzip的组合,进一步优化日志存储。通过调研常见压缩算法,结合日志详情无压缩速度要求的特点,我们采集了线上真实日志作为实验集,选取了protobuf、msgpack等编码方式和xz、zstd两种算法进行了多次对比实验。
在第一轮实验中,我们对比了单条日志在不同编码方式下不同压缩算法的压缩率。实验随机取同一场景下任意一条日志详情进行编码和压缩,重复多次后取各阶段数据长度平均值。其中,由于风控日志包含了许多嵌套和非固定结构,难以使用protobuf等需要预定义结构的序列化方式。因此我们尝试了另一种高效的通用序列化框架msgpack。结果如表2所示。从结果上看,msgpack虽然编码后比json更简短,但由于产生了许多非文本结构,最终压缩率不及json。xz算法由于压缩率无明显优势且内存占用较大而被弃用(图13)。无字典模式下的zstd压缩率略弱于gzip。
表2:风控日志在不同编码方式、压缩算法下的压缩效果(单位:字节)
图13:各压缩算法压缩风控日志的内存占用
在第二轮实验中,我们使用json编码方式,对比了gzip和zstd有无字典两种模式下的压缩率。其中,字典1由1万条UAT爬虫场景日志训练获得,与线上日志差异较大。字典2由10000条线上爬虫场景日志训练。首先是单条日志压缩时不同zstd字典的影响,如表3所示。结果上,不使用字典时压缩率最差,使用不匹配的字典略有提升。而使用匹配的字典后,zstd的压缩率有显著的提高。然后是对爬虫场景不同数量的日志进行批量压缩对压缩率的影响,如表4所示。zstd在小日志压缩上使用匹配的字典压缩效率较好,随着每批次数量增多,压缩率会相对降低,最终与gzip相当。批量压缩能够显著提高两种算法的总体压缩率,但单次超过10条以后遇到了边际效应,收益急速降低。此外,我们基于任意单条日志进行了多轮性能测试,表5展示了其中5轮测试结果。从压缩性能角度分析,无论是否使用字典,zstd压缩的效率都远超过gzip。
表3:风控日志详情在zstd算法下使用不同字典的压缩效果(单位:字节)
表4:不同数量的日志压缩后数据大小总和与压缩率对比(单位:字节)
表5:风控日志在gzip与zstd算法压缩性能对比(单位:ns/op)
综合以上实验,虽然zstd算法在压缩效率上远优于gzip,但仅在使用匹配的字典集时,对单条日志的压缩率远优于gzip。另外,无论哪种压缩算法都在批量压缩中收益明显,最高能够减少60%的存储。最后,由于我们对压缩效率的需求较低,且训练zstd字典等改造成本较大等原因,我们选择对现有的风控日志详情进行批量压缩改造(图14)。实现上,基于railgun提供的聚合队列功能,我们将消费的日志分成n条若干批次,分配一个批处理ID(BatchId)并存储到日志主体中,日志以BatchId为key批量gzip压缩后存入taishan KV。查询时,获取分页下待查的BatchId,去重后批量从taishan KV拉取数据,解压后合并到日志中。对于查询效率,最差情况下,每个BatchId都没有去重,即每条数据多查询了n-1条,请求数量不变,数据量变大N倍。实际查询中,由于大多数查询结果都是同一时段的连续数据集,因此实际去重效果较好,查询效率略有提升。从存储优化上看,taishan KV写入QPS由8k下降至1k左右,每秒写入量由78MB下降为55MB,降幅约30%。表存储TTL为7天,7日存储下降约6TB,降幅约38%。
图14:风控日志详情批量存储与查询过程
缓存——多级缓存+布隆过滤器
缓存是最常见的加速数据交换的技术,通常基于内存等高速存储器实现,其本质就是用空间换时间,牺牲一定的数据实时性,以减少各类IO的频率,提升整体响应速度。常见的缓存方案包括Cache Aside、Read/Write Through、Write-back等,适用于不同的业务场景。
在Gaia引擎中,名单库服务是风险特征判断的重要组成部分,采用了最常用的Cache Aside模式。名单库服务的需求是一种经典的读多写少场景:引擎将分属不同名单的风险主体持久化存储(100QPS以下),同时提供接口查询指定主体是否属于某一名单(上万QPS)。因此,初期的名单库采用了localCache+Redis Cache+MySQL存储的模式实现查询过程:写入时删除缓存,查询时依次查询Cache,直到回源DB查询,并将实际值或空值写入Cache。这一模式在低流量条件下表现优异,直到风控接入流量急速增长至十万以上时,出现了越来越多的瓶颈问题,如:Redis CPU负载高、内存占用高、DB回源超时等,DB存储的名单个体数目也超过了3千万并且持续快速增长。这迫使我们做了许多优化来满足后续潜在的百万级QPS查询需求。其中最有效的就是布隆过滤器(Bloom Filter,BF)多级缓存优化,具体的优化过程如图15所示。对于写过程来说,新增更新服务订阅了名单表的binlog,提供BF的全量/增量更新。对于读过程来说,在原有多级缓存前新增BF Cache查询,在确认特征不存在的情况下直接返回结果。
图15:名单库服务BF多级缓存优化过程——新老流程对比
由于名单库查询时,大多数用户并无风险,名单库查询存在普遍的缓存穿透问题。因此名单库查询天然配适BF的使用场景,但要实际落地,仍然面临着许多问题:
HotKey和BigKey问题。由于全集记录超过3千万条,单个BF容量越大,value越大,越容易出现集中访问同一个key的热点问题。因此需要对BF进行合理的拆分。
维护问题。BF需要维护一个全集数据,因此无论是本地还是分布式实现,都需要具备基于全量数据构建的能力。从数据安全性角度出发,BF存在人为操作等导致非预期异常的可能,BF需要具备备份和快速恢复能力。
数据一致性问题。一方面,由于BF能够确定的表述一个key不存在于全集中,因此需要保证DB与BF的最终一致性。为了保证新记录一定存入BF,插入BF需要支持无限重试。另一方面,由于BF存在假阳率,且不能删除个体,随着名单过期、key数量逼近BF容量等情况的发生,BF实际假阳率会逐级升高导致过滤效果变差。因此需要支持BF容量扩充和实现定期重建的能力。
由于Redis布隆过滤器插件完整的支持了BF的操作和自动扩容,我们选择使用Redis作为BF的分布式实现。对于HotKey和BigKey问题,我们对BF进行了随机分片。为了保证BF Key分布均匀,人为的将分片总数BF_SLICE_SIZE定义为4倍Redis Slot数量,即65536个。每一个分片Key的命名格式为"{libKey_bf_" + sliceIndex + "}",其中sliceIndex为分片序号,使用花括号来保证相同前缀的BF利用rename命令迭代替换时处于同一个Slot中。名单个体将按照sliceIndex = HashKey % BF_SLICE_SIZE计算自己所属的分片,其中HashKey的取值为名单个体值的IEEE CRC32绝对值。此外,我们对BF设置了独立的本地缓存以减少实际调用。由于BF只增不减的特点,对于阳性结果,我们设置了较长TTL。而对于阴性结果,则按业务容忍度设置了秒级TTL,保证及时获取新插入个体。
对于维护问题,我们实现了完整的构建工具。同时,基于安全性考虑实现了备份和快速恢复流程。基于状态机,我们定义了BF的构建过程:初始化、异步构建、同步测试、BF更新等。整个构建过程使用分布式锁保证唯一性,基于railgun定时任务周期性触发,整个过程记录构建进度并提供实时展示和查询。全过程如图16所示。初始化时,会Dump生成正在运行的BF备份文件并存储到对象存储。生成新的BF临时分片,以"_temp"尾缀区分。更新服务基于状态变化将增量个体双写到临时BF中。异步构建过程会分批获取完整的名单表,批量写入存量个体到临时BF中。之后进行同步测试,利用少量增量和存量个体模拟查询临时BF,当所有测试个体都存在于BF时通过测试。最后,将临时BF原子性地替换正式BF,完成构建过程。快速恢复过程基于构建的整体流程,部分模块略有差异:初始化阶段会获取备份文件并尝试restore数据到临时BF中。异步构建时则基于备份时间点开始获取存量数据。
图16:BF构建与快速恢复流程
对于数据一致性问题,我们提供了完整的控制、评估和测试BF一致性的流程。为了保证数据安全,我们定义了BF测试模式和正常模式两种运行方式,并可以按比例配置运行,如图17所示。测试模式下,查询的BF值不会生效,流量进入缓存查询流程。之后基于查询实际值对比结果进行监控报点并建立真阴性比例四个9等监控告警。处于正常模式则会对BF假阳等情况进行报点等。实际上线后,服务长期保持一定比例的流量(当前为0.1%)运行测试模式,用于持续评估线上BF运行状态。图18展示了BF查询结果占比,约95%的查询为阴性,优化收益明显,如表6所示。在后续压测中,名单库服务具备了支撑百万级流量的能力。
图17:名单库服务BF多级缓存优化过程
图18:名单库服务线上流量BF查询结果占比
表6:名单库BF多级缓存优化收益对比
总结
性能优化的手段有多种方式,本篇文章只是结合近期在风控系统中的应用实践进行的一个总结,需要说明的是,其中有的优化手段有利也有弊,得到的同时也在失去,可见,任何优化手段都得以业务可接受为前提,因地制宜才是正道。正如Linux性能优化大师Brendan Gregg一再强调的:切忌过早优化、过度优化。