快速使用 Spark 部署数据处理任务

962 阅读31分钟

这是一篇 Spark 的快速上手指南。全文目录如下:

  1. 简易的开发约定
  2. Spark 框架
  3. 在 Windows 中开发 / 测试 Spark 项目
  4. Spark 从磁盘 / MySQL 读写数据
  5. 将 Spark 作为一个任务提交到 Linux 并定时运行
  6. 基于 Kafka + Spark-Streaming 消费单个 topic
  7. 手动维护消息消费的 offset
  8. 如何监听并处理同一个 Kafka 数据源的多个 topic

除此之外,还需要安装 Zookeeper,Kafka,Redis。不过在本篇文章中,这些工具并不是重点。

Spark 有三种部署模式:单机多线程的 Local 模式;不依赖于其它框架的独立部署 Standalone 模式;依托 Yarn,Mesos,K8s 等进行资源管理的 Cluster 模式。除了 Local 模式外,其它均为分布式服务。笔者能使用的服务器只有一个,因此本文仅介绍如何在 Local 模式下快速启动 Spark 任务。

虽然本文使用 sbt 做项目管理,但文中提到的所有依赖以及 assembly 插件都是通用的,因此使用 Maven 也无伤大雅。不过,Maven 下可能需要单独引入 Scala 语言包。

<dependency>
    <groupId>org.scala-lang</groupId>
    <artifactId>scala-library</artifactId>
    <version>2.11.8</version>
</dependency>

下面是笔者在服务器中部署 Spark 任务所配置的环境:

软件版本
JDKjdk1.8.0u144
Sacla SDKscala-2.11.8
Sparkspark-2.1.1-bin-hadoop2.7
Zookeeperzookeeper-3.4.10
Kafkakafka_2.11_2.1.1

开发

关于各种安装的问题,包括完整的 build.sbt 文件,可以简单参考文章的后半部分:部署与安装。

在开始之前

任何开发者都不希望随着业务复杂度的上升,代码质量越来难以保障。避免这个问题的最好方式,就是在前期做好项目规划。在真正开始介绍 Spark 之前,笔者这里简略介绍自己习惯的开发风格:

自顶向下式的测试驱动开发

Scala 提供两种自顶向下的设计模式:

  1. 首先创建一个 trait / interface,快速厘清每个模块的输入输出,然后将其拆分成多个抽象方法形式的子任务分而治之,这是倾向于 OOP 的设计风格。
  2. 创建一个高度柯里化的高阶函数来表示模块本身,将每一个子任务视作一个函数来分配职责,这是倾向于 FP 的设计风格。

事实上,一切面向接口编程的设计在支持 FP 的语言中都可以使用函数去实现。当然,选择哪种表达风格完全是开发者的自由。比如,即便是 Java 也可以选择 λ 表达式实现 FP 编程,因此表达的形式并不重要。重要的是,自顶向下的设计可以使我们不必在一开始就深陷于各种技术细节中,而是围绕系统整体进行规划。

使用接口划分出清晰的职责,也有利于团队内部的开发者之间明确自己的任务,防止因职责不清而迫使团队将大把的精力浪费在代码合并上。

制定计划后的下一步,首先在严谨的单元测试中验证模块的每一个功能成立。当某一个更基本的功能测试通过时,就意味着它可以被安全地视作法则 ( law ),去组合或是推导更复杂的功能,因为基于可靠法则推理出的法则也一定是可靠的 ( 函数式编程崇尚的代数推导模型 )。总体来看,项目的设计是自顶向下的,但实现的顺序是自底向上的。

这是测试驱动开发 TDD 的思想,笔者曾在 Akka 学习笔记中提到它:Akka 实战:测试驱动开发 TDD - 掘金 (juejin.cn)。开发过程遵循着红 — 绿 — 重构风格:

  1. 首先编写不完善的测试并让它失败 ( 防止意外的单元测试通过 ) 。
  2. 完善逻辑使得这个单元测试通过。
  3. 在保证单元测试通过的前提下,完善更优的实现。

用测试驱动开发还有一点好处:我们可以根据通过的单元测试数量明确地掌握开发进度。org.scalatest 提供了各种风格的 DSL 为开发者带来良好的测试体验。

// %% 会让 sbt 自动去匹配并下载当前 Scala 版本的 scalatest 包。
// 比如笔者的开发环境是 Scala 2.11.8, 那么 sbt 会自动下载 scalatest_2.11-2.2.0.jar 。
"org.scalatest" %% "scalatest" % "2.2.0"  

构建 DSL 丰富表达形式

出于封装的目的,一个庞大且复杂的功能可能会被集成到一个大方法内部,随之而来的是冗长的参数列表。编写一个用户友好的 DSL 作为接口能够降低团队间技术沟通的成本。下面以 buy 方法做一个例子:

def buy(price : Double, amount : Int, coupon : Double = 1): Double = price * amount * coupon

这个 buy 方法可拆分成三个过程:首先确认价格,然后确认购买数量,最后确认折扣 ( 最好将这一项设置为可选的 )。延迟确认参数的一个有效方法是将其柯里化:

def buy(price: Double)(amount : Int)(coupon : Double = 1) : Double = price * amount * coupon

不过对用户而言,直接的柯里化没有带来任何可读性方面的提升。如:

buy(10)(20)(0.8)

因此这里通过设计指令链接的形式间接实现柯里化。见:

case object Dsl {
    case class LinkStage(price : Double = 0,amount : Int = 1, coupon : Double = 1){
        // 内部被封装的真正执行的方法,它依赖外部的 LinkStage 实现柯里化。
        private def calculate = price * amount * coupon
        def amountOf(n : Int): LinkStage = this.copy(amount = n)
        def withCoupon(coupon : Double): LinkStage = this.copy(coupon = coupon)
        def getPrice: Double = calculate
    }
    // 使用 buy 作为这段 dsl 语句的开始。
    def buy(price : Double): LinkStage = LinkStage(price)
}

指令链接的设计思想借鉴自笔者的 Groovy 笔记:Groovy DSL 设计之道 - 掘金 (juejin.cn)。用户导入 Dsl._ 之后,可以编写具有高度可读的代码:

import Dsl._
// DSL 风格的指令链接
val a = buy {50.5} amountOf 3 withCoupon 0.9 getPrice; // getPrice 作为后缀运算符时,使用 ; 表示 DSL 结束。
// 函数调用的指令链接
val b = buy(10.0).amountOf(3).withCoupon(0.9).getPrice
val c = buy(30.9).amountOf(10).getPrice
val d = buy(10).withCoupon(0.8).getPrice

提升代码可读性的另一个途径是使用精心设计的函数命名来表达谓词。比如:

// Scala 3 之前还不能定义带类型参数的表达式,因此必须使用 def 定义泛型方法。
def itself[A]: A => A = (s : A) => s
// 不带类型参数的可以通过 def 声明为方法,也可以使用 val 声明为函数表达式。
def asTwice: Int => Int = (s : Int) => 2 * s

// Scala 的 Eta 拓展允许将方法视作函数表达式去传递。
// 对于无参数方法,为了避免混淆,需要在后面添加 _ 符号。比如 getName _ 。
val u = List(1,2,3,4,5) map asTwice // 2,4,6,8, ...
val v = List(1,2,3,4,5) map itself // 1,2,3,4, ...

实现资源自动开闭

切面编程对于 Spring 开发者而言应该再熟悉不过了。AOP 最适合用在涉及资源打开并回收的场景,比如 I/O 流,网络连接等。其它像记录日志的这种后置操作等也可以用类似的思路去处理。举个例子,我们会使用 Jedis 库去操作 Redis 并保存一些参数。这需要:

  1. 从 JedisPool 连接池那里获取一个连接。
  2. 使用完毕后手动关闭这个连接。

与其在每一处都声明这两个操作,不妨将这个成对的控制放到一个闭包下去管理:

// val universeJedisPool = new JedisPool("localhost",6379), 它被笔者声明在了包对象内。
// Redis 有 0 ~ 15 标号的一共 16 个库 ( 默认是 0 号库 ),index 表示选择哪个库。
def mustClose[T](index : Int)(action : Jedis => T)(implicit j : Jedis = universeJedisPool.getResource): T = {
    j.select(index)
    val r: T = action(j)
    j.close()
    r
}

至于中间的具体操作,可以简单将它看作是一个抽象的 action。这个操作可以是读操作,T 将指代一个有意义的值类型;它也可以是只写操作,T 此时将是 Unit 类型。下面是对 mustClose 闭包的一个应用,对于上层 API 来说,它不再需要关注 Jedis 资源是如何被分配和释放的。

override def setOffset(offset: Long): Unit = mustClose[Unit](offsetIndex) { 
	conn => conn.set("myOffset", offset.toString) 
}

方法命名规范与副作用

当 API 返回 Unit ( 或 Java API 的 void ) 时,用户会意识到:这个方法具备副作用,这有可能是对外写入内容,也有可能是关闭某个资源。具备副作用的 API 一般是不具备幂等性的 ( 如追加写操作 ),因为每执行一次操作外部环境就被永久改变了。因此,用户会格外小心地避免重复调用这类 API。

用户通常会先入为主地根据 API 的命名去推测功能。正因如此,开发者则更应该谨慎命名 API,尤其是那些 具备返回值且内部也包含副作用的 API,我们称这样的 API 是不纯粹 ( not pure ) 的。不要使用 getXXX 命名它们,因为这会误导使用 API 的用户 —— 一个貌似只读的操作却在背地修改了系统上下文。

对于存在副作用而又没有入参的方法,Scala 约定将它们声明为空括号方法,而不是无参数方法。

