Flink vs Storm

[筆記]知乎-用Flink取代Spark Streaming!知乎實時數倉架構演進

知乎的實時數倉實踐以及架構的演進:

  • 實時數倉 1.0 版本,主題:ETL 邏輯實時化,技術方案:Spark Streaming。
  • 實時數倉 2.0 版本,主題:數據分層,指標計算實時化,技術方案:Flink Streaming。
  • 實時數倉未來展望:Streaming SQL 平臺化,元信息管理系統化,結果驗收自動化。

關於選型:

1.0

2016 年年初,業界用的比較多的實時計算框架有 Storm 和 Spark Streaming。Storm 是純流式框架,Spark Streaming 用 Micro Batch 模擬流式計算,前者比後者更實時,後者比前者吞吐量大且生態系統更完善,考慮到知乎的日誌量以及初期對實時性的要求,我們選擇了 Spark Streaming 作爲實時數據的處理框架。

2.0

Flink 相比 Spark Streaming 有更明顯的優勢,主要體現在:低延遲、Exactly-once 語義支持、Streaming SQL 支持、狀態管理、豐富的時間類型和窗口計算、CEP 支持等。

Flink 的 Streaming SQL 有以下優點:易於平臺化、開發效率高、維度成本低等。

目前 Streaming SQL 使用起來也有一些缺陷:1. 語法和 Hive SQL 有一定區別,初使用時需要適應;

2.UDF 不如 Hive 豐富,寫 UDF 的頻率高於 Hive。

未來進一步提升方向:

  • 1.Streaming SQL 平臺化。目前 Streaming SQL 任務是以代碼開發 maven 打包的方式提交任務,開發成本高,後期隨着 Streaming SQL 平臺的上線,實時數倉的開發方式也會由 Jar 包轉變爲 SQL 文件。
  • 2.實時數據元信息管理系統化。對數倉元信息的管理可以大幅度降低使用數據的成本,離線數倉的元信息管理已經基本完善,實時數倉的元信息管理纔剛剛開始。
  • 3.實時數倉結果驗收自動化。對實時結果的驗收只能藉助與離線數據指標對比的方式,以 Hive 和 Kafka 數據源爲例,分別執行 Hive SQL 和 Flink SQL,統計結果並對比是否一致實現實時結果驗收的自動化。

[筆記]從Storm到Flink,汽車之家基於Flink的實時SQL平臺設計思路與實踐

Storm Spout + Bolt 的編程模型比較簡單,且集羣穩定,但隨着實時計算的需求日漸增多,數據規模逐步增大,Storm 在開發及維護成本上都凸顯了不足,最突出的兩個痛點:

1.翻譯 SQL

我們一直是 Lambda 架構,會用 T+1 的離線數據修正實時數據,即最終以離線數據爲準,所以計算口徑實時要和離線完全保持一致,實時數據開發的需求文檔就是離線的 SQL,實時開發人員的核心工作就是把離線的 SQL 翻譯成 Storm 代碼,期間雖然封裝了一些通用的 bolt 來簡化開發,但把離線動輒幾百行的 SQL 精準的翻譯成代碼還是很有挑戰的,並且每次運行都要經過打包,上傳, 重啓的一系列的繁瑣操作,調試成本很高。

2.過於依賴外部存儲

Storm 對狀態支持的不好,通常需要藉助 Redis,HBase 這類 kv 存儲維護中間狀態,我們之前是強依賴 Redis。比如常見的計算 UV 的場景, 最簡單的辦法是使用 Redis 的 sadd 命令判斷 uid 是否爲已經存在,但這種方法會佔用大量內存,如果沒有提前報備的大促或搞活動導致流量翻倍的情況,很容易把 Redis 內存搞滿,運維同學也會被殺個措手不及,同時 Redis 的吞吐能力也限制了整個作業的吞吐量。

在此背景下我們封裝了基於 BloomFilter 的 bolt,BloomFilter 本身也會作爲狀態定期持久化到 reids 中,但是在多維度高基數的場景下,很難精確控制每個 BloomFilter 的大小,同樣會佔用很大內存。同時,過於依賴 Redis,在 Redis 集羣 rtt 過長或部分節點負載高時會導致 Storm 作業 failed。

  Flink 引擎對SQL的支持相對比較完備,天生對狀態支持。

 

fxjwind

 

