Redis实现分布式锁方案
mhr18 2024-11-27 11:57 23 浏览 0 评论
一、并发安全问题
先来引入一个问题,复习复习以前多线程和数据库的知识。
如下所示,有一个Product表,带有库存:
在这里提供一个简单的扣减库存的接口:
@Override
@Transactional
public boolean buy(Integer id) {
boolean b = false;
//1)从数据库获取库存
int stock = mapper.getStock(id);
//2)判断库存是否充足
if (stock > 0) {
System.out.println("库存为:" + stock);
//3)扣减库存
b = mapper.updateStock(id, stock - 1);
}
return b;
}
使用jmeter测试,开启n多个线程同时去扣减id为1的数据的库存(每次减一):
最终的结果显示库存为负数,明显超卖了(有可能是正数),这是在单线程下的并发安全问题。问题的原因是多个线程同时查询,都查询到库存充足,进入if块了,但库存被扣完了,此时就会超卖。
解决这种并发问题有多种:
①在应用层面加锁,也就是加synchronized同步代码块:
public boolean director(Integer id) {
synchronized (this) {
return ((ProductService)AopContext.currentProxy()).buy(id);
}
}
@Override
@Transactional(isolation = Isolation.REPEATABLE_READ, propagation = Propagation.REQUIRED)
public boolean buy(Integer id) {
boolean b = false;
int stock = mapper.getStock(id);
if (stock > 0) {
System.out.println("库存为:" + stock);
b = mapper.updateStock(id, stock - 1);
}
return b;
}
为什么这么写?是因为直接加锁有一点问题——>传送门。
②在数据库层面加悲观锁:在第一条查询语句时就加上排他锁,保证了只有一个线程能够同时查询和更新
<!-- int getStock(Integer id);-->
<select id="getStock" resultType="_int">
select stock from product where id = #{id} for update
</select>
③数据库层面加乐观锁。但乐观锁在高并发场景下失败率会急剧增加,严重影响效率
<update id="updateStock">
update product set stock=#{count},version=version+1 where id=#{id} and version=#{version}
</update>
集群服务的情况:
上面的问题解决方案是在单机服务的基础上,如果我们的服务是分布式的,用户访问的是集群系统中的多个节点,此时加synchronized的方式就不行了,但是在数据库层面加锁还可以解决的。
因为synchronized的锁由本地jvm管控,它是在一个进程的基础上实现的;而分布式系统它包含多个jvm,也就是多个进程,我们代码中的锁对象在这多个jvm中都是独立的,锁的逻辑也是独立的,允许多个线程在不同的节点中同时进入synchronized代码块。
而在数据库层面上锁就不一样了,我们这的数据库是一个全局性的,所有集群中的节点都能感知到它,所以说它可以控制所有节点的加锁情况。但这种锁不是很通用。
二、为什么需要分布式锁?
分布式锁用于在分布式系统中的多个进程之间保证互斥性。单机锁是在一个进程上有用,而分布式系统中一个服务可能是一个集群,分布在多个进程上,此时单机锁就控制不了了,需要使用一个 全局的锁 ,让集群中的所有节点都能感知到它,需要加锁时都去请求该全部锁。
实现分布式锁的方案有哪些?
- 基于数据库的唯一索引 。通过insert记录模拟加锁,通过delete删除记录模拟加锁失败,唯一索引保证只能加锁一次,实现简单、性能不高、锁功能少
- 基于ZooKeeper 。在/lock目录下创建临时有序节点,判断创建的节点序号是否最小。若是,则表示获取到锁;否则则watch /lock目录下序号比自身小的前一个节点
- 基于redis 。通过setnx的结果判断是否加锁成功,性能高,有些地方需配合lua脚本保证命令的原子性
- redisson框架 :对redis锁方案的一个封装,简单易用、支持锁重入、支持阻塞等待、Lua脚本原子操作
分布式锁的特性应该是:多进程可见、互斥、高可用、高性能
三、Redis分布式锁方案
前面说的redis通过 setnx 可以模拟分布式锁这一个过程。如果某线程通过setnx获取到锁,那么后面的线程再执行setnx就会返回0,也就是加锁失败,因为 redis单个命令必须是串行执行的 ,所以可以保证多个线程执行的一致性。
具体实现方式:
private static final String LOCK_NAME = "service:product:lock";
//1.加锁操作
private boolean lock() {
Boolean res = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_NAME, "1");
return res == null ? false : res;
}
//2.解锁操作
private void unlock() {
stringRedisTemplate.delete(LOCK_NAME);
}
public boolean director(Integer id) {
try {
boolean locked = lock();
//如果未获取到锁就等50ms
while (!locked) {
ThreadUtils.sleep(50);
locked = lock();
}
return ((ProductService) AopContext.currentProxy()).buy(id);
} finally {
unlock();
}
}
@Override
@Transactional(isolation = Isolation.REPEATABLE_READ, propagation = Propagation.REQUIRED)
public boolean buy(Integer id) {
boolean b = false;
int stock = mapper.getStock(id);
if (stock > 0) {
System.out.println("库存为:" + stock);
b = mapper.updateStock(id, stock - 1);
}
return b;
}
这是一个简单的redis实现的分布式锁,还比较简陋,存在一些问题:
1.避免死锁
问题:假如节点A获取到锁,如果节点A突然宕机了,此时锁还没有被释放(DEL key操作),所以会出现类似死锁的现象,导致其它节点永远获取不到锁。
解决方案:在申请锁时设定一个 过期时间 ,超过该时间锁自动释放。假设操作共享资源的时间不超过5s,那么过期时间设置为5s即可:
SETNX lock 1 //添加锁
EXPIRE lock 5 //5s自动过期
但是还有个问题:这是两条语句,存在并发问题,如果某个客户都执行了第一条语句获取到了锁,但是还没来得及设置过期时间就宕机了,那么死锁还是会出现。
还好在Redis 2.6.12之后,Redis 扩展了SET命令的参数,将SETNX和EXPIRE融合为一个命令:
SET lock 1 EX 5 NX
命令解释:EX表示设置过期时间,设置为5s,最后的NX表示SETNX中的NX,不存在key才能设置成功。
另外,过期时间设的不好也会出现问题:
- 不能太长,否则持有锁的节点过期,会让其他节点等待太久,没有必要。
- 不能太短,至少要高于业务的执行时间,如果业务还没完就自动释放锁了,其他线程就会过来占用锁,此时互斥资源就会出错,且还会出现客户端A释放客户端B锁的情况 :
2.避免释放别人的锁
超时时间提前过期,会导致:
1.其他线程也能进入临界区,导致业务出错,出现超卖问题。
2.释放其他线程的锁,导致连锁反应,每次都有两个线程可以同时进入临界区。
这里主要是讲解如何避免释放别人的锁。
解决办法:客户端在加锁时,给锁打上自己的标志,解锁时再校验一下是否为当初的标志,如果是才能解锁,否则啥也不干。
可以用线程ID或UUID作为该标志,如下:
//加锁流程
SET lock $threadId EX 5 NX
解锁时进行校验,类似:
if( threadId == redis.get("lock") ){
redis.del("lock");
}
但是该逻辑也是两条语句组成的, 不是原子的 ,而且redis没有单独的语句和这两条语句的作用相同。在极端情况下,就会出现一个问题:
当线程A判断该锁的id是自己的时候,还没释放锁之前,可能就停了,如果这时候锁突然过期了,而且又冲过来一个线程B趁机抢到了redis的锁,此时线程A继续执行,结果把线程B的锁给释放了。
那怎么办? 这就需要融合 Lua脚本 来玩了。
在Redis中可以执行Lua脚本,且是单线程的执行,执行时其他请求必须等待,直到Lua脚本执行完毕。这样就保证了原子性。redis提供的调用函数:
#以另一种方式执行redis命令
redis.call('命令名称','key','参数',...)
#执行LUA脚本(可以传参)。 格式:eval 脚本 参数个数 参数.. 其他参数..
eval script numkeys key [key ...] arg [arg ...]
例如:
eval "return redis.call('set','name','jack')" 0
它就是执行set name jack操作。
我们的解锁Lua脚本可以如下:
if redis.call('GET',KEYS[1]) == ARGV[1] then
return redis.call('DEL',KEYS[1])
else
return 0
end
StringRedisTemplate通过如下方法调用lua脚本:
所以改造后的解锁流程应该如下:
public static final String LOCK_NAME = "service:product:lock";
//脚本对象
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
//初始化解锁的lua脚本对象
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
//设置脚本,可以从外部文件获取,也可以硬编码
UNLOCK_SCRIPT.setScriptText("if redis.call('GET',KEYS[1]) == ARGV[1] then\n" +
" return redis.call('DEL',KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end");
//UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
private void unlock() {
//执行脚本对象
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(LOCK_NAME),
Thread.currentThread().getId()+"");
}
3.锁超时问题
解决方法:
1.加锁时,先设置一个相对较短的过期时间,然后启动一个 守护线程 ,它定时的去检测这个锁的过期时间,如果锁快要过期了,但是锁的持有者还没有完成操作共享资源,那就自动对锁进行续命, 重新设置过期时间 。
该守护线程称为看门狗线程,是在Redisson框架中的一个实现
2. 超时回滚 :当我们解锁时发现锁已经被其他线程获取了,说明此时我们执行的操作已经是“不安全”的了,此时需要进行回滚,并返回失败。该方法是乐观锁的一个思想。
使用看门狗这种方式较可靠,但是在极端情况下还是会发生问题:
- 客户端1获取到锁,将值写入到redis的master节点
- 在master节点同步锁的值到slave节点之前,master发生故障
- redis触发故障转移,将其中一个slave升级为新的master
- 此时新的master节点中并不包含锁的信息,因此其它客户端依旧能获取锁
- 此时相当于有两个线程获取到了锁,可能会导致各种预期之外的情况发生,例如最常见的脏数据
解决方法:上述问题的根本原因主要是由于 redis 异步复制带来的数据不一致问题导致的,因此解决的方向就是保证数据的一致。
当前比较主流的解法和思路有两种:
- Redis作者提出的RedLock
- Zookeeper实现的分布式锁
因为这种情况是很少遇见的,也比较难,所以就不做过多讲解。
如果想让分布式锁有更多功能,可以看看Redssion。
RedLock
整体的流程是这样的,一共分为5步:
- 获取当前时间戳T1。
- 依次 尝试从5个实例,使用相同的key和value获取锁 。当向Redis请求获取锁时,客户端应该设置一个超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。防止在宕机的Redis实例上浪费时间。如果一个实例不可用,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
- 获取当前时间戳T2。当且仅当从大多数(N/2+1)的Redis节点都取到锁,并且获取锁使用的时间小于锁失效时间时(T2-T1<过期时间),锁才算获取成功。
- 如果取到了锁,其有效时间等于有效时间减去获取锁所使用的时间,T2-T1。
- 如果由于某些原因未能获得锁(无法在至少N/2+1个Redis实例获取锁、或获取锁的时间超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
可以看出,该方案为了解决数据不一致的问题,直接舍弃了异步复制,只使用 master 节点,同时由于舍弃了slave,为了保证可用性,引入了N个节点,官方建议是5。
该方案看着挺美好的,但是实际上我所了解到的在实际生产上应用的不多,主要有两个原因:
1)该方案的成本似乎有点高,需要使用5个实例;
2)该方案一样存在问题。
该方案主要存以下问题:
1)严重依赖系统时钟。如果线程1从3个实例获取到了锁,但是这3个实例中的某个实例的系统时间走的稍微快一点,则它持有的锁会提前过期被释放,当他释放后,此时又有3个实例是空闲的,则线程2也可以获取到锁,则可能出现两个线程同时持有锁了。
2)如果线程1从3个实例获取到了锁,但是万一其中有1台重启了,则此时又有3个实例是空闲的,则线程2也可以获取到锁,此时又出现两个线程同时持有锁了。
针对以上问题其实后续也有人给出一些相应的解法,但是整体上来看还是不够完美,所以目前实际应用得不是那么多。
相关推荐
- 【推荐】一个开源免费、AI 驱动的智能数据管理系统,支持多数据库
-
如果您对源码&技术感兴趣,请点赞+收藏+转发+关注,大家的支持是我分享最大的动力!!!.前言在当今数据驱动的时代,高效、智能地管理数据已成为企业和个人不可或缺的能力。为了满足这一需求,我们推出了这款开...
- Pure Storage推出统一数据管理云平台及新闪存阵列
-
PureStorage公司今日推出企业数据云(EnterpriseDataCloud),称其为组织在混合环境中存储、管理和使用数据方式的全面架构升级。该公司表示,EDC使组织能够在本地、云端和混...
- 对Java学习的10条建议(对java课程的建议)
-
不少Java的初学者一开始都是信心满满准备迎接挑战,但是经过一段时间的学习之后,多少都会碰到各种挫败,以下北风网就总结一些对于初学者非常有用的建议,希望能够给他们解决现实中的问题。Java编程的准备:...
- SQLShift 重大更新:Oracle→PostgreSQL 存储过程转换功能上线!
-
官网:https://sqlshift.cn/6月,SQLShift迎来重大版本更新!作为国内首个支持Oracle->OceanBase存储过程智能转换的工具,SQLShift在过去一...
- JDK21有没有什么稳定、简单又强势的特性?
-
佳未阿里云开发者2025年03月05日08:30浙江阿里妹导读这篇文章主要介绍了Java虚拟线程的发展及其在AJDK中的实现和优化。阅前声明:本文介绍的内容基于AJDK21.0.5[1]以及以上...
- 「松勤软件测试」网站总出现404 bug?总结8个原因,不信解决不了
-
在进行网站测试的时候,有没有碰到过网站崩溃,打不开,出现404错误等各种现象,如果你碰到了,那么恭喜你,你的网站出问题了,是什么原因导致网站出问题呢,根据松勤软件测试的总结如下:01数据库中的表空间不...
- Java面试题及答案最全总结(2025版)
-
大家好,我是Java面试陪考员最近很多小伙伴在忙着找工作,给大家整理了一份非常全面的Java面试题及答案。涉及的内容非常全面,包含:Spring、MySQL、JVM、Redis、Linux、Sprin...
- 数据库日常运维工作内容(数据库日常运维 工作内容)
-
#数据库日常运维工作包括哪些内容?#数据库日常运维工作是一个涵盖多个层面的综合性任务,以下是详细的分类和内容说明:一、数据库运维核心工作监控与告警性能监控:实时监控CPU、内存、I/O、连接数、锁等待...
- 分布式之系统底层原理(上)(底层分布式技术)
-
作者:allanpan,腾讯IEG高级后台工程师导言分布式事务是分布式系统必不可少的组成部分,基本上只要实现一个分布式系统就逃不开对分布式事务的支持。本文从分布式事务这个概念切入,尝试对分布式事务...
- oracle 死锁了怎么办?kill 进程 直接上干货
-
1、查看死锁是否存在selectusername,lockwait,status,machine,programfromv$sessionwheresidin(selectsession...
- SpringBoot 各种分页查询方式详解(全网最全)
-
一、分页查询基础概念与原理1.1什么是分页查询分页查询是指将大量数据分割成多个小块(页)进行展示的技术,它是现代Web应用中必不可少的功能。想象一下你去图书馆找书,如果所有书都堆在一张桌子上,你很难...
- 《战场兄弟》全事件攻略 一般事件合同事件红装及隐藏职业攻略
-
《战场兄弟》全事件攻略,一般事件合同事件红装及隐藏职业攻略。《战场兄弟》事件奖励,事件条件。《战场兄弟》是OverhypeStudios制作发行的一款由xcom和桌游为灵感来源,以中世纪、低魔奇幻为...
- LoadRunner(loadrunner录制不到脚本)
-
一、核心组件与工作流程LoadRunner性能测试工具-并发测试-正版软件下载-使用教程-价格-官方代理商的架构围绕三大核心组件构建,形成完整测试闭环:VirtualUserGenerator(...
- Redis数据类型介绍(redis 数据类型)
-
介绍Redis支持五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)及Zset(sortedset:有序集合)。1、字符串类型概述1.1、数据类型Redis支持...
- RMAN备份监控及优化总结(rman备份原理)
-
今天主要介绍一下如何对RMAN备份监控及优化,这里就不讲rman备份的一些原理了,仅供参考。一、监控RMAN备份1、确定备份源与备份设备的最大速度从磁盘读的速度和磁带写的带度、备份的速度不可能超出这两...
你 发表评论:
欢迎- 一周热门
- 最近发表
- 标签列表
-
- oracle位图索引 (63)
- oracle批量插入数据 (62)
- oracle事务隔离级别 (53)
- oracle 空为0 (50)
- oracle主从同步 (55)
- oracle 乐观锁 (51)
- redis 命令 (78)
- php redis (88)
- redis 存储 (66)
- redis 锁 (69)
- 启动 redis (66)
- redis 时间 (56)
- redis 删除 (67)
- redis内存 (57)
- redis并发 (52)
- redis 主从 (69)
- redis 订阅 (51)
- redis 登录 (54)
- redis 面试 (58)
- 阿里 redis (59)
- redis 搭建 (53)
- redis的缓存 (55)
- lua redis (58)
- redis 连接池 (61)
- redis 限流 (51)