Redis分布式锁中 | 小册免费学

162 阅读5分钟

使用锁的动机:

  • 在保证线程安全的前提下,尽量让所有线程都执行成功
  • 在保证线程安全的前提下,只让一个线程执行成功

前者适用于秒杀场景。作为商家,保证线程安全的前提下,让每个订单都生效,直到商品售罄,此时分布式锁的写法可以是:不断重试或阻塞等待。即:递归或while true 循环尝试获取,阻塞等待。

后者适用于分布式系统或多节点项目的定时任务,比如同一份代码部署在A、B两台服务器上,而数据库共用同一个,如果不做限制,两台服务器都会去拉取任务列表执行,导致任务重复执行的情况。

可以考虑分布式锁,在cron促发的时刻只允许一个线程往数据库拉取任务。

分布式锁为什么难设计?

涉及到分布式的处理,一般都很复杂。

Redis分布式锁需考虑锁的失效时间。

  • 为什么要设置锁的过期时间呢
  • 锁的过期时间设置多久合适呢

但在极端的情况下(项目在任务进行时重启或意外宕机了),可能当前任务来不及解Redis分布式锁就挂了(死锁),那么下一个任务就会一直被锁在方法外等待。Redis中锁标记一直未去除掉。

此时需要装一个自动解锁的门,即给锁设置一个过期时间,当过期时间到了之后,锁自动失效,但这时会有一个新的问题出现了: 锁的过期时间设多长合适呢?

很难定,随着项目的发展,定时任务执行的时间可能是变化的。

假如锁的过期时间设置的过长,定时任务执行时间相对短时,假如在执行任务中宕机了,此时并未释放锁,那么过期时间设置的越长,影响的面就越广,会导致其它操作阻滞。

如果设计时间过短,上一个任务还未执行完,下一个任务就来执行了,可能会导致重复执行

之所以设计消息队列,是为了尽可能的缩短任务执行的时间。让它尽可能短(拉取后直接丢给队列直接不处理{这样会出现,拉取了相同的任务,导致任务重复执行})

思考

  • 如何处理锁的过期时间

  • 如何防止重启后的死锁 `@Slf4j @Component @EnableScheduling public class ResumeCollectionTask implements ApplicationListener {

    @Resource private RedisService redisService; @Resource private AsyncResumeParser asyncResumeParser;

    /**

    • 每个服务器随机获取 */ public static final String MACHINE_ID = String.valueOf(ThreadLocalRandom.current().nextLong(10000000));

    /**

    • 项目启动后 尝试删除之前的锁(如果存在) 防止死锁
    • @param contextRefreshedEvent */ @Override public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) { redisService.releaseLock(RedisKeyConst.RESUME_PULL_TASK_LOCK); }

    /**

    • 每一分钟 调用一次此方法 */ @Scheduled(cron = "0 */1 * * * ?") public void resumeSchedule(){ // 尝试上锁,返回true或false,锁的过期时间设置为10分钟(实际要根据项目调整,这也是自己实现Redis分布式锁的难点之一) boolean lock = redisService.tryLock(RedisKeyConst.RESUME_PULL_TASK_LOCK, MACHINE_ID, 10, TimeUnit.MINUTES); // 如果当前服务器成功获取锁,那么整个系统只允许当前程序去MySQL拉取待执行任务 if (lock) { log.info("节点:{}获取锁成功,任务开始",MACHINE_ID); try { collectResume(); }catch (Exception e){ e.printStackTrace(); log.info("节点{},任务执行异常",e); }finally { // 执行完毕释放当前MACHINE_ID的锁 redisService.unLock(RedisKeyConst.RESUME_PULL_TASK_LOCK,MACHINE_ID); log.info("节点:{},任务执行完毕,释放锁",MACHINE_ID); } }else { log.info("节点:{},获取锁失败",MACHINE_ID); } }

    /**

    • 任务主体

    • 1.从数据库拉取符合条件的HR邮箱

    • 2.从HR邮箱拉取附件简历

    • 3.调用远程服务异步解析简历

    • 4.插入待处理任务到数据库,作为记录留存

    • 5.把待处理任务的id丢到Redis Message Queue,让Consumer异步处理 */ private void collectResume() throws InterruptedException { // 模拟从数据库拉取数据 log.info("节点{}:从数据库拉取任务简历",MACHINE_ID); List resumeCollectionDOList = new ArrayList<>(); resumeCollectionDOList.add(new ResumeCollectionDO(1L, "张三的简历.pdf")); resumeCollectionDOList.add(new ResumeCollectionDO(2L, "李四的简历.html")); resumeCollectionDOList.add(new ResumeCollectionDO(3L, "王五的简历.doc")); TimeUnit.SECONDS.sleep(2); log.info("提交任务到消息队列:{}",resumeCollectionDOList.stream().map(ResumeCollectionDO::getName).collect(Collectors.joining(",")));

      resumeCollectionDOList.forEach(resumeCollectionDO -> { // 通过第三方解析简历 获取解析id Long asyncParseId = asyncResumeParser.asyncParse(resumeCollectionDO); // 存入数据库操作

       // 把任务放进Redis 消息队列 交由消费者处理
       ResumeCollectionDTO resumeCollectionDTO = new ResumeCollectionDTO();
       BeanUtils.copyProperties(resumeCollectionDO,resumeCollectionDTO);
       resumeCollectionDTO.setAsyncPredictId(asyncParseId);
      
       redisService.pushQueue(RedisKeyConst.RESUME_PARSE_TASK_QUEUE,resumeCollectionDTO);
      

      }); } } `