def foo : Int = 100  // 无参数方法
def goo() : Unit = println("hello world") // 空括号方法

隐含副作用的 API 会显著地提高测试的难度和调试成本。笔者在后文还会提到,在原本纯粹的转换算子内添加副作用可能会潜移默化地影响到 Spark 计算任务的正确性。除了在最终阶段必须显式地通过副作用收集结果之外,在数据流处理的过程中介入副作用时一定要保持谨慎。当程序出现问题时,应当首先从这些包含副作用的 API 开始排查

使用样例类

笔者推荐将 Spark 任务中所有作为数据容器的类声明为样例类 case class,它们的便捷主要体现在:

  1. 默认 值比较
  2. 默认实现了序列化接口。
  3. 默认实现了 applyunapply 方法,因此样例类的声明不需要 new 关键字,同时还支持模式匹配的方式提取字段。
  4. 提供便捷的 copy 方法进行值拷贝。

其它辅助工具

json4s

本篇使用 json4s 库来将外部的 json 数据解析成系统内部的样例类。在 .sbt 中添加以下依赖:

// scala version: 2.11.8
"org.json4s" %% "json4s-native" % "3.7.0-M6",
"org.json4s" %% "json4s-jackson" % "3.2.11"

下面的 toPersonRecord 方法演示了如何利用 json4s 工具将外部的 Json String 解析成系统内部的样例类:

def toPersonRecord(jsonLine: String): Person = {
    val parsed: JValue = parse(jsonLine)       // 首先通过 parse 方法转换成 JValue。
    val JString(name) = parsed \ "name"        // 表示提取 json 的 name 字段,下同。
    val JInt(age) = parsed \ "age" 
    Person(name,age)
}

通过引入 org.json4s.JsonDSL._ 的内容,还可以反过来将样例类解析成 Json 字符串形式。

import org.json4s.JsonDSL._
case class Person(name : String,age : Int)
val p = Person("Wang Fang",19)

// 通过 ~ 连接 K,V 对
val json = ("name"-> p.name) ~ ("age" -> 19)

// pretty(render(json))
val r = compact(render(json))
println(r)

jedis

jedis 是一个用于连接 Redis 数据库的,由 Java 编写的工具,因此返回值类型是 java.util.* 包下的集合类。这需要额外使用 .asScala 的拓展方法转换成 Scala 的数据结构。

"redis.clients" % "jedis" % "3.0.1"

我们一般会通过创建全局连接池的方式来管理 Redis 连接。

val universeJedisPool = new JedisPool("localhost",6379)

使用连接之后一定要记得主动关闭,否则 Redis Server 端的连接数会被耗尽。前文已经介绍如何通过 mustClose 闭包实现自动分配连接和释放的功能了。

配置加载工具

这里使用 com.typesafe.config 包实现配置文件的保存。

"com.typesafe" % "config" % "1.3.3"

在项目文件夹的 resources 目录下编写 application.conf 文件,参照如下格式编写配置:

io {
  input {
    source = "/home/anaconda/inputs/in.txt"
  }
}

在代码内部,通过初始化 ConfigFactory.load() 实例加载配置项。

private val config = ConfigFactory.load()
val user: String = config.getString("io.input.source")  // 读取 /home/anaconda/inputs/in.txt

sbt assembly 打包插件

整个项目依赖 sbt-assembly 插件打包。在 sbt 项目的 ./project/plugins.sbt 文件中添加此插件:

addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.9")

build.sbt 中配置 assembly 相关参数。等项目完工之后,使用 sbt shell 执行 assembly 命令即可实现打包。

// sbt 1.6.2 
assembly / mainClass := Some("priv.project.i") // 配置主类
assembly / assemblyJarName := "my-project.jar" // 配置 jar 包名

默认情况下,该插件会将项目依赖的所有 jar 包全部包含进去。这样做的好处是:后续向 Spark 提交 jar 包的时候,我们不需要在外部额外配置第三方 jar 包依赖路径。但是当项目依赖的的多个库之间存在冲突时,需要编写规则手动解决。

还需要注意一点。如果执行 assembly 的过程中存在单元测试不通过的情况,那么 sbt 会停止打包任务。

Hello Spark

基本概念

参见:Spark DAG概述_pre_tender的博客-CSDN博客

在大数据处理中,真正的性能瓶颈是对 ( 超 ) 大文件的 I/O 读写。想要实现真正意义的并行,开发者就必须自己想办法对这些大文件进行切割 ( map ),随后调度线程池去执行每一部分,然后再重新合并结果 ( reduce )。Spark 框架则是为了专门解决这类 Map-Reduce 任务而诞生的。

从任务执行的角度来说,Spark 会首先将用户提交的 job 分解成 stage,然后根据计算的依赖关系将这些 stage 描述成一个有向无环图 DAG。每个 stage 又可细分为 task,这些被分割的 task 被分发给集群内可调度的 executors 去执行。

从数据结构的角度来说,Spark 将庞大的数据分割 ( 其实分割这个词并不完全准确,英文文档中的词汇是 shuffle,意 "洗牌" ) 成包含多个 partition 的 RDD 进行处理。

RDD 的全称是 Resilient Distributed Datasets,意为弹性数据集,主要体现在内部的分区数量可以根据需要随时扩张 / 收缩。多个 partitions 会分派给不同的 executors 去处理,以达到并行的目的。这些 executors 可能来自于 Spark 集群的不同节点。RDD 有多种变体,比如从 MySQL 中读取的 RDD 是 JdbcRDD,但从处理角度来看基本是完全一致的。

另一个实用的数据结构是 Spark SQL 包下的 DataFrame,数据帧。它本质上也是 RDD,而区别在于它有一层额外的 Schema 元信息。简单来说,用户可以在一定程度上将 DataFrame 视作一张 SQL 表,通过指定具体的 "列名" 去提取,或操作 RDD 元组内某一个位置的数据。

处理 Spark RDD 很容易,因为大部分都是学习 Scala 时就熟悉的变换方法,包括:mapfilterforeach。在流处理中,只要能提供逻辑正确的 A => B 的函子,就能成功地将一个 RDD[A] 变换为另一个 RDD[B]

Spark 角色

在分布式的环境中,Spark 的物理节点分为 Master 和 Worker。Master 管理整个集群的可用资源 ( 指 CPU 和内存 ),Standalone 模式下不需要依赖 Yarn 等也可独立运行。Master 可以单独配置,也可以从集群的 Workers 选举出来。

Worker 管理本机节点的资源信息,负责创建 Executor 进程。每个 executor 都具有独立工作的线程池。

从宏观来看,整个系统默认的并行度取决于 --num-executors ( executors 数量 ) 和 --executor-cores ( 每个 executor 分配的线程池大小,即逻辑线程数 ) 的乘积。但是,并行度的最小值应当是 2,这主要是为了避免饥饿现象。

从执行任务的进程角度,在非本地模式下,Spark 会整合集群的资源创建一个 Driver 进程和多个 Executor 进程。前者即开发者编写并执行的 main 函数,负责 Spark 上下文创建,数据调配与收集;后者负责参与 task 的运算。

同一台机器可以同时启动 Driver 和 Executor 进程。当用户向 Spark 提交任务时,可以选择让 Driver 进程在提交任务的机器上启动,也可以选择在集群的随机一台机器中启动。

local[*] 模式下不需要去考虑 Master 和 Worker,因为目前只有一个服务器。并且,driver 和 executors 都是在同一个 JVM 下的多线程环境中模拟出来的。

两种操作模式

用户对 RDD 的操作方式可分为两种:

  1. 只进行映射的转换 ( transform ) 算子,比如 flatMapmapfiltergroupByKeyreduceByKey 等。
  2. 会触发实际计算的行动 ( action ) 算子,比如 foreachcollect,以及更为抽象的 reduce 等。

完整的算子列表可以参考官网:RDD Programming Guide - Spark 3.2.1 Documentation (apache.org)

Spark 对 RDD 的计算是惰性的。这意味着如果用户只对 RDD 进行转换操作,那么这个任务实际上并不会被执行。只有行动算子才会实际向 Spark 提交一个 job,因此,用户必须要提供至少一个行动算子。

RDD 宽窄依赖

宽窄依赖,见:Spark-RDD宽窄依赖及Stage划分_xiaopeigen 的博客-CSDN博客

任何 RDD 都可以通过计算依赖路径重新推导 ( 这个计算路径也被描述为血统 lineage,这种性质由纯函数式计算保证 ),这保证了即便某个 Worker 节点在运行途中宕机 ( 指服务器整个挂掉 ),其它可用的 Worker 的节点也可以重新恢复丢失的部分 RDD 数据并继续执行任务,Spark 借此实现了高容错性。

假设 RDD[A] 经过转换操作后推导出了 RDD[B],我们称 RDD[A]RDD[B] 的父 RDD。

在计算时,如果父 RDD 的每一个 partition 最多只被子 RDD 的一个 partition 引用,那么称这是一个窄依赖 ( Narrow dependencies )。如果父 RDD 的每一个 partition 被子 RDD 的多个 partitions 引用,那么称这是一个宽依赖 ( Wide dependencies )。

更通俗的说,如果 RDD[A] 内的每个 partition 指向了 RDD[B] 的更多 partitions,那么就构成一个宽依赖。

wide_narrow.png

窄依赖的算子有 mapflatMapfiltermapPartitions 等。宽依赖的算子有 groupByKeyreduceByKeyconbineByKey 等。而 join 算子则特殊一点 ( 如图所示 ),它可能是窄依赖,也可能是宽依赖的。

