KafkaStream之時間窗口WindowBy

原文鏈接:https://blog.csdn.net/u012364631/article/details/94019707

Kafka Stream的大部分API還是比較容易理解和使用的,但是,其中的時間窗口聚合即windowBy方法還是需要仔細研究下,否則很容易使用錯誤。

本文先引入Kafka Stream,然後主要針對時間窗口聚合API即windowBy()做詳細分析。

引言

Kafka Streams是一個用於構建應用程序和微服務的客戶端庫,其中的輸入和輸出數據存儲在Kafka集羣中。它結合了在客戶端編寫和部署Java/Scala應用程序的簡單性,以及Kafka服務器集羣的優點。

Kafka Stream爲我們屏蔽了直接使用Kafka Consumer的複雜性,不用手動進行輪詢poll(),不必關心commit()。而且,使用Kafka Stream,可以方便的進行實時計算、實時分析。

比如官方Demo,統計topic中不同單詞的出現次數:

public class WordCountApplication {

        public static void main(final String[] args) throws InterruptedException {
        Properties props = new Properties();
        props.put(StreamsConfig.APPLICATION_ID_CONFIG, "wordcount-application");
        props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        props.put(StreamsConfig.COMMIT_INTERVAL_MS_CONFIG, "500");// 默認30s commit一次
        props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass());
        props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass());

        StreamsBuilder builder = new StreamsBuilder();
        // 從名爲“TextLinesTopic”的topic創建流。
        KStream<String, String> textLines = builder.stream("TextLinesTopic");

        KTable<String, Long> wordCounts =
                textLines.flatMapValues(textLine -> Arrays.asList(textLine.toLowerCase().split("\\W+")))
                         .groupBy((key, word) -> word)
                         .count();

        // 官方文檔實例中是 wordCounts.toStream().to("WordsWithCountsTopic", Produced.with(Serdes.String(), Serdes.Long())); 直接寫回kafka
        // 我們這裏爲了方便觀察,直接打印到控制檯
        wordCounts.toStream().print(Printed.toSysOut());

        KafkaStreams streams = new KafkaStreams(builder.build(), props);
        streams.start();

        Thread.currentThread().join();
    }

}

啓動程序、kafka服務端。

啓動kafka-console-producer, 創建主題TextLinesTopic0,併發送消息。

.\bin\windows\kafka-console-producer.bat --broker-list localhost:9092 --topic TextLinesTopic

 .\bin\windows\kafka-topics.bat --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic TextLinesTopic0

word-count

可以看到,每次向kafka寫入一條消息後,我們的demo程序在控制檯會立即輸出產生變化的數據統計。

這其中的簡單原理可以參考http://kafka.apache.org/23/documentation/streams/quickstart#quickstart_streams_process。我們的流計算應用保存一個KTable<String, Long> 用來記錄統計條目,隨着流中元素的到來,KTable中的統計條目發生變化,這些變化回發送到下游流中(本文中的下游流就是控制檯)。

藉助KafkaStream的API,我們可以方便的編寫實時計算應用。比如上面的groupBy、count方法,再比如接下來的windowBy方法,如果不使用KafakStream,直接使用Kafka Consumer自行實現,則比較麻煩。

Kafka Stream的大部分API還是比較容易理解和使用的,但是,其中的時間窗口聚合即windowBy方法還是需要仔細研究下,否則很容易使用錯誤。

WindowBy

根據時間窗口做聚合,是在實時計算中非常重要的功能。比如我們經常需要統計最近一段時間內的count、sum、avg等統計數據。

Kafka中有這樣四種時間窗口

Window name Behavior Short description
Tumbling time window Time-based Fixed-size, non-overlapping, gap-less windows
Hopping time window Time-based Fixed-size, overlapping windows
Sliding time window Time-based Fixed-size, overlapping windows that work on differences between record timestamps
Session window Session-based Dynamically-sized, non-overlapping, data-driven windows

Tumbling time windows

翻滾時間窗口Tumbling time windows是跳躍時間窗口hopping time windows的一種特殊情況,與後者一樣,翻滾時間窗也是基於時間間隔的。但它是固定大小、不重疊、無間隙的窗口。翻滾窗口只由一個屬性定義:size。翻滾窗口實際上是一種跳躍窗口,其窗口大小與其前進間隔相等。由於翻滾窗口從不重疊,數據記錄將只屬於一個窗口。

streams-time-windows-tumbling

