使用MongoDB实现消息队列的异步消息功能

一、消息队列概述

消息队列中间件是分布式系统中重要的组件,主要解决应用耦合,异步消息,流量削锋等问题。实现高性能,高可用,可伸缩和最终一致性架构。是大型分布式系统不可缺少的中间件。

目前在生产环境,使用较多的消息队列有ActiveMQ,RabbitMQ,ZeroMQ,Kafka,MetaMQ,RocketMQ等。

自己实现一个较完善的消息队列要考虑高可用、顺序和重复消息、可靠投递、消费关系解析等等比较复杂的问题,笔者不对这些内容进行阐述,重点结合线程池实现解耦,异步消息功能,对其他功能有兴趣的话推荐美团技术博客的一篇文章消息队列设计的精髓基本都藏在本文里了

二.异步处理的流程

场景说明:用户注册后,需要发注册邮件和注册短信。传统的做法有两种
1.串行的方式;2.并行方式。
(1)串行方式:将注册信息写入数据库成功后,发送注册邮件,再发送注册短信。以上三个任务全部完成后,返回给客户端。

使用MongoDB实现消息队列的异步消息功能_第1张图片

(2)并行方式:将注册信息写入数据库成功后,发送注册邮件的同时,发送注册短信。以上三个任务完成后,返回给客户端。与串行的差别是,并行的方式可以提高处理的时间。
使用MongoDB实现消息队列的异步消息功能_第2张图片

假设三个业务节点每个使用50毫秒钟,不考虑网络等其他开销,则串行方式的时间是150毫秒,并行的时间可能是100毫秒。

因为CPU在单位时间内处理的请求数是一定的,假设CPU1秒内吞吐量是100次。则串行方式1秒内CPU可处理的请求量是7次(1000/150)。并行方式处理的请求量是10次(1000/100)。

小结:如以上案例描述,传统的方式系统的性能(并发量,吞吐量,响应时间)会有瓶颈。如何解决这个问题呢?

引入异步处理。改造后的架构如下:

使用MongoDB实现消息队列的异步消息功能_第3张图片

三.使用MongoDB实现上述的架构

3.1 数据库定义

如上图所示,“发送注册短信“和“发送注册邮件“都是需要异步处理的任务。该任务将均由主线程写入数据库(“任务队列表”),同时有另一个线程读取任务队列表的数据并根据具体的“发送注册短信“和“发送注册邮件“来处理。

任务队列模型定义
TaskQueue.java

@Document
public class TaskQueue {
    @Id
    private String _id;
    private String name;  //任务名称。例如:“发送注册短信“或“发送注册邮件“等
    private String param;  //需要的处理任务的参数
    private int status; // 状态,0:初始 1:处理中 8:处理失败 9:处理成功
    private int retry; // 重试次数,仅对于处理失败,status:8
    private Date created; // 创建时间
    private int threadNid; //处理成功该任务的任务处理线程唯一标示符
    private int priority; // 越小,被执行的概率越大

}

任务处理线程模型定义
ThreadInstance.java

@Document
public class ThreadInstance implements Comparable<ThreadInstance>{
    @Id
    private String _id;
    @Indexed(unique=true)
    private int nid; // 序号,唯一索引。唯一索引的意义后续解释
    private long pid; // 线程id
    private int taskCounts; // 当前任务处理线程正在处理的任务数,通过心跳来更新
    private int execedTasks; //该任务处理线程的总数,通过心跳来更新
    private Date update; // 上次活跃时间,通过心跳来更新
}

3.2异步处理任务的线程池

对应上图,“发送注册短信“和“发送注册邮件“等需要通过单独的线程来完成。因为可以更快的完成异步任务,可能需要多个线程同时来作为异步处理线程。此处,使用线程池来完成。
ThreadPoolExecutor线程池的工作原理

线程池有三个核心关键词:核心线程、工作队列、最大线程和饱和策略

  • 核心线程池对应corePoolSize变量的值,如果运行的线程小于corePoolSize无论当前是否有空闲线程,总是会创建新的线程执行任务(这个过程需要获取全局锁)
  • 如果运行的线程大于corePoolSize,则将任务加入BlockingQueue–对应工作队列
  • 如果BlockingQueue已满,且当前线程数尚未超过maximumPoolSize—最大线程。则线程池继续创建线程。
  • 如果BlockingQueue已满,并且当前线程数超过maximumPoolSize,则根据当前线程池已饱和。根据指定的饱和策略来处理。抛出异常,丢弃等等。
        // 创建一个顺序存储的阻塞队列,并指定大小为500
        BlockingQueue blockingQueue = new ArrayBlockingQueue(500);
        // 创建线程池的饱和策略,AbortPolicy抛异常
        RejectedExecutionHandler handler = new ThreadPoolExecutor.AbortPolicy();
        // 创建线程池,线程池基本大小5 最大线程数为10 线程最大空闲时间10分钟
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 10, 10, TimeUnit.MINUTES, blockingQueue,
                handler);

        // 提交5个job
        for (int i = 0; i < 5; i++) {
            final int nid = i; //nid是每个异步任务处理队列的唯一标识符
            threadPoolExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    //不停的从数据库中获取“发送注册短信“和“发送注册邮件“等任务并完成
                }
            });
        }

