用万字长文来讲讲本地锁至分布式锁的演进和Redis实现,扩展 Redlock 红锁2:https://developer.aliyun.com/article/1394707
3.4、lua 脚本实现分布式锁
其实有阅读过 Redis 官方文档的朋友,在看上面的那个 set
命令的文档时,就会发现,其实滑到下半部分,Redis 就有提到不推荐使用SET resource-name anystring NX EX max-lock-time
这个简单方法来实现分布式锁~
并且也给出了相应的建议:如下图
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
我们使用的其实就是上面的脚本,哈哈
/** * @description: * @author: Ning Zaichun * @date: 2022年09月20日 20:59 */ @Service public class SetNxExLockServiceImpl implements ISetNxExLockService { @Autowired StringRedisTemplate stringRedisTemplate; @Autowired private MenuMapper menuMapper; private static final String SET_NX_EX_MENU_LIST = "set:nx:ex:menu:list"; private static final String SET_NX_EX_MENU_LIST_LOCK = "set:nx:ex:menu:list:lock"; private static final String SET_NX_EX_MENU_LIST_LOCK_VALUE = "lock"; @Override public List<MenuEntity> getList() { // 判断缓存是否有数据 String menuJson = stringRedisTemplate.opsForValue().get(SET_NX_EX_MENU_LIST); if (menuJson != null) { System.out.println("缓存中有,直接返回缓存中的数据"); List<MenuEntity> menuEntityList = JSON.parseObject(menuJson, new TypeReference<List<MenuEntity>>() { }); return menuEntityList; } // 从数据库中查询 List<MenuEntity> result = getMenuJsonFormDbWithLock(); return result; } /** * 问题:释放锁操作 丢失 原子性 * 解决方案: lua 脚本 * 既然删除操作变成了两步,失去了原子性,那么我们就把它改成一步就行啦呀, * 此时就得用上我们的 lua 脚本了 * * @return */ public List<MenuEntity> getMenuJsonFormDbWithLuaLock() { List<MenuEntity> result = new ArrayList<>(); System.out.println("缓存中没有,加锁,重新从数据中查询~==>"); // 给锁设定一个时间 String uuid = UUID.randomUUID().toString(); Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(SET_NX_EX_MENU_LIST_LOCK, uuid, 5L, TimeUnit.SECONDS); if (lock) { System.out.println("获取分布式锁成功..."); try { //加锁成功...执行业务 result = menuMapper.selectList(new QueryWrapper<MenuEntity>()); stringRedisTemplate.opsForValue().set(SET_NX_EX_MENU_LIST, JSON.toJSONString(result)); } finally { // 编写 lua 脚本 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; //删除锁 execute 这个放番中的参数可能还需要提一提 stringRedisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList(SET_NX_EX_MENU_LIST_LOCK), uuid); } return result; } else { System.out.println("获取分布式锁失败...等待重试..."); //加锁失败...重试机制 //休眠一百毫秒 try { TimeUnit.MILLISECONDS.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } return getMenuJsonFormDbWithLuaLock(); } } @Override public Boolean updateMenuById(MenuEntity menu) { // return updateMenuByIdWithLock(menu); // return updateMenuWithExpireLock(menu); return updateMenuWithLuaLock(menu); } /** * 问题:释放锁操作 丢失 原子性 * 解决方案: lua 脚本 * 既然删除操作变成了两步,失去了原子性,那么我们就把它改成一步就行啦呀, * 此时就得用上我们的 lua 脚本了 * * @return */ public Boolean updateMenuWithRedisLock(MenuEntity menu) { String uuid = UUID.randomUUID().toString(); Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(SET_NX_EX_MENU_LIST_LOCK, uuid, 5L, TimeUnit.SECONDS); Boolean update = false; if (lock) { System.out.println("获取分布式锁成功..."); try { //加锁成功...执行业务 stringRedisTemplate.delete(SET_NX_EX_MENU_LIST); // 更新数据库 update = menuMapper.updateById(menu) > 0; } finally { // 获取锁,判断是不是当前线程的锁 String token = stringRedisTemplate.opsForValue().get(SET_NX_EX_MENU_LIST_LOCK); if (uuid.equals(token)) { // 确定是同一把锁, 才释放锁 stringRedisTemplate.delete(SET_NX_EX_MENU_LIST_LOCK); } } return update; } else{ try { Thread.sleep(100); } catch (InterruptedException e) { throw new RuntimeException(e); } return updateMenuWithRedisLock(menu); } } }
借助 lua 脚本我们成功将释放锁的操作变成原子性操作,确保了其正确性。
写到这里,我们对于分布式锁的来龙去脉,应该产生了一些属于自己的理解,现在就只剩下一个锁自动续期的问题没有解决了~
3.5、Redisson 实现分布式锁
锁需要续期,主要就是为了解决在一些业务场景中,业务执行超时,锁已经过期,但业务仍没执行完成的场景。
究其根本就是到底给锁多长时间算合适呢?这点其实是没法准确评估的。
如果不打算给锁自动续期的话,那么我觉得应当对锁的过期时间,适当的延长一些,以确保业务正确执行。
如果你和我一样是一名Java开发者,想要去实现锁自动续期,这个方案在市面上已经有成熟的轮子Redisson 啦~
在Redisson中,有一个著名的看门狗机制,当我们使用 Redisson
来实现分布式锁时,加锁时,每次都会默认设置过期时间30s,然后当业务执行超过10s,也就是锁时间还剩下 20s 时,它就会自动续期。
光说不练假把式,我们直接来用代码实现一下看看吧
引入依赖
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.17.6</version> </dependency>
首先就是SpringBoot
的起手式,编写MyRedissionConfig
类
@Configuration public class MyRedissonConfig { /** * 所有对Redisson的使用都是通过RedissonClient * @return * @throws IOException */ @Bean(destroyMethod="shutdown") public RedissonClient redissonClient() throws IOException { //1、创建配置 Config config = new Config(); // 连接必须要以 redis 开头~ 有密码填密码 config.useSingleServer().setAddress("redis://IP地址:6379").setPassword("000415"); //2、根据Config创建出RedissonClient实例 //Redis url should start with redis:// or rediss:// RedissonClient redissonClient = Redisson.create(config); return redissonClient; } }
我们所有的操作都是基于RedissonClient
来实现的,将它注入到Spring 容器中之后,在需要的时候直接引入即可。
另外 Redisson
它实现了 JUC 包下的大部分锁相关的实现,如果熟悉 JUC 的开发,使用这方面算是没什么学习成本的。如JUC 下的读写锁、信号量等。
我们就来使用一下Redisson
中的读写锁吧~
/** * @description: * @author: Ning Zaichun * @date: 2022年09月20日 20:59 */ @Service public class RedissonServiceImpl implements IRedissonService { @Autowired StringRedisTemplate stringRedisTemplate; @Autowired private MenuMapper menuMapper; private static final String REDISSON_MENU_LIST = "redisson:menu:list"; private static final String REDISSON_MENU_LIST_LOCK_VALUE = "redisson:lock"; @Autowired private RedissonClient redissonClient; @Override public List<MenuEntity> getList() { // 判断缓存是否有数据 String menuJson = stringRedisTemplate.opsForValue().get(REDISSON_MENU_LIST); if (menuJson != null) { System.out.println("缓存中有,直接返回缓存中的数据"); List<MenuEntity> menuEntityList = JSON.parseObject(menuJson, new TypeReference<List<MenuEntity>>() { }); return menuEntityList; } // 从数据库中查询 List<MenuEntity> result = getMenuJsonFromDbWithRedissonLock(); return result; } /** * 问题:其实写成上面那种模样,相对来说,也能解决很多时候的问题了,从头到尾看过来的话,其实也能发现就一个锁自动过期问题没有解决了。 * 但在实现这个之前,我还是说明一下,为什么说在没有解决锁自动过期问题时,就已经能应付大多数场景了。 * 重点在于如何评估 锁自动过期时间,锁自动过期时间到底设置多少合适呢? * 其实如果对于业务理解较为透彻,对于这一部分的业务代码执行时间能有一个较清晰的估算,给定一个合适的时间,在不出现极端情况,基本都能应付过来了。 * <p> * 但是呢,很多时候,还是会怕这个万一的,万一真出现了,可能造成的损失就不止 一万了,哈哈。 * 解决方案 * 1、在市场主流的 Redission 中,针对这样的问题,已经有了解决方案。这也是Redission中常说的看门狗机制。 * <p> * 如果需要自己实现的思路: * 1、这方面的问题,也做了十分浅显的思考,我觉得应该还是依赖于定时任务去实现,但到底该如何实现这个定时任务,我还没法给出一个合适的解决方案。 或许我应该会尝试一下。 * * @return */ public List<MenuEntity> getMenuJsonFromDbWithRedissonLock() { System.out.println("从数据库中查询"); //1、占分布式锁。去redis占坑 //(锁的粒度,越细越快:具体缓存的是某个数据,11号商品) product-11-lock //RLock catalogJsonLock = redissonClient.getLock("catalogJson-lock"); //创建读锁 RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(REDISSON_MENU_LIST_LOCK_VALUE); RLock rLock = readWriteLock.readLock(); List<MenuEntity> result = null; try { rLock.lock(); String menuJson = stringRedisTemplate.opsForValue().get(REDISSON_MENU_LIST); if (menuJson != null) { System.out.println("缓存中有,直接返回缓存中的数据"); List<MenuEntity> menuEntityList = JSON.parseObject(menuJson, new TypeReference<List<MenuEntity>>() { }); return menuEntityList; } try { Thread.sleep(50000); } catch (InterruptedException e) { throw new RuntimeException(e); } //加锁成功...执行业务 //加锁成功...执行业务 result = menuMapper.selectList(new QueryWrapper<MenuEntity>()); // 构建缓存 stringRedisTemplate.opsForValue().set(REDISSON_MENU_LIST, JSON.toJSONString(result)); } finally { rLock.unlock(); } return result; } @Override public Boolean updateMenuById(MenuEntity menu) { return updateMenuWithRedissonLock(menu); } public Boolean updateMenuWithRedissonLock(MenuEntity menu) { RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(REDISSON_MENU_LIST_LOCK_VALUE); RLock writeLock = readWriteLock.writeLock(); Boolean update = false; try { writeLock.lock(); //加锁成功...执行业务 //加锁成功...执行业务 update = menuMapper.updateById(menu) > 0; } finally { writeLock.unlock(); } return update; } }
为了给大家测试一下它的自动续期,我在它第一次查询数据库获取锁时,让线程睡了一会,来进行测试。
测试:
Redission的一些其他用法以及内部是如何实现锁续期的等等,这些都留在下一篇文章中啦。
3.6、关于Redission的一些补充
Redisson 默认锁过期时间 30s,一旦进行修改了的话,Redisson会取消此锁的自动续期机制。 这一步的源码在 RedissonLock
的tryAcquireAsync
中
另外 Redisson
自动续期机制,也被大家称为看门狗机制,每次在锁还剩下20秒的时候,又会自动续到30s。 默认时间在:
关于Redisson底层的一些流程分析,在明天的关于 Redisson 源码浅析的文章当中。
3.7、正确使用Redis的分布式事务锁
其实就下面两点:
- 上锁和设置过期时间需要保证原子性;(加锁)
- 判断锁ID是否为自己所有和解锁需要保证原子性 (解锁)
保证这两步才能保证正确使用分布式锁,另外则是对于锁的过期时间需要进行一个合理评估,适当延长锁过期时间,如果需要实现锁自动续期可采用现有的轮子 Redisson 来实现。
四、小结
4.1、回顾
我们从本地锁一直讲到分布式锁,将Redis实现分布式锁中的一些问题,逐步进行了讲述。
- 从使用简单的
Redis
中的SET KEY NX
命令实现分布式锁 - 到使用
SET KEY NX EX TIME
命令解决死锁问题 - 到增加身份标识(UUID) 解决锁被其他人释放问题
- 再到使用 Lua 脚本,将解锁操作变成原子性操作
- 最后讲述了
Redisson
实现分布式锁,解决了锁自动续期问题
说它非常难的话,其实也没有,但是这是终点吗?
4.2、扩展
其实并不是,不知道你们有没有发现我上面所讲述的分布式锁,从始至终都是将Redis
当作了一个实例来看待。
但在真正的环境中,Redis
远不止一个实例,部署方式也是多样的,主从复制、哨兵模式、集群模式,主从集群等等,那么在这些情况下,按照上面的方式去编写分布式锁会不会有问题呢?
你觉得呢?
答案是只要牵扯到网络通信,那么必然就会产生问题。 (可以说在分布式中,网络永远都是处于个不可信的状态)
举个最简单的例子:
假设现在的部署方式是主从集群+哨兵模式
,这样的好处是,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。
那假设现在我一个请求进来,刚获取到锁,然后主节点就挂了,此时锁还没有同步到从节点上去,即使之后完成了主从切换,但是此次所加的锁也已经丢失。
因此在这样的基础上,Redis 官方继而又推出了 Redlock(红锁算法)。
五、关于红锁 Redlock
关于这些问题,Redis 的作者也提供了一些解决方案【Redlock】 也就是我们常说的红锁。
官方文档:Redlock
红锁分析
- Martin Kleppmann(英国剑桥大学的一名分布式系统研究员)关于 Redlock的分析
- Redis 作者 Antirez 对于
Martin Kleppmann
的分析回复
两人的辩论都十分精彩,非常值得拜读,从中可以领略到诸多关于分布式的思考。
Redlock 简单使用来自 Redis 官网
5.1、红锁算法
在算法的分布式版本中,我们假设我们有 N 个 Redis master。这些节点是完全独立的,所以我们不使用复制或任何其他隐式协调系统。我们已经描述了如何在单个实例中安全地获取和释放锁。我们理所当然地认为算法会使用这种方法在单个实例中获取和释放锁。在我们的示例中,我们设置了 N=5,这是一个合理的值,因此我们需要在不同的计算机或虚拟机上运行 5 个 Redis 主服务器,以确保它们以几乎独立的方式发生故障。
为了获取锁,客户端执行以下操作:
- 它以毫秒为单位获取当前时间。
- 它尝试顺序获取所有 N 个实例中的锁,在所有实例中使用相同的键名和随机值。在步骤 2 中,当在每个实例中设置锁时,客户端使用一个与锁自动释放总时间相比较小的超时来获取它。例如,如果自动释放时间为 10 秒,则超时可能在 ~ 5-50 毫秒范围内。这可以防止客户端在尝试与已关闭的 Redis 节点通信时长时间保持阻塞:如果一个实例不可用,我们应该尽快尝试与下一个实例通信。
- 客户端通过从当前时间中减去步骤 1 中获得的时间戳来计算获取锁所用的时间。当且仅当客户端能够在大多数实例(至少 3 个)中获取锁时,且获取锁的总时间小于锁的有效时间,则认为锁已被获取。
- 如果获得了锁,则其有效时间被认为是初始有效时间减去经过的时间,如步骤 3 中计算的那样。
- 如果客户端由于某种原因未能获得锁(它无法锁定 N/2+1 个实例或有效时间为负数),它将尝试解锁所有实例(即使是它认为没有的实例)能够锁定)。
5.2、算法是异步的吗?
该算法依赖于这样一个假设,即虽然进程之间没有同步时钟,但每个进程中的本地时间以大致相同的速率更新,与锁的自动释放时间相比误差很小。这个假设非常类似于现实世界的计算机:每台计算机都有一个本地时钟,我们通常可以依靠不同的计算机来获得很小的时钟漂移。
在这一点上,我们需要更好地指定我们的互斥规则:只有持有锁的客户端在锁有效时间内(如步骤 3 中获得)内终止其工作,减去一些时间(仅几毫秒为了补偿进程之间的时钟漂移)。
本文包含有关需要绑定时钟漂移的类似系统的更多信息:租赁:分布式文件缓存一致性的有效容错机制。
5.3、失败重试
当客户端无法获取锁时,它应该在随机延迟后重试,以尝试使多个客户端同时尝试获取同一资源的锁(这可能导致没有人的脑裂情况)胜)。此外,客户端在大多数 Redis 实例中尝试获取锁的速度越快,裂脑条件的窗口就越小(并且需要重试),因此理想情况下,客户端应该尝试将SET
命令发送到 N 个实例同时使用多路复用。
值得强调的是,对于未能获得大部分锁的客户端来说,尽快释放(部分)获得的锁是多么重要,这样就无需等待密钥到期才能再次获得锁(但是,如果发生网络分区并且客户端不再能够与 Redis 实例通信,则会在等待密钥到期时造成可用性损失)。
5.4、释放锁
释放锁很简单,无论客户端是否相信它能够成功锁定给定实例,都可以执行。
就是同时给所有的 Redis 实例发消息说要释放这把锁。
我此处只是一个简单的思路,如果对Redlock
展开说,这篇文章的字数可能还需要翻上一倍,而且我感觉如果是没有实践的去分析,文字会稍显稚嫩,同时也会因为无案例支撑,让其真实性也会大打折扣。
想仔细了解的,大家可以多找找,网上也有很多针对两位大佬的辩论分析的文章。
5.5、补充
其实,如果你的应用只需要高性能的分布式锁并且可以接受一定程度上的数据不一致性【像之前说的刚设置完锁,Redis中的主机就宕机,导致没有成功同步到从机,所产生的数据不一致性】,那么实际上之前所讨论的 Redis 分布式锁也是足够了的。
但是如果业务要求一定要保证应用中数据的强一致性,那么我觉得你可以试着找找其他的方式,换成zookeeper
加上一定的补偿机制去试一试。毕竟Redlock(红锁)一方面太重了,不是特别大的项目,我个人觉得也不会用至少五台起步的Redis实例吧,另外一方面,看了两位大佬的讨论,特别极端的情况下,也是有可能出现问题的。
刚刚说到的Zookeeper
实现分布式锁,虽然它也有问题,但总归它是保证CAP
机制中的CP
的,可以保证任何时刻对Zookeeper
的访问请求能得到一致性的数据,但不绝对保证服务一定可用~ 属于是用性能换安全啦~
总之,如果项目中一定要有非常强的数据一致性,在那么对于分布式锁,你也保留一丝怀疑,毕竟它也不是真的100%安全的。
既然看到这里啦,我觉得再看一遍大纲,判断一下自己理解了多少是非常重要的:
关于代码
不知道阅读的小伙伴,有木有发现代码中有一点点小问题~
后期在检查时,案例中的代码是有点不太合适的,应当将所有案例中的递归调用方法改为循环重试,并限制重试次数,而非一直递归调用。
原因:如果一直没有抢到锁,重复的递归调用是有很大可能会导致程序崩溃的,这是不合适的。另外如果是阅读过一些框架源码的话,它们的底层调用大都是写个
while(true)
来达到某个方法的重复调用,而并非是递归调用。此处是我的疏忽,各位见谅见谅。
另外重试机制下可能会出现的问题:
幂等性问题: (查询操作具有天然幂等性)
在分布式架构下,服务之间调用会因为网络原因出现超时失败情况,而重试机制会重复多次调用服务,但是对于被调用放,就可能收到了多次调用。如果被调用方不具有天生的幂等性,那就需要增加服务调用的判重模块,并对每次调用都添加一个唯一的id。
大量请求超时堆积:
超高并发下,大量的请求如果都进行超时重试的话,如果你的重试时间设置不安全的话,会导致大量的请求占用服务器线程进行重试,这时候服务器线程负载就会暴增,导致服务器宕机。对于这种超高并发下的重试设计,我们不能让重试放在业务线程,而是统一由异步任务来执行。
后记
虽然写完也有再次阅读,但不可避免会出现疏忽或遗漏的情况,如在阅读过程中发现任何问题,请及时联系我(留言、私信、微信群、微信nzc_wyh都可,备注掘金),一定会在第一时间进行修正,非常非常感谢各位的阅读,更是能够读到此处,希望每位小伙伴都是满载而归~。
不知道这篇文章有没有帮助到你,希望看完的你,对于分布式锁已经有所了解。
其实所谓的高可用,很多时候都是在解决网络出现问题之后诞生的问题,比如我们刚刚谈到的Redlock;因为网络随时会挂,服务随时会宕机,在这种情况下,我们要保证服务的高可用,就要去加很多很多保障(也就是加很多很多层,哈哈)。
今天的文章就讲到了这里啦~
我是 宁在春,一个宁愿永远活在有你的春天里的那个人,一个喜欢用文字抒发自己情感的人,如果你觉得有所收获,就给我点点赞,点点关注吧~ 哈哈,我也希望能收到来自你的正向反馈,明日再见~
记得热爱生活哦~
十月桂花香,下班的路上,街道附近有几棵桂花树,它们的开花,释放的香味,好似让我感受到了久违的大自然的感觉,如果有机会的话,周六可以外出走走的~