基於flink的電商使用者行為資料分析【2】| 實時熱門商品統計

Alice菌發表於2020-11-24

前言

        在上一期內容中,菌哥已經為大家介紹了電商使用者行為資料分析的主要功能和模組介紹。本期內容,我們需要介紹的是實時熱門商品統計模組的功能開發。
        

在這裡插入圖片描述


        首先要實現的是實時熱門商品統計,我們將會基於UserBehavior資料集來進行分析。

UserBehavior.csv
        專案主體用Scala編寫,採用IDEA作為開發環境進行專案編寫,採用maven作為專案構建和管理工具。首先我們需要搭建專案框架。

建立Maven專案

專案框架搭建

        開啟IDEA,建立一個maven專案,命名為UserBehaviorAnalysis。由於包含了多個模組,我們可以以UserBehaviorAnalysis作為父專案,並在其下建一個名為HotItemsAnalysis的子專案,用於實時統計熱門top N商品

        在UserBehaviorAnalysis下新建一個 maven module作為子專案,命名為HotItemsAnalysis。

        父專案只是為了規範化專案結構,方便依賴管理,本身是不需要程式碼實現的,所以UserBehaviorAnalysis下的src資料夾可以刪掉。

宣告專案中工具的版本資訊

        我們整個專案需要的工具的不同版本可能會對程式執行造成影響,所以應該在最外層的UserBehaviorAnalysis中宣告所有子模組共用的版本資訊。

        在pom.xml中加入以下配置:

<properties>
    <flink.version>1.7.2</flink.version>
<scala.binary.version>2.11</scala.binary.version>
    <kafka.version>2.2.0</kafka.version>
</properties>

新增專案依賴

        對於整個專案而言,所有模組都會用到flink相關的元件,所以我們在UserBehaviorAnalysis中引入公有依賴:

<dependencies>
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-scala_${scala.binary.version}</artifactId>
        <version>${flink.version}</version>
    </dependency>
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-streaming-scala_${scala.binary.version}</artifactId>
        <version>${flink.version}</version>
    </dependency>
<dependency>
        <groupId>org.apache.kafka</groupId>
<artifactId>kafka_${scala.binary.version}</artifactId>
<version>${kafka.version}</version>
</dependency>
<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-connector-kafka_${scala.binary.version}</artifactId>
    <version>${flink.version}</version>
</dependency>
</dependencies>

        同樣,對於maven專案的構建,可以引入公有的外掛:

<build>
    <plugins>
        <!-- 該外掛用於將Scala程式碼編譯成class檔案 -->
        <plugin>
            <groupId>net.alchim31.maven</groupId>
            <artifactId>scala-maven-plugin</artifactId>
            <version>3.4.6</version>
            <executions>
                <execution>
                    <!-- 宣告繫結到maven的compile階段 -->
                    <goals>
                        <goal>testCompile</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>

        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-assembly-plugin</artifactId>
            <version>3.0.0</version>
            <configuration>
                <descriptorRefs>
                  <descriptorRef>
jar-with-dependencies
</descriptorRef>
                </descriptorRefs>
            </configuration>
            <executions>
                <execution>
                    <id>make-assembly</id>
                    <phase>package</phase>
                    <goals>
                        <goal>single</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

        在HotItemsAnalysis子模組中,我們並沒有引入更多的依賴,所以不需要改動pom檔案。

資料準備

        在src/main/目錄下,可以看到已有的預設原始檔目錄是java,我們可以將其改名為scala。將資料檔案UserBehavior.csv複製到資原始檔目錄src/main/resources下,我們將從這裡讀取資料。

        至此,我們的準備工作都已完成,接下來可以寫程式碼了。

模組程式碼實現

        我們將實現一個“實時熱門商品”的需求,可以將“實時熱門商品”翻譯成程式設計師更好理解的需求:每隔5分鐘輸出最近一小時內點選量最多的前N個商品。將這個需求進行分解我們大概要做這麼幾件事情:

  • 抽取出業務時間戳,告訴Flink框架基於業務時間做視窗
  • 過濾出點選行為資料
  • 按一小時的視窗大小,每5分鐘統計一次,做滑動視窗聚合(Sliding Window)
  • 按每個視窗聚合,輸出每個視窗中點選量前N名的商品

