第四节 List作为消息中间件
一、基本依据
Redis的List是一个队列。对于队列这种数据结构来说,有两个非常重要的特性。可左进右出,亦可右进左出。那么,基于这种特点,就能够通过Redis的List实现基本的消息中间件。
从具体的代码上来看,可以先把一些数据使用 leftPush方法加入Redis的队列中。再通过同一key从Redis中指定队列中取出来,这个方法叫做rightPop。
下面的代码展示了如何将消息加入Redis的指定的List中。重点关注leftPush方法的调用。
//核心:左进消息
public void addNotice(Notice notice) {
if (notice != null) {
getListOperations().leftPush(Constant.NOTICE_KEY, notice);
}
}
private ListOperations<String, Notice> getListOperations() {
return redisTemplate.opsForList();
}
另一方面,使用定时任务一直监听这个List。类似与RabbitMQ里面的监听Queue。重点关注rightPop方法的调用。
@Scheduled(cron = "*/10 * * * * ?")
@Async
public void listen() {
LOGGER.info("监听消息ing");
Notice notice = getListOperations().rightPop(Constant.NOTICE_KEY);
while (notice != null) {
sendMail(notice);
//在队列中从右边弹出第一个数据
notice = getListOperations().rightPop(Constant.NOTICE_KEY);
}
}
二、定时任务阻塞问题思考
这个是比较重要的问题。如果使用定时任务,如何保证多个定时任务之间使用不同线程。在实际工作中,曾经就出现过这种情况。我同事的定时任务耗时过长,导致我的定时任务一直得不到执行。测试以为我的定时任务出了问题,并且日志还没有打印出任何错误。实际上我的定时任务压根就没得到执行的机会。其问题根源在于多个定时任务使用了同一个线程。
解决方案:解决SpringBoot多定时任务使用同一线程问题
第一步:在系统中配置大名鼎鼎的线程池ThreadPoolTaskExecutor。
package com.tyzhou;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component;
import java.util.concurrent.Executor;
@Component
public class ThreadPoolConfig {
@Bean(value = "threadPoolTaskExecutor")
public Executor getExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
//线程池维护线程的最少数量,即使没有任务需要执行,也会一直存活
executor.setCorePoolSize(5);
//线程池维护线程的最大数量
executor.setMaxPoolSize(10);
//允许的空闲时间,当线程空闲时间达到keepAliveTime时,线程会退出,直到线程数量=corePoolSize
executor.setKeepAliveSeconds(60);
//缓存队列(阻塞队列)当核心线程数达到最大时,新任务会放在队列中排队等待执行
executor.setQueueCapacity(10);
/**
* 拒绝task的处理策略
* CallerRunsPolicy使用此策略,如果添加到线程池失败,那么主线程会自己去执行该任务,不会等待线程池中的线程去执行
* AbortPolicy丢掉这个任务并且抛出
* DiscardPolicy线程池队列满了,会直接丢掉这个任务并且不会有任何异常
* DiscardOldestPolicy队列满了,会将最早进入队列的任务删掉腾出空间,再尝试加入队列
*/
//executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
第二步:在定时任务类上使用@EnableAsyn注解,并在具体定时任务方法上使用@Async注解。那么,这两个定时任务在同一时刻触发,也不会使用同一个线程。
@Component
@EnableScheduling
@EnableAsync
public class NoticeListener ...
@Scheduled(cron = "*/10 * * * * ?")
@Async
public void listen() {
LOGGER.info("监听消息ing");
}
@Scheduled(cron = "*/10 * * * * ?")
@Async
public void test() {
LOGGER.info("另外一个定时任务在执行ing....");
}
三、定时任务业务复杂情况
这里还有一个十分重要的问题没有解决。如果只是单单的解决了多个定时任务使用同一个线程的问题,这还不够。另外一个令人头大的问题是。如果定时任务的业务很复杂,如何再优化。我在实际项目中遇到的问题就是,仅仅在测试环境一千个学生,定时任务执行的时间就超过一小时。这严重影响当前系统的内存,测试小姐姐苦不堪言。
经过仔细排查,最耗时的操作太平常,代码类似于下面这种。每个学生都占一次循环,且处理每个学生的时间都要花费很多。
for(User user : userList){
service.handleStu(user, otherArg);
}
下面是优化后的代码。使用了大名顶顶的ExecutorService。可参考:线程池ThreadPoolExecutor的使用
优化后的代码思路
第一步:将以前的处理业务,封装到一个线程中,实现Callable接口即可。
package com.tyzhou.mail.listener;
import com.tyzhou.mail.MailService;
import com.tyzhou.mail.modol.Notice;
import com.tyzhou.redis.User;
import lombok.AllArgsConstructor;
import java.util.concurrent.Callable;
@AllArgsConstructor
public class MailThread implements Callable<Boolean> {
private MailService mailService;
private Notice notice;
private User user;
@Override
public Boolean call() throws Exception {
mailService.sendMail(notice, user);
return true;
}
}
第二步:封装任务并使用使用ExecutorService来执行任务,以此减少定时任务执行时间。
public void sendMail(Notice notice) {
try {
//构造线程池
ExecutorService pool = Executors.newFixedThreadPool(30);
//封装要被处理的任务到集合里面
Collection<MailThread> taskList = new ArrayList<>();
for (User user : users) {
//构造任务,MailThread实现Callable接口
taskList.add(new MailThread(mailService, notice, user));
}
//使用线程池来执行任务
pool.invokeAll(taskList);
} catch (Exception e) {
LOGGER.error("发送邮件出现异常:", e.fillInStackTrace());
}
}
大致执行效果如下。红色框表明不同定时任务实现了异步。蓝色框表明,使用ExecutorService实现了多个任务使用了多个线程来处理。
阅读更多