背景介紹
風控簡介
二十一世紀,資訊化時代到來,網際網路行業的發展速度遠快於其他行業。一旦商業模式跑通,有利可圖,資本立刻蜂擁而至,助推更多企業不斷的入場進行快速的複製迭代,企圖成為下一個“行業領頭羊”。
帶著資本入場的玩家因為不會有資金的壓力,只會更多的關注業務發展,卻忽略了業務上的風險點。強大如拼多多也被“薅羊毛”大軍光顧損失千萬。
風控,即風險管理(risk management),是一個管理過程,包括對風險的定義、測量、評估和應對風險的策略。目的是將可避免的風險、成本及損失極小化[1]。
特徵平臺簡介
網際網路企業每時每刻都面臨著黑灰產的各種攻擊。業務安全團隊需要事先評估業務流程中有風險的地方,再設定卡點,用來採集相關業務資訊,識別當前請求是否有風險。專家經驗(防控策略)就是在長期以往的對抗中產生的。
策略的部署需要一個個特徵來支援,那什麼是特徵?
特徵分為基礎型特徵、衍生型特徵、統計型特徵等,舉例如下:
- 基礎型特徵:可以直接從業務獲取的,如訂單的金額、買家的手機號碼、買家地址、賣家地址等
- 衍生特徵:需要二次計算,如買家到買家的距離、手機號前3位等
- 統計型特徵:需要實時統計的,如5分鐘內某手機號下購買訂單數、10分鐘內購買金額大於2w元訂單數等
隨著業務的迅猛發展,單純的專家經驗已不能滿足風險識別需求,演算法團隊的加入使得攔截效果變得更加精準。演算法部門人員通過統一演算法工程框架,解決了模型和特徵迭代的系統性問題,極大地提升了迭代效率。
根據功能不同,演算法平臺可劃分為三部分:模型服務、模型訓練和特徵平臺。其中,模型服務用於提供線上模型預估,模型訓練用於提供模型的訓練產出,特徵平臺則提供特徵和樣本的資料支撐。本文將重點闡述特徵平臺在建設過程中實時計算遇到的挑戰以及優化思路。
挑戰與方案
面臨的挑戰
業務發展的初期,我們可以通過硬編碼的方式滿足策略人員提出的特徵需求,協同也比較好。但隨著業務發展越來越快,業務線越來越多,營銷玩法越來越複雜,使用者數和請求量成幾何倍上升。適用於早期的硬編碼方式出現了策略分散無法管理、邏輯同業務強耦合、策略更新迭代率受限於開發、對接成本高等多種問題。此時,我們急需一套線上可配置、可熱更新、可快速試錯的特徵管理平臺。
舊框架的不足
實時框架1.0:基於 Flink DataStream API構建
如果你熟悉 Flink DataStream API,那你肯定會發現 Flink 的設計天然滿足風控實時特徵計算場景,我們只需要簡單的幾步即可統計指標,如下圖所示:
Flink DataStream 流圖
實時特徵統計樣例程式碼如下:
// 資料流,如topic
DataStream<ObjectNode> dataStream = ...
SingleOutputStreamOperator<AllDecisionAnalyze> windowOperator = dataStream
// 過濾
.filter(this::filterStrategy)
// 資料轉換
.flatMap(this::convertData)
// 配置watermark
.assignTimestampsAndWatermarks(timestampAndWatermarkAssigner(config))
// 分組
.keyBy(this::keyByStrategy)
// 5分鐘滾動視窗
.window(TumblingEventTimeWindows.of(Time.seconds(300)))
// 自定義聚合函式,內部邏輯自定義
.aggregate(AllDecisionAnalyzeCountAgg.create(), AllDecisionAnalyzeWindowFunction.create());
1.0框架不足:
- 特徵強依賴開發人員編碼,簡單的統計特徵可以抽象,稍微複雜點就需要定製
- 迭代效率低,策略提需求、產品排期、研發介入、測試保障、一套流程走完交付最少也是兩週
- 特徵強耦合,任務拆分難,一個 JOB 包含太多邏輯,可能新上的特徵邏輯會影響之前穩定的指標
總的來說,1.0在業務初期很適合,但隨著業務發展,研發速度逐漸成為瓶頸,不符合可持續、可管理的實時特徵清洗架構。
實時框架2.0:基於 Flink SQL 構建
1.0架構的弊端在於需求到研發採用不同的語言體系,如何高效的轉化需求,甚至是直接讓策略人員配置特徵清洗邏輯直接上線?如果按照兩週一迭代的速度,可能線上早被黑灰產薅的“面目全非”了。
此時我們研發團隊注意到 Flink SQL,SQL 是最通用的資料分析語言,數分、策略、運營基本必備技能,可以說 SQL 是轉換需求代價最小的實現方式之一。
看一個 Flink SQL 實現示例:
-- error 日誌監控
-- kafka source
CREATE TABLE rcp_server_log (
thread varchar,
level varchar,
loggerName varchar,
message varchar,
endOfBatch varchar,
loggerFqcn varchar,
instant varchar,
threadId varchar,
threadPriority varchar,
appName varchar,
triggerTime as LOCALTIMESTAMP,
proctime as PROCTIME(),
WATERMARK FOR triggerTime AS triggerTime - INTERVAL '5' SECOND
) WITH (
'connector.type' = 'kafka',
'connector.version' = '0.11',
'connector.topic' = '${sinkTopic}',
'connector.startup-mode' = 'latest-offset',
'connector.properties.group.id' = 'streaming-metric',
'connector.properties.bootstrap.servers' = '${sinkBootstrapServers}',
'connector.properties.zookeeper.connect' = '${sinkZookeeperConnect}}',
'update-mode' = 'append',
'format.type' = 'json'
);
-- 此處省略 sink_feature_indicator 建立,參考 source table
-- 按天 按城市 各業務線決策分佈
INSERT INTO sink_feature_indicator
SELECT
level,
loggerName,
COUNT(*)
FROM rcp_server_log
WHERE
(level <> 'INFO' AND `appName` <> 'AppTestService')
OR loggerName <> 'com.test'
GROUP BY
TUMBLE(triggerTime, INTERVAL '5' SECOND),
level,
loggerName;
我們在開發 Flink SQL 支援平臺過程中,遇到如下問題:
- 一個 SQL 如果清洗一個指標,那麼資料來源將極大浪費
- SQL merge,即一個檢測如果同源 SQL 則進行合併,此時將極大增加作業複雜度,且無法定義邊界
- SQL 上線需要停機重啟,此時如果任務中包含大量穩定指標,會不會是臨界點
技術實現
痛點總結
業務&研發痛點圖
實時計算架構
策略/演算法人員每天需要觀測實時和離線資料分析線上是否存在風險,針對有風險的場景,會設計防控策略,透傳到研發側其實就是一個個實時特徵的開發。所以實時特徵的上線速度、質量交付、易用性完全決定了線上風險場景能否及時堵漏的關鍵。
在統一實時特徵計算平臺構建之前,實時特徵的產出上主要有以下問題:
- 交付速度慢,迭代開發:策略提出到產品,再到研發,提測,在上線觀測是否穩定,速度奇慢
- 強耦合,牽一髮動全身:怪獸任務,包含很多業務特徵,各業務混在一起,沒有優先順序保證
- 重複性開發:由於沒有統一的實時特徵管理平臺,很多特徵其實已經存在,只是名字不一樣,造成極大浪費
平臺話建設,最重要的是“整個流程的抽象”,平臺話的目標應該是能用、易用、好用。基於如上思想,我們嘗提取實時特徵研發痛點:模板化 + 配置化,即平臺提供一個實時特徵的建立模板,使用者基於該模板,可以通過簡單的配置即可生成自己需要的實時特徵。
Flink 實時計算架構圖
計算層
資料來源清洗:不同資料來源抽象 Flink Connector,標準輸出供下游使用
資料拆分:1拆N,一條實時訊息可能包含多種訊息,此時需要資料裂變
動態配置:允許在不停機 JOB 情況下,動態更新或新增清洗邏輯,涉及特徵的清洗邏輯下發
指令碼載入:Groovy 支援,熱更新
RTC: 即 Real-Time Calculate,實時特徵計算,高度抽象的封裝模組
任務感知:基於特徵業務域、優先順序、穩定性,隔離任務,業務解耦
服務層
統一查詢SDK: 實時特徵統一查詢SDK,遮蔽底層實現邏輯
基於統一的 Flink 實時計算架構,我們重新設計了實時特徵清洗架構
Flink 實時計算資料流圖
特徵配置化 & 儲存/讀取
特徵底層的儲存應該是“原子性”的,即最小不可分割單位。為何如此設計?實時統計特徵是和視窗大小掛鉤的,不同策略人員防控對特徵視窗大小有不同的要求,舉例如下:
- 可信裝置判定場景:其中當前手機號登入時長視窗應適中,不宜過短,防擾動
- 提現欺詐判定場景:其中當前手機號登入時長視窗應儘量短,短途快速提現的,結合其它維度,快速定位風險
基於上述,急需一套通用的實時特徵讀取模組,滿足策略人員任意視窗需求,同時滿足研發人員快速的配置清洗需求。我們重構後特徵配置模組如下:
特徵配置抽象模組
實時特徵模組:
- 特徵唯一標識
- 特徵名稱
- 是否支援視窗:滑動、滾動、固定大小視窗
- 事件切片單位:分鐘、小時、天、周
- 主屬性:即分組列,可以多個
- 從屬性:聚合函式使用,如去重所需輸入基礎特徵
業務留給風控的時間不多,大多數場景在 100 ms 以內,實時特徵獲取就更短了,從以往的研發經驗看,RT 需要控制在 10 ms 以內,以確保策略執行不會超時。所以我們的儲存使用 Redis,確保效能不是瓶頸。
清洗指令碼熱部署
如上述,實時特徵計算模組強依賴於上游訊息內傳遞的“主屬性” 和 “從屬性”,此階段也是研發需要介入的地方,如果訊息內主屬性欄位不存在,則需要研發補全,此時不得不加入程式碼的發版,那又會回到原始階段面臨的問題:Flink Job 需要不停的重啟,這顯然是不能接受的。
此時我們想到了 Groovy,能否讓 Flink + Groovy,直接熱部署程式碼?答案是肯定的!
由於我們抽象了整個 Flink Job 的計算流圖,運算元本身是不需要變更的,即 DAG 是固定不變的,變得是運算元內部關聯事件的清洗邏輯。所以,只要關聯清洗邏輯和清洗程式碼本身變更,即不需要重啟 Flink Job 完成熱部署。
Groovy 熱部署核心邏輯如圖所示:
清洗指令碼配置與載入圖
研發或策略人員在管理後臺(Operating System)新增清洗指令碼,並存入資料庫。Flink Job 指令碼快取模組此時會感知指令碼的新增或修改(如何感知看下文整體流程詳解)
- warm up:指令碼首次執行較耗時,首次啟動或者快取更新時提前預熱執行,保證真實流量進入指令碼快速執行
- cache:快取已經在好的 Groovy 指令碼
- Push/Poll:快取更新採用推拉兩種模式,確保資訊不回丟失
- router:指令碼路由,確保訊息能尋找到對應指令碼並執行
指令碼載入核心程式碼:
// 快取,否則無限載入下去會 metaspace outOfMemory
private final static Map<String, GroovyObject> groovyObjectCache = new ConcurrentHashMap<>();
/**
* 載入指令碼
* @param script
* @return
*/
public static GroovyObject buildScript(String script) {
if (StringUtils.isEmpty(script)) {
throw new RuntimeException("script is empty");
}
String cacheKey = DigestUtils.md5DigestAsHex(script.getBytes());
if (groovyObjectCache.containsKey(cacheKey)) {
log.debug("groovyObjectCache hit");
return groovyObjectCache.get(cacheKey);
}
GroovyClassLoader classLoader = new GroovyClassLoader();
try {
Class<?> groovyClass = classLoader.parseClass(script);
GroovyObject groovyObject = (GroovyObject) groovyClass.newInstance();
classLoader.clearCache();
groovyObjectCache.put(cacheKey, groovyObject);
log.info("groovy buildScript success: {}", groovyObject);
return groovyObject;
} catch (Exception e) {
throw new RuntimeException("buildScript error", e);
} finally {
try {
classLoader.close();
} catch (IOException e) {
log.error("close GroovyClassLoader error", e);
}
}
}
標準訊息 & 清洗流程
策略需要統計的訊息維度很雜,涉及多個業務,研發本身也有監控用到的實時特徵需求。所以實時特徵對應的資料來源是多種多樣的。所幸 Flink 是支援多種資料來源接入的,對於一些特定的資料來源,我們只需要繼承實現 Flink Connector 即可滿足需求,我將拿 Kafka舉例,整體流程是如何清洗實時統計特徵的。
首先介紹風控整體資料流,多個業務場景對接風控中臺,風控內部核心鏈路是:決策引擎、規則引擎、特徵服務。
一次業務請求決策,我們會非同步記錄下來,併傳送Kafka訊息,用於實時特徵計算 & 離線埋點。
風控核心資料流圖
標準化訊息模板
Flink 實時計算 Job 在接收到 MQ 訊息後,首先是訊息模板標準化解析,不同的 Topic 對應訊息格式不一致,JSON、CSV、異構(如錯誤日誌類訊息,空格隔斷,物件內包含 JSON 物件)等。
為方便下游運算元統一處理,標準化後訊息結構如下 JSON 結構:
public class RcpPreProcessData {
/**
* 渠道,可以直接寫topic即可
*/
private String channel;
/**
* 訊息分類 channel + eventCode 應唯一確定一類訊息
*/
private String eventCode;
/**
* 所有主從屬性
*/
private Map<String, Object> featureParamMap;
/**
* 原始訊息
*/
private ObjectNode node;
}
訊息裂變
一條“富訊息”可能包含大量的業務資訊,某些實時特徵可能需要分別統計。舉例,一條業務請求風控的上下文訊息,包含本次訊息是否拒絕,即命中了多少策略規則,命中的規則是陣列,可能包含多條命中規則。此時如果想基於一條命中的規則去關聯其它屬性統計,就需要用到訊息的裂變,由1變N。
訊息裂變的邏輯由運營後臺通過 Groovy 指令碼編寫,定位清洗指令碼邏輯則是 channel(父) + eventCode(子),此處尋找邏輯分“父子”,“父”邏輯對當前 channel 下所有邏輯適用,避免單獨配置 N 個 eventCode 的繁瑣,“子”邏輯則對特定的eventCode適用。
訊息清洗 & 剪枝
訊息的清洗就是我們需要知道特徵需要哪些主從屬性,帶著目的清洗更清晰,定位清洗的指令碼同上,依然依據 channel + eventCode 實現。清洗出的主從屬性存在 featureParamMap 中,供下游實時計算使用。
此處需要注意的是,我們一直是帶著原始訊息向下傳遞的,但如果已經確認了清洗的主從屬性,那麼原始訊息就沒有存在的必要了,此時我們需要“剪枝”,節省 RPC 呼叫過程 I/O 流量的消耗。
至此,一條原始訊息已經加工成只包含 channel(渠道)、eventCode(事件型別)、featureParamMap(所有主從屬性),下游運算元只需要且僅需要這些資訊即可計算。
實時計算
依然同上面兩個運算元,實時計算運算元依賴 channel + eventCode 查詢到對應實時特徵後設資料,一個事件可能存在多個實時特徵配置,運營平臺填寫好實時特徵配置後,依據快取更新機制,快速分發到任務中,依據 Key構造器 生成對應的 Key,傳遞下游直接 Sink 到 Redis中。
任務問題排查&調優思路
任務的排查是基於完善的監控上實現的,Flink 提供了很多有用的 Metric 供我們排查問題,如下是我羅列的常見的任務異常,希望對你有所幫助。
TaskManager Full GC 問題排查
出現上面這個異常的可能原因是因為:
- 大視窗:90% TM 記憶體爆表,都是大視窗導致的
- 記憶體洩漏:如果是自定義節點,且涉及到快取等很容易導致記憶體膨脹
解決辦法:
- 合理制定視窗導線,合理分配 TM 記憶體(1.10預設是1G),聚合資料應交由 Back State 管理,不建議自己寫物件儲存
- 可 attach heap 快照排查異常,分析工具如 MAT,需要一定的調優經驗,也能快速定位問題
Flink Job 反壓
出現上面這個異常的可能原因是因為:
- 資料傾斜:90%的反壓,一定是資料傾斜導致的
- 並行度並未設定好,錯誤估計資料流量或單個運算元計算效能
解決辦法:
- 資料清洗參考下文
- 對於並行度,可以在訊息傳遞過程中埋點,看各個節點cost
資料傾斜
核心思路:
- key 加隨機數,然後執行 keyby 時會根據新 key 進行分割槽,此時會打散 key 的分佈,不會造成資料傾斜問題
- 二次 keyby 進行結果統計
打散邏輯核心程式碼:
public class KeyByRouter {
private final static String SPLIT_CHAR = "#";
/**
* 不能太散,否則二次聚合還是會有資料傾斜
*
* @param sourceKey
* @return
*/
public static String randomKey(String sourceKey) {
int endExclusive = (int) Math.pow(2, 7);
return sourceKey + SPLIT_CHAR + (RandomUtils.nextInt(0, endExclusive) + 1);
}
public static String restoreKey(String randomKey) {
if (StringUtils.isEmpty(randomKey)) {
return null;
}
return randomKey.split(SPLIT_CHAR)[0];
}
}
作業暫停並保留狀態失敗
出現上面這個異常的可能原因是因為:
- 作業本身處於反壓的情況,做 Checkpoint 可能失敗了,所以暫停保留狀態的時候做 Savepoint 肯定也會失敗
- 作業的狀態很大,做 Savepoint 超時了
- 作業設定的 Checkpoint 超時時間較短,導致 SavePoint 還沒有做完,作業就丟棄了這次 Savepoint 的狀態
解決辦法:
- 程式碼設定 Checkpoint 的超時時間儘量的長一些,比如 10min,對於狀態很大的作業,可以設定更大
- 如果作業不需要保留狀態,那麼直接暫停作業,然後重啟就行
總結與展望
這篇文章分別從實時特徵清洗框架演進,特徵可配置,特徵清洗邏輯熱部署等方面介紹了目前較穩定的實時計算可行架構。經過近兩年的迭代,目前這套架構在穩定性、資源利用率、效能開銷上有最優的表現,給業務策略人員及業務演算法人員提供了有力的支撐。
未來,我們期望特徵的配置還是迴歸 SQL 化,雖然目前配置已經足夠簡單,但是畢竟屬於我們自己打造的“領域設計語言”,對新來的的策略人員 & 產品人員有一定的學習成本,我們期望的是能夠通過像 SQL 這種全域通過用語言來配置化,類似 Hive 離線查詢一樣,遮蔽了底層複雜的計算邏輯,助力業務更好的發展。
參考文獻:
[1] 風險管控(https://zh.wikipedia.org/wiki/%E9%A3%8E%E9%99%A9%E7%AE%A1%E7%90%86)