3.3任务的抽象接口和实现

无论是“发送注册短信“和“发送注册邮件“,都属于被异步处理都任务,需抽象出一个接口,这样各个异步处理线程才能方便的执行

需要异步处理任务的接口:

Job.java

public interface Job {
  /**
   *@params _id如前所述,每个待处理待任务都是mongo中的一条记录,该参数为mongo主键
   * param 该任务需要的参数
   *@return 8:处理失败 9:处理成功
   */
  public abstract int exec(String _id, String param);
}

发送注册短信任务
MessageJob.java

public class MessageJob implements Job{
    public int exec(String _id, String param) {
        try {
          //TODO 发送短信的代码
          return 9;
        }catch(Exception e) {
          return 8;
        }
    }

}

发送注册短信任务
EmailJob.java

public class EmailJob implements Job{
    public int exec(String _id, String param) {
        try {
          //TODO 发送邮件的代码
          return 9;
        }catch(Exception e) {
          return 8;
        }
    }

}

3.4异步任务处理线程的实现

这是最为核心的内容,代码实现如下:

public class JobTread {
    //当前job线程从数据库中加载出的任务
    private List prepareExecTask;
    //当前线程正在处理的任务数。该变量需要每格一段时间心跳给ThreadInstance,需要volatile修饰
    private volatile int nowExecTasks = 0;
    //上次心跳到当前心跳到时间中处理的进程数,用做记录当前线程已处理的task总数。该变量需要每格一段时间心跳给ThreadInstance,需要volatile修饰
    private volatile int execedTasks = 0;
    @Autowired
    private ThreadInstanceService processService;
    private Timer timer = new Timer();
    @Autowired
    private TaskQueueService taskQueueService;
    private String[] jobs = { "com.mq.job.jobImpl.EmailJob", "com.mq.job.jobImpl.MessageJob" };
    //当前异步任务处理的唯一标识符
    private int nid;
    //保存所有任务的实例(根据jobs类路径数据反射)
    private Map jobMap;

    public JobTread() {

    }

    public JobTread(int nid) {
        this.nid = nid;
        this.jobMap = getJobMap();
        openHeartbeat(Thread.currentThread().getId());
    }
    /**
     *根据所有任务的类路径反射出执行实例并存储在Map中,key为类名,也是TaskQueue模型中的name
     */
    private Map getJobMap() {
        jobMap = new HashMap();
        // 根据jobs中的类名,反射出Class,根据类名为key,Class为value存入map
        for (int i = 0; i < jobs.length; i++) {
            String classPath = jobs[i];
            try {
                Class clazz = (Class) Class.forName(classPath);
                String className[] = clazz.getName().split("\\.");
                jobMap.put(className[className.length - 1], clazz.newInstance());
            } catch (Exception e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
        }
        return jobMap;
    }

    /**
     * 每个Thread加载处理的task
     * @return
     */
    public int loadTask() {

        //从数据库中peak出一定数量的task
        prepareExecTask = taskQueueService.peaks(nid);
        nowExecTasks= prepareExecTask.size();
        prepareExecTask.stream().forEach(it -> this.exec(it));

        return prepareExecTask.size();
    }
    /**
     *使用Timer开启当前线程对TaskQueue模型的心跳。每一定的时间会将当前线程正在处理的任务数和已经处理的任务数记录到数据库中
     */
    private void openHeartbeat(long threadId) {
        // 开启一个定时
        timer.schedule(new TimerTask() {

            @Override
            public void run() {
                // TaskProcess的心跳,随时更新当前获取时间和正常处理的task数量
                processService.touch(nid, threadId, nowExecTasks, execedTasks);
                execedTasks = 0;
            }
        }, 0, 1000*30);
    }

    /**
     * 执行一个task
     */
    public void exec(TaskQueue take) {
        if (take==null) {
            return;
        }
        // 获取Job实例(com.mq.job.jobImpl下的实例)
        Job jobInstance = jobMap.get(take.getName());
        // Job来执行task
        int result = jobInstance.exec(take.get_id(), take.getParam());

        switch (result) {
        case 8:
            // 处理失败, 置Task.status为8
            taskQueueService.execFail(take.get_id());
            break;
        case 9:
            // 处理成功,置Task.status为9
            taskQueueService.execSuccess(take.get_id());
            break;
        }

        nowExecTasks--;
        execedTasks++;

    }
}

该类主要提供了四个方法:

