记一次使用redis当消息队列,实现异步任务处理工具类

3,202 阅读7分钟

需求场景:

系统需要实现一个生成大量pdf文件,并将这些pdf文件压缩成压缩包的功能,由于生成大量pdf文件和压缩pdf文件是一个非常耗时的过程,于是决定采异步处理的方式来处理,并且将这个异步处理写成工具类的形式方便以后复用。

谈到实现异步的方式,很容易让人联想到消息队列,它通常被运用于如下三个场景:

  • 应用耦合:多应用间通过消息队列对同一消息进行处理,避免调用接口失败导致整个过程失败;

  • 异步处理:多应用对消息队列中同一消息进行处理,应用间并发处理消息,相比串行处理,减少处理时间;

  • 限流削峰:广泛应用于秒杀或抢购活动中,避免流量过大导致应用系统挂掉的情况;

而且现在能做消息队列的产品也非常多,RocketMQ、Kafka、RabbitMQ、ActiveMQ等等,其中kafka和RocketMQ是目前用的比较多,和性能比较出色的消息队列。

本来最初的想法也是想使用消息队列去实现这个工具类,但是,被告知想要给系统申请一个RocketMQ消息对列,需要走架构评审,提交各种说明,还要各种解释。。。没办法,只好作罢,于是将目光投向redis,因为系统之前已经申请了redis,虽然redis做消息队列不是最好的,但是也是一种可行的方法。

redis实现消息队列:

redis有一种数据类型是list,这是一个天生就适合做队列的数据类型,我们只需要合理的运用redis提供给我们对list数据类型的操作命令即可实现一个简单的消息队列。

  • lpush: 从list的左边插入一条数据。

  • rpop/brpop: 从list的右边弹出数据

没错,是不是有内味了,数据从左边进,从右边出,先进先出,这不就是队列最基本的功能么,当然你想从右边进(rpush),左边出(lpop/bpop)也是可以的。

rpop和bpop的区别: 这两条命令的区别是rpop在弹出数据的时候是非阻塞的,当list中没有数据的时候,它会立即返回,而bpop是阻塞的,在list中没有数据的时候,它会阻塞住当前线程,这个特性是非常有用的,待会在下文再提。

进队列:

 public long setQueue(String key,String... values){
        return jedisCluster.lpush(key,values);
    }

出队列:

public List<String> getQueueBlock(String... key){
        return jedisCluster.brpop(0,key);
    }

在使用brpop出队的时候,这个命令接受的是一个key的可变参数列表,它会遍历这个列表,对每个可以都顺序取一遍值,对于每个key返回的是一个list集合,集合的第一个值时key值,第二个值时value值,每次返回一条数据,当所有key都没有数据返回的时候阻塞线程。

定义消息数据格式: 敲定消息队列之后,接下来就要把消息队列中要存储的消息数据格式给确定下来,因为我们这个功能是要做成工具类来使用的,所以我们的消息数据格式要通用,方便以后接入各种类型的任务。

xxxTaskMessage

public class xxxTaskMessage{
    /**
     * 任务实体类
     */
    private xxxTaskMessage xxxTaskMessage;
    /**
     * 处理任务的方法名称
     */
    private String xxxMethodName;
    /**
     * 处理任务方法的实例名称
     */
    private String xxxInstanceName;
    .....
    }

线程池工具类:

public class AsynTaskUtil{

    @Autowired
    private JedisClientCluster jedisClientCluster;
  
    /**
     * 线程池最小线程数量
     */
    private static final int MIN_POOL_SIZE = 2;
    /**
     * 线程池最大线程数量
     */
    private static final int MAX_POOL_SIZE = 10;
    /**
     * 线程池阻塞队列大小
     */
    private static final int BLOCK_POOL_SIZE = 10;
    /**
     * 线程存活时间(秒)
     */
    private static final int KEEP_ALIVE_TIME = 300;
    /**
     * 线程池
     */
    private static ThreadPoolTaskExecutor pool;

    static {
        pool = new ThreadPoolTaskExecutor();
        pool.setWaitForTasksToCompleteOnShutdown(true);
        //设置核心线程数
        pool.setCorePoolSize(MIN_POOL_SIZE);
        //设置最大线程数
        pool.setMaxPoolSize(MAX_POOL_SIZE);
        //设置阻塞队列大小
        pool.setQueueCapacity(BLOCK_POOL_SIZE);
        //设置空闲线程存活时间
        pool.setKeepAliveSeconds(KEEP_ALIVE_TIME);
        pool.initialize();
    }


    /**
     * 将任务线程提交到线程池中
     */
    public void executeAutoUnderwriting(Method xxxMethod,Object xxxInstance,xxxTaskDomain xxxTaskDomain){
        Runnable task = new xxxTask(xxxMethod,xxxInstance,xxxTaskDomain);
        // 加入队列运行可能会出现异常,通过这种模式来模仿阻塞队列
        while (true) {
            try {
                pool.execute(task);
                break;
            } catch (Exception e) {
                try {
                    sleep(1000);
                } catch (Exception ex) {
                }
            }
        }
    }

    /**
     * 打印任务线程
     */
    public class xxxTask implements Runnable{
        /**
         * 任务处理方法
         */
       private Method xxxMethod;
        /**
         * 处理任务的方法实例
         */
       private Object xxxInstance;
        /**
         * 任务实体类
         */
       xxxTaskDomain xxxTaskDomain;

