关于redis:面试官你真的了解Redis分布式锁吗

29次阅读

共计 6268 个字符,预计需要花费 16 分钟才能阅读完成。

什么是分布式锁

说到 Redis,咱们第一想到的性能就是能够缓存数据,除此之外,Redis 因为单过程、性能高的特点,它还常常被用于做分布式锁。

锁咱们都晓得,在程序中的作用就是同步工具,保障共享资源在同一时刻只能被一个线程拜访,Java 中的锁咱们都很相熟了,像 synchronized、Lock 都是咱们常常应用的,然而 Java 的锁只能保障单机的时候无效,分布式集群环境就无能为力了,这个时候咱们就须要用到分布式锁。

分布式锁,顾名思义,就是分布式我的项目开发中用到的锁,能够用来管制分布式系统之间同步访问共享资源,一般来说,分布式锁须要满足的个性有这么几点:

1、互斥性:在任何时刻,对于同一条数据,只有一台利用能够获取到分布式锁;

2、高可用性:在分布式场景下,一小部分服务器宕机不影响失常应用,这种状况就须要将提供分布式锁的服务以集群的形式部署;

3、避免锁超时:如果客户端没有被动开释锁,服务器会在一段时间之后主动开释锁,避免客户端宕机或者网络不可达时产生死锁;

4、独占性:加锁解锁必须由同一台服务器进行,也就是锁的持有者才能够开释锁,不能呈现你加的锁,他人给你解锁了;

业界里能够实现分布式锁成果的工具很多,但操作无非这么几个:加锁、解锁、避免锁超时。

既然本文说的是 Redis 分布式锁,那咱们天经地义就以 Redis 的知识点来延长。

实现锁的命令

先介绍下 Redis 的几个命令,

1、SETNX,用法是SETNX key value

SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写,设置胜利就返回 1,否则返回 0。

setnx 用法

能够看出,当把 keylock的值设置为 ”Java” 后,再设置成别的值就会失败,看上去很简略,也如同独占了锁,但有个致命的问题,就是 key 没有过期工夫,这样一来,除非手动删除 key 或者获取锁后设置过期工夫,不然其余线程永远拿不到锁。

既然这样,咱们给 key 加个过期工夫总能够吧,间接让线程获取锁的时候执行两步操作:

`SETNX Key 1`
`EXPIRE Key Seconds`

这个计划也有问题,因为获取锁和设置过期工夫分成两步了,不是原子性操作,有可能 获取锁胜利但设置工夫失败,那样不就白干了吗。

不过也不必急,这种事件 Redis 官网早为咱们思考到了,所以就引出了上面这个命令

2、SETEX,用法SETEX key seconds value

将值 value 关联到 key,并将 key 的生存工夫设为 seconds (以秒为单位)。如果 key 曾经存在,SETEX 命令将覆写旧值。

这个命令相似于以下两个命令:

`SET key value`
`EXPIRE key seconds  # 设置生存工夫 `

这两步动作是原子性的,会在同一时间实现。

setex 用法

3、PSETEX,用法PSETEX key milliseconds value

这个命令和 SETEX 命令类似,但它以毫秒为单位设置 key 的生存工夫,而不是像 SETEX 命令那样,以秒为单位。

不过,从 Redis 2.6.12 版本开始,SET 命令能够通过参数来实现和 SETNX、SETEX、PSETEX 三个命令雷同的成果。

就比方这条命令

`SET key value NX EX seconds` 

加上 NX、EX 参数后,成果就相当于 SETEX,这也是 Redis 获取锁写法外面最常见的。

怎么开释锁

开释锁的命令就简略了,间接删除 key 就行,但咱们后面说了,因为分布式锁必须由锁的持有者本人开释,所以咱们必须先确保以后开释锁的线程是持有者,没问题了再删除,这样一来,就变成两个步骤了,仿佛又违反了原子性了,怎么办呢?

不慌,咱们能够用 lua 脚本把两步操作做拼装,就如同这样:

`if redis.call("get",KEYS[1]) == ARGV[1]`
`then`
 `return redis.call("del",KEYS[1])`
`else`
 `return 0`
`end`

KEYS[1]是以后 key 的名称,ARGV[1]能够是以后线程的 ID(或者其余不固定的值,能辨认所属线程即可),这样就能够避免持有过期锁的线程,或者其余线程误删现有锁的状况呈现。

代码实现

晓得了原理后,咱们就能够手写代码来实现 Redis 分布式锁的性能了,因为本文的目标次要是为了解说原理,不是为了教大家怎么写分布式锁,所以我就用伪代码实现了。

首先是 redis 锁的工具类,蕴含了加锁和解锁的根底办法:

`public class RedisLockUtil {`
 `private String LOCK_KEY = "redis_lock";`
 `// key 的持有工夫,5ms`
 `private long EXPIRE_TIME = 5;`
 `// 期待超时工夫,1s`
 `private long TIME_OUT = 1000;`
 `// redis 命令参数,相当于 nx 和 px 的命令合集 `
 `private SetParams params = SetParams.setParams().nx().px(EXPIRE_TIME);`
 `// redis 连接池,连的是本地的 redis 客户端 `
 `JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);`
 `/**`
 `* 加锁 `
 `*`
 `* @param id`
 `*            线程的 id,或者其余可辨认以后线程且不反复的字段 `
 `* @return`
 `*/`
 `public boolean lock(String id) {`
 `Long start = System.currentTimeMillis();`
 `Jedis jedis = jedisPool.getResource();`
 `try {`
 `for (;;) {`
 `// SET 命令返回 OK,则证实获取锁胜利 `
 `String lock = jedis.set(LOCK_KEY, id, params);`
 `if ("OK".equals(lock)) {`
 `return true;`
 `}`
 `// 否则循环期待,在 TIME_OUT 工夫内仍未获取到锁,则获取失败 `
 `long l = System.currentTimeMillis() - start;`
 `if (l >= TIME_OUT) {`
 `return false;`
 `}`
 `try {`
 `// 休眠一会,不然重复执行循环会始终失败 `
 `Thread.sleep(100);`
 `} catch (InterruptedException e) {`
 `e.printStackTrace();`
 `}`
 `}`
 `} finally {`
 `jedis.close();`
 `}`
 `}`
 `/**`
 `* 解锁 `
 `*`
 `* @param id`
 `*            线程的 id,或者其余可辨认以后线程且不反复的字段 `
 `* @return`
 `*/`
 `public boolean unlock(String id) {`
 `Jedis jedis = jedisPool.getResource();`
 `// 删除 key 的 lua 脚本 `
 `String script = "if redis.call('get',KEYS[1]) == ARGV[1] then" + "return redis.call('del',KEYS[1])" + "else"`
 `+ "return 0" + "end";`
 `try {`
 `String result =`
 `jedis.eval(script, Collections.singletonList(LOCK_KEY), Collections.singletonList(id)).toString();`
 `return "1".equals(result);`
 `} finally {`
 `jedis.close();`
 `}`
 `}`
`}`

具体的代码作用正文曾经写得很分明了,而后咱们就能够写一个 demo 类来测试一下成果:

`public class RedisLockTest {`
 `private static RedisLockUtil demo = new RedisLockUtil();`
 `private static Integer NUM = 101;`
 `public static void main(String[] args) {`
 `for (int i = 0; i < 100; i++) {`
 `new Thread(() -> {`
 `String id = Thread.currentThread().getId() + "";`
 `boolean isLock = demo.lock(id);`
 `try {`
 `// 拿到锁的话,就对共享参数减一 `
 `if (isLock) {`
 `NUM--;`
 `System.out.println(NUM);`
 `}`
 `} finally {`
 `// 开释锁肯定要留神放在 finally`
 `demo.unlock(id);`
 `}`
 `}).start();`
 `}`
 `}`
`}`

咱们创立 100 个线程来模仿并发的状况,执行后的后果是这样的:

代码执行后果

能够看出,锁的成果达到了,线程平安是能够保障的。

当然,下面的代码只是简略的实现了成果,性能必定是不残缺的,一个健全的分布式锁要思考的方面还有很多,理论设计起来不是那么容易的。

咱们的目标只是为了学习和理解原理,手写一个工业级的分布式锁工具不事实,也没必要,相似的开源工具一大堆(Redisson),原理都差不多,而且早已通过业界同行的测验,间接拿来用就行。

尽管性能是实现了,但其实从设计上来说,这样的分布式锁存在着很大的缺点,这也是本篇文章想重点探讨的内容。

分布式锁的缺点

一、客户端长时间阻塞导致锁生效问题

客户端 1 失去了锁,因为网络问题或者 GC 等起因导致长时间阻塞,而后业务程序还没执行完锁就过期了,这时候客户端 2 也能失常拿到锁,可能会导致线程平安的问题。

客户端长时间阻塞

那么该如何避免这样的异样呢?咱们先不说解决方案,介绍完其余的缺点后再来探讨。

二、redis 服务器时钟漂移问题

如果 redis 服务器的机器时钟产生了向前跳跃,就会导致这个 key 过早超时生效,比如说客户端 1 拿到锁后,key 的过期工夫是 12:02 分,但 redis 服务器自身的时钟比客户端快了 2 分钟,导致 key 在 12:00 的时候就生效了,这时候,如果客户端 1 还没有开释锁的话,就可能导致多个客户端同时持有同一把锁的问题。

三、单点实例平安问题

如果 redis 是单 master 模式的,当这台机宕机的时候,那么所有的客户端都获取不到锁了,为了进步可用性,可能就会给这个 master 加一个 slave,然而因为 redis 的主从同步是异步进行的,可能会呈现客户端 1 设置完锁后,master 挂掉,slave 晋升为 master,因为异步复制的个性,客户端 1 设置的锁失落了,这时候客户端 2 设置锁也可能胜利,导致客户端 1 和客户端 2 同时领有锁。

