如何使用Redis实现分布式锁:深入了解在支付宝面试中的应用和实现细节
分布式锁的基本概念
在复习分布式锁的概念时,许多人已经对此有一定的了解,尤其是在阿里和美团等公司的面试中,这一主题常常被提及。
分布式锁简介
在单机多线程环境中,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中的synchronized
和ReentrantLock
均属于可重入锁。
一般情况下,非可重入的分布式锁已能满足大多数业务场景,但在特殊场景中,可能需要使用可重入的分布式锁。
可重入分布式锁的实现核心思路是:在获取锁时判断当前请求是否来自于持锁线程。如果是,则无需重新获取。为此,我们可以为每个锁关联一个可重入计数器和一个占有它的线程。当可重入计数器大于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