程式主體

        在src/main/scala下建立HotItems.scala檔案,新建一個單例物件。定義樣例類UserBehaviorItemViewCount,在main函式中建立StreamExecutionEnvironment 並做配置,然後從UserBehavior.csv檔案中讀取資料,幷包裝成UserBehavior型別。程式碼如下:

/*
 1. @Author: Alice菌
 2. @Date: 2020/11/23 10:38
 3. @Description: 
         電商使用者行為資料分析:熱門商品實時統計
 */
object HotItems {

  // 定義樣例類,用於封裝資料
  case class UserBehavior(userId:Long,itemId:Long,categoryId:Int,behavior:String,timeStamp:Long)
  // 中間輸出的商品瀏覽量的樣例類
  case class ItemViewCount(itemId:Long,windowEnd:Long,count:Long)

  def main(args: Array[String]): Unit = {

    // 定義流處理環境
    val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
    // 為了列印到控制檯的結果不亂序,我們配置全域性的併發為1,這裡改變併發對結果正確性沒有影響
    env.setParallelism(1)
    // 設定時間特徵為事件時間
    env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
    // 讀取文字檔案,以 Window 為例
    val stream: DataStream[String] = env.readTextFile("YOUR_PATH\\resources\\UserBehavior.csv")
    // 對讀取到的資料來源進行處理
    stream.map(data =>{
      val dataArray: Array[String] = data.split(",")
      // 將資料封裝到新建的樣例類中
      UserBehavior(dataArray(0).trim.toLong,dataArray(1).trim.toLong,dataArray(2).trim.toInt,dataArray(3).trim,dataArray(4).trim.toLong)
    })
      // 設定waterMark(水印)  --  處理亂序資料
      .assignAscendingTimestamps(_.timeStamp * 1000)

    // 執行程式
    env.execute("HotItems")

        這裡注意,我們需要統計業務時間上的每小時的點選量,所以要基於EventTime來處理。那麼如何讓Flink按照我們想要的業務時間來處理呢?這裡主要有兩件事情要做。

        第一件是告訴Flink我們現在按照EventTime模式進行處理,Flink預設使用ProcessingTime處理,所以我們要顯式設定如下:

env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);

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

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

.assignAscendingTimestamps(_.timestamp * 1000)

        這樣我們就得到了一個帶有時間標記的資料流了,後面就能做一些視窗的操作。

過濾出點選事件

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

.filter(_.behavior == "pv")

設定滑動視窗,統計點選量

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

在這裡插入圖片描述
在這裡插入圖片描述

.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)會將視窗中的資料都儲存下來,最後一起計算要高效地多。這裡的CountAgg實現了AggregateFunction介面,功能是統計視窗中的條數,即遇到一條資料就加一

在這裡插入圖片描述

// COUNT統計的聚合函式實現,每出現一條記錄就加一
class CountAgg extends AggregateFunction[UserBehavior, Long, Long] {
  override def createAccumulator(): Long = 0L
  override def add(userBehavior: UserBehavior, acc: Long): Long = acc + 1
  override def getResult(acc: Long): Long = acc
  override def merge(acc1: Long, acc2: Long): Long = acc1 + acc2
}

        聚合操作.aggregate(AggregateFunction af, WindowFunction wf)的第二個引數WindowFunction將每個key每個視窗聚合後的結果帶上其他資訊進行輸出。我們這裡實現的WindowResultFunction將<主鍵商品ID,視窗,點選量>封裝成了ItemViewCount進行輸出。

// 商品點選量(視窗操作的輸出型別)
case class ItemViewCount(itemId: Long, windowEnd: Long, count: Long)

        程式碼如下:

// 自定義視窗函式,包裝成 ItemViewCount輸出
class WindowResult() extends WindowFunction[Long,ItemViewCount,Long,TimeWindow] {

