Spark 3.x Spark Core詳解 & 效能優化

iX發表於2022-06-01

Spark Core

1. 概述

Spark 是一種基於記憶體的快速、通用、可擴充套件的大資料分析計算引擎

1.1 Hadoop vs Spark

上面流程對應Hadoop的處理流程,下面對應著Spark的處理流程

image-20220601090758280

Hadoop

  • Hadoop 是由 java 語言編寫的,在分散式伺服器叢集上儲存海量資料並執行分散式 分析應用的開源框架
  • 作為 Hadoop 分散式檔案系統,HDFS 處於 Hadoop 生態圈的最下層,儲存著所有的 數 據 , 支援著 Hadoop的所有服務 。 它的理論基礎源於Google 的 The GoogleFile System 這篇論文,它是 GFS 的開源實現。
  • MapReduce 是一種程式設計模型,Hadoop 根據 Google 的 MapReduce 論文將其實現, 作為 Hadoop 的分散式計算模型,是 Hadoop 的核心。基於這個框架,分散式並行 程式的編寫變得異常簡單。綜合了 HDFS 的分散式儲存和 MapReduce 的分散式計 算,Hadoop 在處理海量資料時,效能橫向擴充套件變得非常容易。
  • HBase 是對 Google 的 Bigtable 的開源實現,但又和 Bigtable 存在許多不同之處。 HBase 是一個基於 HDFS 的分散式資料庫,擅長實時地隨機讀/寫超大規模資料集。 它也是 Hadoop 非常重要的元件。

Spark

  • Spark 是一種由 Scala 語言開發的快速、通用、可擴充套件的大資料分析引擎
  • Spark Core 中提供了 Spark 最基礎與最核心的功能
  • Spark SQL 是 Spark 用來操作結構化資料的元件。通過 Spark SQL,使用者可以使用 SQL 或者 Apache Hive 版本的 SQL 方言(HQL)來查詢資料。
  • Spark Streaming 是 Spark 平臺上針對實時資料進行流式計算的元件,提供了豐富的處理資料流的 API。

由上面的資訊可以獲知,Spark 出現的時間相對較晚,並且主要功能主要是用於資料計算, 所以其實 Spark 一直被認為是 Hadoop 框架的升級版。

Spark or Hadoop

  • Hadoop MapReduce 由於其設計初衷並不是為了滿足迴圈迭代式資料流處理,因此在多並行執行的資料可複用場景(如:機器學習、圖挖掘演算法、互動式資料探勘演算法)中存在諸多計算效率等問題。
  • 所以 Spark 應運而生,Spark 就是在傳統的 MapReduce 計算框架的基礎上,利用其計算過程的優化,從而大大加快了資料分析、挖掘的執行和讀寫速 度,並將計算單元縮小到更適合平行計算和重複使用的 RDD 計算模型。
  • Spark 是一個分散式資料快速分析專案。它的核心技術是彈性分散式資料集(Resilient Distributed Datasets),提供了比 MapReduce 豐富的模型,可以快速在記憶體中對資料集進行多次迭代,來支援複雜的資料探勘演算法和圖形計算演算法。
  • Spark 和Hadoop 的根本差異是多個作業之間的資料通訊問題 : Spark 多個作業之間資料 通訊是基於記憶體,而 Hadoop 是基於磁碟。

1.2 Spark 核心模組

image-20220513103513844 image-20220513104747780

Spark Core

  • Spark Core 中提供了 Spark 最基礎與最核心的功能,Spark 其他的功能如:Spark SQL, Spark Streaming,GraphX, MLlib 都是在 Spark Core 的基礎上進行擴充套件的

Spark SQL

  • Spark SQL 是 Spark 用來操作結構化資料的元件。通過 Spark SQL,使用者可以使用 SQL 或者 Apache Hive 版本的 SQL 方言(HQL)來查詢資料。

Spark Streaming

  • Spark Streaming 是 Spark 平臺上針對實時資料進行流式計算的元件,提供了豐富的處理資料流的 API。

Spark MLlib

  • MLlib 是 Spark 提供的一個機器學習演算法庫。MLlib 不僅提供了模型評估、資料匯入等額外的功能,還提供了一些更底層的機器學習原語。

Spark Graphx

  • GraphX 是 Spark 面向圖計算提供的框架與演算法庫。

1.3 Spark應用場景

  • 低延時的海量資料計算需求
  • 低延時SQL互動查詢需求
  • 準實時(秒級)海量資料計算需求

2. Spark 執行環境

image-20220514193723770

2.1 Local模式

所謂的 Local 模式,就是不需要其他任何節點資源就可以在本地執行 Spark 程式碼的環境,一般用於教學,除錯,演示等

2.1.1 安裝部署

官網下載安裝包,將 spark-XX-bin-hadoopXX.tgz 檔案上傳到 Linux 並解壓縮,放置在指定位置,路徑中不要包含中文或空格。

tar -zxvf spark-XXX-bin-hadoop.XX.tgz -C /opt/module
cd /opt/module
mv spark-3.0.0-bin-hadoop3.2 spark-local

2.1.2 啟動Local環境

  1. 進入解壓縮後的路徑,執行如下指令

    bin/spark-shell
    

    可以在命令列中,執行scala命令,也可以呼叫spark

    測試

    在解壓縮資料夾下的 data 目錄中,新增 word.txt 檔案。

    Hello Scala
    Hello Spark
    

    在命令列工具中執行如下程式碼指令

    sc.textFile("data/word.txt").flatMap(_.split("")).map((_,1)).reduceByKey(_+_).collect
    

    image-20220514194509620

  2. 啟動成功後,可以輸入網址進行 Web UI 監控頁面訪問

    http://虛擬機器or本機ip地址:4040
    
  3. 退出

    按鍵 Ctrl+C 或輸入 Scala 指令

    :quit
    

2.1.3 提交應用

/opt/module/spark-local/bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master local[2] \
./examples/jars/spark-examples_XXXXXjar \
10
  1. --class
    • 表示要執行程式的主類,此處可以更換為自己寫的應用程式
  2. --master local[2]
    • 部署模式,預設為本地模式,數字表示分配的虛擬 CPU 核數量
  3. spark-examples_XXX.jar
    • 執行的應用類所在的 jar 包(根據實際版本輸入),實際使用時,可以設定自己的 jar 包
  4. 數字 10 表示程式的入口引數,用於設定當前應用的任務數量

2.2 Standlone模式

local 本地模式畢竟只是用來進行練習演示的,真實工作中還是要將應用提交到對應的 叢集中去執行,這裡我們來看看只使用 Spark 自身節點執行的叢集模式,也就是我們所謂的 獨立部署(Standalone)模式。Spark 的 Standalone 模式體現了經典的 master-slave 模式。

叢集規劃:

image-20220514194845413

2.2.1 安裝部署

注意: 每個節點上配置相同,可配置一臺節點,然後上傳到其他節點便可

解壓縮檔案

將 spark-XXX.tgz 檔案上傳到 Linux 並解壓縮在指定位置

tar -zxvf spark-XXX.tgz -C /opt/module 
cd /opt/module
mv spark-XXX spark-standalone

修改配置檔案

  1. 進入解壓縮後路徑的 conf 目錄,修改 workers.template 檔名為 workers

    有些老版本是slaves.template

    修改 slaves 檔案,新增 worker 節點

    # 根據自己的主機節點名進行新增
    node1
    node2
    node3
    
  2. 修改 spark-env.sh.template 檔名為 spark-env.sh

    修改 spark-env.sh 檔案,新增 JAVA_HOME 環境變數和叢集對應的 master 節點

    # 根據實際情況進行修改
    export JAVA_HOME=/XXX/jdkXXX
    SPARK_MASTER_HOST=node1
    SPARK_MASTER_PORT=7077
    

    注意:7077 埠,相當於 hadoop3 內部通訊的 8020 埠,此處的埠需要確認自己的 Hadoop 配置

最後

將配置好的spark,分別上傳到每一個節點上

2.2.2 啟動叢集

在任意節點上,執行指令碼命令

/opt/module/spark-standlone/sbin/start-all.sh

檢視 Master 資源監控 Web UI 介面: http://node1:8080

關閉叢集

/opt/module/spark-standlone/sbin/stop-all.sh

2.2.3 提交應用

/opt/module/spark-standlone/bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master spark://node1:7077 \
./examples/jars/spark-examples_XXX.jar \
10
  1. --class 表示要執行程式的主類
  2. --master spark://linux1:7077 獨立部署模式,連線到 Spark 叢集
  3. spark-examples_XXX.jar 執行類所在的 jar 包
  4. 數字 10 表示程式的入口引數,用於設定當前應用的任務數量

執行任務時,會產生多個 Java 程式

image-20220514195813903

2.2.4 提交引數說明

bin/spark-submit \
--class <main-class>
--master <master-url> \
... # other options
<application-jar> \
[application-arguments]

image-20220514200055150

image-20220514200029270

2.2.5 配置歷史服務

由於 spark-shell 停止掉後,叢集監控 node1:4040 頁面就看不到歷史任務的執行情況,所以 開發時都配置歷史伺服器記錄任務執行情況

  1. 修改 spark-defaults.conf.template 檔名為 spark-defaults.conf

    修改 spark-defaults.conf 檔案,配置日誌儲存路徑

    spark.eventLog.enabled true
    spark.eventLog.dir hdfs://node1:8020/logs
    

    注意:路徑自己設定,需要啟動 hadoop 叢集,HDFS 上的 directory 目錄需要提前存在。

  2. 修改 spark-env.sh 檔案, 新增日誌配置

    前後路徑保持一致

    export SPARK_HISTORY_OPTS="
    -Dspark.history.ui.port=18080
    -Dspark.history.fs.logDirectory=hdfs://node1:8020/logs
    -Dspark.history.retainedApplications=30"
    

    引數 1 含義:WEB UI 訪問的埠號為 18080

    引數 2 含義:指定歷史伺服器日誌儲存路徑

    引數 3 含義:指定儲存 Application 歷史記錄的個數,如果超過這個值,舊的應用程式 資訊將被刪除,這個是記憶體中的應用數,而不是頁面上顯示的應用數。

注意:每一個節點上配置保持一致

重新啟動叢集和歷史服務

/opt/module/spark-standlone/sbin/start-all.sh
/opt/module/spark-standlone/sbin/start-history-server.sh

重新執行任務

/opt/module/spark-standlone/bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master spark://node1:7077 \
./examples/jars/spark-examples_XXX.jar \
10

檢視歷史服務http://node1:18080

image-20220514200611155

2.3 YARN模式

獨立部署(Standalone)模式由 Spark 自身提供計算資源,無需其他框架提供資源。這種方式降低了和其他第三方資源框架的耦合性,獨立性非常強。但是,Spark 主要是計算框架,而不是資源排程框架,所以本身提供的資源排程並不是它的強項,所以還是和其他專業的資源排程框架整合會更靠譜一些。

2.3.1 安裝部署

注意: 每個節點上配置相同,可配置一臺節點,然後上傳到其他節點便可

解壓縮檔案

