1、一些技术问题
① Redis除了拿来做缓存,你还见过基于 Redis的什么用法?
② Redis做分布式锁的时候有需要注意的问题?
③ 如果是 Redis是单点部署的,会来什么问题? 那你准备怎么解决单点问题呢?
④ 集群模式下,比如主从模式,有没有什么问题呢?
⑤ 你知道 Redis是怎么解决集群模式也不靠谱的问题的吗?
⑥ 那你简单的介绍下 Redlock吧?你简历上写 redisson,你谈谈
⑦ 你觉得 Redlock有什么问题呢?
⑧ Redis分布式锁如何续期?看门狗知道吗?
2、锁的种类
- 单机版同一个JVM虚拟机内, synchronized或者Lock接口
- 分布式不同个JVM虚拟机内,单机的线程锁机制不再起作用,资源类在不同的服务器之间共享了。
3、一个靠谱分布式锁需要具备的条件和刚需
- 独占性 Onlyone,任何时刻只能有且仅有一个线程持有
- 高可用 若 redis集群环境下,不能因为某一个节点挂了而出现获取锁和释放锁失败的情况
- 防死锁 杜绝死锁,必须有超时控制机制或者撤销操作,有个兜底终止跳出方案
- 不乱抢 防止张冠李戴,不能私下 unlock别人的锁,只能自己加锁自己释放
- 重入性 同一个节点的同一个线程如果获得锁之后,它也可以再次获取这个锁
4、分布式锁
setnx key value
set key value [EX seconds][PX milliseconds][NX|XX]
差评,setnx+expire不安全,两条命令非原子性的
5、Base案例(boot+redis)
使用场景:多个服务间保证同一时刻同一时间段同一用户只能有一个请求(防止关键业务出现并发攻击)
6、示例
1、单机版 没有加锁,并发下数字不对,出现超卖现象
在单机环境下,可以使用 synchronized或Lock来实现
但是在分布式系统中,因为竞争的线程可能不在同一个节点上(同一个jm中),
所以需要一个让所有进程都能访问到的锁来实现(比如 redis或者zookeeper来构建)
不同进程jvm层面的锁就不管用了,那么可以利用第三方的一个组件,来获取锁,未获取到锁,则阻塞当前想要运行的线程
redis单机 cp
redis集群 ap
redis异步复制造成的锁丢失,
比如主节点没来的及把刚刚set进来这条数据给从节点, master就挂了,从机上位但从机上无该数据
zookeeper集群 cp
假如1号机注册给server1,server1同步给server2,server2同步给各个follower,为了保证一致性,只有整个过程都成功了,1号机才收到注册成功。
当 leader重启或者网络故障下,整个ZK集群会重新选举新老大,选举期间 client不可以注册,即zk不可用,所以牺牲了可用性A。只有选举出新老大后,系统才恢复注册。故zk为了保证数据一致性牺牲了可靠性。由于在大型分布式系统中故障难以避免, leader出故障可能性很高,所以很多大型系统都不会选择zk的原因。
7、Redis分布式锁-Redlock算法 Distributed locks with Redis
7.1、官网说明
7.2、使用场景
多个服务间保证同一时刻同一时间段内同一用户只能有一个请求(防止关键业务出现并发攻击)
7.3、Redis分布式锁比较正确的姿势是采用redisson这个客户端工具
7.4、天上飞的理念(RedLock)必然有落地的实现(Redisson)
7.4.1、RedLock理念
Distributed Locks with Redis | Redis
7.4.2、redisson实现
1、Redisson是Java的redis的客户端之一,提供了一些api方便操作redis
2、redisson之官网 Redisson: Redis Java client with features of In-Memory Data Grid
3、redisson之Github https://github.com/redisson/redisson/wiki/%E7%9B%AE%E5%BD%95/
4、redisson之解决分布式锁 8. 分布式锁和同步器 · redisson/redisson Wiki · GitHub
7.5、单机案例
1、加锁: 加锁实际上就是在redis中,给Key键设置一个值,为避免死锁,并给定一个过期时间
2、解锁: 将Key键删除。但也不能乱删,不能说客户端1的请求将客户端2的锁给删除掉,只能自己删除自己的锁
lua脚本:
为了保证解锁操作的原子性,我们用LUA脚本完成这一操作。先判断当前锁的字符串是否与传入的值相等,是的话就删除Key,解锁成功
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
3、超时:锁Key要注意过期时间,不能长期占用
4、面试中回答的主要考点:
- 加锁关键逻辑
/**
*
* @param key
* @param uniqueId
* @param seconds
* @return
*/
public static boolean tryLock(String key,String uniqueId,int seconds){
return "OK".equals(jedis.set(key,uniqueId,"NX","EX",seconds));
}
- 解锁关键逻辑
/**
*
* @param key
* @param uniqueId
* @return
*/
public static boolean releaseLock(String key,String uniqueId){
String luaScript="if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
return jedis.eval(
luaScript,
Collections.singletonList(key),
Collections.singletonList(uniqueId)
).equals(1L);
}
单机模式中,一般都是用set/setnx+lua脚本搞定,想想它的缺点是什么?
上面一般中小公司,不是高并发场景,是可以使用的。单机redis小业务也可以撑得住
7.6、多机案例
7.6.1、基于 setnx的分布式锁有什么缺点?
线程1首先获取锁成功,将键值对写入redis的 master节点;
在 redis将该键值对同步到 slave节点之前, master发生了故障;
redis触发故障转移,其中一个slave升级为新的 master;
此时新的 master并不包含线程1写入的键值对,因此线程2尝试获取锁也可以成功拿到锁;
此时相当于有两个线程获取到了锁,可能会导致各种预期之外的情况发生,例如最常见的脏数据。
我们加的是排它独占锁,同一时间只能有一个建 redis锁成功并持有锁,严禁出现2个以上的请求线程拿到锁。
7.6.2、redis之父提出了 Redlock算法解决这个问题
Redis也提供了Redlock算法,用来实现基于多个实例的分布式锁。锁变量由多个实例维护,即使有实例发生了故障,锁变量仍然是存在的,客户端还是可以完成锁
操作。 Redlock算法是实现高可靠分布式锁的一种有效解决方案,可以在实际开发中使用。
7.6.3、Redlock算法设计理念
1、设计理念
该方案也是基于(set加锁、Lua脚本解锁)进行改良的,所以 redis之父antirez只描述了差异的地方,大致方案如下。
假设我们有N个 redis主节点,例如N=5这些节点是完全独立的,我们不使用复制或任何其他隐式协调系统,为了取到锁客户端执行以下操作:
1 | 获取当前时间,以毫秒为单位; |
2 | 依次尝试从5个实例,使用相同的key和随机值(例如UUID)获取锁。当向 Redis请求获取锁时,客户端应该设置一个超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒则超时时间应该在5-50毫秒之间。这样可以防止客户端在试图与一个宕机的 Redis节点对话时长时间处于阻塞状态。如果一个实例不可用,客户端应该尽快尝试去另外一个Redis实例请求获取锁; |
3 | 客户端通过当前时间减去步骤1记录的时间来计算获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且获取锁使用的时间小于锁失效时间时,锁才算获取成功; |
4 | 如果取到了锁,其真正有效时间等于初始有效时间减去获取锁所使用的时间(步骤3计算的结果)。 |
5 | 如果由于某些原因未能获得锁(无法在至少N/2+1个Redis实例获取锁、或获取锁的时间超过了有效时间),客户端应该在所有的 Redis实例上进行解锁(即便某些 Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。 |
该方案为了解决数据不一致的问题,直接舍弃了异步复制只使用 master节点,同时由于舍弃了 slave,为了保证可用性,引入了N个节点,官方建议是5。本次演示用3台实例来做说明。
客户端只有在满足下面的这两个条件时,才能认为是加锁成功。
条件1:客户端从超过半数(大于等于N/2+1)的Redis实例上成功获取到了锁;
条件2:客户端获取锁的总耗时没有超过锁的有效时间。
2、解决方案
1 先知道什么是容错
失败了多少个机器实例后我还是可以容忍的,所谓的容忍就是数据一致性还是可以OK的,CP数据一致性还是可以满足
加入在集群环境中, redis失败1台,可接受。2X+1=2*1+1=3,部署3台,死了1个剩下2个可以正常工作,那就部署3台。
加入在集群环境中, redis失败2台,可接受。2X+1=2*2+1=5,部署5台,死了2个剩下3个可以正常工作,那就部署5台。
2 为什么是奇数?
最少的机器,最多的产出效果
加入在集群环境中, redis失败1台,可接受。2N+2=2*1+2=4,部署4台
加入在集群环境中, redis失败2台,可接受。2N+2=2*2+2=6,部署6台
7.6.4、案例
环境准备,三台redis,简单安装
[root@shuidi-100 ~]# docker run -p 6381:6379 --name redis-master-1 -d redis:6.0.7
[root@shuidi-100 ~]# docker run -p 6382:6379 --name redis-master-2 -d redis:6.0.7
[root@shuidi-100 ~]# docker run -p 6383:6379 --name redis-master-3 -d redis:6.0.7
maven依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.4</version>
</dependency>
@Slf4j
public class RedLockController {
public static final String CACHE_KEY_REDLOCK = "SHUIDI_REDLOCK";
@Autowired
RedissonClient redissonClient1;
@Autowired
RedissonClient redissonClient2;
@Autowired
RedissonClient redissonClient3;
@GetMapping(value = "/redlock")
public void getLock() {
RLock lock1 = redissonClient1.getLock(CACHE_KEY_REDLOCK);
RLock lock2 = redissonClient2.getLock(CACHE_KEY_REDLOCK);
RLock lock3 = redissonClient3.getLock(CACHE_KEY_REDLOCK);
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLockBoolean;
try {
//waitTime 抢锁的等待时间,正常情况下 等3秒
//leaseTime 就是redis key的过期时间,正常情况下等5分钟300秒
isLockBoolean = redLock.tryLock(3, 300, TimeUnit.SECONDS);
log.info("线程{},是否拿到锁:{}", Thread.currentThread().getName(), isLockBoolean);
if (isLockBoolean) {
System.out.println(Thread.currentThread().getName() + "\t" + "---come in biz");
//业务逻辑,忙10分钟
TimeUnit.MINUTES.sleep(10);
}
} catch (Exception e) {
log.error("redlock exception ", e);
} finally {
//无论如何,最后都要解锁
redLock.unlock();
}
}
}
当只有获取三台redis中两台或以上的锁,才算加锁成功,宕机一台redis不影响锁的获取
7.7、Redisson源码解析
7.7.1、守护线程”续命“
Redis分布式锁过期了,但是业务逻辑还没处理完怎么办?
额外起一个线程,定期检查线程是否还持有锁,如果有则延长过期时间。
Redisson里面就实现了这个方案,使用“看门狗”定期检查(每1/3的锁时间检查1次),如果线程还持有锁,则刷新过期时间;
在获取锁成功后,给锁加一个watchdog watchdog会起一个定时任务,在锁没有被释放且快要过期的时候会续期
7.7.2、缓存续命
加锁
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);"
KEYS[1] 代表的是你加锁的那个key | RLock redissonLock = redissonClient1.getLock(CACHE_KEY_REDLOCK);//这里你自己设置了加锁的那个锁key |
ARGV[2] 代表的是加锁的客户端的ID | 127.0.0.1:6379>hgetall CACHE_KEY_REDLOCK 1) "8743c9c0-0795-4907-87fd-6c719a6b4586:117" 2) "1" |
ARGV[1] 就是锁的默认生存时间 | 默认30秒 |
如何加锁 | 你要加锁的那个锁key不存在的话,你就进行加锁 hincrby 8743c9c0-0795-4907-87fd-6c719a6b4586:117 1 接着会执行 pexpire CACHE_KEY_REDLOCK 30000 |
通过 exists 判断,如果锁不存在,则设置值和过期时间,加锁成身
通过 hexists 判断,如果锁已存在,并且锁的是当前线程,则证明是重入锁,加锁成功
如果锁已存在,但锁的不是当前线程,则证明有其他线程持有锁,返回当前锁的过期时间(代表了CACHE_KEY_REDLOCK这个锁key的剩余生存时间),加锁失败
127.0.0.1:6379>hgetall CACHE_KEY_REDLOCK
1) "8743c9c0-0795-4907-87fd-6c719a6b4586:117" //8743c9c0-0795-4907-87fd-6c719a6b4586 随机字符串,117 线程ID
2) "1" //加锁次数,如果同一线程多次调用lock方法,值递增1.----可重入锁
ttl续命:加大业务逻辑处理时间,看超过10秒钟后,redisson的续命加时
解锁
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
参考文章: