最近新写了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是一样的操作,数据库的结果不会有不同,但是会有负载压力。