將 spark-XXX.tgz 檔案上傳到 Linux 並解壓縮在指定位置

tar -zxvf spark-XXX.tgz -C /opt/module 
cd /opt/module
mv spark-XXX spark-yarn

修改配置檔案

修改 conf/spark-env.sh,新增 JAVA_HOME 和 YARN_CONF_DIR\HADOOP_CONF_DIR 配置

export JAVA_HOME=/XXX/jdk1XX
YARN_CONF_DIR=/opt/module/hadoop/etc/hadoop
HADOOP_CONF_DIR=/opt/module/hadoop/etc/hadoop

2.3.2 啟動叢集

啟動HDFS和YARN叢集

2.3.3 提交應用

/opt/module/spark-yarn/bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master yarn \
--deploy-mode cluster \
./examples/jars/spark-examples_XXX.jar \
10
image-20220514201118184

檢視顯示的連結頁面,點選 History,檢視歷史頁面

image-20220514201205830

2.2.4 配置歷史服務

  1. 修改 spark-defaults.conf.template 檔名為 spark-defaults.conf

    修改 spark-default.conf 檔案,配置日誌儲存路徑

    spark.eventLog.enabled true
    spark.eventLog.dir hdfs://node1:8020/logs
    

    注意:需要啟動 hadoop 叢集,HDFS 上的目錄需要提前存在

  2. 修改 spark-env.sh 檔案, 新增日誌配置

    export SPARK_HISTORY_OPTS="
    -Dspark.history.ui.port=18080
    -Dspark.history.fs.logDirectory=hdfs://node1:8020/logs
    -Dspark.history.retainedApplications=30"
    

    引數 1 含義:WEB UI 訪問的埠號為 18080

    引數 2 含義:指定歷史伺服器日誌儲存路徑

    引數 3 含義:指定儲存 Application 歷史記錄的個數,如果超過這個值,舊的應用程式 資訊將被刪除,這個是記憶體中的應用數,而不是頁面上顯示的應用數。

  3. 修改 spark-defaults.conf

    spark.yarn.historyServer.address=node1:18080
    spark.history.ui.port=18080
    

    注意:每一個節點上配置保持一致

  4. 啟動歷史服務

    sbin/start-history-server.sh
    
  5. 重新提交應用

    /opt/module/spark-yarn/bin/spark-submit \
    --class org.apache.spark.examples.SparkPi \
    --master yarn \
    --deploy-mode cluster \
    ./examples/jars/spark-examples_XXX.jar \
    10
    

3. Spark 執行架構

3.1 執行架構

Spark 框架的核心是一個計算引擎,整體來說,它採用了標準 master-slave 的結構。

如下圖所示,它展示了一個 Spark 執行時的基本結構。圖形中的 Driver 表示 master,負責管理整個叢集中的作業任務排程。圖形中的 Executor 則是 slave,負責實際執行任務。

image-20220514201844880

image-20220526101111391

3.2 核心元件

3.2.1 Driver & Executor

計算元件

Driver

Spark 驅動器節點,用於執行 Spark 任務中的 main 方法,負責實際程式碼的執行工作。

Driver 在 Spark 作業執行時主要負責:

  • 將使用者程式轉化為作業(job)
  • 在 Executor 之間排程任務(task)
  • 跟蹤 Executor 的執行情況
  • 通過 UI 展示查詢執行情況

實際上,我們無法準確地描述 Driver 的定義,因為在整個的程式設計過程中沒有看到任何有關Driver 的字眼。所以簡單理解,所謂的 Driver 就是驅使整個應用執行起來的程式,也稱之為Driver 類。

Executor

Spark Executor 是叢集中工作節點(Worker)中的一個 JVM 程式,負責在 Spark 作業中執行具體任務(Task),任務彼此之間相互獨立。Spark 應用啟動時,Executor 節點被同時啟動,並且始終伴隨著整個 Spark 應用的生命週期而存在。如果有 Executor 節點發生了故障或崩潰,Spark 應用也可以繼續執行,會將出錯節點上的任務排程到其他 Executor 節點上繼續執行。

Executor 有兩個核心功能

  • 負責執行組成 Spark 應用的任務,並將結果返回給驅動器程式
  • 它們通過自身的塊管理器(Block Manager)為使用者程式中要求快取的 RDD 提供記憶體式儲存。RDD 是直接快取在 Executor 程式內的,因此任務可以在執行時充分利用快取 資料加速運算。

3.2.2 Master & Worker

資源管理元件

Spark 叢集的獨立部署環境中,不需要依賴其他的資源排程框架,自身就實現了資源排程的功能,所以環境中還有其他兩個核心元件:Master 和 Worker,這裡的 Master 是一個進 程,主要負責資源的排程和分配,並進行叢集的監控等職責,類似於 Yarn 環境中的 RM, 而 Worker 呢,也是程式,一個 Worker 執行在叢集中的一臺伺服器上,由 Master 分配資源對 資料進行並行的處理和計算,類似於 Yarn 環境中 NM

3.2.3 ApplicationMaster

Hadoop 使用者向 YARN 叢集提交應用程式時,提交程式中應該包含 ApplicationMaster,用於向資源排程器申請執行任務的資源容器 Container,執行使用者自己的程式任務 job,監控整個任務的執行,跟蹤整個任務的狀態,處理任務失敗等異常情況

說的簡單點就是,ResourceManager(資源)和 Driver(計算)之間的解耦合靠的就是 ApplicationMaster。

3.3 核心概念

3.3.1 Executor 與 Core

Spark Executor 是叢集中執行在工作節點(Worker)中的一個 JVM 程式,是整個叢集中的專門用於計算的節點。在提交應用中,可以提供引數指定計算節點的個數,以及對應的資源。這裡的資源一般指的是工作節點 Executor 的記憶體大小和使用的虛擬 CPU 核(Core)數 量。

應用程式相關啟動引數如下:

image-20220514202733531

3.3.2 並行度(Parallelism)

在分散式計算框架中一般都是多個任務同時執行,由於任務分佈在不同的計算節點進行計算,所以能夠真正地實現多工並行執行,記住,這裡是並行,而不是併發。這裡我們將整個叢集並行執行任務的數量稱之為並行度

那麼一個作業到底並行度是多少呢?這個取決於框架的預設配置。應用程式也可以在執行過程中動態修改。

3.3.3 有向無環圖(DAG)

image-20220514202948692

資源之間的依賴關係,不能成環,會形成死鎖

大資料計算引擎框架我們根據使用方式的不同一般會分為四類:

  • 其中第一類就是 Hadoop 所承載的 MapReduce,它將計算分為兩個階段,分別為 Map 階段 和 Reduce 階段
  • 對於上層應用來說,就不得不想方設法去拆分演算法,甚至於不得不在上層應用實現多個 Job 的串聯,以完成一個完整的演算法,例如迭代計算。 由於這樣的弊端,催生了支援 DAG 框 架的產生。
  • 因此,支援 DAG 的框架被劃分為第二代計算引擎。如 Tez 以及更上層的 Oozie。
  • 接下來就是以 Spark 為代表的第三代的計算引擎。第三代計算引擎的特點主要是 Job 內部的 DAG 支援(不跨越 Job),以及實時計算。

這裡所謂的有向無環圖,並不是真正意義的圖形,而是由 Spark 程式直接對映成的資料流的高階抽象模型。簡單理解就是將整個程式計算的執行過程用圖形表示出來,這樣更直觀,更便於理解,可以用於表示程式的拓撲結構。

DAG(Directed Acyclic Graph)有向無環圖是由點和線組成的拓撲圖形,該圖形具有方向,不會閉環。

3.4 提交流程

基於Yarn環境

image-20220514203344486

Spark 應用程式提交到 Yarn 環境中執行的時候,一般會有兩種部署執行的方式:Client 和 Cluster。

兩種模式主要區別在於:Driver 程式的執行節點位置

image-20220527215105976

3.4.1 Yarn Client 模式

Client 模式將用於監控和排程的 Driver 模組在客戶端執行,而不是在 Yarn 中,所以一般用於測試

  • Driver 在任務提交的本地機器上執行
  • Driver 啟動後會和 ResourceManager 通訊申請啟動 ApplicationMaster
  • ResourceManager 分配 container,在合適的 NodeManager 上啟動 ApplicationMaster,負責向 ResourceManager 申請 Executor 記憶體
  • ResourceManager 接到 ApplicationMaster 的資源申請後會分配 container,然後 ApplicationMaster 在資源分配指定的 NodeManager 上啟動 Executor 程式
  • Executor 程式啟動後會向 Driver 反向註冊,Executor 全部註冊完成後 Driver 開始執行 main 函式
  • 之後執行到 Action 運算元時,觸發一個 Job,並根據寬依賴開始劃分 stage,每個 stage 生成對應的 TaskSet,之後將 task 分發到各個 Executor 上執行。

3.4.2 Yarn Cluster 模式

Cluster 模式將用於監控和排程的 Driver 模組啟動在 Yarn 叢集資源中執行。一般應用於實際生產環境

  • 在 YARN Cluster 模式下,任務提交後會和 ResourceManager 通訊申請啟動 ApplicationMaster
  • 隨後 ResourceManager 分配 container,在合適的 NodeManager 上啟動 ApplicationMaster,此時的 ApplicationMaster 就是 Driver
  • Driver 啟動後向 ResourceManager 申請 Executor 記憶體,ResourceManager 接到 ApplicationMaster 的資源申請後會分配 container,然後在合適的 NodeManager 上啟動 Executor 程式
  • Executor 程式啟動後會向 Driver 反向註冊,Executor 全部註冊完成後 Driver 開始執行 main 函式
  • 之後執行到 Action 運算元時,觸發一個 Job,並根據寬依賴開始劃分 stage,每個 stage 生成對應的 TaskSet,之後將 task 分發到各個 Executor 上執行。

4. Spark 核心程式設計

Spark 計算框架為了能夠進行高併發和高吞吐的資料處理,封裝了三大資料結構,用於處理不同的應用場景。

三大資料結構分別是:

  • RDD : 彈性分散式資料集
  • 累加器:分散式共享只寫變數
  • 廣播變數:分散式共享只讀變數

4.1 RDD

4.1.1 什麼是 RDD

RDD(Resilient Distributed Dataset)叫做彈性分散式資料集,是 Spark 中最基本的資料處理模型。程式碼中是一個抽象類,它代表一個彈性的、不可變、可分割槽、裡面的元素可平行計算的集合。

  • 彈性

    • 儲存的彈性:記憶體與磁碟的自動切換

    • 容錯的彈性:資料丟失可以自動恢復

    • 計算的彈性:計算出錯重試機制

    • 分片的彈性:可根據需要重新分片

  • 分散式:資料儲存在大資料叢集不同節點上

  • 資料集:RDD 封裝了計算邏輯,並不儲存資料

  • 資料抽象:RDD 是一個抽象類,需要子類具體實現

  • 不可變:RDD 封裝了計算邏輯,是不可以改變的,想要改變,只能產生新的 RDD,在新的 RDD 裡面封裝計算邏輯

  • 可分割槽、平行計算

