使用锁的动机:
- 在保证线程安全的前提下,尽量让所有线程都执行成功
- 在保证线程安全的前提下,只让一个线程执行成功
前者适用于秒杀场景。作为商家,保证线程安全的前提下,让每个订单都生效,直到商品售罄,此时分布式锁的写法可以是:不断重试或阻塞等待。即:递归或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