ITPub博客

首页 > 数据库 > 数据库开发技术 > 大数据项目中,当单例遇到多线程,咋整?

大数据项目中,当单例遇到多线程,咋整?

原创 数据库开发技术 作者:安瑞哥是码农 时间:2023-04-14 20:54:02 0 删除 编辑


上篇文章讲到,在我的大数据实战项目中,我们把IP关联的地理位置信息给存储到了Redis的SkipList中。


那么这篇文章,咱再来讲讲该如何通过分布式计算引擎,去使用存储在其中的数据?


因为这个数据是存储在Redis数据库中的,所以我们只能在计算引擎内部,通过跟数据库先建立连接,然后再通过Redis的数据读取API,来查询到我们的目标数据。


方法论很简单,但是,这里面比较难处理的,就是这个数据库的连接问题,对于一个大数据应用来说,如果你的分布式计算引擎在使用过程中,需要跟查询第三方数据库不断地有读写交互,那么如果这个连接管理没有做好的话,会直接导致你的分布式计算引擎在数据处理过程中效率缓慢,或者因为连接数太多而导致数据库崩溃。



0. 应用场景


来看这个实战项目的一个具体应用场景,数据处理流程如下图所示:



这里为了简化,我通过structured streaming读取kafka消息队列数据,对数据做一个简单的处理后,然后通过每一条数据的IP地址信息,直接关联Redis中IP的地理位置数据,最后直接通过控制台输出(当然,你可以为了实际需要把结果写其他数据库)。


对于这个简单的场景,代码怎么来写呢?



1. 优化前的代码


你现在能看到在GitHub上的关于Redis的工具类代码,主体部分是这么写的:

public class RedisClientUtils implements RedisParam {
    private static volatile JedisPool jedisPool = null;/**用连接池进行管理*/
    private static volatile Jedis jedis = null; /**使用单例客户端*/

    /**
     * @DESC: 初始化连接池
    * */

    private static void initPool(){
        JedisPoolConfig config = null;
        try {
            config = new JedisPoolConfig();
            config.setMaxTotal(MAX_ACTIVE);
            config.setMaxIdle(MAX_IDLE);
            config.setMaxWaitMillis(MAX_WAIT);
            config.setTestOnBorrow(TEST_ON_BORROW);//使用时进行扫描,确保都可用
            config.setTestWhileIdle(true);//Idle时进行连接扫描
            config.setTestOnReturn(true);//还回线程池时进行扫描
        } catch (Exception e) {
            throw e;
        }
        jedisPool = new JedisPool(config, HOSTS.split(",")[0], PORT, TIMEOUT, PASSWD);
    }
    /**
     *@DESC: 多线程环境下确保只初始化一个连接池
     */

    private static void poolInit() {
        if (jedisPool == null){
            synchronized (RedisClientUtils.class){
                if (jedisPool == null) initPool();
            }
        }
    }

    /**
     * @DESC: 获取连接池对象,适用多线程时,利用其获取多个jedis客户端
     * */

    public static  JedisPool getJedisPool(){
        poolInit();
        return jedisPool;
    }
    /**
     * @DESC: 同步获取Jedis实例,适合单线程
     * @return Jedis
     */

    public static Jedis getSingleRedisClient() {
        poolInit();
        if (jedis == null){
            synchronized (RedisClientUtils.class){
                if (jedis == null) {
                    jedis = jedisPool.getResource();
                }
            }
        }
        return jedis;
    }
    /**
     * @DESC: 释放jedis资源,将资源放回连接池
     * @param jedis
     */

    public static void returnResource(final Jedis jedis) {
        if (jedis != null && jedisPool != null) jedis.close();
    }

}


可以看到,这里用到了Redis的连接池(一个就够了,所以是单例模式),这个好处在于能够提前初始化连接,加速获取连接的效率。


但是,你同时也可以看到,这里的Redis连接,也就是Jedis,同样用了单例模式,当初考虑这么做的目的在于,对于一个分布式任务而言,为了确保一个JVM进程内部,避免因为多次调用getRedisClient方法而产生多个Redis连接,一次考虑只用一个Jedis对象,也就是一个单例的连接。


这样做最大的好处在于,避免开发人员在编码时,因对getRedisClient这个方法使用不当,而让Redis产生过多的连接,从而导致Redis不可用。