RDD vs IO

RDD的資料處理方式類似於IO流,也有裝飾者設計模式

RDD的資料只有在呼叫collect方法時,才會真正執行業務邏輯操作。之前的封裝全部都是功能上的擴充套件

RDD是不儲存資料的,但是IO可以臨時儲存一部分資料

image-20220527212834387

image-20220527212811671

4.1.2 核心屬性

Internally, each RDD is characterized by five main properties:

  • A list of partitions
  • A function for computing each split
  • A list of dependencies on other RDDs
  • Optionally, a Partitioner for key-value RDDs (e.g. to say that the RDD is hash-partitioned)
  • Optionally, a list of preferred locations to compute each split on (e.g. block locations for an HDFS file)

分割槽列表

  • RDD 資料結構中存在分割槽列表,用於執行任務時平行計算,是實現分散式計算的重要屬性

    protected def getPartitions: Array[Partition]
    

分割槽計算函式

  • Spark 在計算時,是使用分割槽函式對每一個分割槽進行計算

    @DeveloperApi
    def compute(split: Partition, context: TaskContext): Iterator[T]
    

RDD 之間的依賴關係

  • RDD 是計算模型的封裝,當需求中需要將多個計算模型進行組合時,就需要將多個 RDD 建立依賴關係

    protected def getDependencies: Seq[Dependency[_]] = deps
    

分割槽器(可選)

  • 當資料為 KV 型別資料時,可以通過設定分割槽器自定義資料的分割槽

    @transient val partitioner: Option[Partitioner] = None
    

首選位置(可選)

  • 計算資料時,可以根據計算節點的狀態選擇不同的節點位置進行計算

    protected def getPreferredLocations(split: Partition): Seq[String] = Nil
    

4.1.3 執行原理

  • 資料處理過程中需要計算資源(記憶體 & CPU)和計算模型(邏輯)。執行時,需要將計算資源和計算模型進行協調和整合

  • Spark 框架在執行時,先申請資源,然後將應用程式的資料處理邏輯分解成一個一個的計算任務。然後將任務發到已經分配資源的計算節點上, 按照指定的計算模型進行資料計算。最後得到計算結果

RDD 是 Spark 框架中用於資料處理的核心模型,接下來我們看看,在 Yarn 環境中,RDD的工作原理

  1. 啟動 Yarn 叢集環境

    image-20220518103600961

  2. Spark 通過申請資源建立排程節點和計算節點

    image-20220518103609398

  3. Spark 框架根據需求將計算邏輯根據分割槽劃分成不同的任務

    image-20220518104419021

  4. 排程節點將任務根據計算節點狀態傳送到對應的計算節點進行計算

    image-20220518103658933

從以上流程可以看出 RDD 在整個流程中主要用於將邏輯進行封裝,並生成 Task 傳送給 Executor 節點執行計算

4.1.4 RDD 建立

在 Spark 中建立 RDD 的建立方式可以分為四種

  1. 集合(記憶體)中建立 RDD

    從集合中建立 RDD,Spark 主要提供了兩個方法:parallelize 和 makeRDD

    val sparkConf = new SparkConf().setMaster("local[*]").setAppName("spark")
    val sparkContext = new SparkContext(sparkConf)
    val rdd1 = sparkContext.parallelize(List(1,2,3,4))
    val rdd2 = sparkContext.makeRDD( List(1,2,3,4))
    rdd1.collect().foreach(println)
    rdd2.collect().foreach(println)
    sparkContext.stop()
    

    從底層程式碼實現來講,makeRDD 方法其實就是 parallelize 方法

    def makeRDD[T: ClassTag](
    	seq: Seq[T],
    	numSlices: Int = defaultParallelism): RDD[T] = withScope {
    		parallelize(seq, numSlices)
    	}
    
    // makeRDD方法可以傳遞第二個引數,這個參數列示分割槽的數量
    // 第二個引數可以不傳遞的,那麼makeRDD方法會使用預設值 : defaultParallelism(預設並行度)
    //    scheduler.conf.getInt("spark.default.parallelism", totalCores)
    //    spark在預設情況下,從配置物件中獲取配置引數:spark.default.parallelism
    //    如果獲取不到,那麼使用totalCores屬性,這個屬性取值為當前執行環境的最大可用核數
    // val rdd = sc.makeRDD(List(1,2,3,4),2)
    
  2. 外部儲存(檔案)建立 RDD

    由外部儲存系統的資料集建立 RDD 包括:本地的檔案系統,所有 Hadoop 支援的資料集, 比如 HDFS、HBase 等

    val sparkConf = new SparkConf().setMaster("local[*]").setAppName("spark")
    val sparkContext = new SparkContext(sparkConf)
    val fileRDD: RDD[String] = sparkContext.textFile("input")
    fileRDD.collect().foreach(println)
    sparkContext.stop()
    
  3. 從其他 RDD 建立

    主要是通過一個 RDD 運算完後,再產生新的 RDD

  4. 直接建立 RDD(new)

    使用 new 的方式直接構造 RDD,一般由 Spark 框架自身使用

4.1.5 RDD並行度與分割槽

預設情況下,Spark 可以將一個作業切分多個任務後,傳送給 Executor 節點平行計算,而能夠平行計算的任務數量我們稱之為並行度。這個數量可以在構建 RDD 時指定。記住,這裡的並行執行的任務數量,並不是指的切分任務的數量,不要混淆了

val sparkConf = new SparkConf().setMaster("local[*]").setAppName("spark")
val sparkContext = new SparkContext(sparkConf)
val dataRDD: RDD[Int] = sparkContext.makeRDD(List(1,2,3,4), 4)
val fileRDD: RDD[String] = sparkContext.textFile( "input", 2)
fileRDD.collect().foreach(println)
sparkContext.stop()

讀取記憶體資料時,資料可以按照並行度的設定進行資料的分割槽操作,資料分割槽規則的 Spark 核心原始碼如下

// Sequences need to be sliced at the same set of index positions for operations
// like RDD.zip() to behave as expected
def positions(length: Long, numSlices: Int): Iterator[(Int, Int)] = {
	(0 until numSlices).iterator.map { i =>
		val start = ((i * length) / numSlices).toInt
		val end = (((i + 1) * length) / numSlices).toInt
		(start, end)
	}
}

讀取檔案資料時,資料是按照 Hadoop 檔案讀取的規則進行切片分割槽,而切片規則和資料讀取的規則有些差異,具體 Spark 核心原始碼如下

