技术架构(3)-分布式锁

参见面试必备:聊聊分布式锁的多种实现

应用场景

分布式锁是为了解决分布式系统中不同节点的进程访问有限共享资源之间的争用,防止重复交易或者数据不一致。

分布式锁本身是单一的,即使用集群来实现分布式锁,它也应该保持状态一致。

完善的分布式锁具备如下特性:

  • 互斥性:互斥是锁的基本特性,分布式锁按需求保证线程或节点级别的互斥;

  • 可重入:同一个节点或同一个线程获取锁,可以再次获取这个锁;

  • 锁超时:支持锁超时释放,防止某个节点不可用后,持有的锁无法释放;

  • 高效性:加锁和解锁的效率高,可以支持高并发;

  • 高可用:需要有高可用机制预防锁服务不可用的情况,如增加降级;

  • 阻塞性:支持阻塞获取锁和非阻塞获取锁两种方式;

  • 公平性:支持公平锁和非公平锁两种类型的锁,公平锁可以按照请求锁的顺序获取锁,而非公平锁不可以。

实现方式

常见的实现方式有三种:

  • 数据库
  • Redis
  • ZooKeeper

数据库

悲观锁

可以使用 select ... for update 来实现分布式锁,锁住互斥的那条记录。

悲观锁实现的分布式锁,整体的流程还是比较清晰的。 先 select ... for update 锁住主键 key_resource 那个记录,如果为空则插入一条记录,如果已有记录判断下状态和超时时间。这里需要注意一下哈,必须要加事务。

乐观锁

乐观锁,顾名思义,就是很乐观,每次更新操作,都觉得不会存在并发冲突,只有更新失败后,才重试。它是基于 CAS 思想实现的。

搞个 version 字段,每次更新修改,都会自增加一,然后去更新余额时,把查出来的那个版本号,带上条件去更新,如果是上次那个版本号,就更新,如果不是,表示别人并发修改过了,就继续重试。

这种方式适合并发不高的场景,一般需要设置一下重试的次数。

Redis

Redis 分布式锁一般有以下这几种实现方式:

  • setnx+expire

  • setnx+value 值是过期时间

  • set 的扩展命令(set ex px nx)

  • set ex px nx+校验唯一随机值,再删除

  • Redisson

  • Redisson+RedLock

setnx+expire

聊到 Redis 分布式锁,很多小伙伴反手就是 setnx+expire,如下:

if(jedis.setnx(key,lock_value) == 1){ //setnx加锁
    expire(key,100); //设置过期时间
    try {
        do something  //业务处理
    }catch(){
    }
  finally {
       jedis.del(key); //释放锁
    }
}

这段代码是可以加锁成功,但是你有没有发现问题,加锁操作和设置超时时间是分开的。

假设在执行完 setnx 加锁后,正要执行 expire 设置过期时间时,进程 crash 掉或者要重启维护了,那这个锁就长生不老了,别的线程永远获取不到锁啦,所以分布式锁不能这么实现!

setnx+value 值是过期时间

long expires = System.currentTimeMillis() + expireTime; //系统时间+设置的过期时间
String expiresStr = String.valueOf(expires);

// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(key, expiresStr) == 1) {
        return true;
} 
// 如果锁已经存在,获取锁的过期时间
String currentValueStr = jedis.get(key);

// 如果获取到的过期时间,小于系统当前时间,表示已经过期
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {

     // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
    String oldValueStr = jedis.getSet(key, expiresStr);

    if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
         // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁
         return true;
    }
}

//其他情况,均返回加锁失败
return false;
}

日常开发中,有些小伙伴就是这么实现分布式锁的,但是会有这些缺点:

  • 过期时间是客户端自己生成的,分布式环境下,每个客户端的时间必须同步。

  • 没有保存持有者的唯一标识,可能被别的客户端释放/解锁。

  • 锁过期的时候,并发多个客户端同时请求过来,都执行了 jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖。

set 的扩展命令(set ex px nx)

这个命令的几个参数分别表示什么意思呢?

SET key value [EX seconds] [PX milliseconds] [NX|XX]
  • EX second :设置键的过期时间为 second 秒。

  • PX millisecond :设置键的过期时间为 millisecond 毫秒。

  • NX :只在键不存在时,才对键进行设置操作。

  • XX :只在键已经存在时,才对键进行设置操作。
