Flink實戰教程:如何計算實時熱門商品

實戰案例介紹

本案例將實現一個“實時熱門商品”的需求,我們可以將“實時熱門商品”翻譯成程序員更好理解的需求:每隔5分鐘輸出最近一小時內點擊量最多的前 N 個商品。


將這個需求進行分解我們大概要做這么幾件事情:

  • 抽取出業務時間戳,告訴 Flink 框架基于業務時間做窗口

  • 過濾出點擊行為數據

  • 按一小時的窗口大小,每5分鐘統計一次,做滑動窗口聚合(Sliding Window)

  • 按每個窗口聚合,輸出每個窗口中點擊量前N名的商品

數據準備

這里我們準備了一份淘寶用戶行為數據集(來自阿里云天池公開數據集)。本數據集包含了淘寶上某一天隨機一百萬用戶的所有行為(包括點擊、購買、加購、收藏)。數據集的組織形式和MovieLens-20M類似,即數據集的每一行表示一條用戶行為,由用戶ID、商品ID、商品類目ID、行為類型和時間戳組成,并以逗號分隔。關于數據集中每一列的詳細描述如下:

列名稱 說明
用戶ID 整數類型,加密后的用戶ID
商品ID 整數類型,加密后的商品ID
商品類目ID 整數類型,加密后的商品所屬類目ID
行為類型 字符串,枚舉類型,包括('pv', 'buy', 'cart', 'fav')
時間戳 行為發生的時間戳,單位秒

你可以通過下面的命令下載數據集到項目的 resources 目錄下:

$ cd my-flink-project/src/main/resources
$ curl https://raw.githubusercontent.com/wuchong/my-flink-project/master/src/main/resources/UserBehavior.csv > UserBehavior.csv

這里是否使用 curl 命令下載數據并不重要,你也可以使用 wget 命令或者直接訪問鏈接下載數據。關鍵是,將數據文件保存到項目的 resources 目錄下,方便應用程序訪問。

編寫程序

src/main/java/myflink 下創建 HotItems.java 文件:

package myflink;

public class HotItems {

    public static void main(String[] args) throws Exception {

    }
}

與上文一樣,我們會一步步往里面填充代碼。第一步仍然是創建一個 StreamExecutionEnvironment,我們把它添加到 main 函數中。

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 為了打印到控制臺的結果不亂序,我們配置全局的并發為1,這里改變并發對結果正確性沒有影響
env.setParallelism(1);

創建模擬數據源

在數據準備章節,我們已經將測試的數據集下載到本地了。由于是一個csv文件,我們將使用 CsvInputFormat 創建模擬數據源。

注:雖然一個流式應用應該是一個一直運行著的程序,需要消費一個無限數據源。但是在本案例教程中,為了省去構建真實數據源的繁瑣,我們使用了文件來模擬真實數據源,這并不影響下文要介紹的知識點。這也是一種本地驗證 Flink 應用程序正確性的常用方式。

我們先創建一個 UserBehavior 的 POJO 類(所有成員變量聲明成public便是POJO類),強類型化后能方便后續的處理。

/** 用戶行為數據結構 **/
public static class UserBehavior {
    public long userId;         // 用戶ID
    public long itemId;         // 商品ID
    public int categoryId;      // 商品類目ID
    public String behavior;     // 用戶行為, 包括("pv", "buy", "cart", "fav")
    public long timestamp;      // 行為發生的時間戳,單位秒
}

接下來我們就可以創建一個 PojoCsvInputFormat 了, 這是一個讀取 csv 文件并將每一行轉成指定 POJO 類型(在我們案例中是 UserBehavior)的輸入器。

// UserBehavior.csv 的本地文件路徑
URL fileUrl = HotItems2.class.getClassLoader().getResource("UserBehavior.csv");
Path filePath = Path.fromLocalFile(new File(fileUrl.toURI()));
// 抽取 UserBehavior 的 TypeInformation,是一個 PojoTypeInfo
PojoTypeInfo pojoType = (PojoTypeInfo) TypeExtractor.createTypeInfo(UserBehavior.class);
// 由于 Java 反射抽取出的字段順序是不確定的,需要顯式指定下文件中字段的順序
String[] fieldOrder = new String[]{"userId", "itemId", "categoryId", "behavior", "timestamp"};
// 創建 PojoCsvInputFormat
PojoCsvInputFormat csvInput = new PojoCsvInputFormat<>(filePath, pojoType, fieldOrder);

下一步我們用 PojoCsvInputFormat 創建輸入源。

DataStream dataSource = env.createInput(csvInput, pojoType);

這就創建了一個 UserBehavior 類型的 DataStream

EventTime 與 Watermark

當我們說“統計過去一小時內點擊量”,這里的“一小時”是指什么呢? 在 Flink 中它可以是指 ProcessingTime ,也可以是 EventTime,由用戶決定。

  • ProcessingTime:事件被處理的時間。也就是由機器的系統時間來決定。

  • EventTime:事件發生的時間。一般就是數據本身攜帶的時間。