窄依赖对于 Spark 的计算优化非常有利,这个衡量标准为:当需要进行数据恢复时,Spark 是否要进行冗余计算。

  1. 对于窄依赖,父 RDD 的一个 partition 只对应子 RDD 的一个 partition,两端 partitions 的数据是严格一一对应的。因此重算窄依赖的 RDD 时,数据利用率是 100%,没有任何多余计算。
  2. 宽依赖则必须要进行冗余运算。因为父 RDD partitions 内部的数据并不全是为了生成丢失的子 RDD 的,还有用于生成其它子 RDD 的数据。花在这部分的算力相当于浪费掉了。

Spark 会按照宽窄依赖划分 stage。显然,窄依赖可以视作流水线操作,因此多个类似 mapfilter 这样的操作可以被划分到一个 stage 内。只有当遇到宽依赖时,才需要划分出一个新的 stage。

开始

我们首先在 Windows / IntelliJ IDEA 的开发环境下,从仓库引入 Spark 的核心开发包编写任务 ( 直接 run 主函数是可以启动的 ),然后再考虑将项目打包提交到完整的 Spark 框架执行。

Windows 系统下需要一些必要的链接库 hadoop.dllwinutils.exe 保证 Spark 程序运行。见:(null) entry in command string: null chmod 0644 - 大数据实战派 - 博客园 (cnblogs.com)。将下载好的两个库文件放到 C:\Windows\System32 目录下,这样就不必配置环境变量了。

先在 sbt 中导入 Spark 的两个核心依赖 spark-corespark-sql所有的 Spark 包都应该保持统一版本

"org.apache.spark" % "spark-core_2.11" % "2.4.3",
"org.apache.spark" % "spark-sql_2.11" % "2.4.3",

下面是一段最基本的 Spark Application 的代码结构:

package priv.project.i

object FirstSparkScript {
  val sparkConfig: SparkConf = new SparkConf()
    .setMaster("local[*]")			// 必须设置这一项
    .setAppName("hello-spark")
    
  val spark: SparkSession = SparkSession.builder().config(sparkConfig).getOrCreate()
  val sc: SparkContext = spark.sparkContext  // Driver 
  import spark.implicits._
    
  // 在主函数内编写 Spark 任务
  def main(args: Array[String]): Unit = {/*TODO*/}
}

创建 Spark 任务只需要三个步骤:

  1. 创建一个 Spark 的配置 SparkConf
  2. 把它传入 SparkSession
  3. 获取这个 SparkSession 的上下文。

选择配置本地模式 local[*]。其中,* 号表示根据本机的 CPU 数量分配工作线程 ( work threads )。

不是所有的配置项都适合以硬编码的形式写在 SparkConf 内部,比如,涉及资源 ( CPU,内存 ) 分配的配置应根据部署环境的实际条件做动态调整。等到后面会介绍如何在外部提供配置信息,那时 main 函数内的 SparkConf 就可以不做任何额外配置了:

val spark = SparkSession.builder().config(new SparkConf()).getOrCreate()

SparkSession 提供隐式的 .toDF 方法将 RDD 转换成 DataFrame,这需要导入 import spark.implicits._。如果需要测试较大文件的处理,目前可以适当给 JVM 分配更大的堆内存来防止 OOM,如 -Xmx4G

Spark IO

处理数据的第一步是先将数据读进内存。下面的代码演示了如何通过 SparkSession 读取外部的 .csv 文件:

// 读取 json 文件也是类似的操作。
val raws : DataFrame = spark.read.format("csv")
	.option("charset","utf-8")
	.option("header", "true").load("C:\\Users\\i\\Desktop\\inputs\\log.csv")     // Windows 环境使用双反斜杠表示路径

// 通过传入 Raw => B 的函数开始对 DataFrame 进行变换。
// Raw 类提供了各种 getXXX(index) 的方法从某列中按照某种类型提取数据。
// 最好使用一个样例类来接收,而不是以元组的形式返回。
raws.map(r => (r.getTimestamp(0),r.getLong(1),r.getString(2)))

将外部数据以 DataFrame 类型读入。读取数据之后会紧接着进行 map 操作,将外部数据导入成系统内部流通的样例类实例。

使用 Tuple 类型也可以对数据进行临时包装。但在大型项目中,大量使用元组不利于阅读和后期维护,因为在元组内所有的列名都是 ._1._2 等数字下标,而非具有意义的字段名称。

可以通过 Spark 上下文提供的 parallelize 方法将普通的 Scala Seq 数据结构提升为 RDD,同时允许手动配置分区数。

// 将 1-8 的序列提升为 RDD,手动设置 8 个分区。
val value: RDD[Int] = sc.parallelize(List(1, 2, 3, 4, 5, 6, 7, 8), 8)

RDD 本身提供基础的 saveAsTextFile 方法实现数据持久化,通过调用 toString 方法将每个对象输出为字符串。DataFrame 能够提供更多的选项,比如编码格式,携带表头等设置。这里以输出 csv 文件为例子:

// 首先将一个 rdd 转换为 DF 类型,
rdd.toDF("name","age")
	.write
	.option("encoding","utf-8")		// 设置编码格式
	.option("header","true")		// 在写出 csv 时可以设置是否保留表头
	.csv("C:\\Users\\i\\Desktop\\outputs\\test") 

注意,输出路径必须是还没有被创建的。

生成的文件数取决于 RDD 包含的 partitions。如果希望系统只生成一份大报表,那么应该使用 coalesce(1) 手动将 RDD 内部的 partition 数量变为一个。另一个重分区的方法是 repartition(m),我们做以下约定:

  1. 当分区变得更少时,使用 coalesce(n)

  2. 当分区变得更多时,使用 repartition(m)

val l = (1 to 128).toList
val xs: RDD[Int] = sc.parallelize(l,8)

// 将 RDD 内部分区重新聚合成一个并输出
xs.coalesce(1).saveAsTextFile("C:\\Users\\i\\Desktop\\log_data\\testOutput\\a")

见:Spark 中 repartition 和 coalesce 的区别与使用场景解析-CSDN 博客

关于 coalesce 和 repartition *

如果你的目标是快速构建一个 Spark 任务,可以先记住上述的结论,然后暂时跳过这部分。

参考文章: spark 的 coalesce 的利弊及原理_浪尖聊大数据-CSDN 博客

repartitioncoalesce 都是 RDD 内部分区的重划分方法。下面是相关的源代码注释:

  /**
   * Return a new RDD that has exactly numPartitions partitions.
   *
   * Can increase or decrease the level of parallelism in this RDD. Internally, this uses
   * a shuffle to redistribute data.
   *
   * If you are decreasing the number of partitions in this RDD, consider using `coalesce`,
   * which can avoid performing a shuffle.
   *
   * TODO Fix the Shuffle+Repartition data loss issue described in SPARK-23207.
   */
  def repartition(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T] = withScope {
    coalesce(numPartitions, shuffle = true)
  }

  /**
   * Return a new RDD that is reduced into `numPartitions` partitions.
   *
   * This results in a narrow dependency, e.g. if you go from 1000 partitions
   * to 100 partitions, there will not be a shuffle, instead each of the 100
   * new partitions will claim 10 of the current partitions. If a larger number
   * of partitions is requested, it will stay at the current number of partitions.
   *
   * However, if you're doing a drastic coalesce, e.g. to numPartitions = 1,
   * this may result in your computation taking place on fewer nodes than
   * you like (e.g. one node in the case of numPartitions = 1). To avoid this,
   * you can pass shuffle = true. This will add a shuffle step, but means the
   * current upstream partitions will be executed in parallel (per whatever
   * the current partitioning is).
   *
   * @note With shuffle = true, you can actually coalesce to a larger number
   * of partitions. This is useful if you have a small number of partitions,
   * say 100, potentially with a few partitions being abnormally large. Calling
   * coalesce(1000, shuffle = true) will result in 1000 partitions with the
   * data distributed using a hash partitioner. The optional partition coalescer
   * passed in must be serializable.
   */
def coalesce(numPartitions:Int,shuffle:Boolean=false):RDD[T] = withScope{ /* code */ }

其中,repartitioncoalesce 的一个特殊情况:repartitionshuffle 选项总是为 trueshuffle 暂且可以被简单理解成是切分数据块然后再分发的过程。显然这是需要时间代价的,尤其是在集群中节点间需要进行网络通讯,因此我们希望在有些情况下尽可能不执行 shuffle。

假定内部 N 个分区的 RDD 被 shuffle 为 M 个分区的场景:

当 N < M 时,必须经过 shuffle 过程将 RDD 内部划分出更多的分区,此时将不可避免地调用 coalesce(n,shuffle = true),而它等价于 repartition 方法。

当 N > M 时,coalesce 方法内部可以划分 M 个分组,每组 N / M 个 partitions。比如,将原定 1000 个 partitions 的 RDD 重划成 100 份,这将产生 100 个分组,每组聚合了 10 个 partitions。此时调用 coalesce(m) 可以避免 shuffle 过程。这种隐式的内部分组对于开发者而言是透明的。

然而,当 N >> M 时,合并分区会极大降低并行度。比如本例为了生成一个文件而使分区数被收束到了 1,这导致程序只能有一个 node 执行计算 ( 源码注释中提到了这种情况 )。此时可以调用 coalesce(m,shuffle = true) 使得上游分区仍保持并行计算 ( 参见源代码处的第三个段落 )。

在一些可能发生数据倾斜的场合,比如经过 filter 操作之后,大部分数据可能会集中在特别少的分区。此时可以主动启用 shuffle 对 RDD 的数据进行重分配,源码注释中的 @node 就是描述的这种情况。数据倾斜的各种优化方案,见:Spark 中常见的数据倾斜现象及解决方案_大数据小陈的博客 - CSDN 博客