public InputSplit[] getSplits(JobConf job, int numSplits) throws IOException {
 long totalSize = 0; // compute total size
 for (FileStatus file: files) { // check we have valid files
     if (file.isDirectory()) {
     	throw new IOException("Not a file: "+ file.getPath());
 	}
 	totalSize += file.getLen();
 }
 long goalSize = totalSize / (numSplits == 0 ? 1 : numSplits);
 long minSize = Math.max(job.getLong(org.apache.hadoop.mapreduce.lib.input.
 FileInputFormat.SPLIT_MINSIZE, 1), minSplitSize);

 ...

 for (FileStatus file: files) {

 ...

 if (isSplitable(fs, path)) {
     long blockSize = file.getBlockSize();
     long splitSize = computeSplitSize(goalSize, minSize, blockSize);
     ...
 }
 protected long computeSplitSize(long goalSize, long minSize,long blockSize) {
 	return Math.max(minSize, Math.min(goalSize, blockSize));
 }

4.2 RDD 轉換運算元

RDD 根據資料處理方式的不同將運算元整體上分為 Value 型別、雙 Value 型別和 Key-Value 型別

Value DESC
map 將處理的資料逐條進行對映轉換,這裡的轉換可以是型別的轉換,也可以是值的轉換
mapPartitions 將待處理的資料以分割槽為單位傳送到計算節點進行處理,這裡的處理是指可以進行任意的處理,哪怕是過濾資料
mapPartitionsWithIndex 將待處理的資料以分割槽為單位傳送到計算節點進行處理,這裡的處理是指可以進行任意的處 理,哪怕是過濾資料,在處理時同時可以獲取當前分割槽索引
flatMap 將處理的資料進行扁平化後再進行對映處理,所以運算元也稱之為扁平對映
glom 將同一個分割槽的資料直接轉換為相同型別的記憶體陣列進行處理,分割槽不變
groupBy 將資料根據指定的規則進行分組, 分割槽預設不變,但是資料會被打亂重新組合,我們將這樣的操作稱之為 shuffle。極限情況下,資料可能被分在同一個分割槽中 一個組的資料在一個分割槽中,但是並不是說一個分割槽中只有一個組
filter 將資料根據指定的規則進行篩選過濾,符合規則的資料保留,不符合規則的資料丟棄。 當資料進行篩選過濾後,分割槽不變,但是分割槽內的資料可能不均衡,生產環境下,可能會出現資料傾斜。
sample 根據指定的規則從資料集中抽取資料
distinct 將資料集中重複的資料去重
coalesce 根據資料量縮減分割槽,用於大資料集過濾後,提高小資料集的執行效率。當 spark 程式中,存在過多的小任務的時候,可以通過 coalesce 方法,收縮合並分割槽,減少分割槽的個數,減小任務排程成本
repartition 該操作內部其實執行的是 coalesce 操作,引數 shuffle 的預設值為 true。無論是將分割槽數多的 RDD 轉換為分割槽數少的 RDD,還是將分割槽數少的 RDD 轉換為分割槽數多的 RDD,repartition 操作都可以完成,因為無論如何都會經 shuffle 過程
sortBy 該操作用於排序資料。在排序之前,可以將資料通過 f 函式進行處理,之後按照 f 函式處理的結果進行排序,預設為升序排列。排序後新產生的 RDD 的分割槽數與原 RDD 的分割槽數一致。中間存在 shuffle 的過程
雙Value DESC
intersection 對源 RDD 和引數 RDD 求交集後返回一個新的 RDD
union 對源 RDD 和引數 RDD 求並集後返回一個新的 RDD
subtract 以一個 RDD 元素為主,除兩個 RDD 中復元素,將其他元素保留下來
zip 將兩個 RDD 中的元素,以鍵值對的形式進行合併
Key - Value DESC
partitionBy 將資料按照指定 Partitioner 重新進行分割槽。Spark 預設的分割槽器是 HashPartitioner
reduceByKey 可以將資料按照相同的 Key 對 Value 進行聚合
groupByKey 將資料來源的資料根據 key 對 value 進行分組
aggregateByKey 將資料根據不同的規則進行分割槽內計算和分割槽間計算
foldByKey 當分割槽內計算規則和分割槽間計算規則相同時,aggregateByKey 就可以簡化為 foldByKey
combineByKey 通用的對 key-value 型 rdd 進行聚集操作的聚集函式(aggregation function),combineByKey()允許使用者返回值的型別與輸入不一致
sortByKey 在一個(K,V)的 RDD 上呼叫,K 必須實現 Ordered 介面(特質),返回一個按照 key 進行排序 的
join 在型別為(K,V)和(K,W)的 RDD 上呼叫,返回一個相同 key 對應的所有元素連線在一起的 (K,(V,W))的 RDD
leftOuterJoin 類似於 SQL 語句的左外連線
cogroup (join & group)在型別為(K,V)和(K,W)的 RDD 上呼叫,返回一個(K,(Iterable,Iterable))型別的 RDD

4.2.1 value

map

  • 函式簽名

    def map[U: ClassTag](f: T => U): RDD[U]
    
  • 函式說明

    將處理的資料逐條進行對映轉換,這裡的轉換可以是型別的轉換,也可以是值的轉換

    val dataRDD: RDD[Int] = sparkContext.makeRDD(List(1,2,3,4))
    val dataRDD1: RDD[Int] = dataRDD.map(
     	num => { num * 2 }
    )
    val dataRDD2: RDD[String] = dataRDD1.map(
     	num => { "" + num}
    )
    

mapPartitions

  • 函式簽名

    def mapPartitions[U: ClassTag]( 
        f: Iterator[T] => Iterator[U], 
        preservesPartitioning: Boolean = false): RDD[U] 
    
  • 函式說明

    將待處理的資料以分割槽為單位傳送到計算節點進行處理,這裡的處理是指可以進行任意的處理,哪怕是過濾資料

    val dataRDD1: RDD[Int] = dataRDD.mapPartitions(
     datas => { datas.filter(_==2) }
    )
    
    // mapPartitions : 可以以分割槽為單位進行資料轉換操作
    //                 但是會將整個分割槽的資料載入到記憶體進行引用
    //                 如果處理完的資料是不會被釋放掉,存在物件的引用。
    //                 在記憶體較小,資料量較大的場合下,容易出現記憶體溢位。
    

map 和 mapPartitions 的區別?

  • Map 運算元是分割槽內一個資料一個資料的執行,類似於序列操作。而 mapPartitions 運算元是以分割槽為單位進行批處理操作
  • Map 運算元主要目的將資料來源中的資料進行轉換和改變。但是不會減少或增多資料。
  • MapPartitions 運算元需要傳遞一個迭代器,返回一個迭代器,沒有要求的元素的個數保持不變,所以可以增加或減少資料
  • Map 運算元因為類似於序列操作,所以效能比較低,而是 mapPartitions 運算元類似於批處理,所以效能較高。但是 mapPartitions 運算元會長時間佔用記憶體,那麼這樣會導致記憶體可能不夠用,出現記憶體溢位的錯誤。所以在記憶體有限的情況下,不推薦使用。

mapPartitionsWithIndex

  • 函式簽名

    def mapPartitionsWithIndex[U: ClassTag](
    	f: (Int, Iterator[T]) => Iterator[U],
    	preservesPartitioning: Boolean = false): RDD[U]
    
  • 函式說明

    將待處理的資料以分割槽為單位傳送到計算節點進行處理,這裡的處理是指可以進行任意的處理,哪怕是過濾資料,在處理時同時可以獲取當前分割槽索引

    val dataRDD1 = dataRDD.mapPartitionsWithIndex(
    	(index, datas) => { datas.map(index, _) }
    )
    

flatMap

  • 函式簽名

    def flatMap[U: ClassTag](f: T => TraversableOnce[U]): RDD[U]
    
  • 函式說明

    將處理的資料進行扁平化後再進行對映處理,所以運算元也稱之為扁平對映

    val dataRDD = sparkContext.makeRDD(List(List(1,2),List(3,4)),1)
    val dataRDD1 = dataRDD.flatMap(list => list)
    

glom

  • 函式簽名

    def glom(): RDD[Array[T]]
    
  • 函式說明

    將同一個分割槽的資料直接轉換為相同型別的記憶體陣列進行處理,分割槽不變

    val dataRDD = sparkContext.makeRDD(List(1,2,3,4),1)
    val dataRDD1:RDD[Array[Int]] = dataRDD.glom()
    

groupBy

  • 函式簽名

    def groupBy[K](f: T => K)(implicit kt: ClassTag[K]): RDD[(K, Iterable[T])]
    
  • 函式說明

    將資料根據指定的規則進行分組, 分割槽預設不變,但是資料會被打亂重新組合,我們將這樣的操作稱之為 shuffle。極限情況下,資料可能被分在同一個分割槽中

    一個組的資料在一個分割槽中,但是並不是說一個分割槽中只有一個組

    val dataRDD = sparkContext.makeRDD(List(1,2,3,4),1)
    val dataRDD1 = dataRDD.groupBy(_%2)
    

filter

  • 函式簽名

    def filter(f: T => Boolean): RDD[T]
    
  • 函式說明

    將資料根據指定的規則進行篩選過濾,符合規則的資料保留,不符合規則的資料丟棄。 當資料進行篩選過濾後,分割槽不變,但是分割槽內的資料可能不均衡,生產環境下,可能會出現資料傾斜

    val dataRDD = sparkContext.makeRDD(List(1,2,3,4),1)
    val dataRDD1 = dataRDD.filter(_%2 == 0)
    

sample

  • 函式簽名

    def sample(
        withReplacement: Boolean,
        fraction: Double,
        seed: Long = Utils.random.nextLong): RDD[T]
    
  • 函式說明

    根據指定的規則從資料集中抽取資料

    val dataRDD = sparkContext.makeRDD(List(
     1,2,3,4
    ),1)
    // 抽取資料不放回(伯努利演算法)
    // 伯努利演算法:又叫 0、1 分佈。例如扔硬幣,要麼正面,要麼反面。
    // 具體實現:根據種子和隨機演算法算出一個數和第二個引數設定機率比較,小於第二個引數要,大於不要
    // 第一個引數:抽取的資料是否放回,false:不放回
    // 第二個引數:抽取的機率,範圍在[0,1]之間,0:全不取;1:全取;
    // 第三個引數:隨機數種子,如果不傳遞第三個引數,那麼使用的是當前系統時間
    val dataRDD1 = dataRDD.sample(false, 0.5)
    // 抽取資料放回(泊松演算法)
    // 第一個引數:抽取的資料是否放回,true:放回;false:不放回
    // 第二個引數:重複資料的機率,範圍大於等於 0.表示每一個元素被期望抽取到的次數
    // 第三個引數:隨機數種子,如果不傳遞第三個引數,那麼使用的是當前系統時間
    val dataRDD2 = dataRDD.sample(true, 2)
    

distinct

  • 函式簽名

    def distinct()(implicit ord: Ordering[T] = null): RDD[T]
    def distinct(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T]
    
  • 函式說明

    將資料集中重複的資料去重

    val dataRDD = sparkContext.makeRDD(List(1,2,3,4,1,2),1)
    val dataRDD1 = dataRDD.distinct()
    val dataRDD2 = dataRDD.distinct(2)
    

coalesce

  • 函式簽名

    def coalesce(numPartitions: Int, shuffle: Boolean = false,
    	partitionCoalescer: Option[PartitionCoalescer] = Option.empty)
    	(implicit ord: Ordering[T] = null)
    	:RDD[T]
    
  • 函式說明

    根據資料量縮減分割槽,用於大資料集過濾後,提高小資料集的執行效率

    當 spark 程式中,存在過多的小任務的時候,可以通過 coalesce 方法,收縮合並分割槽,減少分割槽的個數,減小任務排程成本

    val dataRDD = sparkContext.makeRDD(List(
     1,2,3,4,1,2
    ),6)
    val dataRDD1 = dataRDD.coalesce(2)
    
    // coalesce方法預設情況下不會將分割槽的資料打亂重新組合
    // 這種情況下的縮減分割槽可能會導致資料不均衡,出現資料傾斜
    // 如果想要讓資料均衡,可以進行shuffle處理
    

repartition

  • 函式簽名

    def repartition(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T]
    
  • 函式說明

    該操作內部其實執行的是 coalesce 操作,引數 shuffle 的預設值為 true。無論是將分割槽數多的 RDD 轉換為分割槽數少的 RDD,還是將分割槽數少的 RDD 轉換為分割槽數多的 RDD,repartition 操作都可以完成,因為無論如何都會經 shuffle 過程

    val dataRDD = sparkContext.makeRDD(List(
     1,2,3,4,1,2
    ),2)
    val dataRDD1 = dataRDD.repartition(4)
    

兩者區別

coalesce運算元可以擴大分割槽的,但是如果不進行shuffle操作,是沒有意義,不起作用。
所以如果想要實現擴大分割槽的效果,需要使用shuffle操作
spark提供了一個簡化的操作

  • 縮減分割槽:coalesce,如果想要資料均衡,可以採用shuffle
  • 擴大分割槽:repartition, 底層程式碼呼叫的就是coalesce,而且肯定採用shuffle

sortBy

  • 函式簽名

    def sortBy[K](
     f: (T) => K,
     ascending: Boolean = true,
     numPartitions: Int = this.partitions.length)
     (implicit ord: Ordering[K], ctag: ClassTag[K]): RDD[T]
    
  • 函式說明

    該操作用於排序資料。在排序之前,可以將資料通過 f 函式進行處理,之後按照 f 函式處理的結果進行排序,預設為升序排列。排序後新產生的 RDD 的分割槽數與原 RDD 的分割槽數一致。中間存在 shuffle 的過程

    val dataRDD = sparkContext.makeRDD(List(
     1,2,3,4,1,2
    ),2)
    val dataRDD1 = dataRDD.sortBy(num=>num, false, 4)
    
    // sortBy方法可以根據指定的規則對資料來源中的資料進行排序,預設為升序,第二個引數可以改變排序的方式
    // sortBy預設情況下,不會改變分割槽。但是中間存在shuffle操作
    

4.2.2 double value

交集,並集和差集要求兩個資料來源資料型別保持一致

intersection

對源 RDD 和引數 RDD 求交集後返回一個新的 RDD

def intersection(other: RDD[T]): RDD[T]

val dataRDD1 = sparkContext.makeRDD(List(1,2,3,4))
val dataRDD2 = sparkContext.makeRDD(List(3,4,5,6))
val dataRDD = dataRDD1.intersection(dataRDD2)

union

對源 RDD 和引數 RDD 求並集後返回一個新的 RDD

def subtract(other: RDD[T]): RDD[T]

val dataRDD1 = sparkContext.makeRDD(List(1,2,3,4))
val dataRDD2 = sparkContext.makeRDD(List(3,4,5,6))
val dataRDD = dataRDD1.union(dataRDD2)

subtract

以一個 RDD 元素為主,去除兩個 RDD 中重複元素,將其他元素保留下來。求差集

def subtract(other: RDD[T]): RDD[T]

val dataRDD1 = sparkContext.makeRDD(List(1,2,3,4))
val dataRDD2 = sparkContext.makeRDD(List(3,4,5,6))
val dataRDD = dataRDD1.subtract(dataRDD2)

zip

將兩個 RDD 中的元素,以鍵值對的形式進行合併。

  • 資料型別可以不一致

  • 兩個資料來源要求分割槽數量要保持一致

  • 兩個資料來源要求分割槽中資料數量保持一致

def zip[U: ClassTag](other: RDD[U]): RDD[(T, U)]

val dataRDD1 = sparkContext.makeRDD(List(1,2,3,4))
val dataRDD2 = sparkContext.makeRDD(List(3,4,5,6))
val dataRDD = dataRDD1.zip(dataRDD2)

4.2.3 key-value

partitionBy

  • 函式簽名

    def partitionBy(partitioner: Partitioner): RDD[(K, V)]
    
  • 函式說明 將資料按照指定 Partitioner 重新進行分割槽。Spark 預設的分割槽器是 HashPartitioner

    import org.apache.spark.HashPartitioner
    
    val rdd: RDD[(Int, String)] = sc.makeRDD(Array((1,"aaa"),(2,"bbb"),(3,"ccc")),3) 
    // RDD => PairRDDFunctions
    // 隱式轉換(二次編譯)
    // 重分割槽的分割槽器與當前RDD的分割槽器一樣,則不會再次分割槽
    val rdd2: RDD[(Int, String)] = rdd.partitionBy(new HashPartitioner(2))
    

reduceByKey

image-20220527212701296

  • 函式簽名

    def reduceByKey(func: (V, V) => V): RDD[(K, V)]
    def reduceByKey(func: (V, V) => V, numPartitions: Int): RDD[(K, V)]
    
  • 函式說明

    可以將資料按照相同的 Key 對 Value 進行聚合

    // reduceByKey : 相同的key的資料進行value資料的聚合操作
    // scala語言中一般的聚合操作都是兩兩聚合,spark基於scala開發的,所以它的聚合也是兩兩聚合
    // 【1,2,3】
    // 【3,3】
    // 【6】
    // reduceByKey中如果key的資料只有一個,是不會參與運算的。
    
    val dataRDD1 = sparkContext.makeRDD(List(("a",1),("b",2),("c",3)))
    val dataRDD2 = dataRDD1.reduceByKey(_+_)
    val dataRDD3 = dataRDD1.reduceByKey(_+_, 2)
    

groupByKey

image-20220527212626326

spark中,shuffle操作必須落盤處理,不能在記憶體中資料等待,會導致記憶體溢位

  • 函式簽名

    def groupByKey(): RDD[(K, Iterable[V])]
    def groupByKey(numPartitions: Int): RDD[(K, Iterable[V])]
    def groupByKey(partitioner: Partitioner): RDD[(K, Iterable[V])]
    
  • 函式說明

    將資料來源的資料根據 key 對 value 進行分組

    val dataRDD1 = sparkContext.makeRDD(List(("a",1),("b",2),("c",3)))
    val dataRDD2 = dataRDD1.groupByKey()
    val dataRDD3 = dataRDD1.groupByKey(2)
    val dataRDD4 = dataRDD1.groupByKey(new HashPartitioner(2))
    

兩者區別

  • 從 shuffle 的角度:reduceByKey 和 groupByKey 都存在 shuffle 的操作,但是 reduceByKey 可以在 shuffle 前對分割槽內相同 key 的資料進行預聚合(combine)功能,這樣會減少落盤的資料量,而 groupByKey 只是進行分組,不存在資料量減少的問題,reduceByKey 效能比較高。

  • 從功能的角度:reduceByKey 其實包含分組和聚合的功能。GroupByKey 只能分組,不能聚合,所以在分組聚合的場合下,推薦使用 reduceByKey,如果僅僅是分組而不需要聚合。那麼還是隻能使用 groupByKey

aggregateByKey

  • 函式簽名

    def aggregateByKey[U: ClassTag](zeroValue: U)
    	(seqOp: (U, V) => U, combOp: (U, U) => U): RDD[(K, U)]
    
  • 函式說明

    將資料根據不同的規則進行分割槽內計算和分割槽間計算

    val dataRDD1 = sparkContext.makeRDD(List(("a",1),("b",2),("c",3)))
    val dataRDD2 = dataRDD1.aggregateByKey(0)(_+_,_+_)
    
    // TODO : 取出每個分割槽內相同 key 的最大值然後分割槽間相加
    // aggregateByKey 運算元是函式柯里化,存在兩個引數列表
    // 1. 第一個引數列表中的參數列示初始值
    // 2. 第二個引數列表中含有兩個引數
    // 2.1 第一個參數列示分割槽內的計算規則
    // 2.2 第二個參數列示分割槽間的計算規則
    

foldByKey

  • 函式簽名

    def foldByKey(zeroValue: V)(func: (V, V) => V): RDD[(K, V)]
    
  • 函式說明

    當分割槽內計算規則和分割槽間計算規則相同時,aggregateByKey 就可以簡化為 foldByKey

    val dataRDD1 = sparkContext.makeRDD(List(("a",1),("b",2),("c",3)))
    val dataRDD2 = dataRDD1.foldByKey(0)(_+_)
    

combineByKey

  • 函式簽名

    def combineByKey[C](
    	createCombiner: V => C,
    	mergeValue: (C, V) => C,
    	mergeCombiners: (C, C) => C): RDD[(K, C)]
    
  • 函式說明

    最通用的對 key-value 型 rdd 進行聚集操作的聚集函式(aggregation function)。類似於 aggregate(),combineByKey()允許使用者返回值的型別與輸入不一致

    val list: List[(String, Int)] = List(("a", 88), ("b", 95), ("a", 91), ("b", 93),
    ("a", 95), ("b", 98))
    val input: RDD[(String, Int)] = sc.makeRDD(list, 2)
    val combineRdd: RDD[(String, (Int, Int))] = input.combineByKey(
    	(_, 1),
    	(acc: (Int, Int), v) => (acc._1 + v, acc._2 + 1),
    	(acc1: (Int, Int), acc2: (Int, Int)) => (acc1._1 + acc2._1, acc1._2 + acc2._2)
    )
    

reduceByKey、foldByKey、aggregateByKey、combineByKey 的區別?

  • reduceByKey: 相同 key 的第一個資料不進行任何計算,分割槽內和分割槽間計算規則相同

  • FoldByKey: 相同 key 的第一個資料和初始值進行分割槽內計算,分割槽內和分割槽間計算規則相 同

  • AggregateByKey: 相同 key 的第一個資料和初始值進行分割槽內計算,分割槽內和分割槽間計算規則可以不相同

  • CombineByKey: 當計算時,發現資料結構不滿足要求時,可以讓第一個資料轉換結構。分割槽內和分割槽間計算規則不相同

// reduceByKey:
combineByKeyWithClassTag[V](
    (v: V) => v, // 第一個值不會參與計算
    func, // 分割槽內計算規則
    func, // 分割槽間計算規則
)

// aggregateByKey :
combineByKeyWithClassTag[U](
    (v: V) => cleanedSeqOp(createZero(), v), // 初始值和第一個key的value值進行的分割槽內資料操作
    cleanedSeqOp, // 分割槽內計算規則
    combOp,       // 分割槽間計算規則
)

// foldByKey:
combineByKeyWithClassTag[V](
    (v: V) => cleanedFunc(createZero(), v), // 初始值和第一個key的value值進行的分割槽內資料操作
    cleanedFunc,  // 分割槽內計算規則
    cleanedFunc,  // 分割槽間計算規則
)

// combineByKey :
combineByKeyWithClassTag(
    createCombiner,  // 相同key的第一條資料進行的處理函式
    mergeValue,      // 表示分割槽內資料的處理函式
    mergeCombiners,  // 表示分割槽間資料的處理函式
 )

sortByKey

  • 函式簽名

    def sortByKey(ascending: Boolean = true, numPartitions: Int = self.partitions.length)
     : RDD[(K, V)]
    
  • 函式說明

    在一個(K,V)的 RDD 上呼叫,K 必須實現 Ordered 介面(特質),返回一個按照 key 進行排序的

    val dataRDD1 = sparkContext.makeRDD(List(("a",1),("b",2),("c",3)))
    val sortRDD1: RDD[(String, Int)] = dataRDD1.sortByKey(true)
    val sortRDD1: RDD[(String, Int)] = dataRDD1.sortByKey(false)
    

join

  • 函式簽名

    def join[W](other: RDD[(K, W)]): RDD[(K, (V, W))]
    
  • 函式說明

    在型別為(K,V)和(K,W)的 RDD 上呼叫,返回一個相同 key 對應的所有元素連線在一起的 (K,(V,W))的 RDD

    val rdd: RDD[(Int, String)] = sc.makeRDD(Array((1, "a"), (2, "b"), (3, "c")))
    val rdd1: RDD[(Int, Int)] = sc.makeRDD(Array((1, 4), (2, 5), (3, 6)))
    rdd.join(rdd1).collect().foreach(println)
    

leftOuterJoin

  • 函式簽名

    def leftOuterJoin[W](other: RDD[(K, W)]): RDD[(K, (V, Option[W]))]
    
  • 函式說明

    類似於 SQL 語句的左外連線

    val dataRDD1 = sparkContext.makeRDD(List(("a",1),("b",2),("c",3)))
    val dataRDD2 = sparkContext.makeRDD(List(("a",1),("b",2),("c",3)))
    val rdd: RDD[(String, (Int, Option[Int]))] = dataRDD1.leftOuterJoin(dataRDD2)
    

cogroup

  • 函式簽名

    def cogroup[W](other: RDD[(K, W)]): RDD[(K, (Iterable[V], Iterable[W]))]
    
  • 函式說明

    在型別為(K,V)和(K,W)的 RDD 上呼叫,返回一個(K,(Iterable,Iterable))型別的 RDD

    val dataRDD1 = sparkContext.makeRDD(List(("a",1),("a",2),("c",3)))
    val dataRDD2 = sparkContext.makeRDD(List(("a",1),("c",2),("c",3)))
    val value: RDD[(String, (Iterable[Int], Iterable[Int]))] = dataRDD1.cogroup(dataRDD2)
    

4.3 RDD 行動運算元

4.3.1 reduce

  • 函式簽名

    def reduce(f: (T, T) => T): T
    
  • 函式說明

    聚集 RDD 中的所有元素,先聚合分割槽內資料,再聚合分割槽間資料

    val rdd: RDD[Int] = sc.makeRDD(List(1,2,3,4))
    // 聚合資料
    val reduceResult: Int = rdd.reduce(_+_)
    

4.3.2 collect

  • 函式簽名

    def collect(): Array[T]
    
  • 函式說明

    在驅動程式中,以陣列 Array 的形式返回資料集的所有元素

    val rdd: RDD[Int] = sc.makeRDD(List(1,2,3,4)) // 收集資料到 Driver rdd.collect().foreach(println)
    

4.3.3 count

  • 函式簽名

    def count(): Long
    
  • 函式說明

    返回 RDD 中元素的個數

    val rdd: RDD[Int] = sc.makeRDD(List(1,2,3,4))
    // 返回 RDD 中元素的個數
    val countResult: Long = rdd.count()
    

4.3.4 first

  • 函式簽名

    def first(): T
    
  • 函式說明

    返回 RDD 中的第一個元素

    val rdd: RDD[Int] = sc.makeRDD(List(1,2,3,4))
    // 返回 RDD 中元素的個數
    val firstResult: Int = rdd.first()
    println(firstResult)
    

4.3.5 take

  • 函式簽名

    def take(num: Int): Array[T]
    
  • 函式說明

    返回一個由 RDD 的前 n 個元素組成的陣列

    val rdd: RDD[Int] = sc.makeRDD(List(1,2,3,4))
    // 返回 RDD 中元素的個數
    val takeResult: Array[Int] = rdd.take(2)
    println(takeResult.mkString(","))
    

4.3.6 takeOrdered

  • 函式簽名

    def takeOrdered(num: Int)(implicit ord: Ordering[T]): Array[T]
    
  • 函式說明

    返回該 RDD 排序後的前 n 個元素組成的陣列

    val rdd: RDD[Int] = sc.makeRDD(List(1,3,2,4))
    // 返回 RDD 中元素的個數
    val result: Array[Int] = rdd.takeOrdered(2)
    

4.3.7 aggregate

  • 函式簽名

    def aggregate[U: ClassTag](zeroValue: U)(seqOp: (U, T) => U, combOp: (U, U) => U): U
    
  • 函式說明

    分割槽的資料通過初始值和分割槽內的資料進行聚合,然後再和初始值進行分割槽間的資料聚合

    val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4), 8)
    // 將該 RDD 所有元素相加得到結果
    //val result: Int = rdd.aggregate(0)(_ + _, _ + _)
    val result: Int = rdd.aggregate(10)(_ + _, _ + _)
    