当这种方式运行在多个executor,且每个executor只分配一个CPU核心时,是没有问题的,因为这意味着运行多个JVM,但是每个JVM是单线程模式。


但是,如果你让这种连接管理方式运行在单JVM,多线程的情况下,问题就出现了,原因很简单。


因为多个线程,就意味着需要多个Redis连接同时进行,但是现在这种方式限制了在一个JVM内部,只能有一个连接实例。


我们来证明一下,代码如下:


/**
  * @DESC: 读取kafka数据,并通过Jedis单例模式
  * @Auther: Anryg
  * @Date: 2022/9/1 09:53
  */

object StreamingWithThreadLocal {

    def main(args: Array[String]): Unit = {
        val conf = new SparkConf()
                .setAppName("StreamingWithThreadLocal")
                .setMaster("local[*]")//本地运行模式,如果提交集群,注释掉这行
        val spark = SparkSession.builder().config(conf).getOrCreate()

        val rawDF = spark.readStream
                .format("kafka") //确定数据源的来源格式
                .option("kafka.bootstrap.servers", "192.168.211.107:6667") //指定kafka集群的地址,理论上写一个broker就可以了
                .option("subscribe","test")  //指定topic
                //.option("group.id","test9999") /**不再用该方式来绑定offset,而是每个程序有个唯一的id,该id跟checkpointLocation绑定,虽然group.id属性在运行中依然保留,但是不再跟offset绑定*/
                .option("failOnDataLoss",false)  //如果读取数据源时,发现数据突然缺失,比如被删,则是否马上抛出异常
                .option("fetchOffset.numRetries",3)  //获取消息的偏移量时,最多进行的重试次数
                .option("maxOffsetsPerTrigger",500)/**用于限流,限定每次读取数据的最大条数,不指定则是as fast as possible*/
                .option("startingOffsets","earliest")  //第一次消费时,读取kafka数据的位置
                .load()

        import spark.implicits._
        val ds = rawDF.selectExpr("CAST(value AS STRING)")  //将kafka中的数据的value转为为string,原始为binary类型
                .map(row => {
            val line = row.getAs[String]("value") //获取row对象中的field,其实也只有一个field
            val rawJson = JSON.parseObject(line)      //原始string是一个json,对其进行解析
            val message = rawJson.getString("message")  //获取业务数据部分
            val msgArray = message.split(",")  //指定分隔符进行字段切分
            msgArray
        }).filter(_.length == 9)  //只留字段数为9的数据
                .filter(array => array(2).length >= 8)//确保日期字段符合规范
                .map(array => {
                    val ipAndAddr = IpSearch.getAddrByIP(RedisClientUtils.getSingleRedisClient,array(0)) //使用普通单例方式管理Jedis的模式
                    //val ipAndAddr = IpSearch.getAddrByIP(RedisClientUtilsV2.getRedisThreadLocal.get(),array(0)) //使用ThreadLocal方式管理Jedis的模式
                    (array(0)+array(1)+array(2),array(0),array(1),ipAndAddr)
    }) //将其转化成为元组,为了方便下一步赋予schema
                .toDF("id","client_ip","domain","ip_addr") //给裸数据添加字段名

        ds.printSchema() //打印schema,确认没有问题

        val query = ds.writeStream
                .outputMode(OutputMode.Append())  //指定数据的写入方式
                .format("console")
                .trigger(Trigger.ProcessingTime(10,TimeUnit.SECONDS))/**每60秒执行一次,不指定就是as fast as possible*/
                .option("checkpointLocation","hdfs://192.168.211.106:8020/tmp/offset/test/StreamingWithThreadLocal") /**用来保存offset,用该目录来绑定对应的offset,如果该目录发生改变则程序运行的id会发生变化,类比group.id的变化,写hive的时候一定不要轻易变动*/
                .start()

        query.awaitTermination()
    }

}


简单起见,我直接在本地用多线程的方式演示,很快它就报错了(事实上,如果你在集群运行,如果让每个executor也跑多个线程,报错是一样的,这部分等你来验证):


这个报错的信息非常的扑朔迷离,很难让你一下子知道根本原因在哪,但是好在,咱是知道的。



