端智慧系列文章|端側如何實現實時CEP引擎

閒魚技術發表於2022-12-07

背景
使用者來閒魚,主要是為了獲得自己關心的內容。隨著閒魚的體量越來越大,內容也變得越來越豐富。閒魚基於使用者畫像,可以將使用者關心的內容推送給使用者。具體在哪些場景下才需要觸發推送?我們定義了很多觸發規則,包括停留時長、點選路徑等。
起初我們把觸發規則的邏輯放在服務端(Blink)執行。但實踐下來發現Blink存在諸多限制:

  • 服務端要對客戶端埋點進行資料清洗,考慮到閒魚的DAU已經突破2000w,這個量是非常龐大的,非常消耗服務端資源;

  • Blink的策略是實時執行的,同樣因為資源問題,現在只能同時上線十幾個策略。

如何解決這些問題呢,我們開始考慮能否將Blink的策略跑在客戶端!
CEP模型
Blink,作為是Flink的一個分支,最初是阿里巴巴內部建立的,針對Flink進行了改進,所以我們這裡還是圍繞Flink討論。CEP(Complex Event Process)是Flink中的一個子庫,用來快速檢測無盡資料流中的複雜模式。
Flink CEP
Flink的CEP的核心是NFA(Non-determined Finite Automaton),全稱叫不確定的有限狀態機。提到NFA,就不得不提Jagrati Agrawal等撰寫的關於NFA模型的論文《Efficient Pattern Matching over Event Streams》,本篇論文中描述了NFA的匹配原理。
端智慧系列文章|端側如何實現實時CEP引擎
上面這張圖,就是一個不確定的有限狀態機,它由狀態(State)還有之間的連線(StateTransition)組成的。

  • 狀態(State):狀態是根據flink指令碼里面的程式碼來決定的,最終會有一個 $end$的Final狀態

  • 轉換(StateTransition):State的轉換條件,包括 take/proceed/ignore

不同的條件,代表的含義不同:

  • take: 滿足條件,獲取當前元素,進入下一狀態

  • proceed:不論是否滿足條件,不獲取當前元素,直接進入下狀態(如optional)並進行判斷是否滿足條件。

  • ignore:不滿足條件,忽略,進入下一狀態。

我們只要在端上實現這樣一個狀態機,就可以實現一個CEP引擎。
Python CEP
對於客戶端來說,首先要解決的問題是如何構建一個CEP環境。經過調研,可以複用集團的端智慧容器(Walle),作為Python容器可以執行cep的策略。
在構建NFA之前,首先要解決的一個問題是資料來源,手淘資訊流團隊有一套完整的解決方案BehaviX/BehaviR,可以對UT埋點進行結構化,能很好的結合Walle容器來觸發策略。有了事件來源,還需要解決的是Python指令碼如何執行。Walle平臺可以將多個Python指令碼打包下載並執行,因此,我們可以將CEP封裝成一個Python的庫,然後跟策略指令碼一起下發。
最終的整體架構設計如下圖所示:
端智慧系列文章|端側如何實現實時CEP引擎
本文重點介紹下如何用Python來實現一個CEP的編譯器,這個編譯器主要用來將CEP的描述語言轉換成為NFA。
編譯器原理
在Flink中,java側會有一套完善的API來編寫一個策略指令碼,《efficient Pattern Matching over Event Streams》論文中還定義了一套完備的DSL描述語言,也是會轉化成java檔案去呼叫這些API去完成匹配。那麼接下來會重點討論,flink是如何將上述API轉化成NFA去匹配,以及Python CEP如何實現上述一套完整API介面。
Pattern
在Flink裡面,是透過 Pattern來構建這個NFA,首先用它描述這個不確定性狀態機。首先是構建一個 Pattern的一個連結串列,得到這個連結串列之後,會將每個Pattern對映成為 State的圖,點與點之間會透過 StateTransition來連線。以下面的Python程式碼為例,看下如何API是如何工作的:
例如,需要建立這樣一個規則,描述如下:

以start事件開始,後續跟隨一個middle的事件,後面緊跟著一個end事件作為結尾