4.3.8 fold

  • 函式簽名

    def fold(zeroValue: T)(op: (T, T) => T): T
    
  • 函式說明

    摺疊操作,aggregate 的簡化版操作

    val rdd: RDD[Int] = sc.makeRDD(List(1, 2, 3, 4))
    val foldResult: Int = rdd.fold(0)(_+_)
    

4.3.9 countByKey

  • 函式簽名

    def countByKey(): Map[K, Long]
    
  • 函式說明

    摺疊操作,aggregate 的簡化版操作

    val rdd: RDD[(Int, String)] = sc.makeRDD(List((1, "a"), (1, "a"), (1, "a"), (2,
    "b"), (3, "c"), (3, "c")))
    // 統計每種 key 的個數
    val result: collection.Map[Int, Long] = rdd.countByKey()
    

4.3.10 save 相關運算元

  • 函式簽名

    def saveAsTextFile(path: String): Unit
    def saveAsObjectFile(path: String): Unit
    def saveAsSequenceFile(
        path: String,
        codec: Option[Class[_ <: CompressionCodec]] = None): Unit
    
  • 函式說明

    將資料儲存到不同格式的檔案中

    // 儲存成 Text 檔案
    rdd.saveAsTextFile("output")
    // 序列化成物件儲存到檔案
    rdd.saveAsObjectFile("output1")
    // 儲存成 Sequencefile 檔案
    rdd.map((_,1)).saveAsSequenceFile("output2")
    