2. 优化后的代码


既然上面这种连接的单例管理模式不行,无法满足在单JVM多线程的情况下运行,那咱是不是得想个两全的办法来解决这个问题呢?


于是,我想到了Threadlocal,这个玩意的使用场景之一,就是用来解决单例模式下连接在多个线程之间的传递问题,而且可以保证单个线程内部,每次获取的连接都是同一个,且不同线程之间的连接属于不同的copy。


于是,Threadlocal的这个功能,正好可以用来解决当下我们遇到的这个问题,在此之前,我查阅了很多相关的资料,很多文章虽然也都说可以用这种方式来解决,但是几乎没有一篇文章能够给出明确的解决方案的(可运行的代码),大家都说的一样,就是不给你落地。


所以,任何技术,不要光听别人说,一定要自己去实践,去验证。


来,优化后的Redis连接管理工具类代码如下:


/**
 * @DESC: 通过ThreadLocal来存储Redis的连接对象,满足多线程情况下的多连接需求
 * Created by Anryg on 2018/5/9.
 */

public class RedisClientUtilsV2 implements RedisParam {
    private static volatile JedisPool jedisPool = null;/**用连接池进行管理*/

    private static  Jedis jedis = null;
    private static final ThreadLocal<Jedis> threadLocal = new ThreadLocal<>();


    /**
     * @DESC: 初始化连接池
    * */

    private static void initPool(){
        JedisPoolConfig config = null;
        try {
            config = new JedisPoolConfig();
            config.setMaxTotal(MAX_ACTIVE);
            config.setMaxIdle(MAX_IDLE);
            config.setMaxWaitMillis(MAX_WAIT);
            config.setTestOnBorrow(TEST_ON_BORROW);//使用时进行扫描,确保都可用
            config.setTestWhileIdle(true);//Idle时进行连接扫描
            config.setTestOnReturn(true);//还回线程池时进行扫描
        } catch (Exception e) {
            throw e;
        }
        jedisPool = new JedisPool(config, HOSTS.split(",")[0], PORT, TIMEOUT, PASSWD);
    }
    /**
     *@DESC: 多线程环境下确保只初始化一个连接池
     */

    private static void poolInit() {
        if (jedisPool == null){
            synchronized (RedisClientUtilsV2.class){
                if (jedisPool == null) initPool();
            }
        }
    }

    /**
     * @DESC: 获取连接池对象,适用多线程时,利用其获取多个jedis客户端
     * */

    public static  JedisPool getJedisPool(){
        poolInit();
        return jedisPool;
    }
    /**
     * @DESC: 同步获取Jedisd ThreadLocal实例
     * @return ThreadLocal<Jedis>
     */

    public static ThreadLocal<Jedis> getRedisThreadLocal() {
        poolInit();
        if (threadLocal.get() == null){
            synchronized (RedisClientUtilsV2.class){
                if (threadLocal.get() == null){
                    jedis = jedisPool.getResource();
                    threadLocal.set(jedis);
                }
            }
        }
        return threadLocal;
    }
    /**
     * @DESC: 释放jedis资源,将资源放回连接池
     * @param jedis
     */

    public static void returnResource(final Jedis jedis) {
        if (jedis != null && jedisPool != null) jedis.close();
    }

}

可以看到,核心的改动就是获取Jedis这个动作,由之前的单例模式,改成了获取包含Jedis的Threadlocal对象模式。


虽然在代码上看,好像也只用了一个Jedis,但是当你在不同线程中从Threadlocal这个变量中去取Jedis对象的时候,就变成了多个不同的Jedis连接对象。


来,看下新的spark代码,如下:


/**
  * @DESC: 读取kafka数据,并通过ThreadLocal获取Jedis模式
  * @Auther: Anryg
  * @Date: 2022/9/1 09:53
  */

object StreamingWithThreadLocal {

