从零到一手写基于Redis的分布式锁框架

41次阅读

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

【本文版权归微信公众号 ” 代码艺术 ”(ID:onblog)所有,若是转载请务必保留本段原创声明,违者必究。若是文章有不足之处,欢迎关注微信公众号私信与我进行交流!】

1. 分布式锁缘由

学习编程初期,我们做的诸如教务系统、成绩管理系统大多是单机架构,单机架构在处理并发的问题上一般是依赖于 JDK 内置的并发编程类库,如 synchronize 关键字、Lock 类等。随着业务以及需求的提高,单机架构不再满足我们的要求,这个时候我们不免要进行业务上的分离,例如基于 Maven 进行多模块开发。业务与业务分离之后,遇到的首要问题就是业务之间如何进行通信,相信会有不少读者了解诸如 Dubbo、SpringCloud 之类的 RPC 框架,但这些 RPC 框架并没有自带处理分布式并发问题的功能,所以,分布式并发问题还需要我们自己去实现分布式锁。

2. 分布式锁条件

为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

  1. 互斥性。在任意时刻,只有一个客户端能持有锁。
  2. 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  3. 具有容错性。只要大部分的 Redis 节点正常运行,客户端就可以加锁和解锁。
  4. 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

3. 分布式锁方式

分布式锁一般有三种实现方式:

  1. 数据库乐观锁
  2. 基于 Redis 的分布式锁
  3. 基于 Zookeeper 的分布式锁

下面我按个提一下这三种方式的大致实现思路。

3.1 数据库乐观锁

数据库乐观锁的实现方式是先使用 SELECT 语句查询某字段的值(版本号),该字段即理解为要获取的分布式锁。然后在使用 UPDATE 语句对正常业务数据进行更新,在 UPDATE 语句执行时一定要用 WHERE 条件对版本号进行判断,若版本号在这段时间内并没有发生变化则该语句默认执行成功,否则循环执行即可。

示例代码:

select (status,version) from goods where id=#{id}

update goods set status=2,version=version+1 where id=#{id} and version=#{version};

3.2 基于 Zookeeper 的分布式锁

基于 Zookeeper 实现分布式锁的算法思路大致如下假设锁空间的根节点为 /lock:

  1. 客户端连接 zookeeper,并在 /lock 下创建临时的且有序的子节点,第一个客户端对应的子节点为 /lock/lock-0000000000,第二个为 /lock/lock-0000000001,以此类推。
  2. 客户端获取 /lock 下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁,否则监听 /lock 的子节点变更消息,获得子节点变更通知后重复此步骤直至获得锁。
  3. 执行业务代码。
  4. 完成业务流程后,删除对应的子节点释放锁。

3.3 基于 Redis 的分布式锁

基于 Redis 的分布式锁实现是基于 Redis 自带的 setnx 命令。该命令只有在要设置的字段不存在的情况下才能设置成功,也就是获得分布式锁,否则失败。为了防止客户端异常导致的锁未释放问题,还需要对该字段设置过期时间。

本文将基于 Redis 分布式锁的实现思路设计一个 spring-boot-starter-redis-lock 框架。

核心代码如下:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.concurrent.TimeUnit;

@Component
public class RedisLock {
    @Autowired
    private StringRedisTemplate template;
    @Autowired
    private DefaultRedisScript<Long> redisScript;

    private static final Long RELEASE_SUCCESS = 1L;

    private long timeout = 3000;

    public boolean lock(String key, String value) {
        // 执行 set 命令
        Boolean absent = template.opsForValue().setIfAbsent(key, value, timeout, TimeUnit.MILLISECONDS);//1
        // 其实没必要判 NULL,这里是为了程序的严谨而加的逻辑
        if (absent == null) {return false;}
        // 是否成功获取锁
        return true;
    }

    public boolean unlock(String key, String value) {
        // 使用 Lua 脚本:先判断是否是自己设置的锁,再执行删除
        Long result = template.execute(redisScript, Arrays.asList(key,value));
        // 返回最终结果
        return RELEASE_SUCCESS.equals(result);
    }

    public void setTimeout(long timeout) {this.timeout = timeout;}

    @Bean
    public DefaultRedisScript<Long> defaultRedisScript() {DefaultRedisScript<Long> defaultRedisScript = new DefaultRedisScript<>();
        defaultRedisScript.setResultType(Long.class);
        defaultRedisScript.setScriptText("if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end");
        return defaultRedisScript;
    }

}

