初探分布式调度的应用

最近新写了oa系统,有个日程功能,用户可以创建日程(schedule),设置提醒时间(remindTime),选择接收方(receivers),到remindTime后系统发送提醒消息。

@Data
@Entity
@Table(name = "schedule")
@Where(clause = "is_delete=0")
@SQLDelete(sql = "update schedule s set s.is_delete=1 where s.id=?")
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Schedule {
    .......
    @JsonIgnore
    @Column(name = "remind_time")
    private Date remindTime;//提醒时间
    ......
    @OneToMany(fetch = FetchType.LAZY,cascade = CascadeType.PERSIST)
    @JoinColumn(name = "schedule_id")
    @Where(clause = "is_delete = 0")
    @SQLDeleteAll(sql = "update schedule_receiver sr set sr.is_delete=1 where sr.schedule_id=? and sr.is_delete=0")
    private List<ScheduleReceiver> receivers = new ArrayList<>();
    ......   
    //设置下次提醒的时间
    public void setNextRemindTime(){}
    //获取下次提醒的时间,但是不直接更新entity
    public Date getNextRemindTime(){}
}

之前的实现方案是采用定时任务,每分钟从mysql中查询remindTime小于当前时间的schedule,每个schedule分别发送给对应的receivers,然后设置新的remindTime。定时任务采用的是spring的@Scheduled注解。

@Scheduled(cron = "0 * * * * *")
public void scheduleRemind(){
    //查出所有需要发送的schedule
    List<Schedule> scheduleList = scheduleRepository.findByRemindTimeBefore(new Date());
    scheduleList(schedule -> {
        //每个schedule分别发送到receivers
        sendMessage(schedule,schedule.getReceivers());
        //设置下次提醒的时间
        schedule.setNextRemindTime();
        scheduleRepository.saveAndFlush(schedule);
    });
}

这种实现方式看似正常,但是一旦应用在分布式系统中,如果两个实例A和B同时调用此方法,有可能发生重复发送消息(严重[1])和重复更新remindTime操作(一般[2])的问题。

为了确认上面问题,我对代码进行了修改:

@Scheduled(cron = "0 * * * * *")
public void scheduleRemindThread() {
    CountDownLatch countDownLatch = new CountDownLatch(2);
    Thread t1 = new Thread(new TestThread(countDownLatch));
    Thread t2 = new Thread(new TestThread(countDownLatch));
    t1.start();
    t2.start();
}
 
private class TestThread implements Runnable {
 
    private CountDownLatch countDownLatch;
 
    private TestThread(CountDownLatch countDownLatch) {
        this.countDownLatch = countDownLatch;
    }
 
    @Override
    public void run() {
        scheduleRemind(this.countDownLatch);
    }
}
 
public void scheduleRemind(CountDownLatch countDownLatch){
    //查出所有需要发送的ping消息
    List<Schedule> scheduleList = scheduleRepository.findByRemindTimeBefore(new Date());
    countDownLatch.countDown();
    try {
        countDownLatch.await();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    scheduleList.forEach(schedule -> {
        //每个schedule分别发送到receivers
        sendMessage(schedule,schedule.getReceivers());
        //设置下次提醒的时间
        schedule.setNextRemindTime();
        scheduleRepository.saveAndFlush(schedule);
    });
}

CountDownLatch的作用就不在这里解释了,如果不熟悉的请自行google。
上面代码模拟的是实例A和实例B(这里用两个线程t1和t2代替)同时查询到scheduleList,A的scheduleList和B的scheduleList相同,每个schedule都会发送消息两次。
启动程序,测试!结果发现程序报错了!为什么?

org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: learn.oa.models.entity.Schedule.receivers, could not initialize proxy - no Session
    at org.hibernate.collection.internal.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:587)
    at org.hibernate.collection.internal.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:204)
    at org.hibernate.collection.internal.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:566)
    at org.hibernate.collection.internal.AbstractPersistentCollection.read(AbstractPersistentCollection.java:135)
    at org.hibernate.collection.internal.PersistentBag.iterator(PersistentBag.java:277)
    at java.util.Spliterators$IteratorSpliterator.estimateSize(Spliterators.java:1821)
    at java.util.Spliterator.getExactSizeIfKnown(Spliterator.java:408)
    at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:480)
    at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:471)
    at java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:708)
    at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
    at java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:499)
    at learn.oa.services.schedule.ScheduledService.lambda$scheduleRemind$1(ScheduledService.java:344)
    at java.util.ArrayList.forEach(ArrayList.java:1257)
    at learn.oa.services.schedule.ScheduledService.scheduleRemind(ScheduledService.java:340)
    ......