4.3.11 foreach

  • 函式簽名

    def foreach(f: T => Unit): Unit = withScope {
    	val cleanF = sc.clean(f)
    	sc.runJob(this, (iter: Iterator[T]) => iter.foreach(cleanF))
    }
    
  • 函式說明

    分散式遍歷 RDD 中的每一個元素,呼叫指定函式

    val rdd: RDD[Int] = sc.makeRDD(List(1,2,3,4))
    // 收集後列印
    rdd.map(num=>num).collect().foreach(println)
    println("****************")
    // 分散式列印
    rdd.foreach(println)
    

4.4 RDD 序列化

4.4.1 閉包檢查

從計算的角度, 運算元以外的程式碼都是在 Driver 端執行, 運算元裡面的程式碼都是在 Executor 端執行。

那麼在 scala 的函數語言程式設計中,就會導致運算元內經常會用到運算元外的資料,這樣就形成了閉包的效果,如果使用的運算元外的資料無法序列化,就意味著無法傳值給 Executor 端執行,就會發生錯誤,所以需要在執行任務計算前,檢測閉包內的物件是否可以進行序列化,這個操作我們稱之為閉包檢測。

Scala2.12 版本後閉包編譯方式發生了改變

從計算的角度, 運算元以外的程式碼都是在 Driver 端執行, 運算元裡面的程式碼都是在 Executor 端執行

object Spark_RDD_Serial {

    def main(args: Array[String]): Unit = {
        val sparConf = new SparkConf().setMaster("local").setAppName("WordCount")
        val sc = new SparkContext(sparConf)
        val rdd: RDD[String] = sc.makeRDD(Array("hello world", "hello spark", "hive"))
        val search = new Search("h")
        // 會報錯
        //search.getMatch1(rdd).collect().foreach(println)
       	// 不會報錯
        search.getMatch2(rdd).collect().foreach(println)
        sc.stop()
    }
    // 查詢物件
    // 類的構造引數其實是類的屬性, 構造引數需要進行閉包檢測,其實就等同於類進行閉包檢測
    class Search(query:String){

        def isMatch(s: String): Boolean = {
            s.contains(this.query)
        }
        // 函式序列化案例
        def getMatch1 (rdd: RDD[String]): RDD[String] = {
            rdd.filter(isMatch)
        }
        // 屬性序列化案例
        def getMatch2(rdd: RDD[String]): RDD[String] = {
            val s = query
            rdd.filter(x => x.contains(s))
        }
    }
}

4.5 RDD 依賴關係

RDD 血緣關係

RDD 只支援粗粒度轉換,即在大量記錄上執行的單個操作。將建立 RDD 的一系列 Lineage (血統)記錄下來,以便恢復丟失的分割槽。RDD 的 Lineage 會記錄 RDD 的後設資料資訊和轉 換行為,當該 RDD 的部分分割槽資料丟失時,它可以根據這些資訊來重新運算和恢復丟失的 資料分割槽。

image-20220527213036628

RDD 依賴關係

這裡所謂的依賴關係,其實就是兩個相鄰 RDD 之間的關係

RDD 窄依賴

Narrow Dependency

窄依賴表示每一個父(上游)RDD 的 Partition 最多被子(下游)RDD 的一個 Partition 使用

image-20220527213048283

RDD 寬依賴

Shuffle Dependency

寬依賴表示同一個父(上游)RDD 的 Partition 被多個子(下游)RDD 的 Partition 依賴,會引起 Shuffle

image-20220527213109726

4.5 RDD stage

Spark Job會被劃分為多個Stage,每一個Stage是有一組並行的Task組成的。

劃分依據:是否產生了Shuffle(寬依賴),一個shuffle會產生兩個stage

RDD 階段劃分

DAG(Directed Acyclic Graph)有向無環圖是由點和線組成的拓撲圖形,該圖形具有方向, 不會閉環。例如,DAG 記錄了 RDD 的轉換過程和任務的階段。

image-20220518152517725

RDD 階段劃分原始碼

try {
	// New stage creation may throw an exception if, for example, jobs are run on a
	// HadoopRDD whose underlying HDFS files have been deleted.
	finalStage = createResultStage (finalRDD, func, partitions, jobId, callSite)
} catch {
	case e: Exception =>
	logWarning ("Creating new stage failed due to exception - job: " + jobId, e)
	listener.jobFailed (e)
	return
}
	……
private def createResultStage (
	rdd: RDD[_],
	func: (TaskContext, Iterator[_] ) => _,
	partitions: Array[Int],
	jobId: Int,
	callSite: CallSite
): ResultStage = {
	val parents = getOrCreateParentStages (rdd, jobId)
	val id = nextStageId.getAndIncrement ()
	val stage = new ResultStage (id, rdd, func, partitions, parents, jobId, callSite)
	stageIdToStage (id) = stage
	updateJobIdStageIdMaps (jobId, stage)
	stage
}

……
private def getOrCreateParentStages (rdd: RDD[_], firstJobId: Int): List[Stage]
= {
	getShuffleDependencies (rdd).map {
		shuffleDep =>
		getOrCreateShuffleMapStage (shuffleDep, firstJobId)
	}.toList
}
……
private[scheduler] def getShuffleDependencies ( rdd: RDD[_] ): HashSet[ShuffleDependency[_, _, _]] = {
	val parents = new HashSet[ShuffleDependency[_, _, _]]
	val visited = new HashSet[RDD[_]]
	val waitingForVisit = new Stack[RDD[_]]
	waitingForVisit.push (rdd)
	while (waitingForVisit.nonEmpty) {
		val toVisit = waitingForVisit.pop ()
		if (! visited (toVisit) ) {
			visited += toVisit
			toVisit.dependencies.foreach {
				case shuffleDep: ShuffleDependency[_, _, _] =>
					parents += shuffleDep
				case dependency =>
					waitingForVisit.push (dependency.rdd)
			}
		}
	}
	parents
}
image-20220527213348436

RDD 任務劃分

RDD 任務切分中間分為:Application、Job、Stage 和 Task

  • Application:初始化一個 SparkContext 即生成一個 Application
  • Job:一個 Action 運算元就會生成一個 Job
  • Stage:Stage 等於寬依賴(ShuffleDependency)的個數加 1
  • Task:一個 Stage 階段中,最後一個 RDD 的分割槽個數就是 Task 的個數

注意:Application->Job->Stage->Task 每一層都是 1 對 n 的關係。

image-20220520105818930

RDD 任務劃分原始碼

