常用性能優化手段及在風控系統中的應用
引言
性能優化是個恒久的話題,隨著產品的演進,業務的增長,系統能力總有達到瓶頸的一天,它不可或缺的陪伴著我們走向壯大再走向衰敗,是我們面臨的不可回避的問題。下圖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所示:
類型 | 功能 | 底層實現 | 使用頻率 |
count | 計數 | incr | 高 |
count(distinct) | 去重計數 | zset | 高 |
sum | 累積求和 | incrby | 低 |
avg | 累積求平均 | incrby | 低 |
表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?;陲L控日志的特點,我們把常用特征值(mid、buvid、ip等)和日志主體精簡出來,使用ES存儲并提供關鍵字查詢。其他詳情(參數、中間結果等)則依托于B站自研的KV存儲taishan 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。
編碼方式 | 消息長度 | gzip | xz | zstd(無字典) |
json | 2255 | 1028 | 1092 | 1075 |
msgpack | 1938 | 1088 | 1132 | 1119 |
表2:風控日志在不同編碼方式、壓縮算法下的壓縮效果(單位:字節)
圖13:各壓縮算法壓縮風控日志的內存占用
在第二輪實驗中,我們使用json編碼方式,對比了gzip和zstd有無字典兩種模式下的壓縮率。其中,字典1由1萬條UAT爬蟲場景日志訓練獲得,與線上日志差異較大。字典2由10000條線上爬蟲場景日志訓練。首先是單條日志壓縮時不同zstd字典的影響,如表3所示。結果上,不使用字典時壓縮率最差,使用不匹配的字典略有提升。而使用匹配的字典后,zstd的壓縮率有顯著的提高。然后是對爬蟲場景不同數量的日志進行批量壓縮對壓縮率的影響,如表4所示。zstd在小日志壓縮上使用匹配的字典壓縮效率較好,隨著每批次數量增多,壓縮率會相對降低,最終與gzip相當。批量壓縮能夠顯著提高兩種算法的總體壓縮率,但單次超過10條以后遇到了邊際效應,收益急速降低。此外,我們基于任意單條日志進行了多輪性能測試,表5展示了其中5輪測試結果。從壓縮性能角度分析,無論是否使用字典,zstd壓縮的效率都遠超過gzip。
日志來源場景 | 消息長度 | gzip | zstd (無字典) | zstd (字典1) | zstd (字典2) | 說明 |
裂變拉新分享 | 25277 | 3869 | 4564 | 4236 | 4412 | 非同場景字典 |
爬蟲 | 4283 | 1434 | 1503 | 996 | 438 | 同場景字典 |
表3:風控日志詳情在zstd算法下使用不同字典的壓縮效果
(單位:字節)
每批 日志數 | 總計 日志數 | gzip | zstd (無字典) | zstd (字典1) | zstd (字典2) |
1 | 100 | 154370 (1.000) | 160582 (1.040) | 111708 (0.723) | 59195 (0.383) |
10 | 100 | 58984 (1.000) | 67036 (1.137) | 59942 (1.016) | 47684 (0.808) |
20 | 100 | 56085 (1.000) | 63103 (1.125) | 58509 (1.043) | 56085 (1.000) |
50 | 100 | 49152 (1.000) | 55107 (1.121) | 52927 (1.077) | 48891 (0.995) |
100 | 100 | 52439 (1.000) | 56926 (1.086) | 56076 (1.069) | 53624 (1.023) |
1 | 1000 | 1607512 (1.000) | 1668363 (1.038) | 1154260 (0.718) | 629463 (0.392) |
100 | 1000 | 536579 (1.000) | 580076 (1.081) | 572053 (1.066) | 547021 (1.019) |
500 | 1000 | 546506 (1.000) | 570690 (1.044) | 571252 (1.045) | 565319 (1.034) |
表4:不同數量的日志壓縮后數據大小總和與壓縮率對比(單位:字節)
測試序號 | gzip | zstd (無字典) | zstd (字典1) | zstd (字典2) |
1 | 123142 | 27509 | 31425 | 24474 |
2 | 139387 | 28246 | 31014 | 22763 |
3 | 148184 | 37118 | 37409 | 60840 |
4 | 158618 | 25168 | 29369 | 26504 |
5 | 170312 | 33782 | 47756 | 28951 |
表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:風控日志詳情批量存儲與查詢過程
緩存——多級緩存+布隆過濾器
緩存是最常見的加速數據交換的技術,通?;趦却娴雀咚俅鎯ζ鲗崿F,其本質就是用空間換時間,犧牲一定的數據實時性,以減少各類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,保證及時獲取新插入個體。
對于維護問題,我們實現了完整的構建工具。同時,基于安全性考慮實現了備份和快速恢復流程?;跔顟B機,我們定義了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查詢結果占比
優化項目 | 優化前 | 優化后 | 降幅 | 說明 |
服務CPU使用率 | 50.5% | 17.5% | 65% | 日峰值同比 |
Redis 內存占用 | 256GB | 50GB | 80% | 日峰值同比 |
Redis 網絡IO | 174/187Mbps | 13.7/6.7Mbps | 92%/96% | 日峰值同比 |
Redis CPU使用率 | 42% | 4% | 90% | 日峰值同比 |
BF Redis 內存占用 | 0 | 7GB | - | 新增10個節點 |
BF Redis 網絡IO | 0 | 71.4/7.5Mbps | - | 新增10個節點 |
BF Redis CPU使用率 | 0 | 10% | - | 新增10個節點 |
MySQL 讀QPS | 12k | 600 | 95% | 日峰值同比 |
表6:名單庫BF多級緩存優化收益對比
總結
性能優化的手段有多種方式,本篇文章只是結合近期在風控系統中的應用實踐進行的一個總結,需要說明的是,其中有的優化手段有利也有弊,得到的同時也在失去,可見,任何優化手段都得以業務可接受為前提,因地制宜才是正道。正如Linux性能優化大師Brendan Gregg一再強調的:切忌過早優化、過度優化。