``@Component @Slf4j public class RedisMessageQueueConsumer implements ApplicationListener {

@Resource
private RedisService redisService;
@Resource
private AsyncResumeParser asyncResumeParser;
@Resource
private ObjectMapper objectMapper;

@Override
public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
    log.info("开始监听RedisMessageQueue...");
    // 新建一个异步任务监听
    CompletableFuture.runAsync(()->{
        // 大循环 不断监听消息队列中的消息 阻塞式
        while (true){
            // 阻塞监听 每5秒获取一次 不为空返回
            ResumeCollectionDTO resumeCollectionDTO = (ResumeCollectionDTO) redisService
                    .popQueue(RedisKeyConst.RESUME_PARSE_TASK_QUEUE,5, TimeUnit.SECONDS);
            // 获取到队列消息时处理
            if (resumeCollectionDTO != null){
                int rePullCount = 0;
                int reTryCount = 0;

                log.info("从队列中取出简历:{}",resumeCollectionDTO.getName());
                log.info("----------开始拉取简历:{}------",resumeCollectionDTO.getName());

                // 获取异步解析简历结果ID
                Long asyncPredictId = resumeCollectionDTO.getAsyncPredictId();

                // 每次任务多次调用第三方解析接口 即拉取解析好的简历 直到获取最终结果或丢弃任务
                while (true){
                    try {
                        PredictResult result = asyncResumeParser.getResult(asyncPredictId);
                        // 拉取次数累加
                        rePullCount++;
                        // 简历解析完毕后
                        if (2 == result.getStatus()){
                            // 保存数据库
                            try {
                                log.info("解析简历成功:{}",resumeCollectionDTO.getName());
                                log.info("解析的Json为:{}",result.getResultJson());
                                ResumeCollectionDO resumeCollectionDO = objectMapper.readValue(result.getResultJson(), ResumeCollectionDO.class);
                                log.info("简历:{}存入数据库",resumeCollectionDO);
                                rePullCount = 0;
                                reTryCount = 0;
                                break;
                            }catch (Exception e){
                                discardTask(resumeCollectionDTO);
                                e.printStackTrace();
                                log.info("简历拉取/解析失败...丢弃该任务");
                                rePullCount = 0;
                                reTryCount = 0;
                                break;
                            }
                        }else {
                            // 第三方解析还未完成解析 则重新拉取
                            try {

                            }catch (Exception e){
                                int timeout = 1;
                                if (rePullCount <= 3){
                                    // 前三次 等待1s后重试
                                    timeout = 1;
                                    TimeUnit.SECONDS.sleep(timeout);
                                }else if (rePullCount <= 6){
                                    timeout = 2;
                                    TimeUnit.SECONDS.sleep(timeout);
                                }else if (rePullCount <= 9){
                                    timeout = 2;
                                    TimeUnit.SECONDS.sleep(timeout);
                                }else {
                                    discardTask(resumeCollectionDTO);
                                    log.info("多次拉取解析的简历仍未获得结果,丢弃简历:{}");
                                    rePullCount = 0;
                                    reTryCount = 0;
                                    break;
                                }
                                log.info("简历:{}尚未解析完毕,正进行第{}次重试,停顿后{}秒执行...",resumeCollectionDTO.getName(),rePullCount,timeout);
                            }
                        }

                    } catch (Exception e) {
                        if (reTryCount > 3){
                            discardTask(resumeCollectionDTO);
                            log.info("<<<<<<<<<<<<<<<<<<<简历:{}重试{}次后放弃, rePullCount:{}, retryCount:{}", resumeCollectionDTO.getName(), reTryCount, rePullCount, reTryCount);
                            rePullCount = 0;
                            reTryCount = 0;
                            break;
                        }
                        reTryCount++;
                        log.info("简历:{}远程调用异常,正准备第{}次重试",resumeCollectionDTO.getName(),reTryCount,e);
                        e.printStackTrace();
                    }
                }
            }
        }
    });
}

private void discardTask(ResumeCollectionDTO resumeCollectionDTO) {
    log.info("------丢弃任务:{}------",resumeCollectionDTO.getName());
}

}`

只有一个定时任务能去数据库拉取任务,到时多节点部署大致是下面这样(Redis一般是独立部署的,和节点代码无关)

总结: 上面的代码中存在很多问题。 对异步调用的结果,不要循环等待,而应该分为几步: 1.调用异步接口,得到异步结果唯一id 2.将结果id保存到任务表中,作为一个任务 3.启动定时任务,根据id拉取最终结果(如果还没有结果,跳过当前任务,等下一个定时任务处理)

正式项目中 分布式定时任务可以考虑:xxl-job或elastic-job 分布式锁推荐使用:redisson