流計算技術實戰 - CEP

CEP,Complex event processing

Wiki定義

“Complex event processing, or CEP, is event processing that combines data from multiple sources[2] to infer events or patterns that suggest more complicated circumstances. The goal of complex event processing is to identify meaningful events (such as opportunities or threats)[3] and respond to them as quickly as possible.”

通過上面的Wiki定義,可以看出CEP的特點主要是,
複雜性:多個流join,窗口聚合,事件序列或patterns檢測
低延遲:秒或毫秒級別,比如做信用卡盜刷檢測,或攻擊檢測
高吞吐:每秒上萬條消息

CEP和數據庫

CEP的概念出現比較早,用於解決傳統數據庫所無法解決的實時需求
傳統數據庫,數據是靜態的,查詢是動態的,但做不到實時和連續的輸出查詢結果
而CEP反其道而行之,查詢是靜態的,數據是動態的,這樣就可以滿足實現和連續查詢的需求,但是無法滿足ad hoc查詢需求
所以CEP和傳統數據庫相結合,可以用於解決金融,商業,網絡監控等領域的問題

複雜事件處理(CEP)解決了對連續傳入事件進行模式匹配的問題。 匹配的結果通常是從輸入事件派生的複雜事件。 與對存儲數據執行查詢的傳統DBMS相比,CEP在存儲的查詢上執行數據。 可以立即丟棄與查詢無關的所有數據。 考慮到CEP查詢應用於潛在的無限數據流,這種方法的優勢是顯而易見的。 此外,輸入立即處理。 一旦系統看到匹配序列的所有事件,結果就會立即發出。 這方面有效地帶來了CEP的實時分析能力。
比如比較知名的Esper,功能非常強大,並提供EPL這樣類sql語言,讓用戶感覺到類似使用數據庫的體驗

流計算下的CEP

流式計算概念可以認爲是從Storm或Yahoo S4那個時候開始被大家廣泛接受的
流式計算概念的出現,主要針對當時主流的像Hadoop這樣的MapReduce系統在實時性上的缺陷;時勢造英雄,加上像Twitter這樣普及的實時應用,讓大家認識到數據實時性的重要性,從此實時大數據的時代漸漸來臨

CEP和流式計算是在不同的時代背景下產生的,而由於他們所要解決問題域的重合,註定了在技術上會產生融合;
在Storm的年代,Storm主要是封裝和提供一種類似MapReduce的編程模型,所以當時流式計算業務主要還是ETL和簡單聚合;
爲了滿足CEP需求,可以將Esper引擎跑在Storm上,但是Esper雖然功能很強大,但是實在太重而且比較低效

後續出現輕量級的CEP引擎,如Siddhi,
但我們最終也沒有規模使用,最主要的原因是,它沒有考慮event time和數據亂序的問題,比較難於用於實際的線上場景

在Dataflow論文出來前,確實沒有任何計算平臺,在平臺層面對event time和數據亂序提出系統的方案,Flink實現了Dataflow中的窗口模型,在平臺層面解決了event time和數據亂序的問題
並且Flink提供了專門的CEP的lib,FlinkCEP - Complex event processing for Flink
當然這個CEP lib是會考慮並解決event time和數據亂序問題的
下面我們先來看看Flink CEP是怎麼使用的

Flink CEP

Flink CEP是其實一個Flink庫,跟機器學習庫是一樣的。它是爲了更快,更及時的發現一些我們所關心的事情。

1.從多個數據流中發現複雜事件,它的目標是識別有意義的事件(例如機會或者威脅),並儘快的做出響應

2.Flink CEP一個複雜事件處理庫

特點

查詢是靜態的,數據是動態的,滿足實現和連續查詢的需求

å¨è¿éæå¥å¾çæè¿°

怎麼使用

如果想使用CEP就需要知道場景模型中會遇到哪些問題, CEP中對那些數據比較敏感。或者哪些是我們關心的重點,如果一些數據循環出現,那麼這個數據可能就是有問題的,就可能發生風險的,比如我們銀行卡在短時間內,多地刷卡,也就是在1小時內同一個卡,刷了60筆交易,也就是一分鐘一筆交易,這個在我們的常識中,你覺得這可能嗎?所以這時候這個卡我們就會被判斷爲被盜刷。而CEP該如何識別那?
在CEP中有within函數,也就是限定時間交易多少筆,也就是如果大於60筆交易,而這個交易信息記錄爲一個信息事件,可以放到case class中,對於發生了60次,可以使用time函數。通過CEP輕鬆使用提供的函數,來實現我們的判斷。