    def main(args: Array[String]): Unit = {
        val conf = new SparkConf()
                .setAppName("StreamingWithThreadLocal")
                .setMaster("local[*]")//本地运行模式,如果提交集群,注释掉这行
        val spark = SparkSession.builder().config(conf).getOrCreate()

        val rawDF = spark.readStream
                .format("kafka") //确定数据源的来源格式
                .option("kafka.bootstrap.servers", "192.168.211.107:6667") //指定kafka集群的地址,理论上写一个broker就可以了
                .option("subscribe","test")  //指定topic
                //.option("group.id","test9999") /**不再用该方式来绑定offset,而是每个程序有个唯一的id,该id跟checkpointLocation绑定,虽然group.id属性在运行中依然保留,但是不再跟offset绑定*/
                .option("failOnDataLoss",false)  //如果读取数据源时,发现数据突然缺失,比如被删,则是否马上抛出异常
                .option("fetchOffset.numRetries",3)  //获取消息的偏移量时,最多进行的重试次数
                .option("maxOffsetsPerTrigger",500)/**用于限流,限定每次读取数据的最大条数,不指定则是as fast as possible*/
                .option("startingOffsets","earliest")  //第一次消费时,读取kafka数据的位置
                .load()

        import spark.implicits._
        val ds = rawDF.selectExpr("CAST(value AS STRING)")  //将kafka中的数据的value转为为string,原始为binary类型
                .map(row => {
            val line = row.getAs[String]("value") //获取row对象中的field,其实也只有一个field
            val rawJson = JSON.parseObject(line)      //原始string是一个json,对其进行解析
            val message = rawJson.getString("message")  //获取业务数据部分
            val msgArray = message.split(",")  //指定分隔符进行字段切分
            msgArray
        }).filter(_.length == 9)  //只留字段数为9的数据
                .filter(array => array(2).length >= 8)//确保日期字段符合规范
                .map(array => {
                    //val ipAndAddr = IpSearch.getAddrByIP(RedisClientUtils.getSingleRedisClient,array(0)) //使用普通单例方式管理Jedis的模式
                    val ipAndAddr = IpSearch.getAddrByIP(RedisClientUtilsV2.getRedisThreadLocal.get(),array(0)) //使用ThreadLocal方式管理Jedis的模式
                    (array(0)+array(1)+array(2),array(0),array(1),ipAndAddr)
    }) //将其转化成为元组,为了方便下一步赋予schema
                .toDF("id","client_ip","domain","ip_addr") //给裸数据添加字段名

        ds.printSchema() //打印schema,确认没有问题

        val query = ds.writeStream
                .outputMode(OutputMode.Append())  //指定数据的写入方式
                .format("console")
                .trigger(Trigger.ProcessingTime(10,TimeUnit.SECONDS))/**每60秒执行一次,不指定就是as fast as possible*/
                .option("checkpointLocation","hdfs://192.168.211.106:8020/tmp/offset/test/StreamingWithThreadLocal") /**用来保存offset,用该目录来绑定对应的offset,如果该目录发生改变则程序运行的id会发生变化,类比group.id的变化,写hive的时候一定不要轻易变动*/
                .start()

        query.awaitTermination()
    }

}

运行之后,再也没有报错,正常打印的结果如图所示:


然后,我们再来验证一下Redis的连接数量,看前后有什么变化,先看下这个spark任务运行之前的Redis连接情况:


可以看到,在这个Spark任务启动前,其客户端连接数量为7。


而当该spark任务启动正常运行之后,其连接数量如下图所示:


而且,一直会维持15这个数量不变,说明这个任务会长期保持8个连接,而这8个连接,刚好跟我启动的线程数量保持一致,因为我在代码中用的是local[*],这个*指的是当前电脑的总核心数量,而我用的这台电脑,刚好CPU的核心数量就是8。



3. 最后


不知道做这个项目的小伙伴们,在你们的代码验证过程中有没有发现这个问题呢?这个问题,其实就是涉及到了很多Java的基础。


比如像Java的单列模式如何书写,Threadlocal在连接管理时,该如何使用等等。


所以,以后不要再问学大数据,要不要学Java了,早就说过这些就是基础,你只有把基础打牢了,才会在程序员这条路上走的更远。


最后,如果你把这篇文章的内容吃透,完全可以把它作为程序优化的亮点写在你的简历中,作为你的项目贡献,而且,一定是一个加分项。




你可以添加我的私人微信,拉你入技术讨论群,跟一群热爱技术的小伙伴一起成长...


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

请登录后发表评论 登录
全部评论

注册时间:2022-02-16

  • 博文量
    228
  • 访问量
    115452