在本案例中,我們需要統計業務時間上的每小時的點擊量,所以要基于 EventTime 來處理。那么如果讓 Flink 按照我們想要的業務時間來處理呢?這里主要有兩件事情要做。

第一件是告訴 Flink 我們現在按照 EventTime 模式進行處理,Flink 默認使用 ProcessingTime 處理,所以我們要顯式設置下。

env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);

第二件事情是指定如何獲得業務時間,以及生成 Watermark。Watermark 是用來追蹤業務事件的概念,可以理解成 EventTime 世界中的時鐘,用來指示當前處理到什么時刻的數據了。由于我們的數據源的數據已經經過整理,沒有亂序,即事件的時間戳是單調遞增的,所以可以將每條數據的業務時間就當做 Watermark。這里我們用 AscendingTimestampExtractor 來實現時間戳的抽取和 Watermark 的生成。

注:真實業務場景一般都是存在亂序的,所以一般使用 BoundedOutOfOrdernessTimestampExtractor

DataStream timedData = dataSource
    .assignTimestampsAndWatermarks(new AscendingTimestampExtractor() {
        @Override
        public long extractAscendingTimestamp(UserBehavior userBehavior) {
            // 原始數據單位秒,將其轉成毫秒
            return userBehavior.timestamp * 1000;
        }
    });

這樣我們就得到了一個帶有時間標記的數據流了,后面就能做一些窗口的操作。

過濾出點擊事件

在開始窗口操作之前,先回顧下需求“每隔5分鐘輸出過去一小時內點擊量最多的前 N 個商品”。由于原始數據中存在點擊、加購、購買、收藏各種行為的數據,但是我們只需要統計點擊量,所以先使用 FilterFunction 將點擊行為數據過濾出來。

DataStream pvData = timedData
    .filter(new FilterFunction() {
        @Override
        public boolean filter(UserBehavior userBehavior) throws Exception {
            // 過濾出只有點擊的數據
            return userBehavior.behavior.equals("pv");
        }
    });

窗口統計點擊量

由于要每隔5分鐘統計一次最近一小時每個商品的點擊量,所以窗口大小是一小時,每隔5分鐘滑動一次。即分別要統計 [09:00, 10:00), [09:05, 10:05), [09:10, 10:10)... 等窗口的商品點擊量。是一個常見的滑動窗口需求(Sliding Window)。

DataStream windowedData = pvData
    .keyBy("itemId")
    .timeWindow(Time.minutes(60), Time.minutes(5))
    .aggregate(new CountAgg(), new WindowResultFunction());

我們使用.keyBy("itemId")對商品進行分組,使用.timeWindow(Time size, Time slide)對每個商品做滑動窗口(1小時窗口,5分鐘滑動一次)。然后我們使用 .aggregate(AggregateFunction af, WindowFunction wf) 做增量的聚合操作,它能使用AggregateFunction提前聚合掉數據,減少 state 的存儲壓力。較之.apply(WindowFunction wf)會將窗口中的數據都存儲下來,最后一起計算要高效地多。aggregate()方法的第一個參數用于

這里的CountAgg實現了AggregateFunction接口,功能是統計窗口中的條數,即遇到一條數據就加一。

/** COUNT 統計的聚合函數實現,每出現一條記錄加一 */
public static class CountAgg implements AggregateFunction {

    @Override
    public Long createAccumulator() {
        return 0L;
    }

    @Override
    public Long add(UserBehavior userBehavior, Long acc) {
        return acc + 1;
    }

    @Override
    public Long getResult(Long acc) {
        return acc;
    }

    @Override
    public Long merge(Long acc1, Long acc2) {
        return acc1 + acc2;
    }
}

.aggregate(AggregateFunction af, WindowFunction wf) 的第二個參數WindowFunction將每個 key每個窗口聚合后的結果帶上其他信息進行輸出。我們這里實現的WindowResultFunction將主鍵商品ID,窗口,點擊量封裝成了ItemViewCount進行輸出。

/** 用于輸出窗口的結果 */
public static class WindowResultFunction implements WindowFunction {

    @Override
    public void apply(
            Tuple key,  // 窗口的主鍵,即 itemId
            TimeWindow window,  // 窗口
            Iterable aggregateResult, // 聚合函數的結果,即 count 值
            Collector collector  // 輸出類型為 ItemViewCount
    ) throws Exception {
        Long itemId = ((Tuple1) key).f0;
        Long count = aggregateResult.iterator().next();
        collector.collect(ItemViewCount.of(itemId, window.getEnd(), count));
    }
}

/** 商品點擊量(窗口操作的輸出類型) */
public static class ItemViewCount {
    public long itemId;     // 商品ID
    public long windowEnd;  // 窗口結束時間戳
    public long viewCount;  // 商品的點擊量