Flink CEP與Flink DataStream對比

共同點:
1.流式數據
2.實時強

不同點:
1.CEP是爲了找到數據的規律及關係
2.DataStream是對數據的清洗及過濾

 

 

Example

我們先產生一個輸入流,這個輸入Event流由Event對象和event time組成
那麼要使用EventTime,除了指定TimeCharacteristic外,在Flink中還要assignTimestampsAndWatermarks,其中分別定義了Eventtime和WaterMark,

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);

// (Event, timestamp)
DataStream<Event> input = env.fromElements(
    Tuple2.of(new Event(1, "start", 1.0), 5L),
    Tuple2.of(new Event(2, "middle", 2.0), 1L),
    Tuple2.of(new Event(3, "end", 3.0), 3L),
    Tuple2.of(new Event(4, "end", 4.0), 10L), //觸發2,3,1
    Tuple2.of(new Event(5, "middle", 5.0), 7L),
    // last element for high final watermark
    Tuple2.of(new Event(5, "middle", 5.0), 100L) //觸發5,4
).assignTimestampsAndWatermarks(new AssignerWithPunctuatedWatermarks<Tuple2<Event, Long>>() {

    @Override
    public long extractTimestamp(Tuple2<Event, Long> element, long previousTimestamp) {
        return element.f1; //定義Eventtime
    }

    @Override
    public Watermark checkAndGetNextWatermark(Tuple2<Event, Long> lastElement, long extractedTimestamp) {
        return new Watermark(lastElement.f1 - 5); //定義watermark
    }

}).map(new MapFunction<Tuple2<Event, Long>, Event>() {
    @Override
    public Event map(Tuple2<Event, Long> value) throws Exception {
        return value.f0;
    }
});
接着我們定義需要匹配的pattern,需求就是找出包含”start“, ”middle“, ”end“的一組事件
具體語法參考Flink文檔,這裏不詳述

Pattern<Event, ?> pattern = Pattern.<Event>begin("start").where(new SimpleCondition<Event>() {

    @Override
    public boolean filter(Event value) throws Exception {
        return value.getName().equals("start");
    }
}).followedByAny("middle").where(new SimpleCondition<Event>() {

    @Override
    public boolean filter(Event value) throws Exception {
        return value.getName().equals("middle");
    }
}).followedByAny("end").where(new SimpleCondition<Event>() {

    @Override
    public boolean filter(Event value) throws Exception {
        return value.getName().equals("end");
    }
});
最終在輸入流上執行CEP,
這裏實現PatternSelectFunction來處理匹配到的pattern,處理邏輯是打印出匹配到的3個Event對象的id

DataStream<String> result = CEP.pattern(input, pattern).select(
    new PatternSelectFunction<Event, String>() {

        @Override
        public String select(Map<String, List<Event>> pattern) {
            StringBuilder builder = new StringBuilder();
            System.out.println(pattern);
            builder.append(pattern.get("start").get(0).getId()).append(",")
                .append(pattern.get("middle").get(0).getId()).append(",")
                .append(pattern.get("end").get(0).getId());

            return builder.toString();
        }
    }
);

result.print();
大家想想,這裏匹配到的是哪些Event?
從上面Event的順序看應該是 1,2,3

但結果是 1,5,4,因爲這裏考慮的是Eventtime的順序,這個特性在生產環境中很關鍵,因爲我們無法保證採集數據達到的順序。

Implementation

對於EventTime部分的實現,可以看下AbstractKeyedCEPPatternOperator中的實現,

    public void processElement(StreamRecord<IN> element) throws Exception {
        if (isProcessingTime) {
            // there can be no out of order elements in processing time
            NFA<IN> nfa = getNFA();
            processEvent(nfa, element.getValue(), getProcessingTimeService().getCurrentProcessingTime());
            updateNFA(nfa);

        } else { //EventTime
            long timestamp = element.getTimestamp();
            IN value = element.getValue();

            if (timestamp >= lastWatermark) { //只處理非late record

                // we have an event with a valid timestamp, so
                // we buffer it until we receive the proper watermark.

                saveRegisterWatermarkTimer();

                List<IN> elementsForTimestamp =  elementQueueState.get(timestamp);
                elementsForTimestamp.add(element.getValue());
                elementQueueState.put(timestamp, elementsForTimestamp); //放到隊列中
            }
        }
    }
