问题提出
我们的系统中有一处业务场景:会议提醒,对于每一条会议将会在其开始前一小时发送会议通知;对于新创建的会议,使用ScheduledExecutorService
scheduledExecutorService.schedule(() -> {
sentMeetingNotify(meetingId);
}, (afterTime - 1000 * 60 * 60), TimeUnit.MILLISECONDS);
就能保证会议提醒只注册到其中一台机器上,但是考虑到服务器都在重启的情况,多台服务器都会执行系统的初始化方法从数据库中加载需要发送会议提醒的会议,并使用调度线程池调度发送会议提醒。如何避免这种情况?
其中一种方式就是将发送会议提醒单独拆分出来单独部署一个提醒服务,这样子做会出现单点故障,如果业务系统未挂掉,但是提醒服务挂掉,那么就无法发送会议提醒了,但是如果对于提醒服务也部署多台,那么还是绕回原路,即需要解决分布式任务调度的问题。
问题思考
看了网上有很多优秀的框架都能解决问题~
xxl-job:https://github.com/xuxueli/xxl-job
elastic-job:https://github.com/elasticjob/elastic-job-lite
既然别人都能写,为啥咱不能自己动手写一个?哎嘿嘿,然后碰巧在掘金上看到了一个大佬自己手写的用Redis实现分布式任务job实现。地址:https://juejin.im/post/5cc6ade4f265da038d0b4fd6。那么咱们基于大佬的轮子自己也造一个用Zookeeper的实现的呗。说干就干呗;痴汉笑-哎嘿嘿,嘿嘿嘿。
分布式定时任务的技术点,链接中的大佬该说的都说了,咱也不唠叨了,直接贴出来咱是怎么用Zookeeper实现的分布式任务的。
Zk如何保证多台机器上的任务只会执行一次?
基于zk的分布式锁相信都有耳闻吧,如何保证分布式调度任务也很简单,实现思想就是分布式锁的思想:多个任务节点注册到任务协调器中时同时向zk注册任务节点(zk的临时顺序节点),执行任务时,每个节点都去校验自己注册的任务节点是不是最小的节点,如果是最小节点那么执行否则就不做任何操作。然后zk还有一个很好地特性,就是当客户端断开连接时临时顺序节点也会消失。保证了其中一个任务节点由于别的因素挂掉了,其他任务节点也能正确的执行。
咱贴一部分重要的代码,其他的一部分代码基本同https://github.com/pyloque/jtaskino仓库,这里给出咱自己的特性实现。
/**
* 分布式任务调度器
*
* @author 周宁
* @Date 2019-05-07 11:20
*/
public class TaskScheduler {
/**
* 任务调度线程
*/
private ScheduledExecutorService schedule = Executors.newSingleThreadScheduledExecutor();
/**
* 任务执行线程
*/
private ExecutorService executor;
/**
* 所有的任务
*/
private Map<String, Task> allTasks = new HashMap<>();
/**
* 任务触发器
*/
private Map<String, Trigger> triggers = new HashMap<>();
/**
* 任务监听器
*/
private List<ISchedulerListener> listeners = new ArrayList<>();
/**
* 任务执行协调
*/
private ZkTaskConcert zkTaskConcert;
/**
* 调度中的任务
*/
private List<Task> inScheduledTasks = new ArrayList<>();
public TaskScheduler(String address, String taskGroup) {
this(address, taskGroup, Runtime.getRuntime().availableProcessors() * 2);
}
public TaskScheduler(String address, String taskGroup, int nThreads) {
zkTaskConcert = new ZkTaskConcert(address, taskGroup);
executor = Executors.newFixedThreadPool(nThreads);
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
zkTaskConcert.close();
}));
scanAndScheduleNewTask();
}
/**
* 扫描并调度新加入的任务
*/
private void scanAndScheduleNewTask() {
schedule.scheduleWithFixedDelay(() -> allTasks.forEach((s, task) -> {
if (!inScheduledTasks.contains(task)) {
triggers.get(s).schedule(schedule, executor, this::grabTaskSliently, task, inScheduledTasks);
}
}), 5, 1, TimeUnit.SECONDS);
}
/**
* 添加监听器
*
* @param schedulerListeners
* @return DistributedScheduler
*/
public TaskScheduler addListener(ISchedulerListener... schedulerListeners) {
listeners.addAll(Arrays.asList(schedulerListeners));
return this;
}
/**
* 注册任务
*
* @param trigger
* @param task
* @return
*/
public TaskScheduler register(Trigger trigger, Task task) {
if (triggers.containsKey(task.getTaskName())) {
throw new IllegalArgumentException("任务名称不能重复");
}
//设置任务标识
task.setTaskIdentity(UUID.randomUUID().toString());
zkTaskConcert.createTaskNode(task);
allTasks.put(task.getTaskName(), task);
triggers.put(task.getTaskName(), trigger);
task.callback(taskContext -> {
for (ISchedulerListener iSchedulerListener : listeners) {
iSchedulerListener.onComplete(taskContext);
}
});
return this;
}
/**
* 开始任务
*
* @return DistributedSchedule
*/
public TaskScheduler start() {
scheduleTasks();
for (ISchedulerListener listener : listeners) {
listener.onStart();
}
return this;
}
/**
* 停止所有任务调度
*/
public synchronized void stop() {
zkTaskConcert.close();
//停止所有运行中的任务
triggers.values().forEach(trigger -> trigger.cancel());
triggers.clear();
allTasks.clear();
schedule.shutdown();
try {
schedule.awaitTermination(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
}
executor.shutdown();
try {
executor.awaitTermination(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
}
for (ISchedulerListener listener : listeners) {
listener.onStop();
}
}
/**
* 任务调度
*/
private void scheduleTasks() {
triggers.forEach((taskName, trigger) -> {
Task task = allTasks.get(taskName);
if (task == null) {
return;
}
trigger.schedule(schedule, executor, this::grabTaskSliently, task, inScheduledTasks);
});
}
private boolean grabTaskSliently(Task task) {
if (task.getTaskIdentity().equals(zkTaskConcert.grabTaskIdentity(task))) {
return true;
}
return false;
}
/**
* 触发任务执行
*
* @param taskName
*/
public void triggerTask(String taskName) {
Task task = allTasks.get(taskName);
if (task != null) {
task.run();
}
}
/**
* 取消任务
*
* @param taskName
*/
public void cancelTask(String taskName) {
Trigger trigger = triggers.get(taskName);
if (trigger != null) {
trigger.cancel();
}
}
public static void main(String[] args){
TaskScheduler taskScheduler = new TaskScheduler("192.168.1.232:2181", "test");
taskScheduler.register(Trigger.period(new Date(), 10), Task.newTask("period", () -> {
System.out.println(System.currentTimeMillis() + "taskScheduler1的111");
}));
taskScheduler.register(Trigger.cronOfMinutes(1), Task.newTask("cron", () -> {
System.out.println(System.currentTimeMillis() + "taskScheduler1的222");
}));
taskScheduler.start();
}
}
package com.gysoft.job;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.KeeperException;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* @author 周宁
* @Date 2019-05-09 19:21
*/
public class ZkTaskConcert {
private static final String SEPEARATOR = "/";
private static final String ROOT_PATH = SEPEARATOR + "locks_";
private CuratorFramework client;
private String taskRootGroup;
public ZkTaskConcert(String address, String taskGroup) {
this.taskRootGroup = ROOT_PATH + taskGroup;
CuratorFrameworkFactory.Builder builder = CuratorFrameworkFactory.builder().connectString(address).retryPolicy(new ExponentialBackoffRetry(1000, 3, 3000));
this.client = builder.build();
this.client.start();
try {
if (!this.client.blockUntilConnected(3000 * 3, TimeUnit.MILLISECONDS)) {
this.client.close();
throw new KeeperException.OperationTimeoutException();
}
if(!isExisted(taskRootGroup)){
this.client.create().withMode(CreateMode.PERSISTENT).forPath(taskRootGroup);
}
} catch (Exception e) {
RegExceptionHandler.handleException(e);
}
}
/**
* 判断是否存在
* @param path
* @return boolean
*/
public boolean isExisted(String path) {
try {
return null != this.client.checkExists().forPath(path);
} catch (Exception var3) {
RegExceptionHandler.handleException(var3);
return false;
}
}
/**
* 创建任务节点
*
* @param task
*/
public void createTaskNode(Task task) {
try {
if (!isExisted(genTaskNodePath(task))) {
this.client.create().withMode(CreateMode.PERSISTENT).forPath(genTaskNodePath(task));
}
this.client.create().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(genTaskNodePath(task) + SEPEARATOR+task, task.getTaskIdentity().getBytes());
}catch (Exception e){
RegExceptionHandler.handleException(e);
}
}
/**
* 抢占任务
*
* @return
*/
public String grabTaskIdentity(Task task) {
try {
List<String> childs = this.client.getChildren().forPath(genTaskNodePath(task));
Collections.sort(childs);
return new String(this.client.getData().forPath(genTaskNodePath(task) + SEPEARATOR + childs.get(0)));
} catch (Exception e) {
RegExceptionHandler.handleException(e);
return "";
}
}
/**
* 生成任务节点的path路径
*
* @param task
* @return String
*/
private String genTaskNodePath(Task task) {
return taskRootGroup + SEPEARATOR + task.getTaskName();
}
/**
* 关闭
*/
public void close() {
this.client.close();
}
}
package com.gysoft.job;
import org.apache.zookeeper.KeeperException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* @author 周宁
* @Date 2019-05-22 14:09
*/
public class RegExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(RegExceptionHandler.class);
public static void handleException(Exception cause) {
if (null != cause) {
if (!isIgnoredException(cause) && (null == cause.getCause() || !isIgnoredException(cause.getCause()))) {
if (!(cause instanceof InterruptedException)) {
throw new RegException(cause);
}
Thread.currentThread().interrupt();
} else {
log.debug("ignored exception for: {}", cause.getMessage());
}
}
}
private static boolean isIgnoredException(Throwable cause) {
return cause instanceof KeeperException.ConnectionLossException || cause instanceof KeeperException.NoNodeException || cause instanceof KeeperException.NodeExistsException;
}
private RegExceptionHandler() {
}
}
然后就是使用啦
<bean id="TaskScheduler" class="com.gysoft.job.TaskScheduler" init-method="start" destroy-method="stop">
<constructor-arg name="address" value="${dubbo.registry.host}:${dubbo.registry.port}"/>
<constructor-arg name="taskGroup" value="projectImp"/>
<constructor-arg name="nThreads" value="2"/>
</bean>
private static final ScheduledExecutorService scheduledExecutorService = ThreadPoolExecutorFactory.newScheduledExecutorInstance(ThreadPoolExecutorConfig.newScheduledThreadPoolConfig("MeetingNoticePool", 10));
@Autowired
private TaskScheduler taskScheduler;
/**
* 发送会议通知有两种情况
* 1.重新启动项目,加载需要发送会议通知的会议,如果服务是分布式部署会导致发送多次会议提醒<br/>
* 所以使用taskScheduler做了分布式任务调度<br/>
* 2.创建(编辑)会议,服务的分布式不会导致任务重复调度</br>
* 为什么创建(编辑)会议不用taskScheduler做分布式任务调度?
* 考虑到taskScheduler每次注册任务都需要将任务节点注册到zk上,并且taskScheduler的实现里</br>
* 使用内存保存了当前正在运行的任务,如果一个任务通过taskScheduler注册并调度了,taskScheduler<br/>
* 就需要保存相应的任务,对于创建的会议可能有非常多个,导致内存耗尽,而且创建会议并不会出现任务</br>
* 重复调度的情况,所以这里不使用taskScheduler进行任务调度了嘿嘿<br/>
* 实在搞不明白的老铁,请联系我 18356070692
* @param meetingId 会议id
* @param date 会议开始时间
* @param concurrent 是否并发
*/
private void meetingNotify(String meetingId, Date date,boolean concurrent) {
try {
long afterTime = date.getTime() - System.currentTimeMillis();
if (afterTime >= 1000 * 60 * 60) {
//
if(concurrent){
taskScheduler.register(Trigger.delayOnce(Math.toIntExact((afterTime - 1000 * 60 * 60)/1000)),Task.newTask(meetingId, () -> sentMeetingNotify(meetingId)));
}else{
scheduledExecutorService.schedule(() -> {
sentMeetingNotify(meetingId);
}, (afterTime - 1000 * 60 * 60), TimeUnit.MILLISECONDS);
}
}
} catch (Exception e) {
logger.error("meetingNotify error meetingId={},date={}", meetingId, date, e);
}
}
哎嘿嘿,代码清晰,与我们现有项目完美无缝集成,完美~~~