Tumbling time windows are aligned to the epoch, with the lower interval bound being inclusive and the upper bound being exclusive. “Aligned to the epoch” means that the first window starts at timestamp zero. For example, tumbling windows with a size of 5000ms have predictable window boundaries [0;5000),[5000;10000),... — and not [1000;6000),[6000;11000),... or even something “random” like [1452;6452),[6452;11452),....

看個翻滾窗口的例子:

private static final String BOOT_STRAP_SERVERS = "localhost:9092";
private static final String TEST_TOPIC = "test_topic";
private static final long TIME_WINDOW_SECONDS = 5L; //時間窗口大小

@Test
public void testTumblingTimeWindows() throws InterruptedException {
    Properties props = configStreamProperties();
    StreamsBuilder builder = new StreamsBuilder();
    KStream&lt;String, String&gt; data = builder.stream(TEST_TOPIC);

    Instant initTime = Instant.now();

    data.groupByKey()
        .windowedBy(TimeWindows.of(Duration.ofSeconds(TIME_WINDOW_SECONDS)))
        .count(Materialized.with(Serdes.String(), Serdes.Long()))
        .toStream()
        .filterNot(((windowedKey, value) -&gt; this.isOldWindow(windowedKey, value, initTime))) //剔除太舊的時間窗口,程序二次啓動時,會重新讀取歷史數據進行整套流處理,爲了不影響觀察,這裏過濾掉歷史數據
        .foreach(this::dealWithTimeWindowAggrValue);

    Topology topology = builder.build();
    KafkaStreams streams = new KafkaStreams(topology, props);
    streams.start();

    Thread.currentThread().join();
}

Test啓動前啓動一個KafkaProducer,每1秒產生一條數據,數據的key爲“service_1”,value爲“key@當前時間”。

@BeforeClass
public static void generateValue() {

    Properties props = new Properties();
    props.put("bootstrap.servers", BOOT_STRAP_SERVERS);
    props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
    props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
    props.put("request.required.acks", "0");

    new Thread(() -&gt; {
        Producer&lt;String, String&gt; producer = new KafkaProducer&lt;&gt;(props);
        try {
            while (true) {
                TimeUnit.SECONDS.sleep(1L);
                Instant now = Instant.now();
                String key = "service_1";
                String value = key + "@" + toLocalTimeStr(now);
                producer.send(new ProducerRecord&lt;&gt;(TEST_TOPIC, key, value));
            }
        } catch (Exception e) {
            e.printStackTrace();
            producer.close();
        }
    }).start();
}
private static String toLocalTimeStr(Instant i) {
    return i.atZone(ZoneId.systemDefault()).toLocalDateTime().toString();
}

下面是些公共代碼,之後的例子也有會用到 :

private Properties configStreamProperties() {
    Properties props = new Properties();
    props.put(StreamsConfig.APPLICATION_ID_CONFIG, "streams-ljf-test");
    props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, BOOT_STRAP_SERVERS);
    props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass());
    props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass());
    props.put(StreamsConfig.COMMIT_INTERVAL_MS_CONFIG, "500");//todo 默認值爲30s,會導致30s才提交一次數據。
    return props;
}

private boolean isOldWindow(Windowed&lt;String&gt; windowKey, Long value, Instant initTime) {
    Instant windowEnd = windowKey.window().endTime();
    return windowEnd.isBefore(initTime);
}

private void dealWithTimeWindowAggrValue(Windowed&lt;String&gt; key, Long value) {
    Windowed&lt;String&gt; windowed = getReadableWindowed(key);
    System.out.println("處理聚合結果:key=" + windowed + ",value=" + value);
}

private Windowed&lt;String&gt; getReadableWindowed(Windowed&lt;String&gt; key) {
    return new Windowed&lt;String&gt;(key.key(), key.window()) {
        @Override
        public String toString() {
            String startTimeStr = toLocalTimeStr(Instant.ofEpochMilli(window().start()));
            String endTimeStr = toLocalTimeStr(Instant.ofEpochMilli(window().end()));
            return "[" + key() + "@" + startTimeStr + "/" + endTimeStr + "]";
        }
    };
}

