Redis实现的分布式锁


简介

分布式环境中,需要一种跨JVM的互斥机制来控制共享资源的访问(synchronized基于JVM但是是单机环境,不适用于分布式环境)

例如,为了避免用户操作重复导致交易执行多次,使用分布式锁可以将某用户的同一时刻第一次以外的请求拦截掉。如果使用事务在数据库层面进行限制也能实现但是会增大数据库的压力

例如,在分布式任务系统中味了避免同一任务重复执行,某个节点执行任务之后可以使用分布式锁避免其他节点在同一时刻得到相同任务,比如超卖。

非常像一道面试题:如何实现一个分布式锁?
首先我们需要一个简单的答题套路:需求分析、系统设计、实现方式、缺点不足

思路

需求分析

  1. 能够在高并发的分布式的系统中应用;
  2. 需要实现锁的基本特性:一旦某个锁被分配出去,那么其他的节点无法再进入这个锁所管辖范围内的资源;失效机制避免无限时长的锁与死锁;
  3. 进一步实现锁的高级特性和JUC并发工具类似功能:可重入、阻塞喝非阻塞、公平与非公平、JUC的兵法工具(Semaphore,CountDownLatch,CyclicBarrier)

系统设计

需要满足如下要求

  1. 对加锁、解锁的过程都要是高性能、原子性
  2. 需要在某个分布式节点都能访问到的公共平台上进行锁状态的操作

所以,我们分析出系统的构成应该要有锁状态存储模块、连接存储模块的连接池模块 、锁内部逻辑模块

锁状态存储模块

分布式锁的存储有三种常见实现,因为能满足实现锁的这些条件:高性能加锁解锁、操作的原子性、是分布式系统中不同节点都可以访问的公共平台:

  1. 数据库(利用主键唯一规则、MySQL行锁)
  2. 基于Redis的NX(存在不新增)、EX(过期时间)参数
  3. Zookeeper临时有序节点 三种分布式锁对比

由于锁常常是在高并发的情况下才会使用到的分布式控制工具,所以使用数据库实现会对数据库造成一定的压力,所以不推荐数据库的实现;Zookeeper集群还需要我们去维护,实现起来比较复杂,如果压力不大的情况下,一般不使用Zookeeper实现分布式锁。所以缓存实现分布式锁还是比较常见的,因为缓存比较轻量、缓存的响应快、吞吐高、还有自动失效的机制保证锁一定能释放

连接池模块

可使用JedisPool实现

锁内部逻辑模块

  • 基本功能:加锁、解锁、超时释放
  • 高级功能:可重入、阻塞与非阻塞、公平与非公平、JUC并发工具功能

实现方式

存储模块使用Redis,连接池模块暂时使用JedisPool,锁内部逻辑将从基本功能开始,逐步实现高级功能,下面就是各种功能实现的具体思路与代码。

加锁、超时释放

NX是Redis提供的一个原子操作,如果指定key存在,那么NX失败,如果不存在会进行set操作并返回成功。我们可以利用这个来实现一个分布式锁。主要思路就是,set成功表示获取锁,set失败表示获取失败,失败后需要重试。再加上EX参加可以让该key在超时之后自动删除

下面是一个阻塞锁的加锁操作,将循环去掉并返回执行结果就能写出非阻塞锁

public void lock(String key, String request, int timeout) throws InterruptedException {
    Jedis jedis = jedisPool.getResource();

    while (timeout >= 0) {
        String result = jedis.set(LOCK_PREFIX + key, request, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, DEFAULT_EXPIRE_TIME);
        if (LOCK_MSG.equals(result)) {
            jedis.close();
            return;
        }
        Thread.sleep(DEFAULT_SLEEP_TIME);
        timeout -= DEFAULT_SLEEP_TIME;
    }
}

但超时时间这个参数会引发一个问题,如果超过超时时间但是业务还没执行完全导致并发问题,其他节点上的JVM进程就会拿到锁然后执行业务代码。

解锁

最常见的解锁代码就是直接使用jedis.del()方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它自己的。

比如可能存在这样的情况:客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。

所以我们需要一个具有原子性的方法来解锁,并且要同事判断这把锁是不是自己的。由于Lua中执行是原子性的,可以写成

public boolean unlock(String key, String value) {
    Jedis jedis = jedisPool.getResource();
    //官方提供
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    Object result = jedis.eval(script, Collections.singletonList(LOCK_PREFIX + key), Collections.singletonList(value));

    jedis.close();
    return UNLOCK_MSG.equals(result);
}

测试