  • 根据类路径反射所有的具体的任务类(“发送注册短信“和“发送注册邮件“)的实现,存储与Map中。key为类名,也是TaskQueue(存储异步任务的模型中的name)
  • 从TaskQueue中取出一定数量的任务来处理,并实时维护当前正在处理的任务数和已处理完成的任务数
  • 将当前正在处理的任务数和已处理完成的任务数信息实时更新到ThreadInstance实例中
  • 具体的任务执行,根据TaskQueue.name从Map中取出任务实现,并执行exec方法,具体返回值标示给任务是否处理成功

四.ThreadInstance模型的DAO方法

ThreadInstance即为异步任务处理线程的实例,即提交给线程池的任务实例。该模型的唯一标示符为创建时的序号id,并非线程id,因为考虑到重启时线程id可能会改变。故将向线程池中提交的序号id(nid)作为唯一索引。

DAO方法仅提供一个touch心跳方法,即在Timer中定时执行,来传递当前正在处理的任务数和距离上次心跳后处理的任务总数

public class ThreadInstanceService {
    @Autowired
    private ThreadDao threadDao;
    /**
     *@params nid 当前线程---异步任务处理队列的唯一标示符
     * threadId线程ID
     * taskSize 当前正在处理的任务数
     * execedTasks 距离上次心跳后处理的任务总数
     */
    public void touch(int nid,long threadId, int taskSize, int execedTasks) {
        ThreadInstance thread = threadDao.findAndUpdatByNid(nid,threadId, taskSize, execedTasks);
        //如果当前线程还未创建于ThreadInstance,则创建
        if (thread == null) {
             threadDao.create(nid, threadId);
        }
    }

}

public class ThreadDaoImpl implements ThreadDao{
    @Autowired
    private MongoTemplate template;
    /**
     db.ThreadInstance.update({
       nid: nid
     },{
       $set: {
         pid: threadId,
         taskCounts: taskSize,
         update: new Date()
       },
       $inc: {
         execedTasks: execedTasks
       }
    })
     */
    public ThreadInstance findAndUpdatByNid(long nid, long threadId, int taskSize, int execedTasks) {

        return template.findAndModify(Query.query(Criteria.where("nid").is(nid)), new Update().set("pid", threadId).set("update", new Date()).set("taskCounts", taskSize).inc("execedTasks", execedTasks), ThreadInstance.class);
    }
    /**
     db.ThreadInstance.create({
       nid: nid,
       pid: threadId,
       taskCounts: 0,
       execedTasks: 0,
     })

    **/
    @Override
    public void create(int nid, long threadId) {
        template.insert(new ThreadInstance(nid,threadId,0,0));
    }

}

五.ThreadQueue模型的DAO方法

ThreadQueue模型的每一个记录即为待异步处理的任务,如需要“发送注册短信“和“发送注册邮件“等。

如前所示,ThreadQueue主要提供如下三个方法:

  • 从线程池中获取一定数量的task
  • 标注当前任务已处理成功
  • 标注当前任务处理失败

由于从线程池中获取一定数量的任务是多个线程同时获取的,因次可能存在多个线程获取到同一个任务的可能。相信读者也知道Mongo一个操作是原子性的,因为可以使用findOneAndUpdate,{status:0,{$set:{status:1}}}。这样固然不会存在多个线程获取到同一个记录到情况,笔者第一次也是这么实现的。但当将其放入生产环境后,发现这样每次仅仅从数据库中读一个记录来处理效率非常非常低。大量的任务被阻塞到数据库中。
因此,需要一种一次性可以获取多条记录,又避免重复获取的方法。TastQueue中的threadNid字段就是为此而生

public class TaskQueueService {

    private TaskQueueDao taskQueueDao = new TaskQueueDaoImpl();

    /**
     * 获取该次peak中的优先级
     * 返回为1的概率最大,2,3,4,5依次
     * @return
     */
    public int roll() {
        Double random = Math.random();
        for (int i = 1; i <= 5; i++) {
            if (random > 1 / Math.pow(2, i)) {
                return i;
            }
        }
        return 1;
    }