  override def apply(key: Long, window: TimeWindow, input: Iterable[Long], out: Collector[ItemViewCount]): Unit = {

    // 在前面的步驟中,我們根據商品 id 進行了分組,次數的key就是  商品編號
    val itemId: Long = key
    // 獲取 視窗 末尾
    val windowEnd: Long = window.getEnd
    // 獲取點選數大小 【累加器統計的結果 】
    val count: Long = input.iterator.next()

    // 將獲取到的結果進行上傳
    out.collect(ItemViewCount(itemId,windowEnd,count))
  }
}

        現在我們就得到了每個商品在每個視窗的點選量的資料流。

        為了幫助大家理解,以上幾步體現出來的核心思想,小菌這裡貼出一張圖幫助大家回顧

在這裡插入圖片描述
        

計算最熱門 TopN 商品

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

在這裡插入圖片描述

      // 按每個視窗聚合
      .keyBy(_.windowEnd)
      // 輸出每個視窗中點選量前N名的商品
      .process(new TopNHotItems(3))

        ProcessFunction是Flink提供的一個low-level API,用於實現更高階的功能。它主要提供了定時器timer的功能(支援EventTimeProcessingTime)。本案例中我們將利用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的語義保證。

在這裡插入圖片描述

        程式碼如下:

// 自定義 process function,排序處理資料
class TopNHotItems(nSize:Int) extends KeyedProcessFunction[Long,ItemViewCount,String] {

  // 定義一個狀態變數 list state,用來儲存所有的 ItemViewCont
  private var itemState: ListState[ItemViewCount] = _

  // 在執行processElement方法之前,會最先執行並且只執行一次 open 方法
  override def open(parameters: Configuration): Unit = {
    // 初始化狀態變數
    itemState = getRuntimeContext.getListState(new ListStateDescriptor[ItemViewCount]("itemState", classOf[ItemViewCount]))
  }

  // 每個元素都會執行這個方法
  override def processElement(value: ItemViewCount, ctx: KeyedProcessFunction[Long, ItemViewCount, String]#Context, collector: Collector[String]): Unit = {
    // 每一條資料都存入 state 中
    itemState.add(value)
    // 註冊 windowEnd+1 的 EventTime Timer, 延遲觸發,當觸發時,說明收齊了屬於windowEnd視窗的所有商品資料,統一排序處理
    ctx.timerService().registerEventTimeTimer(value.windowEnd + 100)
  }
   
  // 定時器觸發時,會執行 onTimer 任務
  override def onTimer(timestamp: Long, ctx: KeyedProcessFunction[Long, ItemViewCount, String]#OnTimerContext, out: Collector[String]): Unit = {

    // 已經收集到所有的資料,首先把所有的資料放到一個 List 中
    val allItems: ListBuffer[ItemViewCount] = new ListBuffer()

    import scala.collection.JavaConversions._

    for (item <- itemState.get()) {
      allItems += item
    }
    
    // 將狀態清除
    itemState.clear()

    // 按照 count 大小  倒序排序
    val sortedItems: ListBuffer[ItemViewCount] = allItems.sortBy(_.count)(Ordering.Long.reverse).take(nSize)
      
    // 將資料排名資訊格式化成 String,方便列印輸出
    val result: StringBuilder = new StringBuilder()
    result.append("======================================================\n")
    // 觸發定時器時,我們多設定了0.1秒的延遲,這裡我們將時間減去0.1獲取到最精確的時間
    result.append("時間:").append(new Timestamp(timestamp - 100)).append("\n")

    // 每一個商品資訊輸出 (indices方法獲取索引)
    for( i <- sortedItems.indices){
         val currentTtem: ItemViewCount = sortedItems(i)
         result.append("No").append(i + 1).append(":")
          .append("商品ID=").append(currentTtem.itemId).append("  ")
          .append("瀏覽量=").append(currentTtem.count).append("  ")
          .append("\n")
    }

    result.append("======================================================\n")

    // 設定休眠時間
    Thread.sleep(1000)
    // 收集資料
    out.collect(result.toString())
  }
}

        這部分的內容也可以通過流程圖來表示:

在這裡插入圖片描述

        最後我們可以在main函式中將結果列印輸出到控制檯,方便實時觀測:

.print();

        至此整個程式程式碼全部完成,我們直接執行main函式,就可以在控制檯看到不斷輸出的各個時間點統計出的熱門商品。

部分效果圖

完整程式碼

        最終的完整程式碼如下:

import java.sql.Timestamp