以上的代码都写在RedisLock里,用到了一个嵌入式的Redis工具以及如下代码来帮我们New一个Redis实例

@Configuration
public class EmbeddedRedis implements ApplicationRunner {

    private static RedisServer redisServer;

    @PreDestroy
    public void stopRedis() {
        redisServer.stop();
    }

    @Override
    public void run(ApplicationArguments applicationArguments) {
        redisServer = RedisServer.builder().setting("bind 127.0.0.1").setting("requirepass test").build();
        redisServer.start();
    }
}

测试一下锁的互斥能力,能否再A拿到锁之后,B就无法立即拿到锁

@Test
public void shouldWaitWhenOneUsingLockAndTheOtherOneWantToUse() throws InterruptedException {
    Thread t = new Thread(() -> {
        try {
            redisLock.lock(lock1Key, UUID.randomUUID().toString());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });
    t.start();
    t.join();

    long startTime = System.currentTimeMillis();
    redisLock.lock(lock1Key, UUID.randomUUID().toString(), 3000);
    assertThat(System.currentTimeMillis() - startTime).isBetween(2500L, 3500L);
}

这仅仅测试里加锁操作时候的互斥性,没有测试解锁是否会成功以及解锁之后原来等到锁的进程会继续进行。

超时问题

问题:如果A拿到锁之后设置了超长时长,但是业务执行时长超过了超时时长,导致A还在执行业务但是锁已经被释放,此时其他进程就会拿到锁从而执行相同的业务,此时因为并发导致分布式锁失去了意义。

如果客户端可以通过在key快要过期的时候判断下任务有没有执行完毕,如果还没有那就自动延长过期时间,那么确实可以解决并发的问题,但是锁的超时时长也就失去了意义。所以个人认为最好的解决方式是在锁超时的时候通知服务器去停掉超时任务,但是结合上Redis的发布订阅机制不免有些过重了,而且Redis没有Zookeeper的临时节点,也没有etcd的全局序列号,所以虽然可以实现,但是并不方便,所以etcd、zookeeper则是适合这种场景的载体。

  1. 如果是第一次加锁,直接使用锁的key以及一个创建时全局递增的序列号来创建一个etcd上的键值对,此时等同于拿到了锁,最后返回给业务代码执行
  2. 如果该锁的key已经存在,说明锁正在被别的客户端使用,那么通过watch机制开始监听该key的所有以前的版本,直到这个key的所有以前的版本被删除(相当于是按“公平锁”的方式拿锁的,避免了大量客户端同时抢锁的“惊群效应”),就等于拿到了锁,最后返回给业务代码执行

场景2

假设有两个服务A、B都希望获得锁,执行过程大致如下:

  1. 服务A为了获得锁,向Redis发起如下命令: SET productld:lock Oxx9p03001 NX EX 30000 其中,”productld”由自己定义,可以是与本次业务有关的id, “0xx9p03001”是一串随机值,必须保证全局唯一,”NX”指的是当且仅当key(也就是案例中的”productid:lock”)在Redis中不存在时,返回执行成功,否则执行失败。”EX 30000”指的是在30秒后,key将被自动删除。执行命令后返回成功,表明服务成功的获得了锁。

  2. 服务B为了获得锁,向Redis发起同样的命令: SET productld:lock 0000111 NX EX 30000由于Redis内已经存在同名key,且并未过期,因此命令执行失败,服务B未能获得锁。服务B进入循环请求状态,比如每隔1秒钟(自行设置)向Redis发送请求,直到执行成功并获得锁。

  3. 服务A的业务代码执行时长超过了30秒,导致key超时,因此Redis自动删除了key。此时服务B再次发送命令执行成功,假设本次请求中设置的value值为0000222。此时需要在服务A中对key进行续期。

  4. 服务A执行完毕,为了释放锁,服务A会主动向Redis发起删除key的请求。注意: 在删除key之前,一定要判断服务A持有的value与Redis内存储者的value是否一致。比如当前场景下,Redis中的锁早就不是服务A持有的那一把了,而是由服务2创建,如果贸然使用服务A持有的key来删除锁,则会误将服务2的锁释放掉。此外,由于删除锁时涉及到一系列判断逻辑,因此一般使用lua脚本,具体如下:

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



Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • 2379. Minimum Recolors to Get K Consecutive Black Blocks
  • 2471. Minimum Number of Operations to Sort a Binary Tree by Level
  • 1387. Sort Integers by The Power Value
  • 2090. K Radius Subarray Averages
  • 2545. Sort the Students by Their Kth Score