分布式锁的基本概念

在复习分布式锁的概念时,许多人已经对此有一定的了解,尤其是在阿里和美团等公司的面试中,这一主题常常被提及。

分布式锁简介


在单机多线程环境中,Java开发者通常使用ReentrantLock类或synchronized关键字等JDK自带的本地锁来控制多个线程对共享资源的访问。

从图中可以看出,这些线程对共享资源的访问是互斥的,任何时刻只有一个线程可以获得本地锁来访问共享资源。

在分布式系统中,不同的服务或客户端通常运行在独立的JVM进程上。如果多个JVM进程共享同一份资源,本地锁便无法实现资源的互斥访问,因此分布式锁应运而生。

以订单服务为例,假设该服务部署了三台实例并对外提供服务。在用户下订单之前,需要检查库存以防超卖,因此此时需要加锁来确保库存检查操作的同步访问。由于这些服务运行于不同的JVM进程中,本地锁在此场景下无法正常使用,因此我们必须采用分布式锁。这样,即便多个线程不在同一个JVM进程中,也能获取同一把锁,从而实现共享资源的互斥访问。

一个基本的分布式锁应当满足以下几个条件:

  • 互斥性:在任意时刻,锁只能被一个线程持有;
  • 高可用性:锁服务必须保持高可用性,即使客户端在释放锁时出现问题,锁最终也会释放,不会影响其他线程对共享资源的访问;
  • 可重入性:一个节点可以在获取锁后再次获取该锁。

常用的实现分布式锁的方法通常选择基于Redis或ZooKeeper,Redis的使用频率更高,因此本文也将以Redis为例进行介绍。

基于Redis实现分布式锁


如何创建一个简单的Redis分布式锁?

无论是本地锁还是分布式锁,其核心都在于“互斥”。

在Redis中,SETNX命令可以帮助我们实现互斥。SETNX代表SET if Not eXists(类似于Java中的setIfAbsent方法),只有当key不存在时,才会设置key的值。如果key已存在,SETNX将不执行任何操作。

> SETNX lockKey uniqueValue  
(integer) 1  
> SETNX lockKey uniqueValue  
(integer) 0  

要释放锁,只需通过DEL命令删除相应的key即可。

> DEL lockKey  
(integer) 1  

为了防止误删除其他锁,建议使用Lua脚本,通过key对应的唯一值进行判断。

使用Lua脚本的原因在于其能确保解锁操作的原子性。Redis在执行Lua脚本时,会以原子方式完成,为锁的释放操作提供了保障。

// 释放锁时,先比较锁对应的value值是否相等,避免误释放
if redis.call("get", KEYS[1]) == ARGV[1] then  
    return redis.call("del", KEYS[1])  
else  
    return 0  
end  

为何为锁设置过期时间?

为了避免锁无法被释放,可以考虑给该key(即锁)设置一个过期时间。

127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX  
OK  
  • lockKey:锁的名称;
  • uniqueValue:能够唯一标识锁的随机字符串;
  • NX:只有当lockKey对应的key值不存在时,设置才成功;
  • EX:设置过期时间(以秒为单位),EX 3表示锁会在3秒后自动过期。与EX对应的还有PX(以毫秒为单位)。

务必确保设置key的值和过期时间是一个原子操作! 否则,仍然可能出现锁未被释放的情况。

这种做法确实解决了部分问题,但同样存在漏洞:如果对共享资源的操作时间超过了过期时间,锁将提前过期,导致分布式锁失效。若锁的超时时间设置过长,则会对性能造成影响。

或许你会想:如果对共享资源的操作尚未完成,锁的过期时间能进行自我续期就好了!

如何优雅地实现锁的续期?

对于Java开发者来说,已有现成的解决方案:**Redisson[1]**。其他语言的解决方案可在Redis官方文档中找到,地址为: https://redis.io/topics/distlock

图片

Redisson是一个开源的Java语言Redis客户端,提供了多种开箱即用的功能,包括多种类型的分布式锁实现。Redisson还支持Redis单机、Redis Sentinel、Redis Cluster等多种部署架构。

Redisson中的分布式锁自带自动续期机制,使用非常简单。它的原理也很简单,Redisson提供了一个专门用于监控和续期锁的Watch Dog(看门狗),如果共享资源的操作线程尚未完成,Watch Dog会不断延长锁的过期时间,以确保锁不会因超时而被释放。

Redisson看门狗的自动续期机制

看门狗的名字来源于getLockWatchdogTimeout()方法,该方法返回的是看门狗用于续期的超时时间,默认为30秒(redisson-3.17.6[2])。

// 默认30秒,支持修改
private long lockWatchdogTimeout = 30 * 1000;  
  
public Config setLockWatchdogTimeout(long lockWatchdogTimeout) {  
    this.lockWatchdogTimeout = lockWatchdogTimeout;  
    return this;  
}  
public long getLockWatchdogTimeout() {  
   return lockWatchdogTimeout;  
}  

renewExpiration()方法包含了看门狗的主要逻辑:

private void renewExpiration() {  
        //......  
        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {  
            @Override  
            public void run(Timeout timeout) throws Exception {  
                //......  
                // 异步续期,基于Lua脚本  
                CompletionStage<Boolean> future = renewExpirationAsync(threadId);  
                future.whenComplete((res, e) -> {  
                    if (e != null) {  
                        // 无法续期  
                        log.error("Can't update lock " + getRawName() + " expiration", e);  
                        EXPIRATION_RENEWAL_MAP.remove(getEntryName());  
                        return;  
                    }  
                    if (res) {  
                        // 递归调用实现续期  
                        renewExpiration();  
                    } else {  
                        // 取消续期  
                        cancelExpirationRenewal(null);  
                    }  
                });  
            }  
            // 延迟 internalLockLeaseTime/3(默认10s,也就是30/3)再调用  
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);  
        ee.setTimeout(task);  
    }  

默认情况下,每10秒钟,看门狗会执行续期操作,将锁的超时时间设置为30秒。在续期之前,看门狗也会判断是否需要执行续期操作,若需要则执行,否则取消续期操作。

Watch Dog通过调用renewExpirationAsync()方法实现锁的异步续期:

protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {  
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,  
            // 判断是否为持锁线程,如果是就执行续期操作,就锁的过期时间设置为30s(默认)  
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +  
                "redis.call('pexpire', KEYS[1], ARGV[1]); " +  
                "return 1; " +  
            "end; " +  
            "return 0;",  
            Collections.singletonList(getRawName()),  
            internalLockLeaseTime, getLockName(threadId));  
}  

可以看到,renewExpirationAsync方法实际上是通过Lua脚本来实现续期,这样做主要是为了保证续期操作的原子性。

下面以Redisson的可重入分布式锁RLock为例,演示如何使用Redisson实现分布式锁:

// 1.获取指定的分布式锁对象  
RLock lock = redisson.getLock("lock");  
// 2.拿锁且不设置锁超时时间,具备Watch Dog自动续期机制  
lock.lock();  
// 3.执行业务  
...  
// 4.释放锁  
lock.unlock();  

只有在未指定锁超时时间的情况下,才会使用Watch Dog自动续期机制。

// 手动给锁设置过期时间,不具备Watch Dog自动续期机制  
lock.lock(10, TimeUnit.SECONDS);  

如果选择使用Redis来实现分布式锁,建议直接使用Redisson。

如何实现可重入锁?

可重入锁指的是在同一线程中可以多次获取同一把锁。例如,在执行一个带锁的方法时,如果该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁。Java中的synchronizedReentrantLock均属于可重入锁。

一般情况下,非可重入的分布式锁已能满足大多数业务场景,但在特殊场景中,可能需要使用可重入的分布式锁。

可重入分布式锁的实现核心思路是:在获取锁时判断当前请求是否来自于持锁线程。如果是,则无需重新获取。为此,我们可以为每个锁关联一个可重入计数器和一个占有它的线程。当可重入计数器大于0时,说明锁被占有,此时需要判断占有该锁的线程与请求获取锁的线程是否为同一线程。

在实际项目中,我们无需手动实现,推荐使用之前提到的Redisson,其内置了多种类型的锁,包括可重入锁(Reentrant Lock)、自旋锁(Spin Lock)、公平锁(Fair Lock)、多重锁(MultiLock)、红锁(RedLock)和读写锁(ReadWriteLock)。

图片

Redis在集群环境下如何解决分布式锁的可靠性问题?

为了避免单点故障,生产环境中的Redis服务通常以集群方式部署。

在Redis集群环境中,前面介绍的分布式锁实现可能会出现一些问题。由于Redis集群的数据同步是异步的,如果在Redis主节点获取到锁后,此时主节点宕机,而新的主节点未同步数据,旧的Redis主节点仍然可能持有锁,这样多个应用服务便可能同时获取到锁。

图片

为了解决这个问题,Redis的创始人antirez设计了Redlock算法[3]。

图片

Redlock算法的基本思想是:客户端向Redis集群中的多个独立Redis实例依次请求加锁,如果客户端能够成功地完成对半数以上实例的加锁操作,就认为客户端成功地获得了分布式锁,否则加锁失败。

即便部分Redis节点出现故障,只要保证Redis集群中有超过半数的Redis节点可用,分布式锁服务仍然能够正常运作。

Redlock的实现比较复杂,性能较差,同时在发生时钟漂移的情况下仍存在安全隐患。数据密集型应用系统设计书的作者Martin Kleppmann曾专门撰文批评Redlock,认为这是一个较差的分布式锁实现。如果对此感兴趣,可以查看Redis锁从面试连环炮聊到神仙打架,其中详细介绍了antirez与Martin Kleppmann之间关于Redlock的激烈争论。

在实际项目中,不建议使用Redlock算法,因为其成本与收益不成正比。

如果不是必须要实现绝对可靠的分布式锁,实际上单机版的Redis已能满足需求,其实现简单且性能非常高。如果你确实需要一个绝对可靠的分布式锁,可以考虑基于ZooKeeper来实现,尽管其性能可能较差。

参考资料

[1]Redisson: https://github.com/redisson/redisson

[2]redisson-3.17.6: https://github.com/redisson/redisson/releases/tag/redisson-3.17.6

[3]Redlock 算法: https://redis.io/topics/distlock

[4]How to do distributed locking - Martin Kleppmann - 2016: https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html