执行上面的 setIfAbsent() 方法就只会导致两种结果:1. 当前没有锁(key 不存在),那么就进行加锁操作,并对锁设置个有效期,同时 value 表示加锁的客户端。2. 已有锁存在,不做任何操作。

回顾上面提到的分布式锁的四个条件,在任意时刻,该代码都能保证只有一个客户端能持有锁,并且每一个分布式锁都加了过期时间,保证不会出现死锁,容错性暂时不考虑的话,加锁和解锁通过 key 保证了对多个客户端而言都是同一把锁,value 的作用则是保证对同一把锁的加锁和解锁操作都是同一个客户端。

4. 为什么上述方案不够好

【本文版权归微信公众号 ” 代码艺术 ”(ID:onblog)所有,若是转载请务必保留本段原创声明,违者必究。若是文章有不足之处,欢迎关注微信公众号私信与我进行交流!】

为了理解我们想要提高的到底是什么,我们先看下当前大多数基于 Redis 的分布式锁三方库的现状。用 Redis 来实现分布式锁最简单的方式就是在实例里创建一个键值,创建出来的键值一般都是有一个超时时间的(这个是 Redis 自带的超时特性),所以每个锁最终都会释放(参见前文属性 2)。而当一个客户端想要释放锁时,它只需要删除这个键值即可。表面来看,这个方法似乎很管用,但是这里存在一个问题:在我们的系统架构里存在一个单点故障,如果 Redis 的 master 节点宕机了怎么办呢?有人可能会说:加一个 slave 节点!在 master 宕机时用 slave 就行了!但是其实这个方案明显是不可行的,因为这种方案无法保证第 1 个安全互斥属性,因为 Redis 的复制是异步的。总的来说,这个方案里有一个明显的竞争条件(race condition),举例来说:

  1. 客户端 A 在 master 节点拿到了锁。
  2. master 节点在把 A 创建的 key 写入 slave 之前宕机了。
  3. slave 变成了 master 节点
  4. B 也得到了和 A 还持有的相同的锁(因为原来的 slave 里还没有 A 持有锁的信息)

当然,在某些特殊场景下,前面提到的这个方案则完全没有问题,比如在宕机期间,多个客户端允许同时都持有锁,如果你可以容忍这个问题的话,那用这个基于复制的方案就完全没有问题,否则的话我还是建议你对上述方案进行改进。比如,考虑使用 Redlock 算法。

5.Redlock 算法

在分布式版本的算法里我们假设我们有 N 个 Redis master 节点,这些节点都是完全独立的,我们不用任何复制或者其他隐含的分布式协调算法。我们已经描述了如何在单节点环境下安全地获取和释放锁。因此我们理所当然地应当用这个方法在每个单节点里来获取和释放锁。在我们的例子里面我们把 N 设成 5,这个数字是一个相对比较合理的数值,因此我们需要在不同的计算机或者虚拟机上运行 5 个 master 节点来保证他们大多数情况下都不会同时宕机。一个客户端需要做如下操作来获取锁:

  1. 获取当前时间(单位是毫秒)。
  2. 轮流用相同的 key 和随机值在 N 个节点上请求锁,在这一步里,客户端在每个 master 上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是 10 秒钟,那每个节点锁请求的超时时间可能是 5 -50 毫秒的范围,这个可以防止一个客户端在某个宕掉的 master 节点上阻塞过长时间,如果一个 master 节点不可用了,我们应该尽快尝试下一个 master 节点。
  3. 客户端计算第二步中获取锁所花的时间,只有当客户端在大多数 master 节点上成功获取了锁(在这里是 3 个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
  4. 如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。
  5. 如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1) 还是因为总消耗时间超过了锁释放时间,客户端都会到每个 master 节点上释放锁,即便是那些他认为没有获取成功的锁。

本文代码仓库:https://github.com/ystcode/sp…

参考文章:https://www.cnblogs.com/ironP…

版权声明

【本文版权归微信公众号 ” 代码艺术 ”(ID:onblog)所有,若是转载请务必保留本段原创声明,违者必究。若是文章有不足之处,欢迎关注微信公众号私信与我进行交流!】

正文完
 0