上面的testTumblingTimeWindows()中,創建了一個流任務,先groupByKey(),再調用count()計算每個時間窗口的消息個數。我們創建了一個size爲5秒的翻滾時間窗口。而且generateValue()方法中啓動了一個Producer,每隔一秒發送一條消息。使用JUnit運行testTumblingTimeWindows(),控制檯輸出如下(在創建流計算邏輯時,我們最後使用foreach(this::dealWithTimeWindowAggrValue)將上游流(這裏是filterNot方法的結果)傳來的元素打印到控制檯):

test-tumbling-windows-1

可以看到,每個時間窗口統計到5的時候,重新從1開始count。這也印證了翻滾窗口的特性。

這裏我們再看下groupByKey的特性。

如果將generateValue()方法改爲,模擬另一個服務也在發送消息:

@BeforeClass
public static void generateValue() {

    Properties props = new Properties();
    // ...配置不變,此處省略

    new Thread(() -&gt; {
        Producer&lt;String, String&gt; producer = new KafkaProducer&lt;&gt;(props);
        try {
            while (true) {
                TimeUnit.SECONDS.sleep(1L);
                Instant now = Instant.now();
                String key = "service_1";
                String value = key + "@" + toLocalTimeStr(now);
                producer.send(new ProducerRecord&lt;&gt;(TEST_TOPIC, key, value));
                String key2 = "service_2"; // 模擬另一個服務也在發送消息
                producer.send(new ProducerRecord&lt;&gt;(TEST_TOPIC, key2, value));
            }
        } catch (Exception e) {
            e.printStackTrace();
            producer.close();
        }
    }).start();
}

重新運行testTumblingTimeWindows():

test-tumbling-windows-groupbykey

可以看到,我們的流任務根據key的不同先做group,在進行時間窗口的聚合。

PS:類似groupByKey,還有groupBy,前者可以看做後者的特化,後者可以根據消Message的key、value自定義分組邏輯。關於此,可以參考API官方文檔Stateless transformations

Sliding time windows

Sliding windows are actually quite different from hopping and tumbling windows. In Kafka Streams, sliding windows are used only for join operations, and can be specified through the JoinWindows class.

A sliding window models a fixed-size window that slides continuously over the time axis; here, two data records are said to be included in the same window if (in the case of symmetric windows) the difference of their timestamps is within the window size. Thus, sliding windows are not aligned to the epoch, but to the data record timestamps. In contrast to hopping and tumbling windows, the lower and upper window time interval bounds of sliding windows are both inclusive.

Session Windows

Session windows are used to aggregate key-based events into so-called sessions, the process of which is referred to as sessionization. Sessions represent a period of activity separated by a defined gap of inactivity (or “idleness”). Any events processed that fall within the inactivity gap of any existing sessions are merged into the existing sessions. If an event falls outside of the session gap, then a new session will be created.

streams-session-windows-02

Hopping time windows

我們口中的“滑動窗口”,在Kafka這裏叫做跳躍窗口。

Note Hopping windows vs. sliding windows: Hopping windows are sometimes called “sliding windows” in other stream processing tools. Kafka Streams follows the terminology in academic literature, where the semantics of sliding windows are different to those of hopping windows.

Hopping time windows are aligned to the epoch, with the lower interval bound being inclusive and the upper bound being exclusive. “Aligned to the epoch” means that the first window starts at timestamp zero. For example, hopping windows with a size of 5000ms and an advance interval (“hop”) of 3000ms have predictable window boundaries [0;5000),[3000;8000),... — and not [1000;6000),[4000;9000),... or even something “random” like [1452;6452),[4452;9452),....

跳躍時間窗口Hopping time windows是基於時間間隔的窗口。它們爲固定大小(可能)重疊的窗口建模。跳躍窗口由兩個屬性定義:窗口的size及其前進間隔advance interval (也稱爲hop)。前進間隔指定一個窗口相對於前一個窗口向前移動多少。例如,您可以配置一個size爲5分鐘、advance爲1分鐘的跳轉窗口。由於跳躍窗口可以重疊(通常情況下確實如此),數據記錄可能屬於多個這樣的窗口。

streams-time-windows-hopping