Spark 数据处理

关于笛卡尔积

首先,RDD 的操作内部无法内嵌另一个 RDD。在主函数声明一个对 RDD 的转换操作,实际会创建一个待执行的 闭包。在 Spark 运行到这段代码时,driver 将这个闭包分发给其它 executors 执行。但是,一个 executor 不能擅自引用其它 executor 的闭包。

对于一个入门级的 Spark 项目而言,暂且记住这一点就好。下面是一个自实现 join 表操作的错误示范:

val teacherRDD: RDD[(Int, String)] = sc.parallelize(List(1 -> "Li",2 -> "Wang"))
val courseRDD: RDD[(Int, String)] = sc.parallelize(List(1 -> "Math", 2 -> "CS", 3 -> "English"))

// 企图通过 rdd 嵌套 rdd 的形式自实现 join 。
val teacher_course = teacherRDD.map(
    t1 => {
        // 内部引用了 courseRDD
        val course: (Int, String) = courseRDD.filter(t2 => t1._1 == t2._1).collect().head
        (t1._1,t1._2,course._2)
    }
)

// 触发计算
teacher_course.collect()

如果需要实现表连接的功能,可以考虑转换为 DataFrame 之后进行 join 操作。

val teacherRDD: RDD[(Int, String)] = sc.parallelize(List(1 -> "Li",2 -> "Wang"))
val teacherTable: DataFrame = teacherRDD.toDF("id","name")

val courseRDD: RDD[(Int, String)] = sc.parallelize(List(1 -> "Math", 2 -> "CS", 3 -> "English"))
val courseTable: DataFrame = courseRDD.toDF("id","course")

teacherTable.join(courseTable,"id").show(10)

RDD 本身提供了基本的集合操作:笛卡尔积 ( cartesian ),交 ( intersection ),并 ( union ),差 ( subtract )。不过,完全没有必要使用 RDD 的笛卡尔积实现 join 的功能,因为效率很慢,且耗费大量空间。

广播变量

针对上一个示例其实还有另一个解决方案:首先收集较小的那个 RDD 的结果,然后通过 SparkContext 将其 广播 ( broadcast ) 出去。这意味着:所有的 executors 都可读 ( only-read ) 这个内容。

val teacherRDD: RDD[(Int, String)] = sc.parallelize(List(1 -> "Li",2 -> "Wang"))

// 转换成 Map 可以有效降低查询的时间复杂度。
val teacherTable: Broadcast[Map[Int, String]] = sc.broadcast(teacherRDD.collect().toMap)
val courseRDD: RDD[(Int, String)] = sc.parallelize(List(1 -> "Math", 2 -> "CS", 3 -> "English"))

val r = courseRDD.map {
  // 需要调用 .value 方法提取出广播变量的内部值。 
  course => val teacher: String = teacherTable.value.getOrElse(course._1,"null")
    (course._1,course._2,teacher)
}

r.toDF().show()

在主函数中声明共享变量并广播,或者使用 Spark 的计数器是多个 executors 间共享信息的唯二方式。原因在于:driver 向其它 executors 分发计算闭包时,一切自由变量都是值拷贝的。因此,不仅 executors 之间不共享这个变量,并且它们各自的修改也影响不到主函数内的变量值。下面的示例代码验证了这一点:

// local[*] 模式运行,打印 8 个 1
val rdd: RDD[Int] = sc.parallelize(List(1, 2, 3, 4, 5, 6, 7, 8),8)
var count = 0

// 所有的线程都显示 count=1,因为它们获取的是副本。
rdd.foreach{
    r =>
    count = count + 1
    println(s"partition of : $r, count in executor =$count")
}

// 同时,这不会影响到主程序中原本声明的变量,因此打印结果为 0.
println(s"count in driver :${count}")

关于计数器实现的共享变量可以参考 Spark 官方文档:RDD Programming Guide - Spark 3.2.1 Documentation (apache.org)

见:spark有哪几种共享变量_尚硅谷铁粉的博客-CSDN 博客

SQL 风格的数据查询

在本地模式下,可以使用 DataFrame 提供的 .createTempView() 方法创建一个临时视图,开发者可以直接使用 SQL 语句表述处理逻辑。Spark SQL 内部支持通用的聚合函数,比如 count(*),也允许使用 IN 这样简单的查询子句 ( 不过像 CASE 这种比较复杂的子句貌似就不支持了 ) 。通过 spark.sql 传入 SQL 文本段,查询的结果仍然以 DataFrame 类型返回。

val teacherRDD: RDD[(Int, String)] = sc.parallelize(List(1 -> "Li",2 -> "Wang"))
val teacherTable: DataFrame = teacherRDD.toDF("id","name")
teacherTable.createTempView("teacher_table")   // 创建临时视图

val courseRDD: RDD[(Int, String)] = sc.parallelize(List(1 -> "Math", 2 -> "CS", 3 -> "English"))
val courseTable = courseRDD.toDF("id","course")
courseTable.createTempView("course_table")   // 创建临时视图

// sql 语句不需要加分号
// 查询 Li 老师教课门数。
spark.sql(
  """
    |select count(*) as num, name
    |from teacher_table join course_table on teacher_table.id == course_table.id
    |group by name
    |having name = "Li"
    |""".stripMargin).show()

避免重复运算

反复引用同一个 RDD 会导致它被重复运算,包括内部隐含的 副作用。尤其是不具备幂等性的副作用是不希望被重复触发的,比如 JDBC 可能会意外地试图插入重复主键的数据。见下面的代码演示:

// local[8]
val xs1: RDD[Int] = sc.parallelize(List(1,2,3,4,5,6,7,8),8)
val xs2: RDD[Int] = xs1.map {
  x =>
    // 在 local[N] 模式下,drvier 和 executor 都是在一个 JVM 内的多线程运行的,
    // 因此这行 println 在终端可见。
    // 在分布式的环境中,转换操作可能在其它物理节点 Worker 的 JVM 进行,那时就无法在本地观察到打印结果了。
    println("side-effect")
    x
}

// 屏幕会打印 16 行信息,因为反复引用 xs2 导致它被计算了两次。
println(xs2.collect().length)
println(xs2.collect().length)

尽管最理想的情况是不要在转换操作内做任何多余的事情 ( 因为容易引发 BUG 且不好排查问题 ),然而有时业务需求会迫使我们必须以副作用的形式保留信息。比如,笔者的业务是在处理数据的同时维护并更新一个 Redis 库的缓存表。在这种情况下就必须避免该 RDD 被重复引用。

除此之外,面对大型 RDD 被复用多次的情况,最好是将它 缓存 起来。被缓存的 RDD 只在被首次引用的时候计算一次。见:「Spark从入门到精通系列」7. 优化和调整Spark应用程序 - 知乎 (zhihu.com) 中,关于 数据缓存和持久化 的思路。

缓存有两种方式。第一种是简单的 .cache,它会直接将 RDD 放到内存;另外一种是更通用的持久化方法 .presist。和 .cache 相比,它支持手动设置多种持久化级别,比如:

  1. MEMORY_ONLY:不进行序列化,将 RDD 直接放到内存中,相当于 cache。当内存空间不足时,放弃保存剩下的部分。
  2. MEMORY_AND_DISK:不进行序列化,优先将 RDD 直接放到内存。在内存不够的情况下,再写入磁盘中。
  3. MEMORY_ONLY_SER:序列化的 MEMORY_ONLY 版本。序列化的优势是可以节省空间,但反序列化需要更多时间。
  4. MEMORY_AND_DISK_SER:序列化的 MEMORY_AND_DISK 版本。
  5. DISK_ONLY:不进行序列化,全部写到磁盘。

当不再需要某个 RDD 的缓存时,可以使用 unpresist() 方法释放缓存占用的空间,有一个可选的 Boolean 值选项决定在释放缓存时是否阻塞。

使用 Either 分流异常数据

这里的异常数据,也指代那些因不符合业务逻辑而不进行处理的数据。使用 Scala 原生提供的 Either[A,B] 类型去分拣异常数据 Left[A,Nothing] 和满足条件并正常处理的数据 Right[Nothing,B]。因为 Right 有双关之意,因此习惯上保留的数据使用 Right 包装。

val xs: RDD[Int] = sc.parallelize(List(1,2,3,4,5))

// 通过 Left 和 Right 分流出异常数据 (不处理的数据) 和保留的数据。
val spawnedXs: RDD[Either[Int, Int]] = xs.map {
    case x if x % 2 == 1 => Left(x)       // 不保留的数据使用 Left 标记
    case x if x % 2 == 0 => Right(x * 2)  // 对保留数据进一步处理
}

// 提取出保留并处理好的数据
spawnedXs.filter(_.isRight).map {case Right(x) => x}.collect().foreach{println}

通过这种方式,我们能够将关注点放在如何处理正常数据和异常数据上,从而不必纠结如何在内部过滤异常值。只需借助模式匹配组合偏函数的形式就可以扔掉不需要的数据 ( 或另作处理 ),保留需要的数据。

将代码提交到 Linux 服务器执行

有关于 Spark 在 Linux 下安装的内容,见后文的部署与安装。

打包

个人的笔记本电脑硬件资源是有限的。一旦逻辑开发完成,下一步就是脱离 IDE 的测试开发环境,在更高性能的服务器中部署 Spark 任务了。首先,在服务器端安装独立的 Spark 框架,见:Index of /dist/spark (apache.org),服务器端需要提前预装 JDK ( 最好将 Scala SDK 也装上 )。