如果是EventTime,不會直接processEvent並更新NFA,而是先放到一個隊列elementQueueState裏面。
等後面收到watermark觸發onEventTime時,
會把隊列裏面的數據按時間排序,從小到大,並把大於watermark的拿出來挨個處理,這樣就實現了按EventTime有序,解決了亂序問題。

Improvement

應用中實際使用Flink CEP時,發現有些不方便的地方:

首先,patterns需要用java代碼寫,需要編譯,很冗長很麻煩,沒法動態配置;需要可配置,或提供一種DSL
再者,對於一個流同時只能設置一個pattern,比如對於不同的用戶實例想配置不同的pattern,就沒法支持;需要支持按key設置pattern

DSL

對於第一個問題,我剛開始考慮開發一套DSL,這樣成本比較高,而且社區也在考慮支持SQL
所以我就先基於JSON簡單實現了一個,如下

image

 

這個基本可以滿足當前Flink CEP的常用語法,擴展也比簡單
通過一個JSONArray來表示一個pattern sequence,每個pattern中可以定義多個並,或條件
每個條件由三部分組成,比如,["sql", "contains", "delete"], "sql"是字段名,”contains“是Op,”delete“是value, 意思就是找出sql字段中包含delete的log

現在就不需要用java來寫pattern了,直接傳入配置就ok,如下,

JSONArray jsonArray = JSON.parseArray("pattern配置");

CepBuilder<Log> cepBuilder = new CepBuilder<Log>();
Pattern<Log, ?>  pattern = cepBuilder.patternSequenceBuilder(jsonArray);
這裏我實現一個CepBuilder可以把JSON配置直接轉換成Pattern對象

按Key配置多patterns

爲了滿足爲不同的用戶配置不同的pattern的需求,我修改了下Flink CEP提供的接口,
原先Flink CEP,是這樣定義CEP的,
PatternStream = CEP.pattern(input, pattern)
可以看到對一個input只能定義一個pattern,

所以我定義GroupPatternStream,可以傳入一組patterns

public class GroupPatternStream<K, T> {

    // underlying data stream
    private final DataStream<T> inputStream;

    private final  Map<K, Pattern<T, ?>> patterns;

    GroupPatternStream(final DataStream<T> inputStream, final Map<K, Pattern<T, ?>> patterns) {
        this.inputStream = inputStream;
        this.patterns = patterns;
    }
然後在createPatternStream邏輯中,把每個pattern compile成相應的NFAFactory,最終將nfaFactoryMap作爲參數創建KeyedCEPGroupPatternOperator

public SingleOutputStreamOperator<Map<String, List<T>>> createPatternStream(DataStream<T> inputStream, Map<K, Pattern<T, ?>> patterns) {
    final TypeSerializer<T> inputSerializer = inputStream.getType().createSerializer(inputStream.getExecutionConfig());
    Map<K,  NFACompiler.NFAFactory<T>> nfaFactoryMap = new HashMap<>();

    if(patterns != null){
        for(K key: patterns.keySet()){
            Pattern<T, ?> pattern = patterns.get(key);
            nfaFactoryMap.put(key, NFACompiler.compileFactory(pattern, inputSerializer, false));
        }
    }

    if (inputStream instanceof KeyedStream) {
        patternStream = keyedStream.transform(
            "KeyedCEPPatternOperator",
            (TypeInformation<Map<String, List<T>>>) (TypeInformation<?>) TypeExtractor.getForClass(Map.class),
            new KeyedCEPGroupPatternOperator<>(
                inputSerializer,
                isProcessingTime,
                keySerializer,
                nfaFactory,
                nfaFactoryMap,
                true));
    } else {
        //not-support non-keyed stream
        patternStream = null;
    }

    return patternStream;
}
KeyedCEPGroupPatternOperator,也是我新建的,和原來的KeyedCEPPatternOperator比多了個參數nfaFactoryMap,並且重寫了getNFA函數

public class KeyedCEPGroupPatternOperator<IN, KEY> extends KeyedCEPPatternOperator {