    public static ItemViewCount of(long itemId, long windowEnd, long viewCount) {
        ItemViewCount result = new ItemViewCount();
        result.itemId = itemId;
        result.windowEnd = windowEnd;
        result.viewCount = viewCount;
        return result;
    }
}

現在我們得到了每個商品在每個窗口的點擊量的數據流。

TopN 計算最熱門商品

為了統計每個窗口下最熱門的商品,我們需要再次按窗口進行分組,這里根據ItemViewCount中的windowEnd進行keyBy()操作。然后使用 ProcessFunction 實現一個自定義的 TopN 函數 TopNHotItems 來計算點擊量排名前3名的商品,并將排名結果格式化成字符串,便于后續輸出。

DataStream topItems = windowedData
    .keyBy("windowEnd")
    .process(new TopNHotItems(3));  // 求點擊量前3名的商品

ProcessFunction 是 Flink 提供的一個 low-level API,用于實現更高級的功能。它主要提供了定時器 timer 的功能(支持EventTime或ProcessingTime)。本案例中我們將利用 timer 來判斷何時收齊了某個 window 下所有商品的點擊量數據。由于 Watermark 的進度是全局的,

processElement 方法中,每當收到一條數據(ItemViewCount),我們就注冊一個 windowEnd+1 的定時器(Flink 框架會自動忽略同一時間的重復注冊)。windowEnd+1 的定時器被觸發時,意味著收到了windowEnd+1的 Watermark,即收齊了該windowEnd下的所有商品窗口統計值。我們在 onTimer() 中處理將收集的所有商品及點擊量進行排序,選出 TopN,并將排名信息格式化成字符串后進行輸出。

這里我們還使用了 ListState<ItemViewCount> 來存儲收到的每條 ItemViewCount 消息,保證在發生故障時,狀態數據的不丟失和一致性。ListState 是 Flink 提供的類似 Java List 接口的 State API,它集成了框架的 checkpoint 機制,自動做到了 exactly-once 的語義保證。

/** 求某個窗口中前 N 名的熱門點擊商品,key 為窗口時間戳,輸出為 TopN 的結果字符串 */
public static class TopNHotItems extends KeyedProcessFunction {

    private final int topSize;

    public TopNHotItems(int topSize) {
        this.topSize = topSize;
    }

    // 用于存儲商品與點擊數的狀態,待收齊同一個窗口的數據后,再觸發 TopN 計算
    private ListState itemState;

    @Override
    public void open(Configuration parameters) throws Exception {
        super.open(parameters);
        // 狀態的注冊
        ListStateDescriptor itemsStateDesc = new ListStateDescriptor<>(
                "itemState-state",
                ItemViewCount.class);
        itemState = getRuntimeContext().getListState(itemsStateDesc);
    }

    @Override
    public void processElement(
            ItemViewCount input,
            Context context,
            Collector collector) throws Exception {

        // 每條數據都保存到狀態中
        itemState.add(input);
        // 注冊 windowEnd+1 的 EventTime Timer, 當觸發時,說明收齊了屬于windowEnd窗口的所有商品數據
        context.timerService().registerEventTimeTimer(input.windowEnd + 1);
    }

    @Override
    public void onTimer(
            long timestamp, OnTimerContext ctx, Collector out) throws Exception {
        // 獲取收到的所有商品點擊量
        List allItems = new ArrayList<>();
        for (ItemViewCount item : itemState.get()) {
            allItems.add(item);
        }
        // 提前清除狀態中的數據,釋放空間
        itemState.clear();
        // 按照點擊量從大到小排序
        allItems.sort(new Comparator() {
            @Override
            public int compare(ItemViewCount o1, ItemViewCount o2) {
                return (int) (o2.viewCount - o1.viewCount);
            }
        });
        // 將排名信息格式化成 String, 便于打印
        StringBuilder result = new StringBuilder();
        result.append("====================================\n");
        result.append("時間: ").append(new Timestamp(timestamp-1)).append("\n");
        for (int i=0;i

打印輸出

最后一步我們將結果打印輸出到控制臺,并調用env.execute執行任務。

topItems.print();
env.execute("Hot Items Job");

運行程序

直接運行 main 函數,就能看到不斷輸出的每個時間點的熱門商品ID。

本文通過實現一個“實時熱門商品”的案例,學習和實踐了 Flink 的多個核心概念和 API 用法。包括 EventTime、Watermark 的使用,State 的使用,Window API 的使用,以及 TopN 的實現。希望本文能加深大家對 Flink 的理解,幫助大家解決實戰上遇到的問題。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,825評論 6 546
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,814評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,980評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,064評論 1 319
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,779評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,109評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,099評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,287評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,799評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,515評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,750評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,221評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,933評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,327評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,667評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,492評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,703評論 2 380

推薦閱讀更多精彩內容