在高可用系统中, 加锁已经是常态, 分布式锁也是面试的热点.
既然如此重要, 那就必须熟练的掌握对吧
一. 什么是分布式锁
- 问题描述
在分布式环境下, 我们常常需要解决的是, 多台机器对同个资源的争抢问题
例如: 1、库存超卖 2、防止用户重复下单 3、MQ消息去重 4、订单操作变更 等等... 这些情况需要我们对某个资源进行串行化操作
- 单机情况下, 我们可以简单的通过关键字 synchronize、或者 读写锁 的操作使得流程串行化
- 多节点、多进程. 就得使用分布式锁了
- 具体流程图如下
分布式锁的状态
获取锁: 各个客户端通过竞争获取锁到锁, 才能对共享的资源进行操作
占有锁: 操作资源的时间段, 客户端一直会持有这个锁, 以保证别的节点获取不到锁
阻塞: 当其他节点获取不到锁的时候, 就会进入阻塞状态, 无法对这个资源操作
释放锁: 资源操作完毕后, 持有锁的客户端, 将会释放锁资源. 让其他节点可以获取锁
分布式锁特点
- 互斥性: 任意时刻只能有一个客户端持有锁
- 高可用, 容错性: 锁服务应该做集群高可用, 保证所有客户端能正常的获取释放锁
- 避免死锁: 锁机制不能无限时间锁住资源, 应该有一定的机制自动释放锁 (正常释放或者超时释放)
- 解铃还须系铃人: 加锁解锁要保证同一个客户端所为
二. 分布式锁的实现
分布式锁实现有很多种产品可以实现, 其中比较主流的有
- 基于zookeeper时节点的分布式锁
- 基于Redis的分布式锁 (基于内存, 性能突出)
- DB数据库 实现 (性能较差, 但是原子性可靠性较好)
其中较为大家熟知的是用Redis, 我相信大家也是用Redis去做的对吧, 所以本文也是介绍Redis去实现
1. 获取锁 tryLock
Redis2.6.12版本之前,使用Lua脚本保证原子性
由于 2.6.12 版本之前没有 setnx 命令, 也即是 1.查看是否有 2.设置值 是两步操作, 如果使用代码去执行的话, 将会是两个指令, 这不满足操作的原子性.
而使用 jedis.eval(script, keys, argv), 我们可以传入 Lua 脚本来保证两个操作的原子性, 因为Lua脚本再执行的时候, 是可以保证所有Lua脚本全部执行完毕, 才继续执行其他指令的.注意: 参数 key 是LIst 他的第一位对应 Lua 脚本中的 KEYS[1] , argv同理
当然你也可以利用这个特性, 编写一段具有事物特性的Redis指令块, 并不一定只拿来做锁
(关于 Lua 脚本脚本语言, 本文不会深入讲解, 感兴趣的小伙伴可以上菜鸟教程看看....)
以下是我简单写的一段模仿SETNX 的LUA代码, 并且可以直接设置过期时间 (代码写得有些仓促, 有些许地方还需要优化, 大家将就看看)
-- 如果不存在值, 则设置值并且设置过期时间 返回1 , 存在值则返回0if redis.call('EXISTS',KEYS[1]) == 0 then redis.call('SET',KEYS[1],ARGV[1]) redis.call('EXPIRE',KEYS[1],ARGV[2]) return 1else return 0end
- 客户端执行
@Testpublic void test() throws InterruptedException { Jedis jedis = jedisPool.getResource(); for(int i = 0 ;i<=10; i++){ System.out.println("此时的key" + jedis.get("REDIS_TEST_KEY")); Long eval = (Long) jedis.eval(LUA, Arrays.asList("REDIS_TEST_KEY"), Arrays.asList("value","1")); // 设置锁的值为: value, 过期时间为1s if (0l == eval){ System.out.println("获取锁失败!"); }else { System.out.println("获取锁成功" + jedis.get("REDIS_TEST_KEY")); } Thread.sleep(500l); }}控制台打印此时的keynull获取锁成功value此时的keyvalue获取锁失败!此时的keynull获取锁成功value此时的keyvalue获取锁失败!此时的keynull获取锁成功value此时的keyvalue
- 增加重试机制
@Testpublic void testTryLock() throws InterruptedException { for(int i = 0 ;i<=10; i++){ if (tryLock("REDIS_TEST_KEY",1000l,0l,3)){ System.out.println("取锁成功"); }else{ System.out.println("取锁失败"); } }}/** * @param key 解锁 key * @param weitTime 最多等待时间(单位:毫秒) * @param leaseTime 上锁后自动释放锁时间(单位:毫秒) */public Boolean tryLock(final String key, final Long weitTime, final Long nowTime, final int leaseTime){ Long DEF_WEIT = 500L; //默认每次叠加0.5s的等待时间 //尝试去获取设置锁标志 Jedis jedis = jedisPool.getResource(); Long lock = (Long) jedis.eval(LUA, Arrays.asList(key), Arrays.asList("value",String.valueOf(leaseTime))); // Redis2.6.12 之后也可以直接使用SETNX 进行设值 //long lock = jedis.setnx(key, "value"); //if (01 == lock){ // jedis.expire(key, leaseTime); //} jedis.close(); //释放资源 if (0l == lock){ if (weitTime >= nowTime) { //如果还未超过等待时间 //获取锁失败 try { Thread.sleep(DEF_WEIT); LOG.info("【加锁】等待重试:{}",nowTime); } catch (InterruptedException e) { LOG.warn("【加锁】【等待时间】等待失效,key={}", key); } return tryLock(key,weitTime, nowTime + DEF_WEIT, leaseTime); } return Boolean.FALSE; }else { return Boolean.TRUE; }}控制台打印取锁成功[2020-09-05 23:58:37.411] INFO cn.test.TestRedisLuaLock - 【加锁】等待重试:0[2020-09-05 23:58:37.928] INFO cn.test.TestRedisLuaLock - 【加锁】等待重试:500[2020-09-05 23:58:38.442] INFO cn.test.TestRedisLuaLock - 【加锁】等待重试:1000取锁失败[2020-09-05 23:58:38.970] INFO cn.test.TestRedisLuaLock - 【加锁】等待重试:0[2020-09-05 23:58:39.484] INFO cn.test.TestRedisLuaLock - 【加锁】等待重试:500[2020-09-05 23:58:39.999] INFO cn.test.TestRedisLuaLock - 【加锁】等待重试:1000取锁成功[2020-09-05 23:58:40.527] INFO cn.test.TestRedisLuaLock - 【加锁】等待重试:0[2020-09-05 23:58:41.041] INFO cn.test.TestRedisLuaLock - 【加锁】等待重试:500[2020-09-05 23:58:41.556] INFO cn.test.TestRedisLuaLock - 【加锁】等待重试:1000取锁失败[2020-09-05 23:58:42.085] INFO cn.test.TestRedisLuaLock - 【加锁】等待重试:0[2020-09-05 23:58:42.600] INFO cn.test.TestRedisLuaLock - 【加锁】等待重试:500[2020-09-05 23:58:43.115] INFO cn.test.TestRedisLuaLock - 【加锁】等待重试:1000........
因为我们在tryLock方法内 , 没有主动的去使用 del 命令去删除锁. 所以锁只有被过期失效掉, 或者是正常业务的结束被删除.
这也符合了 解铃还须系铃人的原则了 (当然铃铛挂久了也会老化, 就自己失效调咯)
三. 总结
Redis 分布式锁的实现的主要思路是, 设置成功就返回设置结果 的 思路. 这个SETNX不谋而合, SETNX命令就是: 如果设置成功则返回1.
当然在版本2.6.1的Redis是没有SETNX命令的, 这个时候就使用Lua脚本来帮助我们将多个命令合并成一个原子性的操作
Redis的过期机制, 主要是防止单一程序长时间占用资源, 或者是不正常的结束进程. 导致锁没有正常的释放.
重试机制: 主要是在特短时间内, 允许线程阻塞等待一段时间, 直至取锁成功
关于分布式锁暂时说这么多
小编熬夜码字不易
需要好心人点点关注鼓励鼓励
实在不行点个?也还行
祝各位好梦
往期回顾
反向操作-Eurka的读写锁
Collection 太快受不了 - 集合迭代稳定性