由于本篇只使用 local[*] 本地模式执行任务,因此安装 Spark 之后不需要额外配置并维护Master 和 Worker。

Spark 原生集成了 Spark Streaming,Spark MLlib,Spark SQL,GraphX 等库,因此项目在打包时就不再需要引入这些依赖了。打开 build.sbt 文件,将所有 org.apache.spark 相关的依赖标记为"provided"

"org.apache.spark" % "spark-core_2.11" % "2.4.3" % "provided",
"org.apache.spark" % "spark-sql_2.11" % "2.4.3" % "provided",
"org.apache.spark" % "spark-streaming_2.11" % "2.4.3" % "provided",

对于那些在仅在单元测试时使用的工具,也可以通过标记 "test" 的形式将其从最终的项目 jar 包中排除掉,比如 org.scalatest

"org.scalatest" %% "scalatest" % "2.2.0" % "test",

这样做能够减少项目 jar 包的大小,节省编译时间,降低节点间传输的网络成本,还可以尽可能避免 jar 包冲突问题。

除此以外的其它第三方依赖, json4smysql-connector-java 等,要么在编译时一并装入项目 jar 包内部,要么就在提交任务再将这些依赖包含在路径当中,见下文 spark-submit 命令的 --jar 参数。

不过,jar 包冲突的可能性仍然存在。比如,当项目中同时存在 kafka-clientsspark-streaming-kafka-0-10_2.11 包时 ( 后面的流处理会用到这两个包 ),执行 assembly 命令会提示存在冲突而拒绝打包。解决方式是将其中一个依赖在编译期暂时排除掉,等提交 Spark 任务时再添加进来,见下文的 spark-submit 命令。

spark-submit 与配置

  1. spark-submit 的详细内容可以参见此官网连接:Submitting Applications - Spark 3.2.1 Documentation (apache.org)

  2. 关于配置参数的详细内容可以参见此官网连接:Configuration - Spark 3.2.1 Documentation (apache.org)

将已经编写好的项目 jar 包上传到 Linux 服务器,然后通过 spark-submit 命令将其提交到 Spark 框架下运行。对于那些没有在编译期间放入项目 jar 包内的第三方依赖,需要通过 --jars 参数手动添加,否则无法启动 Spark 任务。多个依赖之间使用 , 分隔。

使用 --class 指定包含 main 主程序的类,并在最后提供项目 jar 包的路径。如果主程序类还接收外部参数,则可以在命令的最后进行补充。

# 想要直接使用 spark-submit 命令需要在 /etc/profiles 文件下将 Spark/bin 添加到 $PATH 路径下。
# spark-submit --name <app-name> \
# --class <main-class> \   <- 包含 Spark 任务的主函数类
# --master <master-url> \  <- 目前不考虑 standalone 模式,设置 local[*] 即可
# --jars <path1>,<path2>,... \  <- 手动添加项目 jar 包没引入的依赖
# [--conf <key>=<value>] \ <- 其它 spark 配置项,见官网。
# [other options] \  <- 可以通过 spark-submit --help 查看
# <application-jar> \ <- 项目 jar 包
# [application-arguments] <- 补充项目 jar 包的启动参数
spark-submit --name "simpleSparkApp" \
--master local[*] \
--driver-memory 16G \
--class priv.project.i.FirstSparkScript
--jars /runtime/lib/spark-streaming-kafka-0-10_2.11-2.4.3.jar \
/home/anaconda/my-project.jar \

还有一种指定 Spark 启动参数的途径是:${SPARK_HOME}/conf/spark-defaults.conf,将一些通用配置写入其中,这样就不必每次都手动添加命令行参数了。参数配置的优先级是:项目 jar 包内部硬编码的 SparkConf > spark-submit 命令行参数 > 默认配置。

注意,如果要为 local[N] 模式的 Spark 任务设置足量内存,需要借助 spark.driver.memory 参数。见:How to tune memory for Spark Application running in local mode - Stack Overflow。设置 spark.executor.memory 对于单机任务而言是没有任何效果的。

官网提示,该参数必须等到向 spark-submit 提交代码时作为命令行参数传给 Spark,而不是在程序主函数内部的 SparkConf 进行配置**,因为 JVM 会先于 Spark 启动**。

通过 spark-submit --help 命令可以获取一些常用配置项的帮助。注意,有些配置项必须在特定环境下才可以使用。其它参数可以通过追加多个 --conf <key>=<value> 的形式进行配置。

在本地模式下提交 Spark 任务不会有 Driver 和 Worker 进程,只会有一个单独的 SparkSubmit 进程。可以通过 jps 命令查看系统中活跃的 Java 进程。Spark运行模式_local(本地模式) - chengzipg - 博客园 (cnblogs.com)

创建定时 Spark to MySQL 任务

考虑以下场景:前端程序实时地将一些结构化的日志数据上传到服务器 MySQL 库的一张表内 ( 当然,日志采集本身有更好的实现方案,比如 flume ) 。而我们则希望服务器能够设置一个定时的 Spark 任务不断处理增量日志,并将批数据的分析结果写回到另一张表。

Spark 提供 JdbcRDD 从 MySQL 中读取批量数据并转换成可用于处理的 RDD。它的创建方法如下面的程序块所示:

// 读取 Spark 程序上次处理过的 offset。
// 记录 offset 的方式有很多种,因此 readOffset 是如何实现的并不重要。
val fromLast = readOffset
// 假设每次处理 3000 份数据。
val capacity = 3000
// 一个好的编程习惯是把它们配置到 application.conf 中,而不是硬编码。
val user = "myuser"
val pwd = "mypwd"

// 提供一个获取数据库连接的方法。
val getConnection: () => Connection = () => DriverManager.getConnection("jdbc:mysql://localhost:3306/testDB?characterEncoding=UTF-8", user, pwd)

// 注意,SQL 语句内必须包含 where id >= ? and id <= ? 子句。
val sql: String = "select user_name, add_time, drop_time, user_ip from routing where id >= ? and id <= ?"

// 提供一个 ResultSet => A 函数转换成系统内部流通的样例类。
val mapRaw: ResultSet => Person = (r: ResultSet) => {
    val name: String = r.getString("user_name")
    val age : Int = r.getInt("age")
    Person(name,age)
}

/*
JdbcRDD 的代码定义如下。
class JdbcRDD[T: ClassTag](
    sc: SparkContext,     			<- driver 端的 SparkContext
    getConnection: () => Connection,            <- 由用户提供获取连接的函数。
    sql: String,				<- 传入读数据的 SQL 语句
    lowerBound: Long,				<- 拉取数据 id 的下界
    upperBound: Long,				<- 拉取数据 id 的上界
    numPartitions: Int,				<- 划分的 partitions 数量
    mapRow: (ResultSet) => T  		        <- 提供将行数据转换为系统 POJO 的函数)
*/
val epochBatch = new JdbcRDD[Person]
(sc, getConnection, sql, fromLast, fromLast + capacity, 8, mapRaw).cache()

注意,sql 模板必须携带 SQL 子句 where id >= ? and id <= ?,因为 JdbcRDD 依赖 lowerBoundupperBound 两个参数从数据库中读取批数据,读取的总数据量是 upperBound- lowerBound+ 1。随后,这些数据会被 shuffle 为指定的 n 份 partitions 分给不同的 executors 去执行。

每一轮任务都需要参考上一次的处理位置来动态计算 lowerBoundupperBound,为此需要维护一个 offset。记录 offset 的方式并不唯一,可以选择以文件形式保存,也可以选择在数据库内保存,笔者选择了 Redis 库去实现。在每轮数据处理结束之后,程序要及时更新 offset 值,以便定时任务像滑动窗口一样不断地从 MySQL 中读取增量数据并处理。

// 实现 OffsetHandler 的接口即可,方式任选。
trait OffsetHandler {
  def updateOffset(offset : Long) : Unit  // 更新 offset 
  def readOffset : Long			 // 读取 offset
}

下面的代码演示了 Spark 向 MySQL 写数据的过程。

// 经 Spark 处理好的 RDD 结果。
val prepare: DataFrame = realWrite.toDF("name","age","grade","course")

// 使用 Try 包装代码,防止因 JDBC 抛出异常导致程序退出。
val maybeSuccess: Try[Unit] = Try {
    prepare
    .write
    .mode(SaveMode.Append)
    .option("isolationLevel","READ_UNCOMMITTED")   // 可以根据 JDBC 的五种隔离级别设置隔离等级,默认就是 READ_UNCOMMITTED.
    .jdbc(jdbcURI, aptracingTableName, properties)
}
// routingBatch.count() 是一开始读入内存的数据总量。
updateOffset(fromLast + routingBatch.count())  

这里使用了 Scala 库提供的 Try 自动捕获 MySQL 可能抛出的异常,以防止程序中途宕机。在笔者的逻辑中,无论这一批数据是否执行成功,程序最终都会更新 Redis 库中的 offset 值,这样做是为了防止因个别的异常数据导致 offset 一直无法增量更新。

由于每一轮数据拉取量是 capacity + 1,因此加和后的 offset 正好是下次运行时的起始点,不需要进行修正。关于 Spark write to MySQL 时可设置的其它参数,见:JDBC To Other Databases - Spark 3.2.1 Documentation (apache.org)

最后一步,打包项目并提交到服务器,通过 crontab -e 命令编辑定时任务即可,下面的配置内容表示每五分钟通过 spark-submit 向 Spark 框架提交分析任务。有关 crontab 的内容可以参考这篇文章:Linux 定时执行shell脚本_盛气凌人666的博客

