首页 > 数据库 > 数据库开发技术 > Spark 要引入外部数据源,咋整?——(中)
上篇文章演示了在 Spark 开发时,怎么样用 Redis「连接单例」模式引入「外部数据源」的解决方案。
「连接单例」最大的优点,可以保护外部数据源,也就是 Redis 的连接,不至于被一些蹩脚的代码给干爆掉,从而影响 Redis 的正常对外服务。
在上篇文末,我出了个两个思考题,其中第一个是问题是:如果要把上次那个只能在「单线程」方式下运行的 Spark 程序,提交到集群,该怎么设置提交参数?
看评论区已经有小伙伴给出了答案,那就是:可以设置多个 executor,但是每个 executor 只给分配一个 CPU 核心。
比如提交到 yarn 集群,可以这么来设置:
--executor-cores 1 --num-executors 3
这样就可以既保证这个 Spark 任务是分布式运行的,但同时,又保护了「连接单例」的可用性,因为它确保了「程序每个 JVM 内部,是用单线程工作,从而避免了对连接对象的争抢」。
但是,上文也说了,这种方法虽然能解决问题,但它却并不十分体面。
那么今天这篇文章,就来回答上篇文章留下的第二个问题,用一种更体面的方式,来实现对这个 Redis 外部数据源的引入——广播变量。
0. 解读广播变量
估计很多玩过 Spark 的同学都知道什么是「广播变量」,以及上上篇文章聊到的 「累加器」,但是能够深入理解,并能把它们灵活玩转的人,一定不多。
先说它们的相同点:
1. 都是从 Driver 端,给序列化到 executor 端的对象;
2. 因为第1条的缘故,所以都不适合承载很大的数据量;
3. 因为需要实现网络传输,所以两者必须都可以被序列化,之前听讨论群里有同学给其他人提建议,说可以把数据库连接作为广播变量,给传到 executor 端,我心想「这哥们一定只是口嗨,因为数据库连接,它不能被序列化,广播个嘚」。
再说它们的不同点:
1. 「累加器」是线程安全的,除了可读,还可以对它进行累加操作,并且保证「线程/进程间即时可见」,考虑的因素比较多。
而「广播变量」仅仅只是保证所有 executor 可读(一次性读取,不能更新),没那么多花里胡哨的功能,技术含量更低一些;
2. 「累加器」在使用的时候,我们不需要额外考虑往里扔的对象,能不能被序列化的问题,因为但凡你是个正常人,最多只会往里面扔数据,而数据本身,绝大部分是可以被序列化的。
但是在用「广播变量」的时候,你就得注意了,不是想当然,以为万物皆可广播,得具备可以被序列化的潜质,比如上面提到的数据库连接,它就不行。
1. 为什么用广播变量
Redis 中的黑名单数据一般来说,不会太大,正常来说,维护个几百万,甚至上千万的数据量都没什么问题(我就这么干过),跟广播变量不宜使用过大数据集的要求相符。
而且广播变量数据,满足「一次性获得,多次读取」的特性,跟我们的业务中,要对数据进行“碰撞”的场景吻合。
对于 Redis 来说,可以只需要用到「一次数据库连接」,避免了对 Redis 的连接管理问题。
2. 代码实现
package com.anryg.bigdata.test.variable
import java.util
import com.anryg.bigdata.RedisClientUtils
import org.apache.log4j.{Level, Logger}
import org.apache.spark.SparkConf
import org.apache.spark.sql.SparkSession
/**
* @DESC: 测试 Spark 用广播变量读取外部数据源
* @Auther: Anryg
* @Date: 2024/7/4 17:36
*/
object SparkReadExternalDataTest02 {
/**
* @DESC: 从Redis读取黑名单数据
* */
private def getBlackDomain(): util.Set[String] ={
val blackDomain = RedisClientUtils.getSetResult(RedisClientUtils.getSingleRedisClient, /**用Jedis连接单例来获取黑名单数据*/
5, /**Redis的db编号*/
"black_domain" /**对应黑名单的key*/
)
blackDomain
}
def main(args: Array[String]): Unit = {
Logger.getLogger("org.apache.spark").setLevel(Level.WARN)
val sparkConf = new SparkConf().setAppName("SparkReadExternalDataTest01")
.setMaster("local[3]")
val spark = SparkSession.builder().config(sparkConf).getOrCreate()
val broadcast = spark.sparkContext.broadcast(getBlackDomain()) /**将获取的黑名单数据广播出去*/
/**构造数据源(主数据)*/
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 对象*/
.foreachPartition(iter => {
val blackDomain = broadcast.value /**从广播变量中获取黑名单数据*/
while (iter.hasNext){
val domain = iter.next()
if (!blackDomain.contains(domain)) println(domain) /**碰撞后,输出合法域名*/
}
})
}
}
怎么样,对比上一篇文章的代码,是不是感觉没什么变化?
为了方便你理解,还是把变化的部分摘出来:
第1个不同:广播获取到的黑名单数据

第2个不同:从广播中获取黑名单数据

然后就没有了,就是这么一点点简单的变化,就能解决上篇文章解决不了的问题。
这个时候,无论你是用「本地多线程」,还是集群模式下的「多进程 + 多线程」,都能跑的畅快自如。
3. 运行结果
先看本地 3 个线程并行的运行结果:

本地设置线程数量

本地3个线程运行结果
从结果输出来看,黑名单碰撞的结果,是符合预期的(2个黑名单给碰掉了)。
再来看集群运行的情况:

Spark on yarn 的并行度设置

第1个executor的输出结果

第2个executor的输出结果

第3个executor的输出结果
同样,从输出结果来看,符合预期。
细心的同学可能留意到了这么一个问题,前文不是提到广播变量的对象,必须是可以被序列化的吗?那这个例子中,你怎么保证广播的对象满足这个条件呢?
来,看这里

它是一个 util.Set 类型,而这个类型,是可以被序列化的(不信你去查)。
最后
对于用 Spark 引入 Redis 外部数据源,进行黑名单碰撞这个场景来说,我们从之前差强人意的「Redis 连接单例」方式,优化成了现在更加优雅的「广播变量」方式,虽然改动不多,但优化效果是明显的。
当然,可能还有同学会有疑问,除了这个广播变量的方式外,还有没有其他同样靠谱的方式呢?比如,如果这个外部数据源非常大,我又不想用广播变量来传输。
答案是肯定的。
上篇文章的评论区有人提到了线程池,但我想告诉你的是,单纯的线程池,只能解决单个进程内部,线程「复用」的问题,它解决不了多个进程之间,线程「滥用」的问题。
那么下篇文章,将继续跟你聊聊,在不用「广播变量」的情况下,Spark 的 executor 内部,将如何优雅获取黑名单数据。
下期见~

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