if(jedis.set(key, lock_value, "NX", "EX", 100s) == 1){ //加锁
    try {
        do something  //业务处理
    }catch(){
  }
  finally {
       jedis.del(key); //释放锁
    }
}

这个方案可能存在这样的问题:

  • 锁过期释放了,业务还没执行完。

  • 锁被别的线程误删。

有些伙伴可能会有个疑问,就是锁为什么会被别的线程误删呢?假设并发多线程场景下,线程 A 获得了锁,但是它没释放锁的话,线程 B 是获取不到锁的,所以按道理它是执行不到加锁下面的代码滴,怎么会导致锁被别的线程误删呢?

假设线程 A 和 B,都想用 key 加锁,最后 A 抢到锁加锁成功,但是由于执行业务逻辑的耗时很长,超过了设置的超时时间 100s。这时候,Redis 就自动释放了 key 锁。这时候线程 B 就可以加锁成功了,接下啦,它也执行业务逻辑处理。假设碰巧这时候,A 执行完自己的业务逻辑,它就去释放锁,但是它就把 B 的锁给释放了。

set ex px nx+校验唯一随机值,再删除

为了解决锁被别的线程误删问题。可以在 set ex px nx 的基础上,加上个校验的唯一随机值,如下:

if(jedis.set(key, uni_request_id, "NX", "EX", 100s) == 1){ //加锁
    try {
        do something  //业务处理
    }catch(){
  }
  finally {
       //判断是不是当前线程加的锁,是才释放
       if (uni_request_id.equals(jedis.get(key))) {
          jedis.del(key); //释放锁
        }
    }
}

在这里,判断当前线程加的锁和释放锁不是一个原子操作。如果调用 jedis.del() 释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。 也是还是存在锁过期释放了,业务还没执行完的问题。

Redisson

对于可能存在锁过期释放,业务没执行完的问题。我们可以稍微把锁过期时间设置长一些,大于正常业务处理时间就好啦。

如果你觉得不是很稳,还可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。

开源框架 Redisson 解决了这个问题。可以看下 Redisson 底层原理图。

图片

只要线程一加锁成功,就会启动一个 watch dog 看门狗,它是一个后台线程,会每隔 10 秒检查一下,如果线程 1 还持有锁,那么就会不断的延长锁 key 的生存时间。因此,Redisson 就是使用 watch dog 解决了锁过期释放,业务还没执行完的问题。

Redisson+RedLock

前面六种方案都只是基于 Redis 单机版的分布式锁讨论,还不是很完美。因为 Redis 一般都是集群部署的:图片

如果线程一在 Redis 的 master 节点上拿到了锁,但是加锁的 key 还没同步到 slave 节点。恰好这时,master 节点发生故障,一个 slave 节点就会升级为 master 节点。

线程二就可以顺理成章地获取同个 key 的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。

为了解决这个问题,Redis 作者 antirez 提出一种高级的分布式锁算法:Redlock。它的核心思想是这样的:部署多个 Redis master,以保证它们不会同时宕掉。并且这些 master 节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个 master 实例上,是与 Redis 单实例使用相同方法来获取和释放锁。

我们假设当前有 5 个 Redis master 节点,在 5 台服务器上面运行这些 Redis 实例。

图片

RedLock 的实现步骤:

  • 获取当前时间,以毫秒为单位。

  • 按顺序向 5 个 master 节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为 10 秒,则超时时间一般在 5-50 毫秒之间,我们就假设超时时间是 50ms 吧)。如果超时,跳过该 master 节点,尽快去尝试下一个 master 节点。

  • 客户端使用当前时间减去开始获取锁时间(即步骤 1 记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是 5/2+1=3 个节点)的 Redis master 节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(如上图,10s> 30ms+40ms+50ms+40ms+50ms)。

  • 如果取到了锁,key 的真正有效时间就变啦,需要减去获取锁所使用的时间。

  • 如果获取锁失败(没有在至少 N/2+1 个 master 实例取到锁,有或者获取锁时间已经超过了有效时间),客户端要在所有的 master 节点上解锁(即便有些 master 节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)。