*/5 * * * * /usr/local/spark-2.1.1/bin/spark-submit --name "reducingLog" --master local[*] --driver-memory 8G --class priv.project.i.log4Mysql --jars /runtime/lib/spark-streaming-kafka-0-10_2.11-2.4.3.jar /runtime/test/spark-log-mysql.jar 

在一切配置完毕之后,我们可以通过 cat /var/spool/mail/root 来查看 Spark 任务在每轮自动执行时产生的日志,以此判断程序是否正常执行。

见:Spark-读取数据并写入数据库_spark 写入数据库

升级为流数据任务

如果 Spark 任务本身能作为守护进程持续地监听到达消息队列的增量数据 ( 称这样的任务是实时或准实时任务 ),那么服务器就不需要再通过 crontab 维护粗时间粒度的定时任务了 ( 称这样的任务是离线任务 )。

现在假设前端系统会将采集到的日志作为 消息 ( message ) 发送给 Kafka 队列。为此,服务器端还需要预安装 Kafka 以及依赖的 Zookeeper ( Kafka 需要它注册集群环境 )。在本篇文章中,只要保证它们能够以本地模式启动就足够了。

通过 Kafka 提供的 kafka-topics.sh 脚本创建一个 topic。前端系统负责向这个 topic 生产 ( produce ) 消息,而 Spark executors 通过订阅这个 topic 来 消费 ( consume ) 消息。

这里将 topic 的消息分区数划分为 8 个,和 Spark 的并发数保持一致。同时由于没有额外的机器对消息做冗余备份,因此副本数设置为 1 即可。

kafka-topics.sh --create -zookeeper localhost:2181 --topic test --partitions 8 --replication-factor 1

我们需要其它的工具完成实时数据流处理的工作,向 build.sbt 文件中新导入两个依赖包:

// 这个库可以用程序模拟 producer 向 kafka 发消息。
"org.apache.kafka" % "kafka-clients" % "0.10.2.1", 
"org.apache.spark" % "spark-streaming-kafka-0-10_2.11" % "2.4.3" % "provided"

通过 KafkaUtils 获取消息

首先给出一段最基本的 Spark Streaming Application 的代码结构。它展示了几个主要的步骤:

  1. 首先创建 Spark 上下文。
  2. 创建一个 StreamingContext。
  3. 通过 KafkaUtils.createDirectStream(...) 方法获取推送过来的消息流。
  4. 在主函数内部启动消息流监听。
object FirstSparkStreamingJob {
  // 这一部分是创建 Spark 的基本配置。
  val sparkConfig: SparkConf = new SparkConf().setMaster("local[*]")
  val spark: SparkSession = SparkSession.builder().config(sparkConfig).getOrCreate()
  import spark.implicits._
  val sc: SparkContext = spark.sparkContext
    
  // 创建 SparkStreaming 上下文。
  val ssc = new StreamingContext(sc, Seconds(20))
    
  // 配置 Kafka 相关参数
  val kafkaPara = Map(
    "bootstrap.servers" -> "localhost:9092",   
    "key.deserializer" -> classOf[StringDeserializer],
    "value.deserializer" -> classOf[StringDeserializer],
    "group.id" -> "test",
  )

  // 首先从 KafkaUtils 获取 InputDStream 形式的消息流
  val kafkaStream: InputDStream[ConsumerRecord[String, String]] = KafkaUtils.createDirectStream[String, String](ssc,
    LocationStrategies.PreferConsistent,
	ConsumerStrategies.Subscribe[String, String](Array("teacher"), kafkaPara)
  )

  def main(args: Array[String]): Unit = {
	/* code */
    // 启动 
    ssc.start()
    ssc.awaitTermination()
  }
}

就像在 Spark 任务中创建 SparkContext 那样,在这里为流处理也创建一个 StreamingContext 上下文:第一个参数接收 SparkContext,而第二个参数表示拉取的频率,在当前的程序中,设置 Spark Steaming 每隔 20 秒从 Kafka 消息队列中拉取 InputDStream 类型的 消息流 ,这里的 Seconds(n) 来自 org.apache.spark.streaming,称为 batchDuration

val ssc = new StreamingContext(sc, Seconds(20))

再往下是 Kafka 的配置以及消息流的创建,代码本身理解起来并没有什么难度,这里不做赘述。这里只稍微强调两点:

  1. 需要主动在 Kafka 配置 group.id ( 名称任意 ) 。Spark 的 executors 在同一个组别下消费 Kafka 数据,这样能够保证每一条消息只会被组内的 executors 消费一次。

  2. LocationStrategies.PreferConsistent 是一个最通用的本地策略,它表示将拉取到的数据尽可能均匀地分发给 executors 进行消费。

DStream

见:DStream_daladongba的博客-CSDN博客_dstream

在一切就绪后,我们将对 DStream 类型的消息流进行操作。DStream 意为离散数据流,是对周期到达的 RDD 的高级抽象。对 DStream 声明操作,相当于声明对每一批到达的 RDD 的操作。DStream 通过 mapflatMap 等转换子 ( 包括下文介绍的 ) 转换为另一个 DStream。除此之外,它还提供了:

foreachRDD 方法相当于 DStream 的行动算子,在下方的代码中,rdd 表达每一批到达的 RDD 数据流。

kafkaStream.foreachRDD {
  rdd => rdd.foreach(println)
}

transform 方法。和 map 方法不同,它接收的是 RDD[A] => RDD[B] 函数。比如,下面的代码演示了从 kafkaStream 原始消息流中过滤出 topic 为 test 的消息,并产生一个新的 DStream 流。

kafkaStream.transform{
  rdd => rdd.filter(s => s.topic() == "test")
}

DStream 提供了对多批次数据的窗口聚合。通过 window 方法设置 windowDuration,它必须是 StreamingContext 中所设置的 batchDuration 的整数倍

// val ssc = new StreamingContext(sc, Seconds(20))
// 相当于每拉取 60 / 20 = 3 批次的数据再进行处理。 
kafkaStream.transform{
    rdd => rdd.filter(s => s.topic() == "test")
}.window(Seconds(60))

记忆 Kafka 的 Offset ( Redis 实现 )

关于 offset 的概念我们已经在之前的离线任务中接触过了,在这里 offset 的作用是类似的。

在开发并测试的过程中,Spark Streaming 程序需要经历反复重启,但是前端的日志仍然会不断地向 Kafka 发送 ( 数据会在队列内开始堆积 )。如何保证程序重启时能从 Kafka 那里继续消费数据呢?

第一种策略是,每次重启时设置新的消费者组 group.id。同时这里还有两个选择:

  1. earliest:从上一次终止的地方继续消费。
  2. latest:从程序运行起接收到的消息开始消费 ( 不处理之前堆积的消息 )。

这两种选择通过 Kafka 的 auto.offset.reset 项进行配置。

val kafkaPara = Map(
  "bootstrap.servers" -> "localhost:9092",
  "key.deserializer" -> classOf[StringDeserializer],
  "value.deserializer" -> classOf[StringDeserializer],
  "group.id" -> groupId,
  "auto.offset.reset" -> "earliest" // 新增配置,还可以选择 latest
)

这里主要介绍另一种策略:仍然使用同一个 group.id ,由程序主动在外部维护消息消费的 offset,这里通过 Redis 内存数据库实现。此时则需配置关闭自动提交。

val kafkaPara = Map(
  "bootstrap.servers" -> "localhost:9092",
  "key.deserializer" -> classOf[StringDeserializer],
  "value.deserializer" -> classOf[StringDeserializer],
  "group.id" -> groupId,
  "enable.auto.commit" -> (false: lang.Boolean) // 关闭自动提交
)

在笔者的方案中,offset 会以 HSET 的数据结构保存。其中 key = "${groupId}"field = "${topic}-${partition}"value="${offset}"。在这个 transform 转换流中,我们不对 rdd 本身做任何操作,仅仅以介入副作用的形式提取 offset 并提交到 Redis。

val markedStream: DStream[ConsumerRecord[String, String]] = kafkaStream.transform { rdd =>
  // 从消息流中提取 offsetRange 消息。  
  val offsetRange: Array[OffsetRange] = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
  // 将 offset 保存到本地的 Redis 2 号库。
  val jedis: Jedis = new Jedis("localhost", 6379)
  jedis.select(2)
  // 保存偏移量
  for (offset <- offsetRange) {
    jedis.hset(groupId, offset.topic + "-" + offset.partition, offset.untilOffset.toString)
  }
  jedis.close()
  rdd
}

kafkaStream 那里获得的 每一条消息 都会被包装为 ConsumerRecord[String,String],这里的两个类型参数指代 Kafka 消息的 key ( 在本篇中没有使用它 ) 和 value,我们默认采取的是 classOf[StringDeserializer] 反序列化器。ConsumerRecord 包含了 Kafka 消息的各种元信息,其中就包括了消息本身携带的 offset 信息。

HasOffsetRanges 是一个特质,下面是有关于它的源码:

/**
 * Represents any object that has a collection of [[OffsetRange]]s. This can be used to access the
 * offset ranges in RDDs generated by the direct Kafka DStream (see
 * [[KafkaUtils.createDirectStream]]).
 * {{{
 *   KafkaUtils.createDirectStream(...).foreachRDD { rdd =>
 *      val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
 *      ...
 *   }
 * }}}
 */
 trait HasOffsetRanges {
  def offsetRanges: Array[OffsetRange]
}

源码中强调:记录 offset 的 Range 只能由 Kafka 直接产生的 DStream ( 即通过 KafkaUtils 获取到的 InputDStream ) 那里获取。换句话说,企图在 Spark RDD 提取 offset 的做法是错误的:

// org.apache.spark.rdd.MapPartitionsRDD cannot be cast to org.apache.spark.streaming.kafka010.HasOffsetRanges
// 参考 https://blog.csdn.net/a904364908/article/details/104434444/
kafkaStream.foreachRDD(rdd =>{
    // err
    val offsetRanges: Array[OffsetRange] = rdd.asInstanceOf[HasOffsetRanges].offsetRanges
    //...
})

同时,创建 kafkaStream 的逻辑也发生了变化:如果 Spark Streaming 任务在 Redis 内部找不到此 groupId 对指定 topic 消费的记录,那么就订阅此 topic 并开始消费;否则,由我们实现的 JedisOffset 工具根据 groupId 查找到对应 topic 的各个分区的 offset ,在打包为 Map[TopicPartition,Long] 类型的映射表后指派给 executors 继续消费数据。

// 创建 DStream 的过程,分为第一次创建和重启两种情况。
var memorizedOffset: Map[TopicPartition, Long] = JedisOffset(groupId)
val kafkaStream: InputDStream[ConsumerRecord[String, String]] =
if (memorizedOffset.isEmpty) {
    KafkaUtils.createDirectStream[String, String](
        ssc,
        LocationStrategies.PreferConsistent,
        ConsumerStrategies.Subscribe[String, String](Array(topic), kafkaPara)
    )
} else {
    KafkaUtils.createDirectStream(
        ssc,
        LocationStrategies.PreferConsistent,
        ConsumerStrategies.Assign[String, String](memorizedOffset.keys, kafkaPara, memorizedOffset)
    )
}

下面是 JedisOffset 的实现。我们之前是通过 ${topic}-${partition} 字符串的形式将 topic 和 partition 信息一并作为 hset field 保存的,因此在提取数据时也要根据相同的规则将 topic 和 partition 提取出来。

object JedisOffset {

  val pool = new JedisPool("localhost",6379)

  def apply(groupId: String): Map[TopicPartition, Long] = {
    // 创建Map形式的Topic、partition、Offset
    var formdbOffset: Map[TopicPartition, Long] = Map[TopicPartition, Long]()
    //获取Jedis连接
    val jedis2: Jedis = pool.getResource
    jedis2.select(2)

    // 查询出Redis中的所有topic partition
    val topicPartitionOffset: util.Map[String, String] = jedis2.hgetAll(groupId)
    // 导入隐式转换
    import scala.collection.JavaConversions._
    // 将Redis中的Topic下的partition中的offset转换成List
    val topicPartitionOffsetlist: List[(String, String)] = topicPartitionOffset.toList
    // 循环处理所有的数据
    for (topicPair <- topicPartitionOffsetlist) {
      // topicPair = "${topic}-${partition}"  
      val split: Array[String] = topicPair._1.split("[-]")
      formdbOffset += (
        // (topic,partition) -> offset
        new TopicPartition(split(0), split(1).toInt) -> topicPair._2.toLong
        )
    }

    jedis2.close()
    formdbOffset
  }
}

准备处理多个 Topic 的数据

订阅多个 topic 本身是很容易的事情,只需要向 ConsumerStrategies.Subscribe 那里以数组的形式注册 topic 就可以了。

ConsumerStrategies.Subscribe[String, String](Array("teacher", "student"), kafkaPara)

真正要考虑的问题是,如何分流出不同 topic 的消息并分别处理。这里可以参考前文 "使用 Either 分流异常数据" 的思路,以接收两个 topic 消息为例:

val markedStream: DStream[(String, String)] = kafkaStream.transform {
    rdd =>
	/* 记录 offset */
    rdd
}.map(m => (m.topic(), m.value()))

// 两个 topics 在此分流。
// 这里引用了两次 spawnRdd,因此使用 cache 缓存数据。
// markedStream 即 kafkaStream。
val spawnedStream = markedStream.map {
    tagTuple =>
    tagTuple._1 match {
        
        case "teacher" =>
          // 将 teacher topic 的消息解析成 Teacher 类。
          val teacherRaw: String = tagTuple._2; Left(toTeacher(teacherRaw)) 
        
        case "student" =>
          // 将 student topic 的消息解析成 Student 类。
          val studentRaw: String = tagTuple._2; Right(toStudent(studentRaw))
    }
}.cache

val teacherStreamRdd: DStream[Teacher] = spawnedStream.filter(_.isLeft).map { case Left(x) => x }
val studentStreamRdd: DStream[Student] = spawnedStream.filter(_.isRight).map { case Right(y) => y }

测试

为了确认项目代码是否能正常工作,在 Spark Streaming 任务启动之后,不妨在另起一个简单脚本向指定的 Kafka topic 内发送 Mock 数据。这用到了之前引入的 kafka-clients 依赖包。

每一条消息都被包装为 ProducerRecord[String,String] 发送。其两个参数类型取决于配置的序列化器 ( 和前文的反序列化器对应 )。不过,ProducerRecord 构造器的第一个参数指代的是 topic 而非 Key。

val kafkaProperties = new Properties()
kafkaProperties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,"localhost:9092")
kafkaProperties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringSerializer")
kafkaProperties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,"org.apache.kafka.common.serialization.StringSerializer")

val forwarder = new KafkaProducer[String,String](kafkaProperties)
forwarder.send (
    // ProducerRecord 构造器的第一个参数是 topic 
    new ProducerRecord[String, String]("teacher","Teacher(name=Li,age=24)"),
    new Callback(){
        override def onCompletion(metadata: RecordMetadata, exception: Exception): Unit = {
            if(exception == null){
                println(
                    s"""
                |sent successfully. topic = ${metadata.topic()}
                |""".stripMargin)
            }
        }
    }
)

其中,Callback 作为监听器,通过注册 onCompletion 方法令 Producer 成功发送数据时在控制台输出信息,以便向我们反馈运行结果,这是一个可选项。

这里仅演示了如何发送 Mock 数据。批量发送的实现思路也很容易,比如编写一个不断读取数据源的循环,然后将这段逻辑中放入循环内部。顺带一提,Linux 下可以通过 nohup <any_command> & 开启一个守护进程 ( no hang up ),这可以避免关闭服务器会话时将阻塞终端运行的进程一同关闭。

小结与后续

到目前为止介绍的内容,应该足够我们构建一个基础的,单机版的任务了。

了解一项技术的第一步就是从一个简单的例子开始入手,然后随着项目的需求变得复杂,处理的数据体量变得庞大,各种没有考虑到的问题和 Bug 就会随之出现。或许有些问题是以现在的技术积累无法解决的,这些问题将会驱动我们继续深入学习。笔者在本篇有意忽略了很多复杂且实际的问题,或许还有一些其它细枝末节也被疏忽了,不过这些大部分都可以靠搜索自行解决。

Spark 本身是一个庞大的框架。在处理 ( 超 ) 大型文件的任务中,除了复杂的业务逻辑,还有可能有各种部署 ( 运维 ) 方面的麻烦。比如说我们必须在节点配置资源方面做出权衡,保证任务既可以最大的效率运行,同时还能极力避免 OOM 这类致命异常。关于 Spark 调优的艺术,可以通过这篇文章感受一下:Spark核心知识,参数配置,内存优化,常见问题大全-CSDN博客

Spark 总体的使用风格是非常偏向于函数式编程的。因此,在 Spark Application 中使用 Scala 可以表达出比 Java 更加简洁同时语义更加丰富的代码。想要了解 Scala 基础语法 / 函数式编程的相关内容,可以访问笔者的相关专栏:Scala 从入门到函数式编程 - 花花子的专栏 - 掘金 (juejin.cn)

部署与安装

该部分主要为了解决在 Linux 下安装各种软件的问题,仅供参考。

build.sbt

下面是本项目中 build.sbt 配置的依赖项部分:

libraryDependencies ++= {
  Seq(
    // scala 单元测试工具:
    "org.scalatest" %% "scalatest" % "2.2.0" % "test",
      
    // json 工具  
    "org.json4s" %% "json4s-native" % "3.7.0-M6", 
    "org.json4s" %% "json4s-jackson" % "3.2.11",
      
    // spark 核心库
    "org.apache.spark" % "spark-core_2.11" % "2.4.3" % "provided",
    "org.apache.spark" % "spark-sql_2.11" % "2.4.3" % "provided",
    "org.apache.spark" % "spark-streaming_2.11" % "2.4.3" % "provided",
      
    // spark-streaming + kafka 
    "org.apache.kafka" % "kafka-clients" % "0.10.2.1",
    "org.apache.spark" % "spark-streaming-kafka-0-10_2.11" % "2.4.3" % "provided",
      
    // 配置读取  
    "com.typesafe" % "config" % "1.3.3",
      
    // mysql 数据库驱动
    "mysql" % "mysql-connector-java" % "8.0.25",
      
    // Redis
    "redis.clients" % "jedis" % "3.0.1",
  )
}
// 在 ~/project/plugins.sbt 中配置:
// addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.9")
assembly / mainClass := Some("priv.project.i")
assembly / assemblyJarName := "my-spark-job.jar"

注意,如果本地仓库原本就没有某个依赖,那么需要先取消标注 provided 令 sbt 从远程仓库中将依赖下载到本地。

Linux Rsyslog

关于 Rsyslog 的安装,可以部分参考:日志收集之rsyslog kafka配置-CSDN博客Haproxy 通过 rsyslog 吐日志给 kafka

包括规则文件的内容,可以参考:Linux rsyslog 过滤规则-CSDN博客