    Map<KEY,  NFACompiler.NFAFactory<IN>> nfaFactoryMap;

    public KeyedCEPGroupPatternOperator(   TypeSerializer<IN> inputSerializer,
        boolean isProcessingTime,
        TypeSerializer<KEY> keySerializer,
        NFACompiler.NFAFactory<IN> nfaFactory,
        Map<KEY,  NFACompiler.NFAFactory<IN>> nfaFactoryMap,
        boolean migratingFromOldKeyedOperator){
        super(inputSerializer, isProcessingTime, keySerializer, nfaFactory,
            migratingFromOldKeyedOperator);

        this.nfaFactoryMap = nfaFactoryMap;
    }

    @Override
    public NFA<IN> getNFA() throws IOException {
        NFA<IN> nfa = (NFA<IN>) nfaOperatorState.value();
        if(nfa == null) {
            Object key = getCurrentKey();
            NFACompiler.NFAFactory<IN> factory =  nfaFactoryMap.get(key);
            if(factory != null){
                nfa = factory.createNFA();
            }

            //if the key didn't define pattern, add EmptyNFA
            if(nfa == null){
                nfa = new EmptyNFA<>();
            }
        }
        return nfa;
    }

}
核心邏輯就在getNFA, 主要就是通過修改這個邏輯來滿足需求
在KeyedCEPPatternOperator中,他每次都會生成同樣的NFA

public NFA<IN> getNFA() throws IOException {
    NFA<IN> nfa = nfaOperatorState.value();
    return nfa != null ? nfa : nfaFactory.createNFA();
}
而在我的邏輯裏面,
會先取出當前上下文的key,
並根據不同的key,創建不同的NFA,這樣就可以實現對不同的key使用不同的pattern進行匹配。這些NFA狀態機是作爲key的state存在stateBackend裏面的,所以每次有相應的key的record流過時,都可以從stateBackend中取到。

然後我們就可以這樣用,
先準備測試數據,

Log log = new Log();
log.putItem("id", "1");
log.putItem("sql", "start counting!");
logs.add(log);

log = new Log();
log.putItem("id", "2");
log.putItem("sql", "start counting!");
logs.add(log);

log = new Log();
log.putItem("id", "1");
log.putItem("sql", "end counting");
logs.add(log);

log = new Log();
log.putItem("id", "2");
log.putItem("sql", "select from 1");
logs.add(log);

log = new Log();
log.putItem("id", "2");
log.putItem("sql", "end counting");
logs.add(log);
DataStream<Log> input = env.fromCollection(logs).keyBy(new KeySelector<Log, String>() {
    public String getKey(Log log){
        return (String)log.getItem("id");
    }
});

構造pattern,

JSONArray jsonArray = JSON.parseArray(
    "[{"id":"start","conditions":[[["sql","contains","start"]]]},{"id":"middle","conditions":[[["sql","contains","end"]]]}]");

JSONArray jsonArray2 = JSON.parseArray(
    "[{"id":"start","conditions":[[["sql","contains","start"]]]},{"id":"middle","conditions":[[["sql","contains","select"]]]},{"id":"end","conditions":[[["sql","contains","end"]]]}]");

CepBuilder<Log> cepBuilder = new CepBuilder<Log>();
Pattern<Log, ?> pattern = cepBuilder.patternSequenceBuilder(jsonArray);
Pattern<Log, ?>  pattern2 = cepBuilder.patternSequenceBuilder(jsonArray2);

Map<String,  Pattern<Log, ?>> patternMap = new HashedMap();
patternMap.put("1", pattern);
patternMap.put("2", pattern2);
對於id=”1“的log,找出包含”start“,”end“的pattern
對於id=”2“的log,找出包含”start“,”select“,”end“的pattern

運行CEP,

    GroupPatternStream<String, Log> groupPatternStream = new GroupPatternStream<>(input, patternMap);
        DataStream<String> result =groupPatternStream.select(
            new PatternSelectFunction<Log, String>() {
                    return pattern.toString();
                }
            });
        result.print();
得到運行結果,
2> {middle=[{id=2, sql=select from 1}], start=[{id=2, sql=start counting!}], end=[{id=2, sql=end counting}]}
4> {middle=[{id=1, sql=end counting}], start=[{id=1, sql=start counting!}]}
可以看到對於不同的key,匹配到了不同的pattern,是不是很酷

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章