private static final long TIME_WINDOW_SECONDS = 5L; //窗口大小設爲5秒
private static final long ADVANCED_BY_SECONDS = 1L; //前進間隔1秒
@Test
public void testHoppingTimeWindowWithSuppress() throws InterruptedException {
    Properties props = configStreamProperties();
    StreamsBuilder builder = new StreamsBuilder();
    KStream&lt;String, String&gt; data = builder.stream(TEST_TOPIC);

    Instant initTime = Instant.now();

    data.groupByKey()
        .windowedBy(TimeWindows.of(Duration.ofSeconds(TIME_WINDOW_SECONDS))
                    .advanceBy(Duration.ofSeconds(ADVANCED_BY_SECONDS))
                    .grace(Duration.ZERO))
        .count(Materialized.with(Serdes.String(), Serdes.Long()))
        .suppress(Suppressed.untilWindowCloses(Suppressed.BufferConfig.unbounded()))
        .toStream()
        .filterNot(((key, value) -&gt; this.isOldWindow(key, value, initTime))) //剔除太舊的時間窗口
        .foreach(this::dealWithTimeWindowAggrValue);

    Topology topology = builder.build();
    KafkaStreams streams = new KafkaStreams(topology, props);
    streams.start();

    Thread.currentThread().join();
}

test-hopping-windows-with-surpress

注意到上面的代碼中,我們還用到了grace(Duration.ZERO)suppress(Suppressed.untilWindowCloses(Suppressed.BufferConfig.unbounded()))

後者的意思是:抑制住上游流的輸出,直到當前時間窗口關閉後,才向下遊發送數據。前面我們說過,每當統計值產生變化時,統計的結果會立即發送給下游。但是有些情況下,比如我們從kafka中的消息記錄了應用程序的每次gc時間,我們的流任務需要統計每個時間窗口內的平均gc時間,然後發送給下游(下游可能是直接輸出到控制檯,也可能是另一個kafka topic或者一段報警邏輯)。那麼,只要當這個時間窗口關閉時,向下遊發送一個最終結果就夠了。而且有的情況下,如果窗口還沒關閉就發送到下游,可能導致錯誤的邏輯(比如數據抖動產生誤報警)。

grace的意思是,設立一個數據晚到的期限,這個期限過了之後時間窗口才關閉。比如窗口大小爲5,當15:20的時候,15:15-15:20的窗口應當關閉了,但是爲了防止網絡延時導致數據晚到,比如15點22分的時候,有可能才接收時間戳是15點20分的數據。所以我們可以把這個晚到時間設爲2分鐘,那麼知道15點22的時候,15:15-15:20的窗口才關閉。

注意一個坑:**如果使用Suppressed.untilWindowCloses,那麼窗口必須要指定grace。因爲默認的grace時間是24小時。所以24小時之內窗口是一直不關閉的,而且由於被suppress住了,所以下游會一直收不到結果。**另外也可以使用Suppressed.untilTimeLimit來指定上游聚合計算的值在多久後發往下游,它與窗口是否關閉無關,所以可以不使用grace

上面的代碼中,爲了方便,我們令grace爲0,也就是當窗口的截止時間到了後立即關閉窗口。

另外我們還使用suppress,抑制住中間的計算結果。所以可以看到,每個窗口關閉後,向下遊(這裏就是控制檯)發送了一個最終結果“5”。

爲了驗證,我們去掉suppress方法試一下。

@Test
public void testHoppingTimeWindow() throws InterruptedException {
    Properties props = configStreamProperties();
    StreamsBuilder builder = new StreamsBuilder();
    KStream&lt;String, String&gt; data = builder.stream(TEST_TOPIC);

    Instant initTime = Instant.now();

    data.groupByKey()
        .windowedBy(TimeWindows.of(Duration.ofSeconds(TIME_WINDOW_SECONDS))
                    .advanceBy(Duration.ofSeconds(ADVANCED_BY_SECONDS))
                    .grace(Duration.ZERO))
        .count(Materialized.with(Serdes.String(), Serdes.Long()))
        .toStream()
        .filterNot(((key, value) -&gt; this.isOldWindow(key, value, initTime)))
        .foreach(this::dealWithTimeWindowAggrValue);

    Topology topology = builder.build();
    KafkaStreams streams = new KafkaStreams(topology, props);
    streams.start();

    Thread.currentThread().join();
}

運行結果如下:

test-hopping-windows-without-surpress

如果不仔細觀察,可能會覺得結果很亂。所以我用方框做了區分:

51秒時第一個消息到達,使得所在的5個窗口都進行聚合計算count後,結果立即發往下游,所以是1,1,1,1,1。

52秒時第二個消息到達,所在的5個窗口都進行count累加計算後,結果立即發往下游,所以是2,2,2,2,1。注意到,最後的“1”是新的窗口(51秒-56秒窗口)的累加計算,所以值爲1。而“46秒-51秒”這個窗口由於已經關閉,就不會再進行累加計算,從而不會有新的結果發送給下游輸出。

