第一章 、项目框架
1.1 、项目概述
本项目来源于企业级电商网站的大数据统计分析平台,该平台以 Spark 框架为核心,对电商网站的日志进行离线和实时分析。
该大数据分析平台对电商网站的各种用户行为(访问行为、购物行为、广告点击行为等)进行分析,根据平台统计出来的数据,辅助公司中的 PM(产品经理)、数据分析师以及管理人员分析现有产品的情况,并根据用户行为分析结果持续改进产品的设计,以及调整公司的战略和业务。最终达到用大数据技术来帮助提升公司的业绩、营业额以及市场占有率的目标。
本项目使用了 Spark 技术生态栈中最常用的三个技术框架,Spark Core、SparkSQL 和 Spark Streaming,进行离线计算和实时计算业务模块的开发。实现了包括用户访问 session 分析、页面单跳转化率统计、热门商品离线统计、广告流量实时统计4 个业务模块。通过合理的将实际业务模块进行技术整合与改造,该项目几乎完全涵盖了 Spark Core、Spark SQL 和 Spark Streaming 这三个技术框架中大部分的功能点、知识点,对于 Spark 技术框架的理解将会在本项目中得到很大的提高。
![](https://i-blog.csdnimg.cn/blog_migrate/a89ccff9c4dd2dd215853e11e2a181df.png)
1.2、项目框架
1.2.1、项目整体框架
本项目分为离线分析系统与实时分析系统两大模块。
在离线分析系统中,我们将模拟业务数据写入 Hive 表中,离线分析系统从 Hive中获取数据,并根据实际需求(用户访问 Session 分析、页面单跳转化率分析、各区域热门商品统计)对数据进行处理,最终将分析完毕的统计数据存储到 MySQL 的对应表格中。
在实时分析系统中,我们将模拟业务数据写入 Kafka 集群中,实时分析系统从Kafka broker 中获取数据,通过 Spark Streaming 的流式处理对广告点击流量进行实时分析,最终将统计结果存储到 MySQL 的对应表格中。
1.2.2、离线日志采集
![](https://i-blog.csdnimg.cn/blog_migrate/427a0646a13ea2d138f60aeab90252fe.png)
1.2.3、实时日志采集宏观流程
![](https://i-blog.csdnimg.cn/blog_migrate/a82076e2c07e3ff5c329d0e090d9ef57.png)
1.2.4、离线/实时日志采集框架
如图是一个企业级的日志处理框架,这一框架实现了对日志信息进行采集、汇总、清洗、聚合、分析的完整过程,并将日志数据分别存储到了离线和实时数据处理模块中,使得分析系统可以通过离线和实时两个角度对数据进行分析统计,并根据统计结果指导业务平台的改良和优化。
1.3 、数据分析
1.3.1、离线数据分析
1. user_visit_action
user_visit_action 表,存放网站或者 APP 每天的点击流数据。通俗地讲,就是用户对网站/APP 每点击一下,就会产生一条存放在这个表里面的数据。
![](https://i-blog.csdnimg.cn/blog_migrate/9cf8ad7c6615cd5d61cd495945c124fd.png)
user_visit_action 表中的字段解析如表 1-1 所示:
表 1-1 user_visit_action表字段解析
2. user_info
user_info 表,是一张普通的用户基本信息表;这张表中存放了网站/APP 所有注册用户的基本信息
user_i 表中的字段解析如表 1-1 所示:
![](https://i-blog.csdnimg.cn/blog_migrate/0400b4249d9be18254123df31b10fa8a.png)
3. product_info
product_info 表,是一张普通的商品基本信息表;这张表中存放了网站/APP 所有商品的基本信息。
![](https://i-blog.csdnimg.cn/blog_migrate/b377361c6838e212c685bd6faf51e2b3.png)
1.3.2 、在线数据分析
程序每 5 秒向 Kafka 集群写入数据,格式如下:
格式 :timestamp province city userid adid
![](https://i-blog.csdnimg.cn/blog_migrate/06d4a4a3b063337d64b22c5be001b767.png)
1.4、项目需求
![](https://i-blog.csdnimg.cn/blog_migrate/ec2876be3e7785a0193216659e2e9bc3.png)
1.3.1、用户访问session统计
用户在电商网站上,通常会有很多的访问行为,通常都是进入首页,然后可能点击首页上的一些商品,点击首页上的一些品类,也可能随时在搜索框里面搜索关键词,还可能将一些商品加入购物车,对购物车中的多个商品下订单,最后对订单中的多个商品进行支付。
用户的每一次操作,其实可以理解为一个 action,在本项目中,我们关注点击、搜索、下单、支付这四个用户行为。
用户 session,是在电商平台的角度定义的会话概念,指的就是,从用户第一次进入首页,session 就开始了。然后在一定时间范围内,直到最后操作完(可能做了几十次、甚至上百次操作),离开网站,关闭浏览器,或者长时间没有做操作,那么 session 就结束了。
以上用户在网站内的访问过程,就称之为一次 session。简单理解,session 就是某一天某一个时间段内,某个用户对网站从打开/进入,到做了大量操作,到最后关闭浏览器。的过程,就叫做 session。
session 实际上就是一个电商网站中最基本的数据和大数据。那么面向消费者/用户端的大数据分析(C 端),最基本的就是面向用户访问行为/用户访问 session的分析。
该模块主要是对用户访问 session 进行统计分析,包括 session 聚合指标计算、按时间比例随机抽取 session、获取每天点击、下单和购买排名前 10 的品类、并获取 top10 品类中排名前 10 的 session。该模块可以让产品经理、数据分析师以及企业管理层形象地看到各种条件下的具体用户行为以及统计指标,从而对公司的产品设计以及业务发展战略做出调整。主要使用 Spark Core 实现。
1.3.2、页面单跳转化率统计
该模块主要是计算关键页面之间的单步跳转转化率,涉及到页面切片算法以及页面流匹配算法。该模块可以让产品经理、数据分析师以及企业管理层看到各个关键页面之间的转化率,从而对网页布局,进行更好的优化设计。主要使用 Spark Core实现。
1.3.3、区域热门商品统计
该模块主要实现每天统计出各个区域的 top3 热门商品。该模块可以让企业管理层看到电商平台在不同区域出售的商品的整体情况,从而对公司的商品相关的战略进行调整。主要使用 Spark SQL 实现。
1.3.4、广告流量实时统计
网站 / app 中经常会给第三方平台做广告,这也是一些互联网公司的核心收入来源;当广告位招商完成后,广告会在网站 / app 的某个广告位发布出去,当用户访问网站 / app 的时候,会看到相应位置的广告,此时,有些用户可能就会去点击那个广告。
我们要获取用户点击广告的行为,并针对这一行为进行计算和统计。
用户每次点击一个广告以后,会产生相应的埋点日志;在大数据实时统计系统中,会通过某些方式将数据写入到分布式消息队列中(Kafka)。
日志发送给后台 web 服务器(nginx),nginx 将日志数据负载均衡到多个 Tomcat服务器上,Tomcat 服务器会不断将日志数据写入 Tomcat 日志文件中,写入后,就会被日志采集客户端(比如 flume agent)所采集,随后写入到消息队列中(kafka),我们的实时计算程序会从消息队列中(kafka)去实时地拉取数据,然后对数据进行实时的计算和统计。
这个模块的意义在于,让产品经理、高管可以实时地掌握到公司打的各种广告的投放效果。以便于后期持续地对公司的广告投放相关的战略和策略,进行调整和优化;以期望获得最好的广告收益。
该模块负责实时统计公司的广告流量,包括广告展现流量和广告点击流量。实现动态黑名单机制,以及黑名单过滤;实现滑动窗口内的各城市的广告展现流量和广告点击流量的统计;实现每个区域每个广告的点击流量实时统计;实现每个区域top3 点击量的广告的统计。主要使用Spark Streaming 实现。
第二章、预备知识
2.1、Scala
2.1.1、Scala操作符
2.1.2、拉链操作
把一对集合 A 和 B 的包含的元素合成到一个集合中:
val prices = List(5.0, 20.0, 9.95)
val quantities = List(10, 2, 1)
prices zip quantities
运行结果
List[(Double, Int)] = List((5.0, 10), (20.0, 2), (9.95, 1))
这个方法之所以叫“拉链(zip)”,是因为它就像拉链的齿状结构一样将两个集合结合在一起。
注意:如果一个集合比另一个集合端,那么结果中的对偶数量和较短的那个集合的元素数量相同。
zipAll 方法可以让你指定较短列表的缺省值。
zipWithIndex 方法返回对偶的列表,其中每个对偶中第二个组成部分是每个元素的下标
2.2 、SparkCore
2.2.1. Spark RDD
Spark 非常重要的一个功能特性就是可以将 RDD 持久化在内存中,当对 RDD执行持久化操作时,每个节点都会将自己操作的 RDD 的 partition 持久化到内存中,并且在之后对该 RDD 的反复使用中,直接使用内存的 partition。这样的话,对于针对一个 RDD 反复执行多个操作的场景,就只要对 RDD 计算一次即可,后面直接使用该 RDD,而不需要反复计算多次该 RDD。
巧妙使用 RDD 持久化,甚至在某些场景下,可以将 Spark 应用程序的性能提高10 倍。对于迭代式算法和快速交互式应用来说,RDD 持久化是非常重要的。
例如,读取一个有着数十万行数据的 HDFS 文件,形成 linesRDD,这一读取过程会消耗大量时间,在 count 操作结束后,linesRDD 会被丢弃,会被后续的数据覆盖,当第二次再次使用 count 时,又需要重新读取 HDFS 文件数据,再次形成新的linesRDD,这回导致反复消耗大量时间,会严重降低系统性能。
如果在读取完成后将 linesRDD 缓存起来,那么下一次执行 count 操作时将会直接使用缓存起来的 linesRDD,这会节省大量的时间。
要持久化一个 RDD,只要调用其 cache()或者 persist()方法即可。在该 RDD 第一次被计算出来时,就会直接缓存在每个节点中,而且 Spark 的持久化机制还是自动容错的,如果持久化的 RDD 的任何 partition 丢失了,那么 Spark 会自动通过其源RDD,使用transformation 操作重新计算该 partition。
cache()和 persist()的区别在于,cache()是 persist()的一种简化方式,cache()的底层就是调用的 persist()的无参版本,同时就是调用 persist(MEMORY_ONLY),将输入持久化到内存中。如果需要从内存中清除缓存,那么可以使用 unpersist()方法。
Spark 自己也会在 shuffle 操作时,进行数据的持久化,比如写入磁盘,主要是为了在节点失败时,避免需要重新计算整个过程。
![](https://i-blog.csdnimg.cn/blog_migrate/a859278603e162acf06b4f5f128c7598.png)
以下为对一个 156 万行大小为 168MB 的文本文件进行处理,textFile 后只进行count 操作,持久化与不持久化的结果如下:
2.2.3、 Spark 共享变量
Spark 一个非常重要的特性就是共享变量。
默认情况下,如果在一个算子的函数中使用到了某个外部的变量,那么这个变量的值会被拷贝到每个 task 中,此时每个 task 只能操作自己的那份变量副本。如果多个 task 想要共享某个变量,那么这种方式是做不到的。
Spark 为此提供了两种共享变量,一种是 Broadcast Variable(广播变量),另一种是 Accumulator(累加变量)。Broadcast Variable 会将用到的变量,仅仅为每个节点拷贝一份,更大的用途是优化性能,减少网络传输以及内存损耗。Accumulator则可以让多个 task 共同操作一份变量,主要可以进行累加操作。Broadcast Variable是共享读变量,task 不能去修改它,而 Accumulator 可以让多个 task 操作一个变量。
1、广播变量
广播变量允许程序员在每个机器上保留缓存的只读变量,而不是给每个任务发送一个副本。 例如,可以使用它们以有效的方式为每个节点提供一个大型输入数据集的副本。Spark 还尝试使用高效的广播算法分发广播变量,以降低通信成本。
Spark action 被划分为多个 Stages,被多个“shuffle”操作(宽依赖)所分割。Spark自动广播每个阶段任务所需的公共数据(一个 Stage 中多个 task 使用的数据),以这种方式广播的数据以序列化形式缓存,并在运行每个任务之前反序列化。 这意味着,显式创建广播变量仅在跨多个阶段的任务需要相同数据或者以反序列化格式缓存数据很重要时才有用。
Spark 提供的 Broadcast Variable 是只读的,并且在每个节点上只会有一个副本,而不会为每个 task 都拷贝一份副本,因此,它的最大作用,就是减少变量到各个节点的网络传输消耗,以及在各个节点上的内存消耗。此外,Spark 内部也使用了高效的广播算法来减少网络消耗。
可以通过调用 SparkContext 的 broadcast()方法来针对每个变量创建广播变量。然后在算子的函数内,使用到广播变量时,每个节点只会拷贝一份副本了,每个节点可以使用广播变量的 value()方法获取值。
2、累加器
累加器(accumulator):Accumulator 是仅仅被相关操作累加的变量,因此可以在并行中被有效地支持。它们可用于实现计数器(如 MapReduce)或总和计数。
Accumulator 是存在于 Driver 端的,从节点不断把值发到 Driver 端,在 Driver端计数(Spark UI 在 SparkContext 创建时被创建,即在 Driver 端被创建,因此它可以读取 Accumulator 的数值),存在于 Driver 端的一个值,从节点是读取不到的。Spark 提供的 Accumulator 主要用于多个节点对一个变量进行共享性的操作。
Accumulator 只提供了累加的功能,但是却给我们提供了多个 task 对于同一个变量并行操作的功能,但是 task 只能对 Accumulator 进行累加操作,不能读取它的值,只有 Driver 程序可以读取 Accumulator 的值。
自定义累加器类型的功能在 1.X 版本中就已经提供了,但是使用起来比较麻烦,在 2.0 版本后,累加器的易用性有了较大的改进,而且官方还提供了一个新的抽象类:AccumulatorV2 来提供更加友好的自定义类型累加器的实现方式。
官方同时给出了一个实现的示例:CollectionAccumulator 类,这个类允许以集合的形式收集 spark 应用执行过程中的一些信息。例如,我们可以用这个类收集 Spark处理数据时的一些细节,当然,由于累加器的值最终要汇聚到 driver 端,为了避免driver 端的 outofmemory 问题,需要对收集的信息的规模要加以控制,不宜过大。
代码清单 2-1 自定义AccumulatorV2
class SessionAggrStatAccumulator extends AccumulatorV2[String, mutable.HashMap[String,
Int]] {
// 保存所有聚合数据
private val aggrStatMap = mutable.HashMap[String, Int]()
// 判断是否为初始值
override def isZero: Boolean = {
aggrStatMap.isEmpty
}
// 复制累加器
override def copy(): AccumulatorV2[String, mutable.HashMap[String, Int]] = {
val newAcc = new SessionAggrStatAccumulator
aggrStatMap.synchronized{
newAcc.aggrStatMap ++= this.aggrStatMap
}
newAcc
}
// 重置累加器中的值
override def reset(): Unit = {
aggrStatMap.clear()
}
// 向累加器中添加另一个值
override def add(v: String): Unit = {
if (!aggrStatMap.contains(v))
aggrStatMap += (v -> 0)
aggrStatMap.update(v, aggrStatMap(v) + 1)
}
// 各个 task 的累加器进行合并的方法
// 合并另一个类型相同的累加器
override def merge(other: AccumulatorV2[String, mutable.HashMap[String, Int]]): Unit =
{
other match {
case acc:SessionAggrStatAccumulator => {
(this.aggrStatMap /: acc.value){ case (map, (k,v)) => map += ( k -> (v + map.getOrElse(k,
0)) )}
}
}
}
// 获取累加器中的值
// AccumulatorV2 对外访问的数据结果
override def value: mutable.HashMap[String, Int] = {
this.aggrStatMap
}
}
2.3、 SparkSQL
2.3.1、RDD 、DataFrame与 DataSet
1. RDD
RDD,全称为 Resilient Distributed Datasets,即分布式数据集,是 Spark 中最基本的数据抽象,它代表一个不可变、 可分区、里面的元素可以并行计算的集合。在Spark 中,对数据的所有操作不外乎创建 RDD、转化已有 RDD 以及调用 RDD 操作进行求值。每个 RDD 都被分为多个分区,这些分区运行在集群中的不同的节点上。RDD 可以包含 Python、Java、Scala 中任意类型的对象,甚至可以包含用户自定义的对象。RDD 具有数据流模型的特点:自动容错、位置感知性调度和可伸缩性。RDD允许用户在执行多个查询时显式地将工作集缓存在内存中,后续的查询能够重用工作集,这极大地提升查询速度。
RDD 支持两种操作:transformation 操作和 action 操作。RDD 的 transformation操作是返回一个新的 RDD 的操作,比如 map 和 filter(),而 action 操作则是向驱动器程序返回结果或者把结果写入外部系统的操作,比如 count()和 first()。
2. DataFrame
DataFrame 是一个分布式数据容器。相比于 RDD,DataFrame 更像传统数据库中的二维表格,除了数据之外,还记录数据的结构信息,即 schema。同时,与 Hive类似,DataFrame 也支持嵌套数据类型(struct,array 和 map)。从 API 易用性的角度上看,DataFrame API 提供的是一套高层的关系操作,比函数式的 RDD API 要更加友好,门槛更低。由于与 R 和 Pandas 中的 DataFrame 类似,Spark DataFrame 很好地继承了传统单机数据分析的开和体验。
![](https://i-blog.csdnimg.cn/blog_migrate/ad517b3c51f949e82c328f6332baecb1.png)
如上图所示,左侧的 RDD[Person]虽然以 Person 为类型参数,但是 Spark 框架本身不了解 Person 类的内部结构。而右侧的 DataFrame 却提供了详细的结构信息,使得 SparkSQL 可以清楚地知道该数据集中包含那些列,每列的名称是什么。DataFrame 多了数据的结构信息,即 schema。RDD 是分布式的 Java 对象的集合。DataFrame 是分布式的 Row 对象的集合。DataFrame 处理提供了比 RDD 更为丰富的算子以外,更重要的是提升了执行效率、减少数据读取以及执行计划的优化,比如filter 下推、裁剪等。
DataFrame 是 DataSet 的特例,DataFrame = DataSet[Row],所以可以通过 as 方法将 DataFrame 转换为 DataSet。Row 是一个类型,跟 Car、Person 这些类型一样,所有的表结构信息都用 Row 来表示。
3. DataSet
DataSet 是 DataFrame API 的一个拓展,是 Spark 最新的数据抽象。DataSet 具有用户友好的 API 风格,既具有类型安全检查也具有 DataFrame 的查询优化特性。DataSet 支持编解码器,当需要访问非堆上的数据时可以避免反序列化整个对象,提高了效率。
样例类被用来在 DataSet 中定义数据的结构信息,样例类中每个属性的名称直接映射到 DataSet 中的字段名称。
DataSet 是强类型的。比如可以有 DataSet[Car],DataSet[Person]DataFrame 只知道字段,但是不知道字段的类型,所以在执行这些操作的时候是没有办法在编译的时候检查是否类型失败的,比如你可以对一个 String 类型进行加减法操作,在执行的时候才会报错,而 DataSet 不仅仅知道字段,而且知道字段类型,所以有更为严格的错误检查。就更 JSON 对象和类对象之间的类比。
2.3.2、DataSet 与 与 RDD
介绍一下 Spark 将 RDD 转换成 DataFrame 的两种方式:
1.通过反射获取 Schema:使用 case class 的方式,不过在 scala 2.10 中最大支持22 个字段的 case class,这点需要注意;
2.通过编程获取 Schema:通过 spark 内部的 StructType 方式,将普通的 RDD 转换成 DataFrame。
代码清单 2-2 DataSet与RDD互操作
object SparkRDDtoDF {
// 1. 通过编程获取 Schema
def rddToDF(sparkSession:SparkSession):DataFrame = {
//设置 schema 结构
val schema = StructType(
Seq(
StructField("name",StringType,true)
,StructField("age",IntegerType,true)
)
)
val rowRDD = sparkSession.sparkContext
.textFile("file:/E:/scala_workspace/z_spark_study/people.txt",2)
.map( x => x.split(",")).map( x => Row(x(0),x(1).trim().toInt))
sparkSession.createDataFrame(rowRDD,schema)
}
// 2. 通过反射获取 Schema
case class Person(name:String,age:Int)
def rddToDFCase(sparkSession : SparkSession):DataFrame = {
//导入隐饰操作,否则 RDD 无法调用 toDF 方法
import sparkSession.implicits._
val peopleRDD = sparkSession.sparkContext
.textFile("file:/E:/scala_workspace/z_spark_study/people.txt",2)
.map( x => x.split(",")).map( x => Person(x(0),x(1).trim().toInt)).toDF()
peopleRDD
}
def main(agrs : Array[String]):Unit = {
val conf = new SparkConf().setMaster("local[2]")
conf.set("spark.sql.warehouse.dir","file:/E:/scala_workspace/z_spark_study/")
conf.set("spark.sql.shuffle.partitions","20")
val sparkSession = SparkSession.builder().appName("RDD to DataFrame")
.config(conf).getOrCreate()
// 通过代码的方式,设置 Spark log4j 的级别
sparkSession.sparkContext.setLogLevel("WARN")
import sparkSession.implicits._
//use case class convert RDD to DataFrame
//val peopleDF = rddToDFCase(sparkSession)
// 通过编程的方式完成 RDD 向
val peopleDF = rddToDF(sparkSession)
peopleDF.show()
peopleDF.select($"name",$"age").filter($"age">20).show()
}
}
2.3.3 RDD 、DataFrame 与 与 DataSet
1. DataFrame/DataSet 转 转 RDD
val rdd1=testDF.rdd
val rdd2=testDS.rdd
2. RDD 转 转 DataFrame
import spark.implicits._
val testDF = rdd.map {line=>
(line._1,line._2)
}.toDF("col1","col2")
3. RDD 转 转 DataSet
4. DataFrame 转 转 DataSet
import spark.implicits._
val testDF = testDS.toDF
5. DataSet 转 转 DataFrame
import spark.implicits._
//定义字段名和类型
case class Coltest(col1:String,col2:Int)extends Serializable
val testDS = testDF.as[Coltest]
2.3.4 用户自定义聚合函数(UDAF)
1. 弱类型 UDAF 函数
通过继承 UserDefinedAggregateFunction 来实现用户自定义聚合函数。
代码清单 2-3 弱类型UDAF
/**
* 用户自定义聚合函数
*/
class GroupConcatDistinctUDAF extends UserDefinedAggregateFunction {
/**
* 聚合函数输入参数的数据类型
*/
override def inputSchema: StructType = StructType(StructField("cityInfo", StringType) ::
Nil)
/**
* 聚合缓冲区中值的类型
* 中间进行聚合时所处理的数据类型
*/
override def bufferSchema: StructType = StructType(StructField("bufferCityInfo",
StringType) :: Nil)
/**
* 函数返回值的数据类型
*/
override def dataType: DataType = StringType
/**
* 一致性检验,如果为 true,那么输入不变的情况下计算的结果也是不变的
*/
override def deterministic: Boolean = true
/**
* 设置聚合中间 buffer 的初始值
* 需要保证这个语义:两个初始 buffer 调用下面实现的 merge 方法后也应该为初始 buffer 即如果你初始值是
1,然后你 merge 是执行一个相加的动作,两个初始 buffer 合并之后等于 2,不会等于初始 buffer 了。这样的初始
值就是有问题的,所以初始值也叫"zero value"
*/
override def initialize(buffer: MutableAggregationBuffer): Unit = {
buffer(0)= ""
}
/**
* 用输入数据 input 更新 buffer 值,类似于 combineByKey
*/
override def update(buffer: MutableAggregationBuffer, input: Row): Unit = {
// 缓冲中的已经拼接过的城市信息串
var bufferCityInfo = buffer.getString(0)
// 刚刚传递进来的某个城市信息
val cityInfo = input.getString(0)
// 在这里要实现去重的逻辑
// 判断:之前没有拼接过某个城市信息,那么这里才可以接下去拼接新的城市信息
if(!bufferCityInfo.contains(cityInfo)) {
if("".equals(bufferCityInfo))
bufferCityInfo += cityInfo
else {
// 比如 1:北京
// 1:北京,2:上海
bufferCityInfo += "," + cityInfo
}
buffer.update(0, bufferCityInfo)
}
}
/**
* 合并两个 buffer,将 buffer2 合并到 buffer1.在合并两个分区聚合结果的时候会被用到,类似于
reduceByKey
* 这里要注意该方法没有返回值,在实现的时候是把 buffer2 合并到 buffer1 中去,你需要实现这个合并细节
*/
override def merge(buffer1: MutableAggregationBuffer, buffer2: Row): Unit = {
var bufferCityInfo1 = buffer1.getString(0);
val bufferCityInfo2 = buffer2.getString(0);
for(cityInfo <- bufferCityInfo2.split(",")) {
if(!bufferCityInfo1.contains(cityInfo)) {
if("".equals(bufferCityInfo1)) {
bufferCityInfo1 += cityInfo;
} else {
bufferCityInfo1 += "," + cityInfo;
}
}
}
buffer1.update(0, bufferCityInfo1);
}
/**
* 计算并返回最终的聚合结果
*/
override def evaluate(buffer: Row): Any = {
buffer.getString(0)
}
}
2. 强类型 UDAF
通过继承 Aggregator 来实现强类型自定义聚合函数。
代码清单 2-4 强类型UDAF
// 定义 case 类
case class Employee(name: String, salary: Long)
case class Average(var sum: Long, var count: Long)
object MyAverage extends Aggregator[Employee, Average, Double] {
/**
* 计算并返回最终的聚合结果
*/
def zero: Average = Average(0L, 0L)
/**
* 根据传入的参数值更新 buffer 值
*/
def reduce(buffer: Average, employee: Employee): Average = {
buffer.sum += employee.salary
buffer.count += 1
buffer
}
/**
* 合并两个 buffer 值,将 buffer2 的值合并到 buffer1
*/
def merge(b1: Average, b2: Average): Average = {
b1.sum += b2.sum
b1.count += b2.count
b1
}
/**
* 计算输出
*/
def finish(reduction: Average): Double = reduction.sum.toDouble / reduction.count
/**
* 设定中间值类型的编码器,要转换成 case 类
* Encoders.product 是进行 scala 元组和 case 类转换的编码器
*/
def bufferEncoder: Encoder[Average] = Encoders.product
/**
* 设定最终输出值的编码器
*/
def outputEncoder: Encoder[Double] = Encoders.scalaDouble
}
2.3.5 、开窗函数
开窗函数与聚合函数一样,都是对行的集合组进行聚合计算。
开窗用于为行定义一个窗口(这里的窗口是指运算将要操作的行的集合),它对一组值进行操作,不需要使用 GROUP BY 子句对数据进行分组,能够在同一行中同时返回基础行的列和聚合列。
开窗函数的调用格式为: 函数名( 列) OVER( 选项)
第一大类:聚合开窗函数 -> 聚合函数(列) OVER (选项),这里的选项可以是PARTITION BY 子句,但不可是 ORDER BY 子句。
第二大类:排序开窗函数 -> 排序函数(列) OVER(选项),这里的选项可以是ORDER BY 子句,也可以是 OVER(PARTITION BY 子句 ORDER BY 子句),但不可以是 PARTITION BY 子句。
代码清单 2-5 开窗函数main函数
def main(args: Array[String]): Unit = {
val sparkConf = new SparkConf().setAppName("score").setMaster("local[*]")
val sparkSession = SparkSession.builder().config(sparkConf).getOrCreate()
import sparkSession.implicits._
val scoreDF = sparkSession.sparkContext.makeRDD(Array(Score("a1", 1, 80),
Score("a2", 1, 78),
Score("a3", 1, 95),
Score("a4", 2, 74),
Score("a5", 2, 92),
Score("a6", 3, 99),
Score("a7", 3, 99),
Score("a8", 3, 45),
Score("a9", 3, 55),
Score("a10", 3, 78))).toDF("name", "class
", "score")
scoreDF.createOrReplaceTempView("score")
scoreDF.show()
}
1、聚合开窗函数
OVER 关键字表示把聚合函数当成聚合开窗函数而不是聚合函数。SQL 标准允许将所有聚合函数用做聚合开窗函数。
sparkSession.sql("select name, class, score, count(name) over() name_count from score")
查询结果如下所示:
+----+-----+-----+----------+
|name|class|score|name_count|
+----+-----+-----+----------+
| a1| 1| 80| 10|
| a2| 1| 78| 10|
| a3| 1| 95| 10|
| a4| 2| 74| 10|
| a5| 2| 92| 10|
| a6| 3| 99| 10|
| a7| 3| 99| 10|
| a8| 3| 45| 10|
| a9| 3| 55| 10|
| a10| 3| 78| 10|
+----+-----+-----+----------+
在上边的例子中,开窗函数 COUNT(*) OVER()对于查询结果的每一行都返回所有符合条件的行的条数。OVER 关键字后的括号中还经常添加选项用以改变进行聚合运算的窗口范围。如果 OVER 关键字后的括号中的选项为空,则开窗函数会对结果集中的所有行进行聚合运算。
开窗函数的 OVER 关键字后括号中的可以使用 PARTITION BY 子句来定义行的分区来供进行聚合计算。与 GROUP BY 子句不同,PARTITION BY 子句创建的分区是独立于结果集的,创建的分区只是供进行聚合计算的,而且不同的开窗函数所创建的分区也不互相影响。下面的 SQL语句用于显示按照班级分组后每组的人数:
sparkSession.sql("select name, class, score, count(name) over(partition by class)
name_count from score").show()
查询结果如下所示:
|name|class|score|name_count|
+----+-----+-----+----------+
| a1| 1| 80| 3|
| a2| 1| 78| 3|
| a3| 1| 95| 3|
| a6| 3| 99| 5|
| a7| 3| 99| 5|
| a8| 3| 45| 5|
| a9| 3| 55| 5|
| a10| 3| 78| 5|
| a4| 2| 74| 2|
| a5| 2| 92| 2|
+----+-----+-----+----------+
OVER(PARTITION BY class)表示对结果集按照 class 进行分区,并且计算当前行所属的组的聚合计算结果。在同一个 SELECT 语句中可以同时使用多个开窗函数,而且这些开窗函数并不会相互干扰。比如下面的 SQL 语句用于显示每一个人员的信息、所属城市的人员数以及同龄人的人数:
sparkSession.sql("select name, class, score, count(name) over(partition by class)
name_count1 from score").show()
sparkSession.sql("select name, class, score, count(name) over(partition by score)
name_count2 from score").show()
2. 排序开窗函数
对于排序开窗函数来讲,它支持的开窗函数分别为:ROW_NUMBER(行号)、RANK(排名)、DENSE_RANK(密集排名)和 NTILE(分组排名)。
sparkSession.sql("select name, class, score, row_number() over(order by score) rank from
score").show()
sparkSession.sql("select name, class, score, rank() over(order by score) rank from
score").show()
sparkSession.sql("select name, class, score, dense_rank() over(order by score) rank from
score").show()
sparkSession.sql("select name, class, score, ntile(6) over(order by score) rank from
score").show()
执行的结果如下:
+----+-----+-----+----+
|name|class|score|rank|
+----+-----+-----+----+
| a8| 3| 45| 1|
| a9| 3| 55| 2|
| a4| 2| 74| 3|
| a2| 1| 78| 4|
| a10| 3| 78| 5|
| a1| 1| 80| 6|
| a5| 2| 92| 7|
| a3| 1| 95| 8|
| a6| 3| 99| 9|
| a7| 3| 99| 10|
+----+-----+-----+----+
+----+-----+-----+----+
|name|class|score|rank|
+----+-----+-----+----+
| a8| 3| 45| 1|
| a9| 3| 55| 2|
| a4| 2| 74| 3|
| a2| 1| 78| 4|
| a10| 3| 78| 4|
| a1| 1| 80| 6|
| a5| 2| 92| 7|
| a3| 1| 95| 8|
| a6| 3| 99| 9|
| a7| 3| 99| 9|
+----+-----+-----+----+
+----+-----+-----+----+
|name|class|score|rank|
+----+-----+-----+----+
| a8| 3| 45| 1|
| a9| 3| 55| 2|
| a4| 2| 74| 3|
| a2| 1| 78| 4|
| a10| 3| 78| 4|
| a1| 1| 80| 5|
| a5| 2| 92| 6|
| a3| 1| 95| 7|
| a6| 3| 99| 8|
| a7| 3| 99| 8|
+----+-----+-----+----+
+----+-----+-----+----+
|name|class|score|rank|
+----+-----+-----+----+
| a8| 3| 45| 1|
| a9| 3| 55| 1|
| a4| 2| 74| 2|
| a2| 1| 78| 2|
| a10| 3| 78| 3|
| a1| 1| 80| 3|
| a5| 2| 92| 4|
| a3| 1| 95| 4|
| a6| 3| 99| 5|
| a7| 3| 99| 6|
+----+-----+-----+----+
看到上面的结果了吧,下面来介绍下相关的内容。我们得到的最终结果是按照score 进行升序显示的。
对于 row_number() over(order by score) as rownum 来说,这个排序开窗函数是按score 升序的方式来排序,并得出排序结果的序号。
对于 rank() over(order by score) as rank 来说,这个排序形容函数是按 FSalary 升序的方式来排序,并得出排序结果的排名号。这个函数求出来的排名结果可以并列,并列排名之后的排名将是并列的排名加上并列数(简单说每个人只有一种排名,然后出现两个并列第一名的情况,这时候排在两个第一名后面的人将是第三名,也就是没有了第二名,但是有两个第一名)。
对于 dense_rank() over(order by score) as dense_rank 来说,这个排序函数是按score 升序的方式来排序,并得出排序结果的排名号。这个函数与 rank()函数不同在于,并列排名之后的排名只是并列排名加1(简单说每个人只有一种排名,然后出现两个并列第一名的情况,这时候排在两个第一名后面的人将是第二名,也就是两个第一名,一个第二名)。
对于 ntile(6) over(order by score)as ntile 来说,这个排序函数是按 FSalary 升序的方式来排序,然后 6 等分成 6 个组,并显示所在组的序号。
排序函数和聚合开窗函数类似,也支持在 OVER 子句中使用 PARTITION BY 语
句。
例如:
sparkSession.sql("select name, class, score, row_number() over(partition by class order
by score) rank from score").show()
sparkSession.sql("select name, class, score, rank() over(partition by class order by score)
rank from score").show()
sparkSession.sql("select name, class, score, dense_rank() over(partition by class order
by score) rank from score").show()
sparkSession.sql("select name, class, score, ntile(6) over(partition by class order by score)rank from score").show()
需要注意一点,在排序开窗函数中使用 PARTITION BY 子句需要放置在ORDER BY 子句之前。
2.4 SparkStreaming
2.4.1、Dstream transformation 算子概览
2.4.2、Dstream updataStateByKey算子
updateStateByKey 操作,可以让我们为每一个 key 维护一根 state,并持续不断地更新该 state。
1. 首先,要定义一个 state,可以是任意的数据类型;
2. 其次,要定义 state 更新函数——指定一个函数如何使用之前的 state 和新值来更新 state;
对于每个 batch,Spark 都会为每个之前已经存在的 key 去应用一次 state 更新函数,无论这个 key 在 batch 中是否有新的数据。如果 state 更新函数返回 none,那么key 对应的 state 就会被删除。
当然,对于每一个新出现的 key,也会执行 state 更新函数。
注意,updateStateByKey 操作,要求必须开启 Checkpoint 机制。
代码清单 2-6 updateStateByKey实例
object updateStateByKeyWordCount {
def main(args: Array[String]): Unit = {
val conf = new SparkConf().setMaster("local[2]").setAppName("Wordcount")
val ssc = new StreamingContext(conf, Seconds(1))
ssc.checkpoint("hdfs://s100:8020/wordcount_checkpoint")
val lines = ssc.socketTextStream("localhost", 9999)
val words = lines.flatMap(_.split(" "))
val pairs = words.map(word => (word, 1))
val wordCount = pairs.updateStateByKey((values:Seq[Int], state:Option[Int]) =>{
var newValue = state.getOrElse(0)
for(value <- values){
newValue += value
}
Option(newValue)
})
wordCount.print()
ssc.start()
ssc.awaitTermination()
}
}
2.4.3 、窗口操作
Spark Streaming 提供了窗口计算,允许在数据的滑动窗口上应用转换,下图说明了这个滑动窗口:
![](https://i-blog.csdnimg.cn/blog_migrate/9a8c7be8de3edcf4c0bd2d4af53a995f.png)
![](https://i-blog.csdnimg.cn/blog_migrate/263945726f6dbd36c080ddca7f4aad4d.png)
如图所示,每当窗口滑过源 DStream 时,落在窗口内的源 RDD 被组合并运行,以产生窗口 DStream 的 RDD。 在这种具体情况下,操作应用于最近 3 个时间单位的数据,并以 2 个时间单位滑动。 这表明任何窗口操作都需要指定两个参数。
窗口长度 - 窗口的持续时间(此图中窗口长度为 3)。
滑动间隔 - 执行窗口操作的间隔(此图中滑动间隔为 2)。
这两个参数必须是源 DStream 的 batch 间隔的倍数(此图中 batch 间隔为 1)。
batch 间隔为切割 RDD 的间隔,滑动间隔为每隔多长时间来计算一次,窗口长度为每次计算的数据量是多少。
2.4.4、Receiver 与 与 Direct
1. Receiver
Receiver 是使用 Kafka 的高层次 Consumer API 来实现的。Receiver 每隔一段batch 时间去 Kafka 获取那段时间最新的消息数据,Receiver 从 Kafka 获取的数据都是存储在 Spark Executor 的内存中的,然后 Spark Streaming 启动的 job 会去处理那些数据。
对于高阶消费者,谁来消费分区不是由 Spark Streaming 决定的,也不是 Storm决定的,有一个高阶消费者 API,由高阶消费者决定分区向消费者的分配,即由高阶消费者 API 决定消费者消费哪个分区,而消费者读取数据后什么时候提交 offset也不是由它们自己决定的,高阶消费者 API 会根据参数配置隔几秒提交一次。
这会引起一个问题,当 Spark Streaming 中的 Receiver 读取 Kafka 分区数据时,假设读取了 100 条数据,高阶消费者 API 会执行 offset 的提交,例如每隔 3 秒,这100 条数据就是 RDD,假设此 RDD 还没有处理完,高阶消费者 API 执行了 offset提交,但是 Spark Streaming 挂掉了,由于 RDD 在内存中,那么 RDD 的数据就丢失了,如果想重新拿数据,从哪里去拿不是由 Spark Streaming 说了算的,是由高阶API 决定的,由于 offset 已经提交,高阶 API 认为这个数据 Spark Streaming 已经拿过了,再拿要拿 100 条以后的数据,那么之前丢失的 100 条数据就永远丢失了。
针对这一问题,Spark Streaming 设计了一个规则,即 Spark Streaming 预写日志规则(Write Ahead Log,WAL),每读取一批数据,会写一个 WAL 文件,在 WAL文件中,读了多少条就写多少条,WAL 文件存储于 HDFS 上。假设 RDD 中有 100条数据,那么 WAL 文件中也有 100 条数据,此时如果 Spark Streaming 挂掉,那么回去读取 HDFS 上的 WAL 文件,把 WAL 文件中的 100 条数据取出再生成 RDD,然后再去消费。由于这一设计需要写 HDFS,会对整体性能造成影响。
假设有 6 个分区,高阶消费者的话会在 Spark 集群的 Worker 上启动 Receiver,有 6 个分区则会用 6 个线程去读取分区数据,这是在一个 Worker 的一个 Receiver中有 6 个线程同时读取 6 个分区的数据,随着数据量越来越大,数据读取会成为瓶颈,此时可以创建多个 Receiver 分散读取分区数据,然后每个 Receiver 创建一个Dstream,再把这些流全部都合并起来,然后进行计算。读取时,一方面把 RDD 放在内存中,一方面写 HDFS 中的 WAL 文件。
根据上面的情景,又要创建多个 Receiver,又要进行合并,又要在内存中存储 RDD,又要写 HDFS 上的 WAL 文件,高级 API 的缺点还是比较多的。高阶消费者是由高阶消费者 API 自己提交 offset 到 ZooKeeper 中。
2. Direct
低阶消费者需要自己维护 offset, Spark Streaming 从分区里读一部分数据,然后将 offset 保存到 CheckpointPath 目录中,比如 5s 生成一个 Spark Streaming job(每个 action 操作启动一次 job),每个 job 生成的时候,会写一次 CheckpointPath下的文件,Checkpoint 中有 job 信息和 offset 信息(当然还有 RDD 依赖关系等其他信息),即保存了未完成的 job 和分区读取的 offset,一旦 Spark Streaming 挂掉后重启,可以通过从 CheckpointPath 中的文件中反序列化来读取 Checkpoint 的数据。
假设有 5 个分区,第一次 Spark Streaming 读取 100 条数据,那么每个 partition都会读取 100 条数据,这 100 条数据对应 offset 是 0~99,这 5 个分区的 100 条数据直接对应 RDD 的 5 个分区,针对这一 RDD 会启动一个 job 进行处理,job 启动时会将 job 信息和 offset (0~99)写入 CheckpointPath,处理完成前保存 job 和 offset,一旦处理完成,job 信息会被删除,但是 offset 信息会被保留,通过这次的 offset 确定下一次的读取范围,即 100~199,新的 job 信息会被写入,新的 offset 100~199 覆盖原来的 0~99。如果处理第二批次的时候挂掉了,offset 还在,就可以重读这块数据。
2.5、Java
2.5.1 对象池
在学习 MySQL 时,我们接触到了数据库连接池技术,数据库连接池负责分配、管理和释放数据库连接,它允许应用程序重复使用一个现有的数据库连接,而不是再重新建立一个;释放空闲时间超过最大空闲时间的数据库连接来避免因为没有释放数据库连接而引起的数据库连接遗漏。这项技术能明显提高对数据库操作的性能。
在实际开发时,对象的创建和销毁操作也是非常消耗资源的,因此,我们考虑使用对象池技术。当我们需要创建对象时,向对象池申请一个对象,如果对象池里有空闲的可用节点,就会把节点返回给用户;当我们需要销毁对象时,将对象返回给对象池即可。
我们常用的数据库连接池是 C3P0 等数据库连接池,根据对象池的概念,我们发现对象池与数据库连接池有很大的相似之处,其实,很多数据库连接池就是借助对象池技术实现的,因此,我们可以通过对象池实现自己的数据库连接池。
代码清单 2-1 MySQL代理
/**
* MySQL 客户端代理对象
*
* @param jdbcUrl MySQL URL
* @param jdbcUser MySQL 用户
* @param jdbcPassword MySQL 密码
* @param client 默认客户端实现
*/
case class MySqlProxy(jdbcUrl: String, jdbcUser: String, jdbcPassword: String, client:
Option[Connection] = None) {
// 获取客户端连接对象
private val mysqlClient = client getOrElse {
DriverManager.getConnection(jdbcUrl, jdbcUser, jdbcPassword)
}
/**
* 执行增删改 SQL 语句
*
* @param sql
* @param params
* @return 影响的行数
*/
def executeUpdate(sql: String, params: Array[Any]): Int = {
var rtn = 0
var pstmt: PreparedStatement = null
try {
// 第一步:关闭自动提交
mysqlClient.setAutoCommit(false)
// 第二步:根据传入的 sql 语句创建 prepareStatement
pstmt = mysqlClient.prepareStatement(sql)
// 第三步:为 prepareStatement 中的每个参数填写数值
if (params != null && params.length > 0) {
for (i <- 0 until params.length) {
pstmt.setObject(i + 1, params(i))
}
}
// 第四步:执行增删改操作
rtn = pstmt.executeUpdate()
// 第五步:手动提交
mysqlClient.commit()
} catch {
case e: Exception => e.printStackTrace
}
rtn
}
/**
* 执行查询 SQL 语句
*
* @param sql
* @param params
*/
def executeQuery(sql: String, params: Array[Any], queryCallback: QueryCallback) {
var pstmt: PreparedStatement = null
var rs: ResultSet = null
try {
// 第一步:根据传入的 sql 语句创建 prepareStatement
pstmt = mysqlClient.prepareStatement(sql)
// 第二步:为 prepareStatement 中的每个参数填写数值
if (params != null && params.length > 0) {
for (i <- 0 until params.length) {
pstmt.setObject(i + 1, params(i))
}
}
// 第三步:执行查询操作
rs = pstmt.executeQuery()
// 第四步:处理查询后的结果
queryCallback.process(rs)
} catch {
case e: Exception => e.printStackTrace
}
}
/**
* 批量执行 SQL 语句
*
* @param sql
* @param paramsList
* @return 每条 SQL 语句影响的行数
*/
def executeBatch(sql: String, paramsList: Array[Array[Any]]): Array[Int] = {
var rtn: Array[Int] = null
var pstmt: PreparedStatement = null
try {
// 第一步:关闭自动提交
mysqlClient.setAutoCommit(false)
pstmt = mysqlClient.prepareStatement(sql)
// 第二步:为 prepareStatement 中的每个参数填写数值
if (paramsList != null && paramsList.length > 0) {
for (params <- paramsList) {
for (i <- 0 until params.length) {
pstmt.setObject(i + 1, params(i))
}
pstmt.addBatch()
}
}
// 第三步:执行批量的 SQL 语句
rtn = pstmt.executeBatch()
// 第四步:手动提交
mysqlClient.commit()
} catch {
case e: Exception => e.printStackTrace
}
rtn
}
// 关闭 MySQL 客户端
def shutdown(): Unit = mysqlClient.close()
}
如代码清单 2-1 所示,我们完成了 MySQL 代理类 MySqlProxy 的创建,每个MySqlProxy 对象都会完成一次与 MySQL 的连接并提供操作 MySQL 数据库的接口,那么如果我们将 MySqlProxy 对象创建的工作交给对象池,那么就可以实现重复利用与 MySQL 建立的连接,这与数据库连接池的功能是一样的。
在本项目中,我们使用了 Apache common-pool2 框架,Apache common-pool2包提供了一个通用的对象池技术的实现。可以很方便的基于它来实现自己的对象池,比如 DBCP 和 Jedis 他们的内部对象池的实现就是依赖于 common-pool2。
common-pool2 有四个核心:
1. 工作类:要通过对象池创建对象的类,例如 MySqlProxy 类;
2. 工厂类:生产工作类的工厂,工厂类是基于 BasePooledObjectFactory 的;
3. 配置类:对象池活跃对象个数、最大空闲数等信息都需要配置,基于GenericObjectPoolConfig;
4. 对象池:实际的对象池类,基于 GenericObjectPool,其对象的创建需要传入工厂类对象和配置类对象。
common-pool2 的对应关系如图 2-1 所示:
![](https://i-blog.csdnimg.cn/blog_migrate/abb3457d116ec7578e0877dd2ddcf187.png)
代码清单 2-2 对象池实现
/**
* 创建自定义工厂类,继承 BasePooledObjectFactory 工厂类,负责对象的创建、包装和销毁
* @param jdbcUrl
* @param jdbcUser
* @param jdbcPassword
* @param client
*/
class PooledMySqlClientFactory(jdbcUrl: String, jdbcUser: String, jdbcPassword: String,
client: Option[Connection] = None) extends BasePooledObjectFactory[MySqlProxy] with
Serializable {
// 用于池来创建对象
override def create(): MySqlProxy = MySqlProxy(jdbcUrl, jdbcUser, jdbcPassword, client)
// 用于池来包装对象
override def wrap(obj: MySqlProxy): PooledObject[MySqlProxy] = new
DefaultPooledObject(obj)
// 用于池来销毁对象
override def destroyObject(p: PooledObject[MySqlProxy]): Unit = {
p.getObject.shutdown()
super.destroyObject(p)
}
}
/**
* 创建 MySQL 池工具类
*/
object CreateMySqlPool {
// 加载 JDBC 驱动,只需要一次
Class.forName("com.mysql.jdbc.Driver")
// 在 org.apache.commons.pool2.impl 中预设了三个可以直接使用的对象池:GenericObjectPool、
GenericKeyedObjectPool 和 SoftReferenceObjectPool
// 创建 genericObjectPool 为 GenericObjectPool
// GenericObjectPool 的特点是可以设置对象池中的对象特征,包括 LIFO 方式、最大空闲数、最小空闲数、是
否有效性检查等等
private var genericObjectPool: GenericObjectPool[MySqlProxy] = null
// 伴生对象通过 apply 完成对象的创建
def apply(): GenericObjectPool[MySqlProxy] = {
// 单例模式
if (this.genericObjectPool == null) {
this.synchronized {
// 获取 MySQL 配置参数
val jdbcUrl = ConfigurationManager.config.getString(Constants.JDBC_URL)
val jdbcUser = ConfigurationManager.config.getString(Constants.JDBC_USER)
val jdbcPassword = ConfigurationManager.config.getString(Constants.JDBC_PASSWORD)
val size = ConfigurationManager.config.getInt(Constants.JDBC_DATASOURCE_SIZE)
val pooledFactory = new PooledMySqlClientFactory(jdbcUrl, jdbcUser, jdbcPassword)
val poolConfig = {
// 创建标准对象池配置类的实例
val c = new GenericObjectPoolConfig
// 设置配置对象参数
// 设置最大对象数
c.setMaxTotal(size)
// 设置最大空闲对象数
c.setMaxIdle(size)
c
}
// 对象池的创建需要工厂类和配置类
// 返回一个 GenericObjectPool 对象池
this.genericObjectPool = new GenericObjectPool[MySqlProxy](pooledFactory,
poolConfig)
}
}
genericObjectPool
}
}
GenericObjectPool 的核心方法如下:
borrowObject():从对象池中取出一个对象。
returnObject():将使用完成的对象还给对象池。
在每次使用CreateMySqlPool时 , 通过borrowObject()提取对象 ,通过returnObject()归还对象。