我们在不久前介绍了SpringBoot定时任务,最近又一起探究了如何使用Redis实现简单的消息队列,都是一些不错的小知识点。为了能跟前面的内容产生联动,这次我们打算把Redis分布式锁相关的介绍融合进定时任务的案例中,学起来更带劲~
Redis的锁长啥样?
上一篇我们粗略介绍了JVM锁,比如synchronized关键字和ReentrantLock,它们都是实实在在已经实现的锁,而且还有标志位啥的。但Redis就是一个内存...怎么作为锁呢?
有一点大家要明确,Redis之所以能用来做分布式锁,肯定不只是因为它是一片内存,否则JVM本身也占有内存,为什么无法自己实现分布式锁呢?
我个人的理解是,要想自定义一个分布式锁,必须至少满足几个条件:
独立于多节点系统之外的一片内存
唯一性(可以通过单线程,也可以通过选举机制,能保证唯一即可)
当然,如果性能高一点,甚至支持高可用就更好啦
以上三点Redis都能满足。在上面三个条件下,其实怎么设计锁,完全取决于个人如何定义锁。就好比现实生活中,通常我们理解的锁就是有个钥匙孔、需要插入钥匙的金属小物件。然而锁的形态可不止这么一种,随着科技的发展,什么指纹锁、虹膜锁层出不穷,但归根结底它们之所以被称为“锁”,是因为都保证了“唯一”。
如果我们能设计一种逻辑,它能造成某个场景下的“唯一事件”,那么它就可以被称为“锁”。比如,某家很有名的网红店,一天只接待一位客人。门口没有营业员,就放了一台取号机,里面放了一张票。你如果去迟了,票就没了,你就进不了这家店。这个场景下,没票的顾客进不去,被锁在门外。此时,取票机造成了“唯一事件”,那么它就可以叫做“锁”。
而Redis提供了setnx指令,如果某个key当前不存在则设置成功并返回true,否则不再重复设置,直接返回false。这不就是编程界的取号机吗?当然,实际用到的命令可不止这一个,具体如何实现,大家等下看代码即可。
Demo构思
在我看来,同样需要使用锁,动机可能完全相反:
在保证线程安全的前提下,尽量让所有线程都执行成功
在保证线程安全的前提下,只让一个线程执行成功
前者适用于秒杀等场景。作为商家,当然希望在不发生线程安全问题的前提下,让每一个订单都生效,直到商品售罄。此时分布式锁的写法可以是“不断重试”或“阻塞等待”,即:递归或while true循环尝试获取、阻塞等待。

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

此时可以考虑使用分布式锁,在cron触发的时刻只允许一个线程去往数据库拉取任务:

在实现Redis分布式锁控制定时任务唯一性的同时,我们引入之前的Redis消息队列。注意,这与Redis分布式锁本身无关,就是顺便复习一遍Redis消息队列而已,大家可以只实现Redis分布式锁+定时任务的部分。
整个Demo的结构大致如图:

当然,实际项目中一般是这样的:

分布式锁为什么难设计?
首先,要和大家说一下,但凡牵涉到分布式的处理,没有一个是简单的,上面的Demo设计也不过是玩具,用来启发 大家的思路。
为什么要把demo设计得这么复杂呢?哈哈,因为这是我在上一家公司自己设计的,遇到了很多坑...拿出来自嘲一番,与各位共勉。
我当时的设计思路是:
由于小公司没有用什么Elastic-Job啥的,就是很普通的多节点部署。为了避免任务重复执行,我想设计一个分布式锁。但因为当时根本不知道redisson,所以就自己百度了redis实现分布式锁的方式,然后依葫芦画瓢自己手写了一个 。
但我写完redis分布式锁后,在实际测试过程中发现还需要考虑锁的失效时间...
这里有两个问题:
为什么要设置锁的过期时间?
锁的过期时间设置多久合适?
最简单的实现方案是这样的,一般没问题:

但极端的情况下(项目在任务进行时重启或意外宕机),可能当前任务来不及解锁就挂了(死锁),那么下一个任务就会一直被锁在方法外等待。就好比厕所里有人被熏晕了,没法开门,而外面的人又进不去...

此时需要装一个自动解锁的门,到时间自动开门,也就是要给锁设置一个过期时间。但紧接着又会有第二个问题:锁的失效时间设多长合适?
很难定。
因为随着项目的发展,定时任务的执行时间很可能是变化的。
如果设置时间过长,极端点,定为365天。假设任务正常执行,比如10分钟就结束,此时执行完毕的任务自己会主动解锁。但万一和上面一样宕机了,虽说你设置了过期时间,但下一个任务需要等一年才能执行...本质上和没有设置过期时间一样!就好比...你自己想想什么例子合适,能加深你的理解哦。
如果设置时间过短,上一个人还没拉完,门就“咔嚓”一声开了,尴尬不,重复执行了。
终上所述,我当时之所以设计得这么复杂,就是想尽量缩短任务执行的时间,让它尽可能短(拉取后直接丢给队列,自己不处理),这样锁的时间一般设置30分钟就没啥问题。另外,对于死锁问题,我当时没有考虑宕机的情况,只考虑了意外重启…问题还有很多,文末会再总结。
请大家阅读下面代码时思考两个问题:
Demo如何处理锁的过期时间
Demo如何防止死锁
项目搭建
新建一个空的SpringBoot项目。
拷贝下方代码,构建工程:

构建完以后,拷贝一份,修改端口号为8081,避免和原先的冲突

统一管理Redis Key:RedisKeyConst
/**
* 统一管理Redis Key
*
* @author qiyu
*/
public abstract class RedisKeyConst {
/**
* 分布式锁的KEY
*/
public static final String RESUME_PULL_TASK_LOCK = "resume_pull_task_lock";
/**
* 简历异步解析任务队列
*/
public static final String RESUME_PARSE_TASK_QUEUE = "resume_parse_task_queue";
}
Redis消息队列:RedisMessageQueueConsumer
/**
* 消费者,异步获取简历解析结果并存入数据库
*
* @author qiyu
*/
@Slf4j
@