53秒第三個消息到達,之前的2,2,2,2,1的第一個“2”所在窗口關閉了,然後剩下的三個分別加1,變成了3,3,3,2。另外還有一個新的時間窗口打開。所以最後得到3,3,3,2,1。

時間窗口上聚合計算的坑

上面我特意強調了兩點,一是所在的窗口進行聚合計算,二是聚合計算的結果立即發往下游。第二點我們已經驗證了。我們將最開始Tumbling time window的程序加上suppres進一步驗證一下。

聚合計算結果何時到達下游

之前的代碼會輸出123451234512345…,而且每個12345都是同一個窗口輸出的。可見聚合結果計算後,默認會立即發給下游。

test-tumbling-windows-1

改變代碼如下:

@Test
public void testTumblingTimeWindowWithSuppress() throws InterruptedException {
    Properties props = configStreamProperties();
    StreamsBuilder builder = new StreamsBuilder();
    KStream&lt;String, String&gt; data = builder.stream(TEST_TOPIC);

    Instant initTime = Instant.now();

    data.groupByKey()
        .windowedBy(TimeWindows.of(Duration.ofSeconds(TIME_WINDOW_SECONDS)).grace(Duration.ZERO))
        .count(Materialized.with(Serdes.String(), Serdes.Long()))
        .suppress(Suppressed.untilWindowCloses(Suppressed.BufferConfig.unbounded()))
        .toStream()
        .filterNot(((key, value) -&gt; this.isOldWindow(key, value, initTime))) 
        .foreach(this::dealWithTimeWindowAggrValue);

    Topology topology = builder.build();
    KafkaStreams streams = new KafkaStreams(topology, props);
    streams.start();

    Thread.currentThread().join();
}

test-tumbling-windows-with-suppress

可以看到,只有當窗口關閉後,窗口的聚合結果纔會發送到下游。所以最終下游只得到了555555…

何時進行聚合計算

我們再來看下第一點:當新的數據到來時,所在的時間窗口都會進行聚合計算。

有的人可能會誤解,如果使用了Suppressed.untilWindowCloses,是不是隻用在窗口關閉時進行一次求和計算就好了。其實不是這樣的,只要一個數據落到了某個窗口內(同一數據可以落到多個窗口),窗口便會立即進行聚合計算。

我們繼續使用testTumblingTimeWindowWithSuppress()的例子,改動如下:

@Test
public void testTumblingTimeWindowWithSuppress() throws InterruptedException {
    Properties props = configStreamProperties();
    StreamsBuilder builder = new StreamsBuilder();
    KStream&lt;String, String&gt; data = builder.stream(TEST_TOPIC);

    Instant initTime = Instant.now();

    data.groupByKey()
        .windowedBy(TimeWindows.of(Duration.ofSeconds(TIME_WINDOW_SECONDS)).grace(Duration.ZERO))
        .aggregate(() -&gt; 0L, this::aggrDataInTimeWindow, Materialized.with(Serdes.String(), Serdes.Long())) // 使用自定義aggregator
        .suppress(Suppressed.untilWindowCloses(Suppressed.BufferConfig.unbounded()))
        .toStream()
        .filterNot(((key, value) -&gt; this.isOldWindow(key, value, initTime))) 
        .foreach(this::dealWithTimeWindowAggrValue);

    Topology topology = builder.build();
    KafkaStreams streams = new KafkaStreams(topology, props);
    streams.start();

    Thread.currentThread().join();
}

private Long aggrDataInTimeWindow(String key, String value, Long curValue) {
    curValue++;
    System.out.println("聚合計算:key=" + key + ",value=" + value + "\nafter aggr, curValue=" + curValue);
    return curValue;
}

之前我們使用count()方法,現在我們使用aggregate()方法來達到count的同樣功能,另外打印一行日誌,這樣我們就可以知道何時進行的聚合計算。

PS:aggregate方法接收三個參數,第一個指明聚合計算的初始值,第二個指明如何將流中當前元素累加到歷史的聚合值上,第三個指明聚合計算後key和value的數據類型:

<VR> KTable<Windowed<K>, VR> aggregate(final Initializer<VR> initializer,
final Aggregator<? super K, ? super V, VR> aggregator,
final Materialized<K, VR, WindowStore<Bytes, byte[]>> materialized);