val tasks: Seq[Task[_]] = try {
	stage match {
		case stage: ShuffleMapStage =>
			partitionsToCompute.map { 
				id =>
				val locs = taskIdToLocations(id)
				val part = stage.rdd.partitions(id)
				new ShuffleMapTask(stage.id, stage.latestInfo.attemptId,
                    taskBinary, part, locs, stage.latestInfo.taskMetrics, properties, Option(jobId),
                    Option(sc.applicationId), sc.applicationAttemptId)
			}
		case stage: ResultStage =>
			partitionsToCompute.map { 
				id =>
				val p: Int = stage.partitions(id)
				val part = stage.rdd.partitions(p)
				val locs = taskIdToLocations(id)
				new ResultTask(stage.id, stage.latestInfo.attemptId,
					taskBinary, part, locs, id, properties, stage.latestInfo.taskMetrics,
					Option(jobId), Option(sc.applicationId), sc.applicationAttemptId)
			}
	}
	……
val partitionsToCompute: Seq[Int] = stage.findMissingPartitions()
……
override def findMissingPartitions(): Seq[Int] = {
	mapOutputTrackerMaster
		.findMissingPartitions(shuffleDep.shuffleId)
		.getOrElse(0 until numPartitions)
}

4.6 RDD 持久化

image-20220527213500891

4.6.1 Cache 快取

RDD 通過 Cache 或者 Persist 方法將前面的計算結果快取,預設情況下會把資料以快取在 JVM 的堆記憶體中。但是並不是這兩個方法被呼叫時立即快取,而是觸發後面的 action 運算元時,該 RDD 將會被快取在計算節點的記憶體中,並供後面重用。

// cache 操作會增加血緣關係,不改變原有的血緣關係
println(wordToOneRdd.toDebugString)
// 資料快取。
wordToOneRdd.cache()
// 可以更改儲存級別
//mapRdd.persist(StorageLevel.MEMORY_AND_DISK_2)

儲存級別

object StorageLevel {
    val NONE = new StorageLevel(false, false, false, false)
    val DISK_ONLY = new StorageLevel(true, false, false, false)
    val DISK_ONLY_2 = new StorageLevel(true, false, false, false, 2)
    val MEMORY_ONLY = new StorageLevel(false, true, false, true)
    val MEMORY_ONLY_2 = new StorageLevel(false, true, false, true, 2)
    val MEMORY_ONLY_SER = new StorageLevel(false, true, false, false)
    val MEMORY_ONLY_SER_2 = new StorageLevel(false, true, false, false, 2)
    val MEMORY_AND_DISK = new StorageLevel(true, true, false, true)
    val MEMORY_AND_DISK_2 = new StorageLevel(true, true, false, true, 2)
    val MEMORY_AND_DISK_SER = new StorageLevel(true, true, false, false)
    val MEMORY_AND_DISK_SER_2 = new StorageLevel(true, true, false, false, 2)
    val OFF_HEAP = new StorageLevel(true, true, true, false, 1)
image-20220520110754800

快取有可能丟失,或者儲存於記憶體的資料由於記憶體不足而被刪除,RDD 的快取容錯機制保證了即使快取丟失也能保證計算的正確執行。通過基於 RDD 的一系列轉換,丟失的資料會被重算,由於 RDD 的各個 Partition 是相對獨立的,因此只需要計算丟失的部分即可,並不需要重算全部 Partition。

Spark 會自動對一些 Shuffle 操作的中間資料做持久化操作(比如:reduceByKey)。這樣做的目的是為了當一個節點 Shuffle 失敗了避免重新計算整個輸入。但是,在實際使用的時候,如果想重用資料,仍然建議呼叫 persist 或 cache。

4.6.2 checkPoint 檢查點

所謂的檢查點其實就是通過將 RDD 中間結果寫入磁碟

由於血緣依賴過長會造成容錯成本過高,這樣就不如在中間階段做檢查點容錯,如果檢查點之後有節點出現問題,可以從檢查點開始重做血緣,減少了開銷。

對 RDD 進行 checkpoint 操作並不會馬上被執行,必須執行 Action 操作才能觸發。

// 設定檢查點路徑
sc.setCheckpointDir("./checkpoint1")
// 建立一個 RDD,讀取指定位置檔案:hello atguigu atguigu
val lineRdd: RDD[String] = sc.textFile("input/1.txt")
// 業務邏輯
val wordRdd: RDD[String] = lineRdd.flatMap(line => line.split(" "))
val wordToOneRdd: RDD[(String, Long)] = wordRdd.map {
    word => {
        (word, System.currentTimeMillis())
    }
}
// 增加快取,避免再重新跑一個 job 做 checkpoint
wordToOneRdd.cache()
// 資料檢查點:針對 wordToOneRdd 做檢查點計算
wordToOneRdd.checkpoint()
// 觸發執行邏輯
wordToOneRdd.collect().foreach(println)

快取和檢查點區別

  1. Cache 快取只是將資料儲存起來,不切斷血緣依賴。Checkpoint 檢查點切斷血緣依賴
  2. Cache 快取的資料通常儲存在磁碟、記憶體等地方,可靠性低。Checkpoint 的資料通常儲存在 HDFS 等容錯、高可用的檔案系統,可靠性高。
  3. 建議對 checkpoint()的 RDD 使用 Cache 快取,這樣 checkpoint 的 job 只需從 Cache 快取中讀取資料即可,否則需要再從頭計算一次 RDD

4.7 RDD 分割槽器

Spark 目前支援 Hash 分割槽和 Range 分割槽,和使用者自定義分割槽。Hash 分割槽為當前的預設分割槽。分割槽器直接決定了 RDD 中分割槽的個數、RDD 中每條資料經過 Shuffle 後進入哪個分割槽,進而決定了 Reduce 的個數。

  • 只有 Key-Value 型別的 RDD 才有分割槽器,非 Key-Value 型別的 RDD 分割槽的值是 None
  • 每個 RDD 的分割槽 ID 範圍:0 ~ (numPartitions - 1),決定這個值是屬於那個分割槽的

Hash 分割槽:對於給定的 key,計算其 hashCode,併除以分割槽個數取餘

class HashPartitioner(partitions: Int) extends Partitioner {
    require(partitions >= 0, s"Number of partitions ($partitions) cannot be negative.")
    def numPartitions: Int = partitions
    def getPartition(key: Any): Int = key match {
        case null => 0
        case _ => Utils.nonNegativeMod(key.hashCode, numPartitions)
	}
    override def equals(other: Any): Boolean = other match {
        case h: HashPartitioner =>
            h.numPartitions == numPartitions
        case _ =>
        	false
    }
    override def hashCode: Int = numPartitions
}

Range 分割槽:將一定範圍內的資料對映到一個分割槽中,儘量保證每個分割槽資料均勻,而且分割槽間有序

class RangePartitioner[K : Ordering : ClassTag, V](
        partitions: Int,
        rdd: RDD[_ <: Product2[K, V]],
        private var ascending: Boolean = true)
    extends Partitioner {
    // We allow partitions = 0, which happens when sorting an empty RDD under the default settings.
    require(partitions >= 0, s"Number of partitions cannot be negative but found $partitions.")
	private var ordering = implicitly[Ordering[K]]
    // An array of upper bounds for the first (partitions - 1) partitions
    private var rangeBounds: Array[K] = {
    	...
    }
    def numPartitions: Int = rangeBounds.length + 1
    private var binarySearch: ((Array[K], K) => Int) = CollectionsUtils.makeBinarySearch[K]
        
    def getPartition (key: Any): Int = {
        val k = key.asInstanceOf[K]
        var partition = 0
        if (rangeBounds.length <= 128) {
            // If we have less than 128 partitions naive search
            while (partition < rangeBounds.length && ordering.gt (k, rangeBounds (partition) ) ) {
                partition += 1
            }
        } else {
            // Determine which binary search method to use only once.
            partition = binarySearch (rangeBounds, k)
            // binarySearch either returns the match location or -[insertion point]-1
            if (partition < 0) {
                partition = - partition - 1
            }
            if (partition > rangeBounds.length) {
                partition = rangeBounds.length
            }
        }
        if (ascending) {
            partition
        } else {
            rangeBounds.length - partition
        }
    }
    override def equals (other: Any): Boolean = other match {
        ...
    }
    override def hashCode (): Int = {
        ...
    }
    @throws(classOf[IOException])
    private def writeObject (out: ObjectOutputStream): Unit =
    	Utils.tryOrIOException {
            ...
        }
        @throws(classOf[IOException])
        private def readObject (in: ObjectInputStream): Unit = Utils.tryOrIOException {
            ...
        }
    }     

4.8 RDD 檔案讀取與儲存

Spark 的資料讀取及資料儲存可以從兩個維度來作區分:檔案格式以及檔案系統。

檔案格式分為:text 檔案、csv 檔案、sequence 檔案以及 Object 檔案

檔案系統分為:本地檔案系統、HDFS、HBASE 以及資料庫

text 檔案

// 讀取輸入檔案
val inputRDD: RDD[String] = sc.textFile("input/1.txt")
// 儲存資料
inputRDD.saveAsTextFile("output")

sequence 檔案

SequenceFile 檔案是 Hadoop 用來儲存二進位制形式的 key-value 對而設計的一種平面檔案(Flat File)。在 SparkContext 中,可以呼叫 sequenceFile[keyClass, valueClass](path)

// 儲存資料為 SequenceFile
dataRDD.saveAsSequenceFile("output")
// 讀取 SequenceFile 檔案
sc.sequenceFile[Int,Int]("output").collect().foreach(println)

object 物件檔案

物件檔案是將物件序列化後儲存的檔案,採用 Java 的序列化機制。可以通過 objectFile[T: ClassTag](path)函式接收一個路徑,讀取物件檔案,返回對應的 RDD,也可以通過呼叫 saveAsObjectFile()實現對物件檔案的輸出。因為是序列化所以要指定型別

// 儲存資料
dataRDD.saveAsObjectFile("output")
// 讀取資料
sc.objectFile[Int]("output").collect().foreach(println)

4.9 累加器

image-20220527213546922

累加器用來把 Executor 端變數資訊聚合到 Driver 端。在 Driver 程式中定義的變數,在 Executor 端的每個 Task 都會得到這個變數的一份新的副本,每個 task 更新這些副本的值後, 傳回 Driver 端進行 merge

4.9.1 系統累加器

val rdd = sc.makeRDD(List(1,2,3,4,5))
// 宣告累加器
var sum = sc.longAccumulator("sum");
rdd.foreach(
    num => {
        // 使用累加器
        sum.add(num)
    }
)
// 獲取累加器的值
println("sum = " + sum.value)

4.9.2 自定義累加器

// 自定義累加器
// 1. 繼承 AccumulatorV2,並設定泛型
// 2. 重寫累加器的抽象方法
class WordCountAccumulator extends AccumulatorV2[String, mutable.Map[String, Long]]{
    var map : mutable.Map[String, Long] = mutable.Map()
    // 累加器是否為初始狀態
    override def isZero: Boolean = {
        map.isEmpty
    }
    // 複製累加器
    override def copy(): AccumulatorV2[String, mutable.Map[String, Long]] = {
        new WordCountAccumulator
    }
    // 重置累加器
    override def reset(): Unit = {
        map.clear()
    }
    // 向累加器中增加資料 (In)
    override def add(word: String): Unit = {
        // 查詢 map 中是否存在相同的單詞
        // 如果有相同的單詞,那麼單詞的數量加 1
        // 如果沒有相同的單詞,那麼在 map 中增加這個單詞
        map(word) = map.getOrElse(word, 0L) + 1L
    }

    // 合併累加器
    override def merge(other: AccumulatorV2[String, mutable.Map[String, Long]]):
    Unit = {
        val map1 = map
        val map2 = other.value
        // 兩個 Map 的合併
        map = map1.foldLeft(map2)(
            ( innerMap, kv ) => {
                innerMap(kv._1) = innerMap.getOrElse(kv._1, 0L) + kv._2
                innerMap
            }
        )
    }
    // 返回累加器的結果 (Out)
    override def value: mutable.Map[String, Long] = map
}

4.10 廣播變數

image-20220527213618736

廣播變數用來高效分發較大的物件。向所有工作節點傳送一個較大的只讀值,以供一個或多個 Spark 操作使用。比如,如果你的應用需要向所有節點傳送一個較大的只讀查詢表, 廣播變數用起來都很順手。在多個並行操作中使用同一個變數,但是 Spark 會為每個任務分別傳送

image-20220527155607305
val rdd1 = sc.makeRDD(List( ("a",1), ("b", 2), ("c", 3), ("d", 4) ),4)
val list = List( ("a",4), ("b", 5), ("c", 6), ("d", 7) )
// 宣告廣播變數
val broadcast: Broadcast[List[(String, Int)]] = sc.broadcast(list)
val resultRDD: RDD[(String, (Int, Int))] = rdd1.map {
    case (key, num) => {
        var num2 = 0
        // 使用廣播變數
        for ((k, v) <- broadcast.value) {
            if (k == key) {
                num2 = v
            }
        }
        (key, (num, num2))
    }
}

5. 進階

5.1 SortByKey原理

image-20220527205719552

有一臺伺服器:32G記憶體,如何在記憶體中對1T資料排序

  • 先抽樣,看分佈,然後分塊(至少32塊),對每個塊進行排序,最後合併

5.2 shuffle

在Spark中,什麼情況下,會產生shuffle?

  • reduceByKey,groupByKey,sortByKey,countByKey,join等等

Spark shuffle一共經歷了這幾個過程:

  1. 未優化的 Hash Based Shuffle
  2. 優化後的 Hash Based Shuffle
  3. Sort-Based Shuffle

5.2.1 ShuffleMapStage 與 ResultStage

image-20220527220058216

在劃分 stage 時,最後一個 stage 稱為 finalStage,它本質上是一個 ResultStage 物件,前面的所有 stage 被稱為 ShuffleMapStage。

ShuffleMapStage

  • 的結束伴隨著 shuffle 檔案的寫磁碟。

ResultStage

  • 基本上對應程式碼中的 action 運算元,即將一個函式應用在 RDD 的各個 partition 的資料集上,意味著一個 job 的執行結束。

5.2.2 HashShuffle 解析

1. 未優化的 HashShuffle

如下圖中有 3 個 Reducer,從 Task 開始那邊各自把自己進行 Hash 計算(分割槽器: hash/numreduce 取模),分類出 3 個不同的類別,每個 Task 都分成 3 種類別的資料,想把不同的資料匯聚然後計算出最終的結果,所以 Reducer 會在每個 Task 中把屬於自己類別的數 據收集過來,匯聚成一個同類別的大集合,每 1 個 Task 輸出 3 份本地檔案,這裡有 4 個 Mapper Tasks,所以總共輸出了 4 個 Tasks x 3 個分類檔案 = 12 個本地小檔案。

image-20220527220312047

image-20220527221610178

2. 優化後的 HashShuffle

優化的 HashShuffle 過程就是啟用合併機制,合併機制就是複用 buffer,開啟合併機制 的配置是 spark.shuffle.consolidateFiles。該引數預設值為 false,將其設定為 true 即可開啟優化機制。通常來說,如果我們使用 HashShuffleManager,那麼都建議開啟這個選項。

這裡還是有 4 個 Tasks,資料類別還是分成 3 種型別,因為 Hash 演算法會根據你的 Key 進行分類,在同一個程式中,無論是有多少過 Task,都會把同樣的 Key 放在同一個 Buffer 裡,然後把 Buffer 中的資料寫入以 Core 數量為單位的本地檔案中,(一個 Core 只有一種類 型的 Key 的資料),每 1 個 Task 所在的程式中,分別寫入共同程式中的 3 份本地檔案,這裡 有 4 個 Mapper Tasks,所以總共輸出是 2 個 Cores x 3 個分類檔案 = 6 個本地小檔案。

image-20220527220445952

image-20220527221827986

5.2.3 SortShuffle 解析

1. 普通 SortShuffle

在該模式下,資料會先寫入一個資料結構,reduceByKey 寫入 Map,一邊通過 Map 區域性聚合,一邊寫入記憶體。Join 運算元寫入 ArrayList 直接寫入記憶體中。然後需要判斷是否達到閾值,如果達到就會將記憶體資料結構的資料寫入到磁碟,清空記憶體資料結構。

溢寫磁碟前,先根據 key 進行排序,排序過後的資料,會分批寫入到磁碟檔案中。預設批次為 10000 條,資料會以每批一萬條寫入到磁碟檔案。寫入磁碟檔案通過緩衝區溢寫的方式,每次溢寫都會產生一個磁碟檔案,也就是說一個 Task 過程會產生多個臨時檔案

最後在每個 Task 中,將所有的臨時檔案合併,這就是 merge 過程,此過程將所有臨時檔案讀取出來,一次寫入到最終檔案。意味著一個 Task 的所有資料都在這一個檔案中。同時單獨寫一份索引檔案,標識下游各個Task的資料在檔案中的索引,start offset和end offset。

image-20220527220824236

2. bypass SortShuffle

bypass 執行機制的觸發條件如下:

  1. shuffle reduce task 數量小於等於 spark.shuffle.sort.bypassMergeThreshold 引數的值,預設為 200
  2. 不是聚合類的 shuffle 運算元(比如 reduceByKey)

此時 task 會為每個 reduce 端的 task 都建立一個臨時磁碟檔案,並將資料按 key 進行 hash 然後根據 key 的 hash 值,將 key 寫入對應的磁碟檔案之中。當然,寫入磁碟檔案時也是先寫入記憶體緩衝,緩衝寫滿之後再溢寫到磁碟檔案的。最後,同樣會將所有臨時磁碟檔案都合併成一個磁碟檔案,並建立一個單獨的索引檔案

該過程的磁碟寫機制其實跟未經優化的 HashShuffleManager 是一模一樣的,因為都要建立數量驚人的磁碟檔案,只是在最後會做一個磁碟檔案的合併而已。因此少量的最終磁碟檔案,也讓該機制相對未經優化的 HashShuffleManager 來說,shuffle read 的效能會更好。

而該機制與普通 SortShuffleManager 執行機制的不同在於:不會進行排序。也就是說, 啟用該機制的最大好處在於,shuffle write 過程中,不需要進行資料的排序操作,也就節省掉了這部分的效能開銷。

image-20220527221117117

6. 效能優化

6.1 高效能序列化庫

  • Spark傾向於序列化的便捷性,預設使用了Java序列化機制
  • Java序列化機制的效能並不高,序列化的速度相對較慢,而且序列化以後的資料,相對來說比較大,比較佔用記憶體空間
  • Spark提供了兩種序列化機制:Java序列化和Kryo序列化

Kryo序列化

  • Kryo序列化比Java序列化更快,而且序列化後的資料更小,通常小十倍
  • 如果要使用Kryo序列化機制,首先要用SparkConf和Spark序列化器設定為KryoSerializer
  • 使用Kryo時,針對需要序列化的類,需要預先進行註冊,這樣才能獲得最佳效能,如果不註冊,Kryo必須時刻儲存型別的全類名,反而佔用不少記憶體
  • Spark預設對Scala中常用的型別自動在Kryo進行了註冊
  • 如果在運算元中,使用了外部的自定義型別的物件,那麼還是需要對其進行註冊
    • 格式:conf.registerKryoClasses(...)
  • 注意:如果要序列化的自定義的型別,欄位特別多,此時就需要對Kryo本身進行優化,因為Kryo內部的換儲存可能不夠存放那麼大的class物件
    • 需要呼叫SparkConf.set()方法,設定spark.kryoserializer.buffer.mb引數的值,將其調大,預設值為2,單位是MB

6.2 持久化&checkpoint

  • 針對程式中多次被transformation或者action操作的RDD進行持久化操作,避免對一個RDD反覆進行計算,再進一步優化,使用序列化Kryo的持久化級別
  • 為了保證RDD持久化資料在可能丟失的情況下還能實現高可靠,則需要對RDD執行CheckPoint操作

6.3 JVM垃圾回收調優

預設情況下,Spark使用每個Executor 60%的記憶體空間來快取RDD,那麼只有40%的記憶體空間來存放運算元執行期間建立的物件

  • 如果垃圾回收頻繁發生,就需要對這個比例進行調優,通過引數spark.storage.memoryFraction來修改比例

image-20220530111644485

統一記憶體管理

統一記憶體管理機制,與靜態記憶體管理的區別在於儲存記憶體和執行記憶體共享同一塊空間,可以動態佔用對方的空閒區域,統一記憶體管理的堆內記憶體結構如圖所示:

image-20220531112830971

image-20220531112853360

其中最重要的優化在於動態佔用機制,其規則如下:

  1. 設定基本的儲存記憶體和執行記憶體區域(spark.storage.storageFraction 引數),該設定確定了雙方各自擁有的空間的範圍
  2. 雙方的空間都不足時,則儲存到硬碟;若己方空間不足而對方空餘時,可借用對方的空間;(儲存空間不足是指不足以放下一個完整的 Block)
  3. 執行記憶體的空間被對方佔用後,可讓對方將佔用的部分轉存到硬碟,然後”歸還”借用的空間
  4. 儲存記憶體的空間被對方佔用後,無法讓對方”歸還”,因為需要考慮 Shuffle 過程中的很多因素,實現起來較為複雜。

6.4 提高並行度

  • 要儘量設定合理的並行度,來充分地利用叢集的資源,才能充分提高Spark程式的效能

  • 可以手動使用textFile()、parallelize()等方法的第二個引數來設定並行度,也可以使用spark.default.parallelism引數,來設定統一的並行度,Spark官方推薦,給叢集的每個cpu core設定2-3個task

image-20220530144109360

6.5 資料本地化

資料本地化級別 解釋
PROCESS_LOCAL 資料和計算它的程式碼在同一個JVM程式中
NODE_LOCAL 資料和計算它的程式碼在一個節點上,但是不在一個JVM程式中
NO_PREF 資料從哪裡過來,效能都是一樣的
RACK_LOCAL 資料和計算它的程式碼在一個機架上
ANY 資料可能在任意地方,比如其他網路環境內,或者其它機架上
  1. Spark傾向於使用最好的本地化級別排程task,但不現實
  2. Spark預設會等待指定時間,期望task要處理的資料所在的節點上的Executor空閒出一個cpu,從而將task分配過去,只要超過了時間,那麼spark就會將task分配到其他任意一個空閒的Executor上
  3. 可以設定spark.locality系列引數,來調節spark等待task可以進行資料本地化的時間
    • spark.locality.wait.process
    • spark.locality.wait.node
    • spark.locality.wait.rack

6.6 運算元優化

6.6.1 map vs mapPartitions

  • map: 一次處理一條資料

    • 因為可以GC回收處理過的資料,所以一般不會導致OOM異常
  • mapPartitions: 一次處理一個分割槽的資料

    • 如果元素過多,可能會導致OOM異常
    • 效能更高

建議針對初始化連結之類的操作,使用mapPartitions,放在mapPartitions內部
例如:建立資料庫連結,使用mapPartitions可以減少連結建立的次數,提高效能
注意:建立資料庫連結的程式碼建議放在次數,不要放在Driver端或者it.foreach內部
資料庫連結放在Driver端會導致連結無法序列化,無法傳遞到對應的task中執行,所以運算元在執行的時候會報錯
資料庫連結放在it.foreach()內部還是會建立多個連結,和使用map運算元的效果是一樣的

6.6.2 foreach vs foreachPartition

  • foreach:一次處理一條資料
  • foreachPartition:一次處理一個分割槽的資料

6.6.3 repartition

  • 對RDD進行重分割槽
    • 可以調整RDD的並行度
    • 可以解決RDD中資料傾斜的問題

6.6.4 reduceByKey vs groupByKey

reduceByKey會先進行預聚合,會減少資料量,效能更高

image-20220531101833726

相關文章