       public xxxTask(Method xxxMethod,Object xxxInstance,xxxTaskDomain xxxDomain){
           this.xxxMethod=xxxMethod;
           this.xxxInstance=xxxInstance;
           this.xxxTaskDomain=xxxTaskDomain;
       }

       @Override
       @Transactional(propagation= Propagation.SUPPORTS)
       public void run() {
           try {
               //将任务状态置成 进行中
               //反射调用处理打印任务的具体处理方法
               xxxMethod.invoke(xxxInstance,xxxTaskDomain);
           } catch (Exception e) {
               //将任务状态修改为失败
           }
       }
   }

}

上面就是采用线程池的方法去处理任务的核心代码,在定义消息队列存储格式的时候,我们要将该任务的处理的具体实现方法名,该方法所属的实例名称,和任务的实体信息封装进去,这样我们的就可以采用反射的方式用一个线程池去处理不同类型的任务,从而达到可复用的目的。

注: 线程存活时间不能太短,这样会导致系统频繁创建线程。

消费者线程:

上面我们实现了消息队列和封装好线程池之后,需要有人拉取消息,并且将这些消息任务提交到线程池去处理,所以我们需要一个消费者线程去拉取消息(生产-消费者模式)。

 public class ConsumerThread implements Runnable, InitializingBean {
     /**
     * 加载该类成功后启动消费者线程
     * @throws Exception
     */
    @Override
    public void afterPropertiesSet() throws Exception {
        new Thread(this).start();
    }

    @Override
    public void run() {
        loger.info("消费者线程启动");
        List<String> value = null;
        List<String> keys = new ArrayList<String>();
        //获取任务类型key数据,注:新增一个新的任务类型时,一定要在RedisConstantsEnum注册打印任务类型
        RedisConstantsEnum[] redisConstantsEnums = RedisConstantsEnum.values();
        for (RedisConstantsEnum redisConstants:redisConstantsEnums){
            keys.add(redisConstants.getValue());
        }
        String[] keysParam = keys.toArray(new String[redisConstantsEnums.length]);
        //轮询获取redis消息队列中的消息
        while (true){
            try{
                //阻塞获取redis消息队列中的任务消息
                value=jedisClientCluster.getQueueBlock(keysParam);
                if(value!=null){
                    xxxTaskDomain xxxTaskDomain = new xxxTaskDomain();
                    try {
                        loger.info("获取消息成功 : key :"+value.get(0)+"value :"+value.get(1));
                        xxxTaskMessage xxxTaskMessage = JSON.parseObject(value.get(1),xxxTaskMessage.class);
                        String xxxInstanceName = xxxTaskMessage.getxxxInstanceName();
                        String methodName = xxxTaskMessage.getxxxMethodName();
                        //获取处理该任务的实例
                        Object xxxInstance = PlatformContext.getApplicationContext().getBean(xxxInstanceName);
                        //获取处理该任务的具体方法
                        Method xxxMethod = xxxInstance.getClass().getMethod(methodName,xxxTaskDomain.class);
                        
                        xxxTaskDomain = xxxTaskMessage.getxxxTaskDomain();
                        //使用线程池处理该任务
                        asynTaskUtil.executeAutoUnderwriting(xxxMethod,xxxInstance,xxxTaskDomain);
                    } catch (Exception e){
                        loger.info("处理消息失败:"+e.getMessage());
                        //将任务状态修改为失败
                    }
                }
            } catch (Exception e){
                loger.info("获取redis消息失败:"+e.getMessage());
                e.printStackTrace();
            }
            //休眠1秒
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
    }
 }

RedisConstantsEnum: 该类是任务类型常量枚举类,新增任务类型时,需在该枚举类中注册相关的数据类型。

ConsumerThread 继承InitializingBean 接口,实现afterPropertiesSet()方法,目的是系统启动中,实例完该类之后,自动执行实现afterPropertiesSet方法中的逻辑,自动开启消费者线程。

消费者线程不断的轮询redis中的key在RedisConstantsEnum中的list,当有消息返回的时候,从消息中取出该任务的具体处理方法和所属实例,并调用asynTaskUtil.executeAutoUnderwriting(xxxMethod,xxxInstance,xxxTaskDomain); 将任务发送给线程池处理。

当没有消息返回时,消费者线程就会阻塞,让出cpu,达到性能优化。 这也是brpop比rpop好用的地方。

关于消息消费失败:

  • 如果整个消息消费失败了,除了在代码上对该消息进行一定程度的重试,然后对消息进行落地,将该消息保存到数据库中,对该消息的状态进行控制(1-未开始,2-进行中,3-完成,4-失败),并且记录错误信息。

  • 如果有需要,可编写一个定时任务,每隔一段时间就对失败的消息进行扫描,重新push到消息队列中去(这里需要按照消息失败类型对消息进行细分,因为可能,该消息是坏数据引起的失败,这种消息就无需再进行重试,这一方面需要详细的处理。。。。个人理解)。

  • 如果是消费过程中,某个记录处理失败了,整条消息是成功的,这时可以处理好异常,并将失败的记录,缓存到redis中,方便后续的处理。

结尾:

以上是简单介绍最近做系统时使用redis做消息队列实现异步任务处理的方法。由于该工具类是基于系统需求做的,当中有很多细节需要注意,比如异常的处理与抛出,事务的回滚,消息失败的处理等等,这里不做一一赘述,结合实际场景才能够发现问题!!!!写的不全,后续会再补。