運行後:

aggregator-called-tumbling-windows

可以看到,雖然被suppress了,但是聚合函數會在每次數據到來時被調用。

進一步地,我們在使用hopping time windows 進行驗證:到達的數據落到的每個窗口上,都會立即、分別調用該窗口的聚合函數。

@Test
public void testHoppingTimeWindowWithSuppress() throws InterruptedException {
    Properties props = configStreamProperties();
    StreamsBuilder builder = new StreamsBuilder();
    KStream&lt;String, String&gt; data = builder.stream(TEST_TOPIC);

    Instant initTime = Instant.now();

    data.groupByKey()
        .windowedBy(TimeWindows.of(Duration.ofSeconds(TIME_WINDOW_SECONDS))
                    .advanceBy(Duration.ofSeconds(ADVANCED_BY_SECONDS))
                    .grace(Duration.ZERO))
        .aggregate(() -&gt; 0L, this::aggrDataInTimeWindow, Materialized.with(Serdes.String(), Serdes.Long())) // 使用自定義aggregator
        .suppress(Suppressed.untilWindowCloses(Suppressed.BufferConfig.unbounded()))
        .toStream()
        .filterNot(((key, value) -&gt; this.isOldWindow(key, value, initTime))) 
        .foreach(this::dealWithTimeWindowAggrValue);

    Topology topology = builder.build();
    KafkaStreams streams = new KafkaStreams(topology, props);
    streams.start();

    Thread.currentThread().join();
}

結果如下:

aggregator-called-hopping-windows

可以看到,由於我們設置的時間窗口size=5s,前進間隔hop=1s,所以每個數據可以同時落到5個窗口內(見圖)。

小結

明白了事件窗口的聚合計算邏輯,我們在編程是就可以避免一些錯誤。比如自定義聚合函數時,Aggregator內應當只負責聚合計算,不應把其他的邏輯(比如將計算結果保存到db)寫到Aggreagator裏面。如果這樣做了,一旦修改了時間窗口的配置,修改了時間窗口類型、grace、suppress等,會導致混亂的結果。Aggreagator應當只封裝聚合算法,而其他的邏輯如filter、map等應當單獨封裝。

Time

最後我們研究下Kafka Stream中的時間概念。

上面我們利用時間窗口進行了實時計算,用起來很方便。但是你有沒有想過,當我們的流任務收到一條消息時,是如何定義這條消息的時間戳呢?

這個問題其實不光是Kafka Stream的問題,也牽扯到Kafka基本生產者消費者模型。但是由於實時計算的特點,在Kafka Stream中需要格外關注。

Kafka有這樣幾個時間概念: http://kafka.apache.org/23/documentation/streams/core-concepts#streams_time

  • Event time - 事件時間:事件真正發生的時間點,比如一個GPS設備在某刻捕獲到了位置變化,產生了一個記錄,這就是事件時間。(也就是說,事件時間與Kafka無關)
  • Processing time - 處理時間:KafkaStream應用處理數據的時間點,即消息被應用消費時的時間點。此時間點比EventTime晚,有可能是毫秒、小時甚至幾天。
  • Ingestion time - 攝入時間:消息被存入到Kafka的時間點。(準確地說是存入到Topic分區的時間點)。

攝入時間與事件時間的區別:前者是消息存入到topic的時間,後者是事件發生的事件。 攝入時間與處理時間的去表:後者是被KafkaStream應用消費到的時間點。如果一個記錄從未被消費,則它擁有攝入時間而沒有處理時間。

The choice between event-time and ingestion-time is actually done through the configuration of Kafka (not Kafka Streams): From Kafka 0.10.x onwards, timestamps are automatically embedded into Kafka messages. Depending on Kafka’s configuration these timestamps represent event-time or ingestion-time. The respective Kafka configuration setting can be specified on the broker level or per topic. The default timestamp extractor in Kafka Streams will retrieve these embedded timestamps as-is. Hence, the effective time semantics of your application depend on the effective Kafka configuration for these embedded timestamps.

指定事件時間

應用可以自行將事件時間信息保存到消息內容裏,然後將消息發送到kafka。在KafkaStream應用中,繼承TimeStampExtractor,在重載的extract方法中定義如何從消息中抽取時間時間。並在構造KafkaStream的props裏配置上該自定義的時間提取器。

比如我們自定義一個TimeStampExtractor,它可以從消息體中抽取我們在發送時寫入的時間信息。

