本文是继()的下篇,为了不让你对标题产生审美疲劳,我特意换了个名字。
熟悉「ThreadLocal」的同学应该都知道,这个知识点在纯 Java 界,已经被各种文章讲烂了,但是,这玩意跟 Spark 的结合,却鲜有文章提及。
那今天这篇内容呢,就结合我的实践经历,来跟你聊聊,Spark 怎么样用它,来优雅地解决外部数据源 Redis(其他数据库也一样) 的引入问题。
0. 为什么不能放开连接
前面文章提到,为了降低 Redis 的连接管理成本,于是我们考虑使用「连接单例」,但是如果直接只使用这个连接单例,就会导致在「多线程」情况下,对单个连接的争抢问题(。
这个时候,有同学可能觉得,那我根据线程需要来 new 不就好了吗,比如像下面这种:
.foreachPartition(iter => {
val connection = new RedisConnection()
while (iter.hasNext){
connection...
//todo
}
}
这样写,确实可以解决问题。
但是,如果在团队中,万一有些水平比较蹩脚的同学,手一哆嗦,就给写成了下面这样:
.foreach(data => {
val connection = new RedisConnection()
connection...
//todo
})
就问你慌不慌?
根据我的经验,但凡像这种需要你很小心去注意的代码逻辑,一定要把它封装成「工具类」,供团队小伙伴统一使用,否则,你把这些地方的自主权交给个人,指不定会冒出什么样的牛鬼蛇神,分分钟把你的数据库给干崩掉。
1. ThreadLocal 能解决什么
既然连接单例有缺点,而完全放开连接管理给开发的同学,又有失控的风险(就算用线程池,也一样有这个风险)。
那有没有一种,既能控制数据库的连接个数,又可以完全满足多线程情况下,对连接数量的动态需求呢?
有!连接单例 + ThreadLocal 组合。
关于 ThreadLocal 的概念,网上的资料已经烂大街了,这里不赘述,我只想告诉你,它在当前场景里解决问题是:我只需要在一个进程中 new 一个连接对象,然后通过 ThreadLocal 给「按需」复制到各个线程里面,让每个线程都可以拥有属于自己的那份连接。
这里最妙的地方,就在于「按需」这两个字,在代码实现上,你虽然只 new 了一个连接,但如果你在启动任务时,想让每个 executor 并行多跑几个 partition 的数据,这个时候,它就可以根据每个 executor 并行的 partition 数量,自动来创建等量的数据库连接对象。
来,看一眼 Redis 连接单例,跟 ThreadLocal 组合的核心代码:

定义全局的连接对象单例

获取装有 Redis 连接对象的 ThreadLocal 对象
2. 正式代码
Spark 的主体代码如下:
package com.anryg.bigdata.test.variable
import com.anryg.bigdata.RedisClientUtilsV2
import org.apache.spark.SparkConf
import org.apache.spark.sql.SparkSession
/**
* @DESC: 测试Spark利用Redis的连接单例 + ThreadLocal来引入外部数据源
* @Auther: Anryg
* @Date: 2024/7/9 17:45
*/
object SparkThreadLocalTest03 {
def main(args: Array[String]): Unit = {
val sparkConf = new SparkConf().setAppName("SparkThreadLocalTest03")
val spark = SparkSession.builder().config(sparkConf).getOrCreate()
/**构造数据源*/
val seq = Seq("baidu.com",
"qq.com",
"xiaomi.com",
"12345.com",
"abc.com",
"def.net",
"xxx.com",
"google.com",
"sina.com",
"163.com",
"yyy.net",
"360.com",
"cctv.cn",
"12306.com")
spark.sparkContext.makeRDD(seq) /**生成 RDD 对象*/
.foreach(domain => {
/**通过ThreadLocal获取Redis连接,并拿到黑名单数据*/
val blackDomainSet = RedisClientUtilsV2.getSetResult(RedisClientUtilsV2.getRedisThreadLocal.get(),
5,
"black_domain")
if (!blackDomainSet.contains(domain)) {
Thread.sleep(5000) /**为了方便观察Redis的连接情况,sleep几秒*/
println(domain)
}
})
}
}
通过现在的这种写法,你就既不用担心 Spark 在多线程运行时,连接对象争抢的问题,也不用担心连接对象滥用的问题。
注意看这里:

有的同学看到,代码中对 Redis 连接对象的获取逻辑,是被内置在「foreach」算子中的,可能会想:我C,那这岂不是会每一条数据都要 new 一个连接对象?这连接对象不得爆了呀?
有这个怀疑的,只能说明你对「单例」的理解还不够透彻,在当前代码里,Redis 用的是连接单例,所以这里不管你用的是「foreach」还是「foreachPartition」,因为在「同一个进程内部」,所以效果都是一样的,因为它们用的都是同一个连接。
而「ThreadLocal」在这里起的作用,就是把连接对象,根据线程的个数,自动进行复制,有几个线程,就复制几份,不用担心线程间,连接争抢的问题。
3. 看运行效果
在看效果前,我们先来瞅一眼 Redis 在程序运行前的基础连接情况。

当前的基础连接个数为 6 (有其他任务长期占有了6个连接)。
3.1 线程数设置为1时

此时,Redis 的总连接数增加1个,变为 7。
3.2 线程数设置为2时

此时,Redis 的总连接数增加2个,变为 8。
3.3 线程数设置为3时

此时,Redis 的总连接数增加3个,变为 9。
3.4 线程数设置为4时

此时,Redis 的总连接数增加 4 个,变为 10。
3.5 线程数设置为5时

此时,Redis 的总连接数增加 5 个,变为 11。
3.6 线程数设置为6时

咦?怎么不是 12 个呢?开动你的脑瓜子想一想。
因为根据分区规则,第 6 个分区是空的,木有数据,所以也就不创建新的连接了。
从上面的测试结果来看,ThreadLocal 的确可以做到,随着线程数量的增加,连接数量按需增长(之前直播也演示过)。
当然,以上所有测试,黑名单的碰撞结果都没有问题,因为不是这次测试的重点,就不贴出来了。
最后
相比之前纯粹的「连接单例」模式,当前这种「连接单例 + ThreadLocal」的模式,解决了「连接争抢」的问题。
而又相比之前的「广播变量」模式,省去了从 driver 端,到 executor 端的数据传输问题。
那对于当前的这种,在 Spark 中引入外部数据源的方式,你中意否?

原文作者:安瑞哥是码农;如有侵权,请联系管理员删除。