在笔者真实部署的项目中,服务器通过 Rsyslog 工具采集上游发送的数据包并转发到 Kafka 消息队列,并最终由一个 SparkSubmit 守护进程消费。这只需要安装 syslog-kafka 插件并编写一点规则文件,因为 Rsyslog 是一个高度可拓展的工具。

不过,Rsyslog 只有在 8.24 及以上的版本中才支持此插件。Linux 虽然自带 Rsyslog 工具,但是版本可能并不高。因此,可以先卸载旧的 Rsyslog,然后在此仓库:Index of /v8-stable/epel-7/x86_64/RPMS (adiscon.com) 中下载新版的 Rsyslog 工具和 对应版本 的 syslog-kafka 插件 ( Ctrl + F 搜索起来效率会更高 )。

注意,需要首先安装 Rsyslog,然后才能安装 syslog-kafka 插件。 如果插件最终安装成功了,那么在 /lib64/rysylog/ 下 ( 32 位系统为 /lib/rsyslog ) 中会存在 omkafka.so 链接库 ( 包括其它拓展也都是 omxxx.so 形式存在的,据说也支持 Redis )。

笔者的前端程序会通过 UDP 报文将日志发送到服务器的 14308 端口供 Rsyslog 接收。在 /etc/rsyslog.d/ 下创建一个 *.conf 规则文件:

module(load="imudp")
module(load="omkafka")

template(name="full_log" type="string" string="%msg%")
ruleset(name="2kafka"){
  action(type="omkafka" template="full_log" queue.workerthreads="2" failedMsgFile="omkafka-failed.data" broker="localhost:9092" topic="test")
  action(type="omfile" file="/runtime/rsyslog.log")
}
input(type="imudp" port="14308" ruleset="2kafka")

这里会额外将收到的数据日志记录到一个 /runtime/rsyslog.log 下。随着系统的不断运行,这个文件体量会越来越大。因此,不妨配置一种动态文件记录的方式,让 Rsyslog 将每天的日志分别存储:

module(load="imudp")
module(load="omkafka")
​
template(name="each_day" type="string" string="/runtime/data_each_day/data_%$year%-%$month%-%$day%.log")
template(name="full_log" type="string" string="%msg%")
ruleset(name="2kafka"){
  action(type="omkafka" template="full_log" queue.workerthreads="2" failedMsgFile="omkafka-failed.data" broker="localhost:9092" topic="aprouting")
  action(type="omfile" dynaFile="each_day")
}
input(type="imudp" port="14308" ruleset="2kafka")

重启 Rsyslog 程序并检查配置文件是否有出错的地方:

service rsyslog restart
rsyslogd -N 1

关于 MySQL

这篇博客给出了安装包形式的 MySQL 8.0 配置教程:MySQL8.0安装教程,在Linux环境安装MySQL8.0教程-CSDN博客

注,CentOS 系统下使用 yum 可以省时省力的解决安装问题。笔者使用安装包是因为服务器的安全政策不允许连接外部网络。

配置 MySQL 时需要注意两个问题。在获得初始化 root 密码后,应首先进入 MySQL 中重设密码:

ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'yourpwd';

这里将密码的格式设置为兼容 MySQL 5.7 的 mysql_native_password 类型,防止 JDBC,或者 Navicat 工具出现连接错误的问题。

其次,MySQL 默认是仅允许本机连接的。想要程序能够通过远程网络访问,还必须将用户 ( 这里为 root ) 的 host 选项从 localhost 更改为 % ( MySQL 的通配符,表示任何 IP 地址都可以连接。后期可以配置为具体的地址 )。

update user set host='%' where user='root';

重要的一步,在机器内部的防火墙规则中设置允许外部以 tcp 协议连接 3306 端口 ( 如果机器需要开放其它的端口,思路也是一样的 )。CentOS 7 需要使用 firewall-cmd 开放端口:

firewall-cmd --zone=public --add-port=3306/tcp --permanent
# 开放完需要重启服务
firewall-cmd --reload
# 检查 3306 端口
firewall-cmd --zone=public --query-port=3306/tcp

如果是购买的云服务器,那么务必在控制台中检查访问规则是否也开放了 3306 端口否则,即使在机器内部防火墙开启了端口,外部也仍可能无法连接

关于 Spark 安装

这篇博客给出了安装 Spark 的流程:Linux下安装Spark_柴狗狗的博客-CSDN博客_linux spark安装

Spark 的旧版本列表,见:Index of /dist/spark/spark-2.1.1 (apache.org)

一般情况下,ssh 默认不允许密码认证。这可能会导致 spark-submit 等命令拒绝执行,并提示:localhost: root@localhost: Permission denied (publickey,password)。在单机环境中,一种简单的解决方案是打开 ssh 的密码认证:

sudo vim /etc/ssh/sshd_config

在配置文件中添加这一行并保存:

PermitRootLogin:yes

重启 ssh 服务使配置生效:

systemctl restart sshd

另一种方案是为 Spark 配置 ssh 免密登录,这是在集群环境中必须配置的一步工作,通常需要通过编写 Shell 脚本完成,这里略。

Spark 默认使用 INFO 级别的 log4j 日志打印信息。开发者可以手动在项目的 resources 文件夹下配置 log4j.properties 覆盖它。

#log4j.rootLogger=warning,stdout
#log4j.appender.stdout=org.apache.log4j.ConsoleAppender
#log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
#log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss,SSS}  %5p --- [%50t]  %-80c(line:%5L)  :  %m%n
log4j.rootCategory=INFO, console
log4j.appender.console=org.apache.log4j.ConsoleAppender
log4j.appender.console.target=System.err
log4j.appender.console.layout=org.apache.log4j.PatternLayout
log4j.appender.console.layout.ConversionPattern=%d{yy/MM/dd HH:mm:ss} %p %c{1}: %m%n

# Set the default spark-shell log level to ERROR. When running the spark-shell, the
# log level for this class is used to overwrite the root logger's log level, so that
# the user can have different defaults for the shell and regular Spark apps.
log4j.logger.org.apache.spark.repl.Main=ERROR

# Settings to quiet third party logs that are too verbose
log4j.logger.org.spark_project.jetty=ERROR
log4j.logger.org.spark_project.jetty.util.component.AbstractLifeCycle=ERROR
log4j.logger.org.apache.spark.repl.SparkIMain$exprTyper=ERROR
log4j.logger.org.apache.spark.repl.SparkILoop$SparkILoopInterpreter=ERROR
log4j.logger.org.apache.parquet=ERROR
log4j.logger.parquet=ERROR

# SPARK-9183: Settings to avoid annoying messages when looking up nonexistent UDFs in SparkSQL with Hive support
log4j.logger.org.apache.hadoop.hive.metastore.RetryingHMSHandler=FATAL
log4j.logger.org.apache.hadoop.hive.ql.exec.FunctionRegistry=ERROR

关于 Redis

在默认情况下,以客户身份连接 Redis 是需要身份认证的。而实际上,笔者考虑到当前只有部署在服务器本地的 Spark 任务会通过 Jedis 工具请求连接 Redis,因此为了简单起见,笔者在 Redis 的配置文件 ( ~/redis/etc/redis.conf ) 中取消了身份验证,同时保证 Redis 只处理来自本地的连接。

bind 127.0.0.1
protected-mode no

Redis 是一个运行在内存的数据库。为了尽可能避免磁盘读写,可以适当的给 Redis 分配更多的内存空间 ( 比如 4 Gb ):

maxmemory 4G

Redis 默认使用 RDB 方式实现粗时间粒度的数据持久化,一般来说这足够用了。但是当 Redis 重启时,这有可能丢失一段时间的数据 ( 具体取决于 RDB 配置的落盘时间间隔 )。Redis 还提供另一种 AOF 持久化的方式 ( 类似于通过记录 Redis 写操作的日志来实现数据恢复功能 ),这需要配置 appendonly 选项为 yes。Redis 提供的 appendfsync 有三种策略:

  1. always:每写入一条就进行持久化。性能最低,但是安全性相对最高。
  2. everysec:每秒进行持久化。是性能和安全性的折中选择,是默认的方案。
  3. no:由操作系统决定何时被持久化。
appendonly yes
appendfsync everysec # alawys, everysec, no

RDB 和 AOF 两种模式可以同时开启,但是 AOF 的优先级将会更高 ( 因为它能恢复更多的数据 )。RDB 和 AOF 的两种策略可以参考:Redis 中的 RDB 和 AOF 的区别 CSDN 博客

关于 Kafka / Zookeeper 安装

Kafka + Zookeeper 的单机版安装,见:Linux 安装 Kafka(单机)

Kafka 和 Zookeeper 的安装本身并不困难,通过 kafka-server-start.sh 后台启动 Kafka,避免退出连接时连带将 Kafka 一同关闭。

# 守护进程启动,还有一种 nohup 的后台运行方式。
kafka-server-start.sh -daemon config/server.properties &   

这里仅演示 Kafka 的一些实用命令。注意:下面是 Windows 版的 Kafka 批处理命令,但是使用方法和 Linux 是完全一样的。

为 Kafka 新建一个 topic,指定消息分区数 --partitions 和副本数量:--replicatiion-factor

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

查看 Kafka 创建的所有主题:

.\bin\windows\kafka-topics.bat --list --zookeeper localhost:2181

在终端创建一个指定 topic 的消息生产者,可以直接向 Kafka 发送消息:

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

在终端创建一个指定 topic 的消息消费者。--from-beginning 是一个可选项,如果添加此参数,则表示从头开始消费数据。

.\bin\windows\kafka-console-consumer.bat --bootstrap-server localhost:9092 --topic test --from-beginning