简化下步骤就是:

  • 按顺序向 5 个 master 节点请求加锁

  • 根据设置的超时时间来判断,是不是要跳过该 master 节点。

  • 如果大于等于 3 个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。

  • 如果获取锁失败,解锁!

ZooKeeper

Zookeeper 的节点 Znode 有四种类型:

  • 持久节点:默认的节点类型。创建节点的客户端与 zookeeper 断开连接后,该节点依旧存在。

  • 持久顺序节点:所谓顺序节点,就是在创建节点时,Zookeeper 根据创建的时间顺序给该节点名称进行编号,持久节点顺序节点就是有顺序的持久节点。

  • 临时节点:和持久节点相反,当创建节点的客户端与 zookeeper 断开连接后,临时节点会被删除。

  • 临时顺序节点:有顺序的临时节点。

Zookeeper 分布式锁实现应用了临时顺序节点。

ZK 获取锁过程

当第一个客户端请求过来时,Zookeeper 客户端会创建一个持久节点 locks。如果它(Client1)想获得锁,需要在 locks 节点下创建一个顺序节点 lock1。

图片

接着,客户端 Client1 会查找 locks 下面的所有临时顺序子节点,判断自己的节点 lock1 是不是排序最小的那一个,如果是,则成功获得锁。图片

这时候如果又来一个客户端 client2 前来尝试获得锁,它会在 locks 下再创建一个临时节点 lock2。图片

客户端 client2 一样也会查找 locks 下面的所有临时顺序子节点,判断自己的节点 lock2 是不是最小的,此时,发现 lock1 才是最小的,于是获取锁失败。client2 向它排序靠前的节点 lock1 注册 Watcher 事件,用来监听 lock1 是否存在,也就是说 client2 抢锁失败进入等待状态。

图片

此时,如果再来一个客户端 Client3 来尝试获取锁,它会在 locks 下再创建一个临时节点 lock3。

图片

同样的,client3 一样也会查找 locks 下面的所有临时顺序子节点,判断自己的节点 lock3 是不是最小的,发现自己不是最小的,就获取锁失败。它也是不会甘心的,它会向在它前面的节点 lock2 注册 Watcher 事件,以监听 lock2 节点是否存在。图片

释放锁

我们再来看看释放锁的流程,Zookeeper 的客户端业务完成或者发生故障,都会删除临时节点,释放锁。

如果是任务完成,Client1 会显式调用删除 lock1 的指令。

图片

如果是客户端故障了,根据临时节点特性,lock1 是会自动删除的。

图片

lock1 节点被删除后,Client2 可开心了,因为它一直监听着 lock1。

lock1 节点删除,Client2 立刻收到通知,也会查找 locks 下面的所有临时顺序子节点,发下 lock2 是最小,就获得锁。图片

同理,Client2 获得锁之后,Client3 也对它虎视眈眈:

  • Zookeeper 设计定位就是分布式协调,简单易用。如果获取不到锁,只需添加一个监听器即可,很适合做分布式锁。
  • Zookeeper 作为分布式锁也缺点:如果有很多的客户端频繁的申请加锁、释放锁,对于 Zookeeper 集群的压力会比较大。

三种分布式锁对比

数据库分布式锁实现

优点:简单,使用方便,不需要引入 Redis、Zookeeper 等中间件。

缺点:

  • 不适合高并发的场景

  • db 操作性能较差

Redis 分布式锁实现

优点:

  • 性能好,适合高并发场景

  • 较轻量级

  • 有较好的框架支持,如 Redisson

缺点:

  • 过期时间不好控制

  • 需要考虑锁被别的线程误删场景

Zookeeper 分布式锁实现

缺点:

  • 性能不如 Redis 实现的分布式锁

  • 比较重的分布式锁。

优点:

  • 有较好的性能和可靠性

  • 有封装较好的框架,如 Curator

对比汇总如下:

  • 从性能角度(从高到低)Redis > Zookeeper >= 数据库
  • 从理解的难易程度角度(从低到高)数据库 > Redis > Zookeeper
  • 从实现的复杂性角度(从低到高)Zookeeper > Redis > 数据库
  • 从可靠性角度(从高到低)Zookeeper > Redis > 数据库

posted on 2022-05-31 16:24  别样风景天  阅读(56)  评论(0编辑  收藏  举报

导航