public class MyTimestampExtractor implements TimestampExtractor {

    @Override
    public long extract(ConsumerRecord&lt;Object, Object&gt; record, long timeMill) {
        String value = record.value().toString();
        String eventTimeStr = value.split("@")[1]; //發送消息時 value = key + "@" + timeStr
        LocalDateTime eventTime = LocalDateTime.parse(eventTimeStr);
        Instant instant = eventTime.toInstant(ZoneOffset.ofHours(8));
        return instant.toEpochMilli();
    }

}

我們在發送消息的時候,將時間信息放到消息內容裏,但是我們做個小把戲,將時間對齊到每分鐘的0秒。

@BeforeClass
public static void generateValue() {

    Properties props = new Properties();
    props.put("bootstrap.servers", BOOT_STRAP_SERVERS);
    props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
    props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
    props.put("request.required.acks", "0");

    new Thread(() -&gt; {
        Producer&lt;String, String&gt; producer = new KafkaProducer&lt;&gt;(props);
        try {
            while (true) {
                TimeUnit.SECONDS.sleep(1L);
                Instant now = Instant.now();
                String key = "service_1";
                // 將時間信息放到消息內容裏,但是我們做個小把戲,將時間對齊到每分鐘的0秒
                String value = key + "@" + alignToMinute(now);
                producer.send(new ProducerRecord&lt;&gt;(TEST_TOPIC, key, value));
            }
        } catch (Exception e) {
            e.printStackTrace();
            producer.close();
        }
    }).start();
}

然後需要指定使用我們自定義的時間提取器。

private static final long TIME_WINDOW_SECONDS = 5L;
@Test
public void testEventTime() throws InterruptedException {
    Properties props = configStreamProperties();
    // 指定使用自定義的時間提取器
    props.put(StreamsConfig.DEFAULT_TIMESTAMP_EXTRACTOR_CLASS_CONFIG, MyTimestampExtractor.class);

    StreamsBuilder builder = new StreamsBuilder();
    KStream<String, String> data = builder.stream(TEST_TOPIC);

    Instant initTime = Instant.now();

    data.groupByKey()
        .windowedBy(TimeWindows.of(Duration.ofSeconds(TIME_WINDOW_SECONDS))) // 使用翻滾窗口
        .count(Materialized.with(Serdes.String(), Serdes.Long()))
        .toStream()
        .filterNot(((key, value) -> this.isOldWindow(key, value, initTime)))
        .foreach(this::dealWithTimeWindowAggrValue);
    
    Topology topology = builder.build();
    KafkaStreams streams = new KafkaStreams(topology, props);
    streams.start();
    
    Thread.currentThread().join();
}

我們的窗口大小仍然是5秒,使用翻滾窗口,聚合計算的值立即輸出到下游(控制檯)。

還記的在Tumbling time windows小節裏的例子嗎,當時的輸出是123451234512345…。但是我們現在使用自定義時間提取器,從消息內容裏提取時間信息,而在發送時做了點小把戲,所以在同一分鐘內接收到的消息,提出來的時間都是0秒的,也就是都會落到第一個時間窗口內(0秒-5秒窗口)。

test-eventtime

使用內嵌的時間戳

如果不制定自定義的時間提取器,時間又是哪裏來的呢? kafka每條消息中其實自帶了時間戳,作爲CreateTime 我們在發送消息時,一般時這樣寫的