import com.hypers.HotItems.{ItemViewCount, UserBehavior}
import org.apache.flink.api.common.functions.AggregateFunction
import org.apache.flink.api.common.state.{ListState, ListStateDescriptor}
import org.apache.flink.configuration.Configuration
import org.apache.flink.streaming.api.TimeCharacteristic
import org.apache.flink.streaming.api.functions.KeyedProcessFunction
import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.api.scala.function.WindowFunction
import org.apache.flink.streaming.api.windowing.time.Time
import org.apache.flink.streaming.api.windowing.windows.TimeWindow
import org.apache.flink.util.Collector
import scala.collection.mutable.ListBuffer

/*
 * @Author: Alice菌
 * @Date: 2020/11/23 10:38
 * @Description: 
         電商使用者行為資料分析:熱門商品實時統計
 */
object HotItems {

  // 定義樣例類,用於封裝資料
  case class UserBehavior(userId:Long,itemId:Long,categoryId:Int,behavior:String,timeStamp:Long)
  // 中間輸出的商品瀏覽量的樣例類
  case class ItemViewCount(itemId:Long,windowEnd:Long,count:Long)

  def main(args: Array[String]): Unit = {

    // 定義流處理環境
    val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
    // 設定並行度
    env.setParallelism(1)
    // 設定時間特徵為事件時間
    env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)
    // 讀取文字檔案
    val stream: DataStream[String] = env.readTextFile("G:\\idea arc\\BIGDATA\\project\\src\\main\\resources\\UserBehavior.csv")
    // 對讀取到的資料來源進行處理
    stream.map(data =>{
      val dataArray: Array[String] = data.split(",")
      // 將資料封裝到新建的樣例類中
      UserBehavior(dataArray(0).trim.toLong,dataArray(1).trim.toLong,dataArray(2).trim.toInt,dataArray(3).trim,dataArray(4).trim.toLong)
    })
      // 設定waterMark(水印)  --  處理亂序資料
      .assignAscendingTimestamps(_.timeStamp * 1000)
      // 過濾出 “pv”的資料  -- 過濾出點選行為資料
      .filter(_.behavior == "pv")
      // 因為需要統計出每種商品的個數,這裡先對商品id進行分組
      .keyBy(_.itemId)
      // 需求: 統計近1小時內的熱門商品,每5分鐘更新一次  -- 滑動視窗聚合
      .timeWindow(Time.hours(1),Time.minutes(5))
      // 預計算,統計出每種商品的個數
      .aggregate(new CountAgg(),new WindowResult())
      // 按每個視窗聚合
      .keyBy(_.windowEnd)
      // 輸出每個視窗中點選量前N名的商品
      .process(new TopNHotItems(3))
      .print("HotItems")

    // 執行程式
    env.execute("HotItems")


  }
}

// 自定義預聚合函式,來一個資料就加一
class CountAgg() extends AggregateFunction[UserBehavior,Long,Long]{

  // 定義累加器的初始值
  override def createAccumulator(): Long = 0L

  // 定義累加規則
  override def add(value: UserBehavior, accumulator: Long): Long = accumulator + 1

  // 定義得到的結果
  override def getResult(accumulator: Long): Long = accumulator

  // 合併的規則
  override def merge(a: Long, b: Long): Long = a + b

}

/**
  * WindowFunction [輸入引數型別,輸出引數型別,Key值型別,視窗型別]
  * 來處理視窗中的每一個元素(可能是分組的)
  */
// 自定義視窗函式,包裝成 ItemViewCount輸出
class WindowResult() extends WindowFunction[Long,ItemViewCount,Long,TimeWindow] {

  override def apply(key: Long, window: TimeWindow, input: Iterable[Long], out: Collector[ItemViewCount]): Unit = {

    // 在前面的步驟中,我們根據商品 id 進行了分組,次數的key就是  商品編號
    val itemId: Long = key
    // 獲取 視窗 末尾
    val windowEnd: Long = window.getEnd
    // 獲取點選數大小 【累加器統計的結果】
    val count: Long = input.iterator.next()

    // 將獲取到的結果進行上傳
    out.collect(ItemViewCount(itemId,windowEnd,count))
  }
}

// 自定義 process function,排序處理資料
class TopNHotItems(nSize:Int) extends KeyedProcessFunction[Long,ItemViewCount,String] {