我在Schedule实体类的receivers上使用了@OneToMany注解,并采用懒加载模式。但是之前的写法没问题,我新建线程就有问题?问题出在线程上?
搜索资料发现如果避免上面异常的出现,应该加入拦截器OpenEntityManagerInViewInterceptor,并且spring boot项目中,默认加入此拦截器,这样就支持在web请求中,可以在事务外获取@OneToOne、@OneToMany、@ManyToOne、@ManyToMany注解懒加载关联的属性。但是scheduleRemind方法并不是一个前端调用的方法,该怎么办呢?

public void preHandle(WebRequest request) throws DataAccessException {
   ......
      try {
         EntityManager em = createEntityManager();
         EntityManagerHolder emHolder = new EntityManagerHolder(em);
         TransactionSynchronizationManager.bindResource(getEntityManagerFactory(), emHolder);
 
         ......
      }
      catch (PersistenceException ex) {
         throw new DataAccessResourceFailureException("Could not create JPA EntityManager", ex);
      }
   }
}

看了拦截器的代码,发现这三行,web请求进入后,创建了个entityManager,并且把entityManager放入ThreadLocal中,这样就做到了entityManager与当前线程绑定。虽然scheduleRemind()不是web请求,但是我可以在线程中做拦截器一样的工作。

这可能就是我想要的,到底是不是,经过试验就知道了。

@Scheduled(cron = "0 * * * * *")
public void scheduleRemindThread() {
    CountDownLatch countDownLatch = new CountDownLatch(2);
    Thread t1 = new Thread(new TestThread(countDownLatch));
    Thread t2 = new Thread(new TestThread(countDownLatch));
    t1.start();
    t2.start();
}
 
private class TestThread implements Runnable {
 
    private CountDownLatch countDownLatch;
 
    private TestThread(CountDownLatch countDownLatch) {
        this.countDownLatch = countDownLatch;
    }
 
    @Override
    public void run() {
         EntityManager em = createEntityManager();
         EntityManagerHolder emHolder = new EntityManagerHolder(em);
         TransactionSynchronizationManager.bindResource(getEntityManagerFactory(), emHolder);
        scheduleRemind(this.countDownLatch);
    }
}
 
