【大厂必备】系列之Redis分布式锁

265 阅读9分钟

上期文章我们聊了一下**【大厂必备】系列之Redis雪崩、击穿、穿透**,相信大家一定收获满满,这些可是进入大厂的必备板砖啊,这期我们接着放大招,来聊聊让人胆颤心惊的Redis分布式锁,哈哈哈……

在这里插入图片描述

生活中,锁一般是被用于隔离两个空间或物体,比如,你用一把锁把你的心爱的宝贝锁起来,以防别人窃走。而在计算机世界里,因为资源往往是有限的,多个用户或请求同时请求某一资源,就会出现资源竞争问题,所以,锁主要是用来解决资源共享的问题。

分布式锁就是专门用来解决分布式应用之间共享资源的高并发问题。目前业界主流的实现方式有基于ZooKeeperRedis两种,这篇我们主要介绍基于Redis的分布式锁。

分布式锁是什么

我们知道,随着互联应用的发展,单个服务器很难抗住所有压力,这时候就需要把功能分布到不同的机器上面,而在分布式环境下,基于本地单机的锁是无法控制分布式系统中分开部署客户端的并发行为,所以需要分布式锁。

实现分布式锁必须同时满足的四个条件:

1)互斥性,在任意时刻,只有一个客户端能持有锁;

2)不会发生死锁,即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其它客户端能加锁;

3)容错性,只要大部分的Redis节点运行正常,客户端就可以加锁和解锁;

4)加锁和解锁必须是同一个客户端

Redis分布式锁的实现

Redis中主要通过SETNX命令来实现分布式锁的功能,

格式如下:

SETNX key value

如果不存在,则设置key为保存的字符串,在这种情况下,它等于SET,当已经有值的时候,不执行任何操作;

返回值:

1 已设置了key 0 未设置key

示例:

redis> SETNX mykey "Hello"
(integer) 1
redis> SETNX mykey "World"
(integer) 0
redis> GET mykey
"Hello"
redis> 

SETNX底层源码

这是分布锁SETNX指令最底层的源码(t_string.c/setGenericCommand):

// SET/ SETEX/ SETTEX/ SETNX 最底层实现
void setGenericCommand(client *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) {
    longlong milliseconds = 0; /* initialized to avoid any harmness warning */

    // 如果定义了 key 的过期时间则保存到上面定义的变量中
    // 如果过期时间设置错误则返回错误信息
    if (expire) {
        if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != C_OK)
            return;
        if (milliseconds <= 0) {
            addReplyErrorFormat(c,"invalid expire time in %s",c->cmd->name);
            return;
        }
        if (unit == UNIT_SECONDS) milliseconds *= 1000;
    }

    // lookupKeyWrite 函数是为执行写操作而取出 key 的值对象
    // 这里的判断条件是:
    // 1.如果设置了 NX(不存在),并且在数据库中找到了 key 值
    // 2.或者设置了 XX(存在),并且在数据库中没有找到该 key
    // => 那么回复 abort_reply 给客户端
    if ((flags & OBJ_SET_NX && lookupKeyWrite(c->db,key) != NULL) ||
        (flags & OBJ_SET_XX && lookupKeyWrite(c->db,key) == NULL))
    {
        addReply(c, abort_reply ? abort_reply : shared.null[c->resp]);
        return;
    }

    // 在当前的数据库中设置键为 key 值为 value 的数据
    genericSetKey(c->db,key,val,flags & OBJ_SET_KEEPTTL);
    // 服务器每修改一个 key 后都会修改 dirty 值
    server.dirty++;
    if (expire) setExpire(c,c->db,key,mstime()+milliseconds);
    notifyKeyspaceEvent(NOTIFY_STRING,"set",key,c->db->id);
    if (expire) notifyKeyspaceEvent(NOTIFY_GENERIC,
        "expire",key,c->db->id);
    addReply(c, ok_reply ? ok_reply : shared.ok);
}

从源码可以很明显的看出,SETNX 和 EXPIRE 并不是 原子指令,所以在一起执行会出现问题。

Redis分布式锁的问题

问题一:锁超时

虽然上面的例子看起来好简单,但是考虑一下这个场景:

假设现在有两个平级客户端,如果一个得到锁的线程在执行任务的过程中挂掉了,来不及释放锁,那么这块资源就会永远的被锁住,别的线程就永远无法获得锁了。

在这里插入图片描述

所以,SETNX的key必须设置一个超时时间,伪代码如下:

if(setnx(key1) == 1){
    expire(key30try {
        do something ......
    }catch()
  {
  }
  finally {
       del(key)
    }
}

这段逻辑看起来没什么问题,但风险往往就存在于那些不经意的瞬间,假如有这样一个场景:

问题二:SETNX和EXPIRE的非原子性

当某个线程执行SETNX,成功得到了锁,但是还没有来得及执行EXPIRE指令,节点突然挂掉了,

在这里插入图片描述

这样一来,这把锁由于没来得及设置过期时间,出现了和上面同样的问题,别的线程也无法获取锁了。不过,这个情况,Redis的作者也考虑到了,于是在Redis2.6.12中为SET指令新增了一个可选参数:

Redis>set mykey:test myvalue ex 30 nx
ok
---todo---
Redis>del mykey:test

这样,用SET命令替代SETNX指令,可以解决这个问题;

问题三:超时后误删其它线程的锁

但是,问题是永远解决不完的,比如下面这种极端情况:

线程A成功获取到锁,并且设置超时时间是30s,但是,由于线程A的任务太多,30s内还没有执行完任务,结果锁提前释放掉了,这时候,线程B提前获取到了锁,随后,线程A完成任务,使用del指令来释放锁,但是,这时候线程B的逻辑还没有执行完,导致线程A删除的实际上是线程B的锁。

在这里插入图片描述

出现这个场景的原因: 第一,分布锁执行的任务时间过长; 第二,没有区分当前锁的拥有者;

所以,为了避免这个问题,首先就是不要用Redis分布式锁执行时间过长的任务,其次,就是在加锁的时候可以把当前的线程ID当作value(也可以是随机字符串,只要是唯一的字符串就可以),然后,在删除之前先判断key对应的value是不是自己线程的ID,确保当前线程占有的锁不会被其它线程释放,从而避免误删其它线程的锁

但是,这时候又出现了问题,判断key对应的value和删除锁在Redis中是两个操作,并不是原子性的操作,但是,逢山开路,遇水架桥,聪明的程序员们早就想好了解决方案,那就是用Lua脚本来保证这两个操作原子性执行。

Lua脚本如下:

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

问题四:集群问题

上面的场景是单机情况下,我们如何安全的获取和释放锁,但是,我们是要在大厂搬砖的人,怎么会局限于单机呢,不搞个几十台机器玩玩,成何体统嘛!哈哈哈……

我们在Redis之主从复制、持久化、哨兵这篇文章中说过,大厂都是集群模式部署,比如这样一个场景,客户端A在RedisA master上申请到了一把锁,这个时候RedisA master突然挂掉了,由于Redis的哨兵机制,它会自动选出来一个RedisB的从机作为master,这时候,客户端B获取对客户端A已经持有锁的同一资源的锁,这很明显违反了我们上面提到的分布锁的互斥性条件。因为Redis的复制是异步的。

所以,我们就需要一种能够在集群模式下解决分布锁的方法,很明显,作者也为我们考虑到了,我们这就来看看大神提出的RedLock算法。

RedLock算法

假设现在我们有5的Redis主节点,这些节点是完全独立的,这时候我们想要获取锁,客户端需要执行以下操作:

1)获取当前的unix时间戳;

2)依次尝试从5个实例中,使用相同的key和随机值获取锁。在获取锁的时候,客户端应该设置一个小于锁失效的网络连接和响应超时时间。比如锁失效时间为10秒,则超时时间应该在5-50ms之间,这样可以避免服务端Redis已经挂掉的情况下,客户端还在等待响应结果,如果服务端没有在规定时间内响应,客户端应该尽快尝试另一个Redis;

3)客户端使用当前时间减去开始获取锁的时间,从而得到获取锁使用的时间,当前仅当从大多数(这里是3个节点)的Redis节点中都获取到了锁,并且使用的时间小于锁失效的时间,锁才算获取成功;

4)如果获取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间;

5)如果没有获取到锁,客户端应该在所有的Redis实例上进行解锁;

失败了怎么办?

当一个客户端无法获取锁时,应该在随机延迟后重试;但是,在没有获取到大多数锁的情况下,客户端一定要尽快释放已经获取到的锁,这样就无需等待key过期后才能再次获取锁,但是,如果由于网络原因,导致客户端不能与Redis通信,则只能等待key到期后再重试。

如何释放锁?

只需向所有的Redis实例发送释放锁的指令即可,不用关心之前有没有从Redis实例成功获取到锁。

这里贴上官方解释,包括Redlock算法的各种语言的实现,有兴趣的可以去瞧瞧啊 redis.io/topics/dist…

不过生产环境一般是用现成的分布式锁框架,比如基于Java实现的Redisson分布式锁框架,如果需要特定场景,也可以完全自己研发一套分布式锁框架。

总结

这就是Redis中著名分布式锁了,只要你想在大厂搬砖,一定会用到或进门的时候被问到的

虽然这些问题看起来好像很简单,但是放在生产环境,几十上百台机器如果发生这种情况,那就是项目事故了,对公司的影响可能是致命的,所以,一定要熟练的掌握。

OK,这期文章可真是精华,也是进入大厂的必备板砖之一,写到最后,我都不忍心发出来了,哈哈哈……

不过咱是又渣又成熟的大叔,当然是要让大家白嫖的嘛!

如果你觉得大叔写得还不错,**求关注、求点赞、求分享,**毕竟大叔熬肝码字也是很幸苦的嘛,当然,如果你是个妹子,也欢迎直接小窗找我哦!

我是诗远君,一个流浪在互联网江湖的大叔。

我们下期见!