  // 定義一個狀態變數 list state,用來儲存所有的 ItemViewCont
  private var itemState: ListState[ItemViewCount] = _

  // 在執行processElement方法之前,會最先執行並且只執行一次 open 方法
  override def open(parameters: Configuration): Unit = {
    // 初始化狀態變數
    itemState = getRuntimeContext.getListState(new ListStateDescriptor[ItemViewCount]("itemState", classOf[ItemViewCount]))
  }

  // 每個元素都會執行這個方法
  override def processElement(value: ItemViewCount, ctx: KeyedProcessFunction[Long, ItemViewCount, String]#Context, collector: Collector[String]): Unit = {
    // 每一條資料都存入 state 中
    itemState.add(value)
    // 註冊 windowEnd+1 的 EventTime Timer, 延遲觸發,當觸發時,說明收齊了屬於windowEnd視窗的所有商品資料,統一排序處理
    ctx.timerService().registerEventTimeTimer(value.windowEnd + 100)
  }

  // 定時器觸發時,會執行 onTimer 任務
  override def onTimer(timestamp: Long, ctx: KeyedProcessFunction[Long, ItemViewCount, String]#OnTimerContext, out: Collector[String]): Unit = {

    // 已經收集到所有的資料,首先把所有的資料放到一個 List 中
    val allItems: ListBuffer[ItemViewCount] = new ListBuffer()

    import scala.collection.JavaConversions._

    for (item <- itemState.get()) {
      allItems += item
    }

    // 將狀態清除
    itemState.clear()

    // 按照 count 大小  倒序排序
    val sortedItems: ListBuffer[ItemViewCount] = allItems.sortBy(_.count)(Ordering.Long.reverse).take(nSize)

    // 將資料排名資訊格式化成 String,方便列印輸出
    val result: StringBuilder = new StringBuilder()
    result.append("======================================================\n")
    // 觸發定時器時,我們多設定了0.1秒的延遲,這裡我們將時間減去0.1獲取到最精確的時間
    result.append("時間:").append(new Timestamp(timestamp - 100)).append("\n")

    // 每一個商品資訊輸出 (indices方法獲取索引)
    for( i <- sortedItems.indices){
         val currentTtem: ItemViewCount = sortedItems(i)
         result.append("No").append(i + 1).append(":")
          .append("商品ID=").append(currentTtem.itemId).append("  ")
          .append("瀏覽量=").append(currentTtem.count).append("  ")
          .append("\n")
    }

    result.append("======================================================\n")

    // 設定休眠時間
    Thread.sleep(1000)
    // 收集資料
    out.collect(result.toString())
  }
}

        為了讓小夥伴們更好理解,菌哥基本每行程式碼都寫上了註釋,就衝這波細節,還不給安排一波三連?開個玩笑,回到主題上,我們再來討論一個問題。

        實際生產環境中,我們的資料流往往是從Kafka獲取到的。如果要讓程式碼更貼近生產實際,我們只需將source更換為Kafka即可

    val properties = new Properties()
    properties.setProperty("bootstrap.servers", "localhost:9092")
    properties.setProperty("group.id", "consumer-group")
    properties.setProperty("key.deserializer",
      "org.apache.kafka.common.serialization.StringDeserializer")
    properties.setProperty("value.deserializer",
      "org.apache.kafka.common.serialization.StringDeserializer")
    properties.setProperty("auto.offset.reset", "latest")

        當然,根據實際的需要,我們還可以將Sink指定為Kafka、ES、Redis或其它儲存,這裡就不一一展開實現了。

參考

https://www.bilibili.com/video/BV1y54y127h2?from=search&seid=5631307517601819264

小結

        本期內容主要為大家分享瞭如何基於flink在電商使用者行為分析專案中對實時熱門商品統計模組進行開發的過程。下一期我們會介紹專案中另一個模組實時流量統計的功能開發,敬請期待!你知道的越多,你不知道的也越多,我是Alice,我們下一期見!

        受益的朋友記得三連支援小菌!

        

文章持續更新,可以微信搜一搜「 猿人菌 」第一時間閱讀,思維導圖,大資料書籍,大資料高頻面試題,海量一線大廠面經…期待您的關注!

相關文章