面试某电商公司,挂在了 Redis 分布式锁

JavaGuide

共 12812字,需浏览 26分钟

 ·

2021-04-01 11:48

什么是分布式锁

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

锁我们都知道,在程序中的作用就是同步工具,保证共享资源在同一时刻只能被一个线程访问,Java 中的锁我们都很熟悉了,像synchronizedLock都是我们经常使用的,但是 Java 的锁只能保证单机的时候有效,分布式集群环境就无能为力了,这个时候我们就需要用到分布式锁。

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

  1. 互斥性 :在任何时刻,对于同一条数据,只有一台应用可以获取到分布式锁;
  2. 高可用性 :在分布式场景下,一小部分服务器宕机不影响正常使用,这种情况就需要将提供分布式锁的服务以集群的方式部署;
  3. 防止锁超时 :如果客户端没有主动释放锁,服务器会在一段时间之后自动释放锁,防止客户端宕机或者网络不可达时产生死锁;
  4. 独占性 :加锁解锁必须由同一台服务器进行,也就是锁的持有者才可以释放锁,不能出现你加的锁,别人给你解锁了;

业界里可以实现分布式锁效果的工具很多,但操作无非这么几个:加锁、解锁、防止锁超时。

既然本文说的是 Redis 分布式锁,那我们理所当然就以 Redis 的知识点来延伸。

实现锁的命令

先介绍下 Redis 的几个命令,

1、SETNX,用法是SETNX key value

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

可以看出,当把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  # 设置生存时间

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

3、PSETEX ,用法PSETEX key milliseconds value

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

不过,从 Redis 2.6.12 版本开始,SET命令可以通过参数来实现和SETNXSETEXPSETEX   三个命令的效果。

就比如这条命令

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 台以上的锁就算是成功了,用流程图演示大概就像这样:

好了,算法也介绍完了,从设计上看,毫无疑问,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 单点故障存在的隐患,也没有解决时钟漂移、客户端长时间阻塞而导致的锁超时失效问题。

从这一点上看,RedLock 算法也并没有保证锁的安全性。

结论

有人可能要进一步问了,那该怎么做才能保证锁的绝对安全呢?

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

一般来说,用 Redis 控制共享资源并且还要求数据安全要求较高的话,最终的保底方案是对业务数据做幂等控制,这样一来,即使出现多个客户端获得锁的情况也不会影响数据的一致性。当然,也不是所有的场景都适合这么做,具体怎么取舍就需要各位看官自己处理啦,毕竟,没有完美的技术,只有适合的才是最好的。

推荐👍 :1049天,100K!简单复盘!

推荐👍 :汇报一下2020的工作

推荐👍 :Github掘金计划:Github上的一些优质项目搜罗

我是 Guide哥,拥抱开源,喜欢烹饪。Github 接近 10w 点赞的开源项目 JavaGuide 的作者。未来几年,希望持续完善 JavaGuide,争取能够帮助更多学习 Java 的小伙伴!共勉!凎!
原创不易,欢迎点赞分享。咱们下期再会!
浏览 37
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报