为了解决 Redis 单点问题,redis 的作者提出了 RedLock 算法。

RedLock 算法

该算法的实现前提在于 Redis 必须是多节点部署的,能够无效避免单点故障,具体的实现思路是这样的:

1、获取以后工夫戳(ms);

2、先设定 key 的无效时长(TTL),超出这个工夫就会主动开释,而后 client(客户端)尝试应用雷同的 key 和 value 对所有 redis 实例进行设置,每次链接 redis 实例时设置一个比 TTL 短很多的超时工夫,这是为了不要过长时间期待曾经敞开的 redis 服务。并且试着获取下一个 redis 实例。

比方:TTL(也就是过期工夫)为 5s,那获取锁的超时工夫就能够设置成 50ms,所以如果 50ms 内无奈获取锁,就放弃获取这个锁,从而尝试获取下个锁;

3、client 通过获取所有能获取的锁后的工夫减去第一步的工夫,还有 redis 服务器的时钟漂移误差,而后这个时间差要小于 TTL 工夫并且胜利设置锁的实例数 >= N/2 + 1(N 为 Redis 实例的数量),那么加锁胜利

比方 TTL 是 5s,连贯 redis 获取所有锁用了 2s,而后再减去时钟漂移(假如误差是 1s 左右),那么锁的真正无效时长就只有 2s 了;

4、如果客户端因为某些起因获取锁失败,便会开始解锁所有 redis 实例。

依据这样的算法,咱们假如有 5 个 Redis 实例的话,那么 client 只有获取其中 3 台以上的锁就算是胜利了,用流程图演示大略就像这样:

key 无效时长

好了,算法也介绍完了,从设计上看,毫无疑问,RedLock 算法的思维次要是为了无效避免 Redis 单点故障的问题,而且在设计 TTL 的时候也思考到了服务器时钟漂移的误差,让分布式锁的安全性进步了不少。

但事实真的是这样吗?反正我集体的话感觉成果一般般,

首先第一点,咱们能够看到,在 RedLock 算法中,锁的无效工夫会减去连贯 Redis 实例的时长,如果这个过程因为网络问题导致耗时太长的话,那么最终留给锁的无效时长就会大大减少,客户端访问共享资源的工夫很短,很可能程序处理的过程中锁就到期了。而且,锁的无效工夫还须要减去服务器的时钟漂移,然而应该减多少适合呢,要是这个值设置不好,很容易呈现问题。

而后第二点,这样的算法尽管思考到用多节点来避免 Redis 单点故障的问题,但但如果有节点产生解体重启的话,还是有可能呈现多个客户端同时获取锁的状况。

假如一共有 5 个 Redis 节点:A、B、C、D、E,客户端 1 和 2 别离加锁

  1. 客户端 1 胜利锁住了 A,B,C,获取锁胜利(但 D 和 E 没有锁住)。
  2. 节点 C 的 master 挂了,而后锁还没同步到 slave,slave 降级为 master 后失落了客户端 1 加的锁。
  3. 客户端 2 这个时候获取锁,锁住了 C,D,E,获取锁胜利。

这样,客户端 1 和客户端 2 就同时拿到了锁,程序平安的隐患仍然存在。除此之外,如果这些节点外面某个节点产生了工夫漂移的话,也有可能导致锁的平安问题。

所以说,尽管通过多实例的部署进步了可用性和可靠性,但 RedLock 并没有齐全解决 Redis 单点故障存在的隐患,也没有解决时钟漂移以及客户端长时间阻塞而导致的锁超时生效存在的问题,锁的安全性隐患仍然存在。

论断

有人可能要进一步问了,那该怎么做能力保障锁的相对平安呢?

对此我只能说,鱼和熊掌不可兼得,咱们之所以用 Redis 作为分布式锁的工具,很大水平上是因为 Redis 自身效率高且单过程的特点,即便在高并发的状况下也能很好的保障性能,但很多时候,性能和平安不能齐全兼顾,如果你肯定要保障锁的安全性的话,能够用其余的中间件如 db、zookeeper 来做管制,这些工具能很好的保障锁的平安,但性能方面只能说是差强人意,否则大家早就用上了。

一般来说,用 Redis 管制共享资源并且还要求数据安全要求较高的话,最终的保底计划是对业务数据做幂等管制,这样一来,即便呈现多个客户端取得锁的状况也不会影响数据的一致性。当然,也不是所有的场景都适宜这么做,具体怎么取舍就须要各位看官本人解决啦,毕竟,没有完满的技术,只有适宜的才是最好的。


作者:鄙人薛某,一个不拘于技术的互联网人,想看更多精彩文章能够关注我的公众号,扫描下方二维码或者微信搜寻 鄙人薛某 即可,回复【电子书】还能获取学习材料哦~~~ 咱们下期再见!

正文完
 0