用Pattern編寫如下所示:

Pattern.begin("start").where(SimpleCondition())\.followed_by('middle').where(SimpleCondition())\.next_('end').where(SimpleCondition())

這個程式碼裡面宣告瞭3個Pattern,依次命名為 startmiddleendPattern裡面儲存了指向前面節點的引用 previous,整個Pattern連結串列構建完如下圖所示:
端智慧系列文章|端側如何實現實時CEP引擎
最終拿到的是 end節點的一個引用 Ref,Pattern中會有一個變數指向前一個節點,這樣就可以得到一個Pattern的反向連結串列。
Pattern的對外介面定義如下:

classPattern:# 靜態方法,用來生成起始的pattern@staticmethoddef begin(self, name):pass# 標記緊接著的事件def followed_by(self, name):pass# 標記不需要緊跟的事件def not_followed_by(self, name):pass# 標記緊跟的事件def next_(self, name):pass# 標記事件迴圈次數def times(self, times):pass# 標記當前事件觸發的條件def where(self, condition):pass# 標記當前事件的and條件def and_(self, condition):pass# 標記當前事件的or條件def or_(self, condition):pass# 用於聚合def group_by(self, fields):pass# 用於聚合,渠道特定欄位的值def fields(self, key_by_state_name, field):pass# 用於聚合,統計事件具體的數量def count(self, field, condition):pass

不同介面會生成不同的消費策略的節點,具體細節可以參考 StateTransition有了Pattern連結串列,接下來就需要編譯器(Compiler)了,它主要是將Pattern連結串列轉化成NFA圖,首先來看下NFA的2個核心元件:StateStateTransition
State
結構定義如下:

  1. classState(object):


  2. def __init__(self, name, state_type):

  3.        self.__name = name                # 節點的名稱,同Pattern的名稱

  4.        self.__state_type = state_type    # 節點的型別:Start/Normal/Stop/Final

  5.        self.__state_transitions = []    # 到其他節點的邊

State一共有4種型別:Start/Final/Normal/Stop
生成NFA的過程就是將反向解析Pattern連結串列的過程,大概的過程如下:

  1. 建立一個 $end$的結束節點( Final

  2. 再從後往前建立每個state節點,作為中間節點( Normal/Stop

  3. 最後建立一個開始節點( Start

State的名稱就是Pattern的節點名稱,建立完成之後如下圖所示。
端智慧系列文章|端側如何實現實時CEP引擎
Transition
State代表了當前狀態機的狀態,不同狀態之前的切換定義成 StateTransition
結構定義如下:

  1. classStateTransition:


  2. def __init__(self, source_state, action, target_state, condition):

  3.        self.__source_state = source_state    # 開始的State節點

  4.        self.__action = action                # 具體action型別:take/ignore/proceed

  5.        self.__target_state = target_state    # 結束的State節點

  6.        self.__condition = condition        # 節點之間的判斷條件

邊的生成邏輯跟Pattern的事件消費策略相關,以下是事件消費策略:

classConsumingStrategy:    STRICT = 0# 嚴格匹配下個    SKIP_TILL_NEXT = 1# 跳過下一個    SKIP_TILL_ANY = 2# 跳過任意一個    NOT_FOLLOW = 3# 非跟隨模式    NOT_NEXT = 4# 非緊鄰模式

不同的消費策略,得到的狀態機如下圖所示:
端智慧系列文章|端側如何實現實時CEP引擎

  • STRICT: 如果命中了事件了,會進到下個狀態

  • SKIP_TILL_NEXT: 如果命中了會進入下一個狀態,否則會再當前節點迴圈,進入ignore的邊

  • SKIP_TILL_ANY: 不管是否命中條件,都會一直在當前狀態迴圈

  • NOT_FOLLOW: 如果遇到了一個匹配的,就會進入Stop狀態

  • NOT_NEXT: 如果命中一條,則進入Stop狀態

在Pattern中,不同的介面會建立出不同的消費策略節點,例如 followed_by介面會建立 SKIP_TILL_NEXT的節點。
Times
如果有的規則,要求特定的事件,迴圈出現幾次,那現在就要用到times介面。比如瀏覽3次寶貝這個規則,規則就可以寫成:

Pattern.begin('e1').where(SimpleCondition()).times(3);

最終就會得到一個 Times=3的Pattern,編譯器在拿到這個Pattern之後,一樣先建立一個$end$的Final節點,在處理times的時候,會建立重複的節點,只不過名稱不同,不同的點之間用take連結起來,如下圖所示:
端智慧系列文章|端側如何實現實時CEP引擎
Python CEP聚合
Flink是透過InputStream將匹配的事件轉移給CEPOperator,執行聚合操作;但是在客戶端的聚合,一次執行就一個事件流,所以可以將聚合簡化到一次匹配過程中,因此我們對於Flink的聚合操作做了改造,使其更適合端上的場景。
那麼聚合的指令碼寫法如下:

_pattern = Pattern.begin("start").where(self.start_filter)\.followed_by('middle').where(SimpleCondition())\.next_('end').where(self.end_filter)\.group_by('group_by').fields('start', 'userId')

這裡宣告瞭,以 start節點中的 userId作為聚合的節點,我們就會得到如下的 Pattern連結串列:
端智慧系列文章|端側如何實現實時CEP引擎
在解析 group_by節點的時候,我們需要做個特殊處理,判斷如果有聚合節點,我們就需要再 $end$節點和前面節點之間插入一個聚合的節點和哨兵位節點,哨兵位節點命名為 $aggregationStartState$,最終效果如下圖所示:
端智慧系列文章|端側如何實現實時CEP引擎
在NFA匹配的過程中,當匹配結束,就可以將匹配到的事件流,傳到聚合節點,再進一步聚合。$aggregationStartState$節點和 group_by節點之間,是透過proceed結合,不需要滿足特定條件就可以執行。
具體的實現過程如下,可見與Flink不同的是,我們建立了一個特殊的 State節點 AggregationState

  1. # 建立聚合節點

  2. def __create_aggregation_state(self, sink_state):

  3. # 渠道聚合節點的condition

  4.    _aggregation_condition = self.__current_pattern.get_aggregation_condition()


  5. # 建立AggregationState

  6.    not_next = AggregationState(

  7.        self.__current_pattern.get_name(),

  8. StateType.Normal,

  9.        _aggregation_condition.get_key_by_state_name(),

  10.        _aggregation_condition.get_field())

  11.    self.__states.append(not_next)


  12. # 獲取take的條件

  13.    take_condition = self.__get_take_condition(self.__current_pattern)

  14.    not_next.add_take(sink_state, take_condition)


  15. # 將遊標指向上一個節點

  16.    self.__following_pattern = self.__current_pattern

  17.    self.__current_pattern = self.__current_pattern.get_previous()


  18. return not_nex

Show me the code

講了太多原理的東西,接下來看下程式碼裡面如何工作的,先來看下如何來編寫一個CEP策略。

策略指令碼
現在看下如何寫一個完整的python版本的cep規則,以寶貝詳情頁為例,規則描述如下:

需要匹配使用者檢視3次寶貝詳情頁

那規則的寫法如下:

  1. # 1. 建立用來匹配的Pattern

  2. _pattern = Pattern.begin('e1').where(KVCondition('scene', 'Page_xyItemDetail')).times(3)


  3. # 2. 將需要匹配的事件流_batch_data和待匹配的Pattern

  4. # CEP內部會先將pattern轉化成NFA,然後再用NFA去匹配事件流

  5. _cep = CEP.pattern(_batch_data['eventSeq'], _pattern)


  6. # 用來選擇的邏輯

  7. def select_function(data):

  8. pass


  9. # 3. 匹配完成,透過cep的select介面查詢匹配到的結果

  10. self.result = _cep.select(select_function)

CEP.pattern()函式里面,會先建立 NFA,然後去進行匹配,可見整個匹配策略指令碼非常的短小精悍。
生成NFA

如下程式碼用來將 Pattern連結串列轉化成 NFA圖:

# 最後一個Pattern節點不允許是NotFollowedByif self.__current_pattern.get_quantifier().get_consuming_strategy() == ConsumingStrategy.NOT_FOLLOW:raiseException('NotFollowedBy is not supported as a last part of a Pattern!')# 校驗Pattern的名稱,必須唯一self.__check_pattern_name_uniqueness()# 校驗Pattern的策略self.__check_pattern_skip_strategy()# 首先建立Final節點sink_state = self.__create_ending_state()# 判定是否有聚合節點if self.__current_pattern.get_aggregation_condition() isnotNone:# 首先建立聚合節點    sink_state = self.__create_aggregation_state(sink_state)# 然後建立聚合幾點的起始節點    sink_state = self.__create_aggregation_start_state(sink_state)# 建立狀態機中的中間節點,此函式會迴圈知道Start節點的Patternsink_state = self.__create_middle_states(sink_state)# 最後建立Start節點self.__create_start_state(sink_state)# 根據state列表和window來建立NFAreturn NFA(self.__states, self.__window_time, False)

效果
閒魚已經上了幾個策略,整體看來比較穩定,不過還有很多最佳化的空間。從實測效果來看,端側從觸發策略到執行Action用時不會超過1s,其中還包含了一次網路請求的時間。
效能資料

  • 執行時間

端智慧系列文章|端側如何實現實時CEP引擎
單個指令碼,執行時間大概在100ms左右。

  • 記憶體使用

端智慧系列文章|端側如何實現實時CEP引擎
現在記憶體使用峰值還是比較高,大概在15M左右。關於記憶體過大的問題,目前正在討論一個方案:Python CEP可以持久化當前NFA的狀態,然後再觸發策略的時候,只帶從未觸發過的事件流,避免很多重複計算。之前執行一次指令碼要處理500個事件,現在可能就縮減到100之內,可以極大的減小記憶體消耗。同時帶來另外一個問題,就是執行指令碼的都會有一個IO操作,耗時會增加。
Flink與客戶端對比
現在對於Flink和客戶端Python CEP做一個簡單的對比:
端智慧系列文章|端側如何實現實時CEP引擎
相比Flink,端側CEP還是有它的優勢,在端側可以直接利用客戶端的埋點資訊進行計算,執行時長縮減了80%,而且也支援動態釋出。Python指令碼支援2端通投,在保證2端埋點一致的前提下,也極大的減少了維護成本。
未來
現在端計算還存在很多待最佳化的地方:

  1. 端計算是用Python實現,無法做到像Flink的狀態機常駐記憶體,每次都要重新建立匹配,帶來了額外的消耗

  2. 在事件流的清洗上面,現在是透過回朔拿到之前的事件流,存在大量的重複計算,後續可以借鑑Flink的Window機制來進行最佳化。

  3. 目前編譯器暫時還不支援Group Pattern,後續還要對其進行擴充套件。

  4. Python指令碼現在還是需要手動編寫,後續還可以考慮透過DSL來自動生成。

整體看來,Python指令碼執行策略還是有一定的效能損耗,不管是在建立NFA或者是匹配過程,後續可以考慮將匹配引擎用C++實現,然後真正做到常駐記憶體,從而做到高效的執行效率。後期做到NFA持久化之後,C++也可以複用整套持久化協議,從而最佳化整個引擎的執行效率。除此之外,策略在執行的過程中,還可以考慮用TensorFlowLite最佳化引數策略引數,從而真正做到千人前面的策略。

參考文件

  1. 對於Flink的理解

  2. CEP in Flink(1) - CEP規則解析

  3. 《Efficient Pattern Matching over Event Streams》

閒魚團隊是Flutter+Dart FaaS前後端一體化新技術的行業領軍者,就是現在!客戶端/服務端java/架構/前端/質量工程師面向社會招聘,base杭州阿里巴巴西溪園區,一起做有創想空間的社群產品、做深度頂級的開源專案,一起擴充技術邊界成就極致!

*投餵簡歷給小閒魚→guicai.gxy@alibaba-inc.com

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69900359/viewspace-2675164/,如需轉載,請註明出處,否則將追究法律責任。

相關文章