在当今的高并发系统中,分布式锁是保障数据一致性和系统稳定性的重要手段。今天,我们就来深入探讨一下Redis分布式锁,揭开它神秘的面纱。
1 本地锁与分布式锁的区别
在Java开发的早期阶段,我们接触过synchronized
和Lock
锁,这些都属于本地锁。本地锁的特点是仅对当前节点有效,举个例子:假设我们有两个节点node A和node B,当node A获取了本地锁时,node B依然可以获取相同的锁。
如果我们的服务仅部署了一个节点,本地锁是完全能够满足需求的。但随着业务的发展,为了应对高并发、实现高可用和高性能,很多系统会采用多节点(集群部署)的方式。此时,本地锁就显得力不从心了,分布式锁便应运而生。
为什么本地锁锁不住?
- 锁范围有限:有多个服务实例时,本地锁只在自己的实例里起作用。像
synchronized
,不同实例的锁对象(字节码 )不一样。大量用户查文章详情,缓存没数据时,不同实例的线程都能去连MySQL,本地锁拦不住。- 跨进程管不了:服务分布式部署,在不同JVM进程里。本地锁只能管自己进程内的线程,别的进程线程来访问MySQL ,它管不了。
- 高并发顶不住:高并发时很多请求同时来,本地锁在单个JVM里抢锁很厉害,而且它也没法阻止其他JVM进程的线程访问MySQL ,数据库还是可能被大量请求弄“挂” 。
分布式锁的锁对象不在服务实例中,而是在服务实例的外部。分布式锁的核心思想是,当一个节点获取到锁后,其他节点无法获取该锁,从而保证了在分布式环境下的资源同步访问。
分布式锁的多种实现方式:
- Redis分布式锁;
- Zookeeper分布式锁;
- MySQL分布式锁。
2 Redis分布式锁、Zookeeper分布式锁与MySQL分布式锁的差异
谈到分布式锁,就不得不提到CAP理论,即强一致性(Consistency)、可用性(Availability)和分区容错性(Partition Tolerance),三者只能选其二。
- Redis分布式锁 :它追求的是高可用性和分区容错性。Redis在写入主节点数据后,会立即返回成功,而不关心异步主节点同步从节点数据是否成功。由于Redis是基于内存的,其性能极高,官方给出的指标是每秒可达到10W的吞吐量。适用于对性能要求较高、允许一定数据延迟一致性的场景,比如一般的电商商品浏览、秒杀活动中的部分非核心数据校验等场景。
- Zookeeper分布式锁 :Zookeeper更侧重于强一致性和分区容错性。在写入主节点数据后,Zookeeper会等待从节点同步完数据后才返回成功,这在一定程度上牺牲了可用性。常用于对数据一致性要求极高的场景,例如金融行业的账务处理等场景。
- MySQL分布式锁 :
- 实现原理:通常利用数据库自身的事务机制和行锁、表锁等特性来实现。比如通过在特定表中插入或更新特定记录,并利用事务的原子性来获取锁。若插入或更新成功则表示获取锁成功,否则获取失败。
- 性能方面:相比Redis基于内存的操作,MySQL分布式锁由于涉及磁盘I/O(即使有缓存机制 ),在高并发场景下性能相对较低。例如在大量短时间内的锁竞争场景中,Redis可能轻松应对每秒数万甚至数十万的请求,而MySQL可能每秒只能处理数千请求。
- 一致性与可用性:它在一定程度上能保证数据的强一致性,因为基于事务机制,数据操作要么全部成功,要么全部失败。但在可用性方面,当数据库出现故障(如主库宕机 )时,可能导致锁服务不可用,且恢复时间相对较长。而且,若锁表等关键资源出现瓶颈,会严重影响整个系统的可用性。
- 适用场景:适用于对一致性要求较高,并发量不是特别极端高,且业务逻辑相对复杂,需要借助数据库事务特性来保证数据完整性的场景,比如一些企业内部的业务流程审批系统,涉及多步骤数据更新和状态转换,利用MySQL分布式锁可以结合事务更好地控制流程顺序和数据一致性。
综合考虑,为了追求更好的用户体验度,在高并发且对性能要求较高、对数据一致性有一定容忍度的场景下,很多时候会选择Redis分布式锁来实现;而在对一致性要求极高,对性能要求相对没那么苛刻的场景下,可能会选择Zookeeper分布式锁或MySQL分布式锁。
3 使用Redis分布式锁的背景
以查询文章详情为例:
用户根据articleId查询文章详情时,正常流程是先查询缓存,如果缓存中有数据,直接返回;如果缓存中没有数据,则需要到MySQL中查询。在并发量不高的情况下,这个流程没有问题。但当并发量很高时,就会出现问题。假设缓存中没有数据,大量用户会同时访问DB层的MySQL。而MySQL的资源相对珍贵,且性能不如Redis,很容易导致MySQL被打宕机,进而影响整个服务。
为了解决这个问题,当大量用户同时访问同一篇文章时,我们只允许一个用户去MySQL中获取数据。由于服务是集群化部署的,所以需要用到Redis分布式锁。通过加锁的方式,可以有效地保护DB层数据库,保证系统的高可用性。
4 Redis分布式锁的实现方式
4.1 Redis实现分布式锁
- 项目仓库(GitHub):https://github.com/itwanger/paicoding
- 项目仓库(码云):https://gitee.com/itwanger/paicoding
- 分支:
origin/feature/redis_distributed_lock_20230531
4.1.1 第一种方式:setIfAbsent(key,value,time)
使用redisTemplate.opsForValue().setIfAbsent(key, value, time, TimeUnit.SECONDS)
,对应的Redis命令是set key value EX time NX
。这是一个复合操作,由setNx + setEx
组成,底层采用lua脚本来保证原子性,要么全部成功,否则加锁失败。其含义是:如果key不存在,则加锁成功,返回true;否则加锁失败,返回false 。
代码实现:
/*** Redis分布式锁第一种方法** @param articleId* @return ArticleDTO*/
private ArticleDTO checkArticleByDBOne(Long articleId) {String redisLockKey =RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;ArticleDTO article = null;// 加分布式锁:此时value为null,时间为90s(结合自己场景设置合适过期时间)Boolean isLockSuccess = redisUtil.setIfAbsent(redisLockKey, null, 90L);if (isLockSuccess) {// 加锁成功可以访问数据库article = articleDao.queryArticleDetail(articleId);} else {try {// 短暂睡眠,为了让拿到锁的线程有时间访问数据库拿到数据后set进缓存,// 这样在自旋时就能够从缓存中拿到数据;注意时间依旧结合自己实际情况Thread.sleep(200);} catch (InterruptedException e) {e.printStackTrace();}// 加锁失败采用自旋方式重新拿取数据this.queryDetailArticleInfo(articleId);}return article;
}
这种方式的主要逻辑是:当缓存中没有数据时,开始加锁,加锁成功则允许访问数据库,加锁失败则自旋重新访问。但它存在一个缺点,虽然在setIfAbsent
中设置了过期时间,但可能会出现业务执行完之后,锁还被持有的情况。虽然Redis有淘汰策略,但这种情况还是不建议出现,因为Redis缓存资源非常重要,正确的做法应该是业务执行完后直接释放锁。
4.1.2 第二种方式:setIfAbsent(key,value,time)
的优化
为了解决第一种方式中锁不能及时释放的问题,我们在业务执行完毕之后(增加finally
块)立即删除key值。
代码实现:
/*** Redis分布式锁第二种方法** @param articleId* @return ArticleDTO*/
private ArticleDTO checkArticleByDBTwo(Long articleId) {String redisLockKey =RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;ArticleDTO article = null;Boolean isLockSuccess = redisUtil.setIfAbsent(redisLockKey, null, 90L);try {if (isLockSuccess) {article = articleDao.queryArticleDetail(articleId);} else {Thread.sleep(200);this.queryDetailArticleInfo(articleId);}} catch (InterruptedException e) {e.printStackTrace();} finally {// 和第一种方式相比增加了finally中删除keyRedisClient.del(redisLockKey);}return article;}
但这种方式也存在问题,比如线程A获取到锁并正在执行业务,还未执行完成时,锁的过期时间到了,该锁被释放。此时线程B可以获取该锁并执行业务逻辑,而当线程A执行完成后,它释放的将是线程B的锁,即释放了别人的锁。
4.1.3 第三种方式:改进误释放锁的问题
为了解决误释放他人锁的情况,我们在加锁时设置一个value值,然后在释放锁前判断给key的value是否和前面设置的value值相等,相等则说明是自己的锁,可以删除;否则是别人的锁,不能删除。
代码实现:
/*** Redis分布式锁第三种方法** @param articleId* @return ArticleDTO*/
private ArticleDTO checkArticleByDBThree(Long articleId) {String redisLockKey =RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;// 设置value值,保证不误删除他人锁String value = RandomUtil.randomString(6);Boolean isLockSuccess = redisUtil.setIfAbsent(redisLockKey, value, 90L);ArticleDTO article = null;try {if (isLockSuccess) {article = articleDao.queryArticleDetail(articleId);} else {Thread.sleep(200);this.queryDetailArticleInfo(articleId);}} catch (InterruptedException e) {e.printStackTrace();} finally {// 这种先get出value,然后再比较删除;这无法保证原子性,为了保证原子性,采用了lua脚本/*String redisLockValue = RedisClient.getStr(redisLockKey);if (!ObjectUtils.isEmpty(redisLockValue) && StringUtils.equals(value, redisLockValue)) {RedisClient.del(redisLockKey);}*/// 采用lua脚本来进行先判断,再删除;和上面的这种方式相比保证了原子性Long cad = redisLuaUtil.cad("pai_" + redisLockKey, value);log.info("lua 脚本删除结果:" + cad);}return article;}
不过,这种方式又带来了一个新问题,那就是过期时间的值该如何设置呢?
- 如果时间设置过短,可能业务还未执行完毕,锁就已经过期被释放,其他线程可以拿到锁去访问DB,这就违背了我们加锁的初衷;
- 如果时间设置过长,可能在加锁成功后还未执行到释放锁时,节点宕机了,那么在锁未过期的这段时间,其他线程无法获取锁。
- 针对这个问题,我们可以写一个守护线程,每隔固定时间查看业务是否执行完毕,如果没有执行完毕,则延长其过期时间,即为锁续期。
4.2 Redission实现分布式锁
Redission实现分布式锁的流程是:首先获取锁(get lock()
),然后尝试加锁,加锁成功后执行下面的业务逻辑,执行完毕之后释放该分布式锁。
代码实现:
/*** Redis分布式锁第四种方法** @param articleId* @return ArticleDTO*/
private ArticleDTO checkArticleByDBFour(Long articleId) {ArticleDTO article = null;String redisLockKey =RedisConstant.REDIS_PAI + RedisConstant.REDIS_PRE_ARTICLE + RedisConstant.REDIS_LOCK + articleId;RLock lock = redissonClient.getLock(redisLockKey);//lock.lock();try {//尝试加锁,最大等待时间3秒,上锁30秒自动解锁if (lock.tryLock(3, 30, TimeUnit.SECONDS)) {article = articleDao.queryArticleDetail(articleId);} else {// 未获得分布式锁线程睡眠一下;然后再去获取数据Thread.sleep(200);this.queryDetailArticleInfo(articleId);}} catch (InterruptedException e) {e.printStackTrace();} finally {//判断该lock是否已经锁 并且 锁是否是自己的if (lock.isLocked() && lock.isHeldByCurrentThread()) {lock.unlock();}}return article;
}
Redission解决了Redis实现分布式锁中出现的锁过期问题和释放他人锁的问题。它还是可重入锁,内部机制是默认锁过期时间是30s,然后会有一个定时任务每10s去扫描一下该锁是否被释放,如果没有释放则延长至30s,这就是看门狗机制。如果请求没有获取到锁,那么它将通过while
循环继续尝试加锁。
5 总结
通过本文,我们从原理到实践,详细介绍了Redis分布式锁的相关知识。我们了解了本地锁与分布式锁的区别,Redis分布式锁、Zookeeper分布式锁、MySQL分布式锁的差异,以及Redis分布式锁的几种实现方式。虽然Redission实现分布式锁基本解决了大部分问题,但当Redis是主从架构时,它也存在一些问题,比如线程A在master节点加锁后还未同步到slave节点,此时master节点挂了,线程B仍可以加锁,这涉及到高一致性问题,Redission无法解决。
如果想要解决高一致性问题,可以使用红锁或者zk锁,它们保证了高一致性,但不建议使用,因为为了保证高一致性,它们丢失了高可用性,对用户体验感不好,而且上述问题出现的几率不大,我们不能因为很小的问题而舍弃其高可用性。
希望本文能帮助大家更好地理解和应用Redis分布式锁,在实际项目中根据具体需求选择合适的分布式锁实现方式。
6 参考链接
- 技术派Redis分布式锁