producer.send(new ProducerRecord&lt;&gt;(TOPIC, key, value)

看線ProducerRecord的這個構造方法

public ProducerRecord(String topic, K key, V value) {
        this(topic, null, null, key, value, null);
}
 /**
     * Creates a record with a specified timestamp to be sent to a specified topic and partition
     * 
     * @param topic The topic the record will be appended to
     * @param partition The partition to which the record should be sent
     * @param timestamp The timestamp of the record, in milliseconds since epoch. If null, the producer will assign
     *                  the timestamp using System.currentTimeMillis().
     * @param key The key that will be included in the record
     * @param value The record contents
     * @param headers the headers that will be included in the record
     */
public ProducerRecord(String topic, Integer partition, Long timestamp, K key, V value, Iterable&lt;Header&gt; headers) {
        //...
}

我們注意到第三個參數,如果傳入的是null,則會使用System.currentTimeMillis()

KafkaStream在不配置自定義TimeStampExtractor時,會使用這個消息中內嵌的時間戳,而這個時間戳可能是Producer程序中ProducerRecord生成的時候的時刻,也可能是消息寫入到topic的log文件中的時刻。

相關配置:message.timestamp.type

name desc type default VALID VALUES
message.timestamp.type Define whether the timestamp in the message is message create time or log append time string CreateTime [CreateTime, LogAppendTime]

該配置在broker和topic維度上可分別配置。

我們再進行實驗,這次不配置自定義的TimestampExtractor了。這時默認的TimeStampExtractor會使用消息中內嵌的時間戳。

@Test
public void testEventTime() throws InterruptedException {
    Properties props = configStreamProperties();
    // 指定使用自定義的時間提取器
    // props.put(StreamsConfig.DEFAULT_TIMESTAMP_EXTRACTOR_CLASS_CONFIG, MyTimestampExtractor.class);

    StreamsBuilder builder = new StreamsBuilder();
    KStream&lt;String, String&gt; data = builder.stream(TEST_TOPIC);

    Instant initTime = Instant.now();
    data.groupByKey()
        .windowedBy(TimeWindows.of(Duration.ofSeconds(TIME_WINDOW_SECONDS)))
        .count(Materialized.with(Serdes.String(), Serdes.Long()))
        .toStream()
        .filterNot(((key, value) -&gt; this.isOldWindow(key, value, initTime)))
        .foreach(this::dealWithTimeWindowAggrValue);
    
    Topology topology = builder.build();
    KafkaStreams streams = new KafkaStreams(topology, props);
    streams.start();
    Thread.currentThread().join();
}

在發送的時候,傳入內嵌的時間戳的值,但是我們做個小把戲,將時間對齊到每分鐘的30秒。這時默認的TimeStampExtractor從內嵌的時間戳提取出來後,會發現他們都落在“30秒-35秒”這個窗口內。

test-embed-time

上面講的是流任務面對收到的消息時,如何獲取時間信息。

而當流任務如果要將處理過的內容打回Kafka時,是如何添加時間信息的呢?

Whenever a Kafka Streams application writes records to Kafka, then it will also assign timestamps to these new records. The way the timestamps are assigned depends on the context:

  • When new output records are generated via processing some input record, for example, context.forward()triggered in the process()function call, output record timestamps are inherited from input record timestamps directly.
  • When new output records are generated via periodic functions such as Punctuator#punctuate(), the output record timestamp is defined as the current internal time (obtained through context.timestamp()) of the stream task.
  • For aggregations, the timestamp of a resulting aggregate update record will be that of the latest arrived input record that triggered the update.

Note, that the describe default behavior can be changed in the Processor API by assigning timestamps to output records explicitly when calling #forward().

總結

  • Kafka Stream中有4種時間窗口:Tumbling time windowHopping time windowsliding time windowsession time window
  • 可以使用supress方法不讓每次新的數據落到窗口內時,都立即向下遊發送新的統計值。
  • 如果使用Suppressed.untilWindowCloses,那麼窗口必須要指定grace。因爲默認的grace時間是24小時。所以24小時之內窗口是一直不關閉的,而且由於被suppress住了,所以下游會一直收不到結果。
  • 可以使用Suppressed.untilTimeLimit來指定上游聚合計算的值在多久後發往下游,它與時間窗口是否關閉無關,所以可以不使用grace。
  • 到達的數據落到的每個窗口上,都會立即、分別調用該窗口的聚合函數,計算結果默認情況下立即發送到下游,除非使用了suppress()。
  • Aggregator內應當只負責聚合計算,不應把其他的邏輯(比如將計算結果保存到db)寫到Aggreagator裏面。如果這樣做了,一旦修改了時間窗口的配置,修改了時間窗口類型、grace、suppress等,會導致混亂的結果。
  • KafkaStream的默認TimeStampExtractor,會提取消息中內嵌的時間戳,供依賴於時間的操作(如windowBy)使用。這個時間戳可能是Producer程序中ProducerRecord生成的時刻,也可能是消息寫入到topic的log文件中的時刻,取決於message.timestamp.type配置。
  • 如果要使用事件時間,發送消息時可將事件時間信息保存到消息內容裏,然後將消息發送到kafka。在KafkaStream應用中,繼承TimeStampExtractor,在重載的extract方法中定義如何從消息中抽取時間時間。並在構造KafkaStream的props裏配置上該自定義的時間提取器。

參考文檔

Kafka Stream 官方文檔

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