利用redis分布式锁执行定时任务的问题| Java Debug 笔记

1,892 阅读4分钟

本文正在参加「Java主题月 - Java Debug笔记活动」,详情查看活动链接

前言

昨天我写了微信获取token接口超限制问题,然后我给出的解决方案虽热把问题解决了,但是现在看来这个解决方案有点愚蠢,也许只有我这种笨人才会想出来的方法,很不优雅,今天用户小眼睛聊技术在文章下评论了代码用lock锁实现手动加锁,获取完手动释放锁的机制优雅的解决了该问题,顿时让我恍然大悟,菜是原罪呀,这种多线程的编程我还是接触的少,就是压根没想过用锁解决,一旦点明顿时眼前一亮,最后我准备按这位小伙伴的方案改,在基础上优化了存每个token的时候需要加过期时间的随机数,不能让所有token在相同时间全部过期,如果全部过期加锁的方案就会有问题,这次我真的体会到了掘金真的是个帮助开发者成长的社区,非常感谢小伙伴小眼睛聊技术,我不在社区写这个bug我一直都得不到这种优雅的解法,写的不对,写的菜没关系,能成长就是最大的收获,所以我今天又来写bug了。可能改完还是个bug。

问题

突然有一天好好的定时任务不执行了,项目重启也不执行,在做在线作业的时候有个需求是每周天下午四点统计本周的学生做作业情况,然后发现周报停留在某一周,这之后就没有周报的数据统计了。

分析问题

直接打开执行周报的定时任务代码,这个代码不是我写的,我贴下原代码。由于是部署了多个节点,所以用了redis的分布式锁,如果不用分布式锁,那么每个节点都执行一次定时任务,那么周报肯定执行重复了。代码如下:

private static final String LOCK = "task0-job-lock-on";

private static final String KEY = "task0lock-on";
@Scheduled(cron="${data.sync.cron}")
public void studentWeeklyReport(){
    boolean lock = false;
    try {
        lock = redisTemplate.opsForValue().setIfAbsent(KEY, LOCK);
        if (lock) {
            System.out.println("start student Weekly Report!" + new Date());
            //调用业务逻辑代码
            System.out.println("end student Weekly Report!!" + new Date());
        } else {
            logger.info("未获取到锁,不执行定时任务");
        }
    } finally {
        if (lock) {
            redisTemplate.delete(KEY);
            logger.info("任务结束,释放锁!");
        } else {
            logger.info("没有获取到锁,无需释放锁!");
        }
    }
}

说明:data.sync.cron是配置执行定时任务的Cron表达式,整个逻辑很简单,通过redisTemplate.opsForValue().setIfAbsent(KEY, LOCK)判断redis是否存在absentValue为LOCK的值,如果不存在返回true,并把当前值放进redis,那么下次调用的时候如果没有删除当前key的时候,获取到的是false。如果为true在执行业务代码生成周报,执行完之后在finally又判断了lock是否存在,如果还是true那么删除key。

看完代码第一感觉,可以直接改成如下代码:

@Scheduled(cron = "${data.sync.cron}")
public void studentWeeklyReport() {
    boolean lock = false;
    try {
        lock = redisTemplate.opsForValue().setIfAbsent(KEY, LOCK);
        if (lock) {
            System.out.println("start student Weekly Report!" + new Date());
            //调用业务逻辑代码
            System.out.println("end student Weekly Report!!" + new Date());
        }
    } finally {
        redisTemplate.delete(KEY);
        logger.info("任务结束,释放锁!");
    }
}

这个代码和上面的代码逻辑完全没变,但是这个代码肯定解决不了问题,如果代码在执行业务逻辑代码的时候,恰好服务器宕机了,或者上线停机重启,那么redis里面会永远存在absentValue的值,下次定时任务进来直接就退出了,那么解决方法就是加一个过期时间,如果出现宕机的时候,到时间会自动过期,那么就不存在这种问题,对于加过期时间,不建议使用如下写法。

 @Scheduled(cron = "${data.sync.cron}")
    public void studentWeeklyReport() {
        boolean lock = false;
        try {
            lock = redisTemplate.opsForValue().setIfAbsent(KEY, LOCK);
            redisTemplate.expire(KEY, 60, TimeUnit.SECONDS);
            if (lock) {
                System.out.println("start student Weekly Report!" + new Date());
                //调用业务逻辑代码
                System.out.println("end student Weekly Report!!" + new Date());
            }
        } finally {
            redisTemplate.delete(KEY);
            logger.info("任务结束,释放锁!");
        }
    }

setIfAbsent()下一行设置key的过期时间redisTemplate.expire(KEY, 60, TimeUnit.SECONDS)这两步不是原子性操作,在刚好执行到这两行中间如果服务宕机了,那么和上面情况一样,redis永久存在absentValue的值。redis提供了设置值和过期时间具有原子性的方法。那么修改如下:

  @Scheduled(cron = "${data.sync.cron}")
  public void studentWeeklyReport() {
        boolean lock = redisTemplate.opsForValue().setIfAbsent(KEY, LOCK, 1000 * 60, TimeUnit.MILLISECONDS);
        if (!lock) {
            return;
        }
        
        try {

            System.out.println("start student Weekly Report!" + new Date());
            //调用业务逻辑代码
            System.out.println("end student Weekly Report!!" + new Date());

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            redisTemplate.delete(KEY);
            logger.info("任务结束,释放锁!");
        }
    }

这样在存值的时候同时设置了过期时间,即使业务执行过程中出现宕机,那么redis里面的值到了过期时间也会自动删除,不影响下次的定时任务执行。

注意:设置超时时间一定要大于你真实执行任务的时间,如果小余,前面的任务还没执行完,redis的key自动过期,那么下一次的任务就会进来,导致任务重复执行。那么还是有问题,这个时间我暂时不知道怎么去设置合理,对与我这个业务执行周期是一周,这个过期时间很好预估,但是精确的去判断是否过期我还没思路。

这个项目最后交给别的项目组维护了,我也再没跟这个定时任务,不知道这样修改还有其他啥问题没,如果有更好的解决方案欢迎讨论。

总结

想了想,有一种情况如果别人知道我的key的值,我自己线程没有删除,我的key被别的线程误删除,那么也有问题,这种可以用ThreadLocal实现来解决问题。思路就是哪个线程放的key哪个线程才能删除key,其他线程不让删除。具体实现我再去研究下,今天的bug先到这。我去研究下ThreadLocal....