public void scheduleRemind(CountDownLatch countDownLatch){
    //查出所有需要发送的ping消息
    List<Schedule> scheduleList = scheduleRepository.findByRemindTimeBefore(new Date());
    countDownLatch.countDown();
    try {
        countDownLatch.await();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    scheduleList.forEach(schedule -> {
        //每个schedule分别发送到receivers
        sendMessage(schedule,schedule.getReceivers());
        //设置下次提醒的时间
        schedule.setNextRemindTime();
        scheduleRepository.saveAndFlush(schedule);
    });
}

运行程序,异常消失,通过日志发现,确实发送了两次schedule消息到receivers。

后面的工作就是如何改进了。查询资料发现解决分布式调度主要有两种方式,Quartz和Spring Cloud Task。由于Spring Cloud Task资料太少,首先pass。看了看Quartz,发现原理是实例A和实例B从数据库中获取任务,数据库中采用了悲观锁,保证了只有一个实例能获取到任务。还有人采用redis中同一时间只能有一个线程修改数据的方式来,具体也没说明白,我觉得是使用watch悲观锁来实现的。大概都是使用一个分布式锁,来保证一个任务只能被一个实例执行。但是上面两个实现都有些复杂,我能不能自己实现呢?因为schedule中天然存在可以作为乐观锁依据的remindTime(每次发送schdule消息后,都需要更新remindTime),更新schedule的时候只要带上之前的remindTime,就能保证同一个schedule只被更新一次。哪个实例更新了schedule,哪个实例就发送schedule消息给receivers。这样就解决了重复发送消息的问题。

@Scheduled(cron = "0 * * * * *")
public void scheduleRemindThread() {
    CountDownLatch countDownLatch = new CountDownLatch(2);
    Thread t1 = new Thread(new TestThread(countDownLatch));
    Thread t2 = new Thread(new TestThread(countDownLatch));
    t1.start();
    t2.start();
}
 
private class TestThread implements Runnable {
 
    private CountDownLatch countDownLatch;
 
    private TestThread(CountDownLatch countDownLatch) {
        this.countDownLatch = countDownLatch;
    }
 
    @Override
    public void run() {
         EntityManager em = createEntityManager();
         EntityManagerHolder emHolder = new EntityManagerHolder(em);
         TransactionSynchronizationManager.bindResource(getEntityManagerFactory(), emHolder);
        scheduleRemind(this.countDownLatch);
    }
}
 
public void scheduleRemind(CountDownLatch countDownLatch){
    //查出所有需要发送的ping消息
    List<Schedule> scheduleList = scheduleRepository.findByRemindTimeBefore(new Date());
    countDownLatch.countDown();
    try {
        countDownLatch.await();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    scheduleList.forEach(schedule -> {
            Date remindTime1 = schedule.getRemindTime();
            Date remindTime2 = schedule.getNextRemindTime();
            Integer id = schdule.getId();
 
            Query query = entityManager.createQuery("update Schdule set remindTime=:remindTime2 where id=:id and remindTime=:remindTime1");
            query.setParameter("remindTime1", remindTime1);
            query.setParameter("id", id);
            query.setParameter("remindTime2", remindTime2);
 
            entityManager.getTransaction().begin();
            int i = query.executeUpdate();
            entityManager.getTransaction().commit();
 
            //更新成功的发送ping消息到im
            if(i!=0) {
                //每个schedule分别发送到receivers
                sendMessage(schedule,schedule.getReceivers());
            }
        });
    }
}

这样,即使实例A和实例B(两个线程t1和t2代替)同时查询到相同的scheduleList,也不会重复发送schdule消息(主要!),也不会重复更新remindTime到数据库中(次要!)。运行结果确实如设想一样,证明方法可行。

当然我们要实现的是在分布式系统中执行,而不是两个线程中执行,把多线程去掉,只保留乐观锁部分就ok了。

@Scheduled(cron = "0 * * * * *")
public void schduleRemind() {
    //绑定entityManager到线程
    EntityManager entityManager = entityManagerFactory.createEntityManager();
    EntityManagerHolder entityManagerHolder = new EntityManagerHolder(entityManager);
    TransactionSynchronizationManager.bindResource(entityManagerFactory,entityManagerHolder);
 
    List<Schdule> schduleList = schduleRepository.findByRemindTimeBefore(new java.util.Date());
    schduleList.forEach(schdule -> {
        Date remindTime1 = schdule.getRemindTime();
        Date remindTime2 = schdule.getNextRemindTime();
        Integer id = schdule.getId();
 
        Query query = entityManager.createQuery("update Schdule set remindTime=:remindTime2 where id=:id and remindTime=:remindTime1");
        query.setParameter("remindTime1", remindTime1);
        query.setParameter("id", id);
        query.setParameter("remindTime2", remindTime2);
 
        entityManager.getTransaction().begin();
        int i = query.executeUpdate();
        entityManager.getTransaction().commit();
 
        //更新成功的发送ping消息到im
        if(i!=0) {
                //每个schedule分别发送到receivers
                sendMessage(schedule,schedule.getReceivers());
        }
    });
}

总结思考:
· 通过解决这个问题,初步了解了分布式调度框架的实现原理,无论是使用zookeeper、redis、还是数据库表,都是利用分布式锁来控制执行任务的服务。因为schedule天然带有版本控制(remindTime),所以采用了自己实现的乐观锁方案。其他的定时任务中,还需要根据实际情况来选择实现方案。
· 分布式系统中,设计一定要有分布式思想。而不是代码看着ok就ok了,因为任何时候,都有可能有你不知道的其他人在做同样的事情,所以设计中一定要考虑到。不是说使用了spring could就是分布式系统了,设计是否考虑了最初的可能性也是考量之一。
· 附带解决了非web请求下,如何解决LazyInitializationException异常,就是手动把entityManager与线程绑定。

注:
[1]这里需求只是发送消息,可能看着不是那么严重,但是如果任务是定时转账呢?
[2]两次更新remindTime是一样的操作,数据库的结果不会有不同,但是会有负载压力。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值