    /**
     * 从线程池中获取一定数量的task
     * 
     * @param threadNid
     *            需要获取task的threadNid.非线程id而是作为唯一索引的nid
     * @return List
     */
    public List peaks(int threadNid) {

        // 获取task状态为0,1(初始化,处理中)。避免将加载到内存中task因重启而丢失
        List statuses = new ArrayList();
        statuses.add(0);
        statuses.add(1);

        // 获取task的tag为null和当前线程的
        List tags = new ArrayList();
        tags.add(null);
        tags.add(threadNid);

        //获取一个优先级
        int priority = this.roll();

        //获取出未处理的和当前线程正在处理的TaskQueue
        List taskQueues = taskQueueDao.peaks(statuses, tags, priority);
        //取出所有的_id。 多个线程可能会获取出相同的任务,均是未处理的
        List ids = taskQueues.stream().map(it -> it.get_id()).collect(Collectors.toList());

        // 将上述获取到所有未处理的或当前线程正在处理的所有线程全部更新为当前线程正常处理
        // 此处的更新的条件除了$in:ids外,还必须threadNid为null。
        // 对于一个任务来说,无论有多少个线程获取到它。但只能有一个线程更新成功“处理中”,即status更新为1,threadNid更新为当前线程nid
        int updateSize = taskQueueDao.execing(ids, threadNid);

        // 如果更新出的数量为0,说明所有的task全部是处理中的,返回空
        if (updateSize == 0) {
            return new ArrayList();
        }

        // 此时,再次获取出所有当前线程处理中的task,即多个线程不会返回相同的任务给上级
        List execStatuses = new ArrayList();
        execStatuses.add(1);
        List execTags = new ArrayList();
        execTags.add(threadNid);

        return taskQueueDao.peaks(execStatuses, execTags, priority);
    }

    //更新status为9
    public void execSuccess(String _id) {
        taskQueueDao.success(_id);
    }

    //更新status为8
    public void execFail(String _id) {
        taskQueueDao.fail(_id);
    }
}
public class TaskQueueDaoImpl implements TaskQueueDao{
    @Autowired
    private MongoTemplate template;
    /**
    db.TaskQueue.find({
      status: {$in: statuses},
      threadNid: {$in: threadNids},
      priority: priority,
    }).limit(20)
    **/
    @Override
    public List peaks(List statuses, List threadNids, int priority) {
        return template.find(Query.query(Criteria.where("status").in(statuses).and("threadNid").in(threadNids).and("priority").is(priority)).limit(20), TaskQueue.class);
    }

    /**
    db.TaskQueue.update({_id: _id},{$set:{status: 9}})
    **/
    @Override
    public void success(String _id) {

        template.updateFirst(Query.query(Criteria.where("_id").is(_id)), Update.update("status", 9), TaskQueue.class);
    }

    /**
    db.TaskQueue.update({_id: _id},{$set:{status: 8}})
    **/
    @Override
    public void fail(String _id) {

        template.updateFirst(Query.query(Criteria.where("_id").is(_id)), Update.update("status", 3), TaskQueue.class);
    }

    @Override
    public int execing(List _ids, int threadNid) {

        WriteResult writeResult = template.updateMulti(Query.query(Criteria.where("_id").in(_ids).and("threadNid").is(null)), Update.update("status", 1).set("threadTag", threadNid), TaskQueue.class);
        return writeResult.getN();
    }
}

六.测试

运行如下代码在TaskQueue中创建300个任务,然后启动线程池,提交5个线程来处理这300个任务。

    public static void testData() {

          new Thread(new Runnable() {

            @Override
            public void run() {
                for(int i=0;i<150;i++){
                    getMongoTemplate().save(new TaskQueue("EmailJob",UUID.randomUUID().toString()+"@sina.com"));
                    getMongoTemplate().save(new TaskQueue("MessageJob","随机手机号"));

                }
            }
        }).start();
    }
// 提交5个job
        for (int i = 0; i < 5; i++) {
            final int nid = i;
            threadPoolExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    exec(nid);
                }
            });
        }

public static void exec(int nid) {
        JobTread thread = new JobTread(nid);
        while (true) {
            int tasks = thread.loadTask();
            if (tasks == 0) {
                // 休息一会
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
        }
    }

此时会在ThreadInstance模型中产生5条记录,并且实时会更新出每个线程正常处理的任务数,和处理的任务总数。当运行完成后,运行如下语句,输出300:

 db.getCollection('threadInstance').aggregate( [
     {
       $group:
         {
           _id: 'execedTasks',
          count:{$sum:'$execedTasks'}
         }
     }
   ])

你可能感兴趣的:(NoSQL,并发编程)