在分布式微服务体系大行其道的今天,分布式锁的概念已经深入每个程序猿的内心,实现分布式锁的方式也有很多种,例如使用数据库、Redis、zookeeper都可以实现分布式锁,今天我们来分析一下使用Redis来实现分布式锁的底层实现原理。
在分布式场景中,存在这么一种可能:多个实例需要互斥访问共享资源,最典型的案例就是秒杀,瞬时流量高、标的数量有限,要防止超卖的情况发生。
分布式锁一般具有以下特征:
- 互斥性:同一时刻只能有一个线程持有锁
- 可重入性:同一节点上的同一个线程获取到锁之后,还能再次获取锁
- 锁超时:持有锁的线程不能始终持有锁,必须要有超时机制,防止死锁
- 高性能和高可用性:加锁和解锁需要高效,不能占用太多系统资源和处理时间,高可用需要防止锁失效情况的发生
- 响应中断:可以及时从阻塞状态中唤醒
##Redis对分布式锁实现的理论底层支持
关于利用Redis来实现分布式锁理论知识,可以参考小米信息部技术团队的文章,这里就不赘述了链接如下: xiaomi-info.github.io/2019/12/17/…
我们今天主要讲的是如何使用Spring Integration 这个项目来实现Redis分布式锁。
Spring Integration项目支持基于Spring的应用程序中的轻量级消息传递,并通过声明性适配器支持与外部系统的集成。这些适配器在Spring对远程、消息传递和调度的支持之上提供了更高级别的抽象。Spring Integration的主要目标是为构建企业集成解决方案提供一个简单的模型,同时维护对生成可维护、可测试代码至关重要的关注点分离。 RedisLockRegistry 类源码:
package org.springframework.integration.redis.util;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.integration.support.locks.ExpirableLockRegistry;
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils;
/**
* Implementation of {@link ExpirableLockRegistry} providing a distributed lock using Redis.
* Locks are stored under the key {@code registryKey:lockKey}. Locks expire after
* (default 60) seconds. Threads unlocking an
* expired lock will get an {@link IllegalStateException}. This should be
* considered as a critical error because it is possible the protected
* resources were compromised.
* <p>
* Locks are reentrant.
* <p>
* <b>However, locks are scoped by the registry; a lock from a different registry with the
* same key (even if the registry uses the same 'registryKey') are different
* locks, and the second cannot be acquired by the same thread while the first is
* locked.</b>
* <p>
* <b>Note: This is not intended for low latency applications.</b> It is intended
* for resource locking across multiple JVMs.
* <p>
* {@link Condition}s are not supported.
*
* @author Gary Russell
* @author Konstantin Yakimov
* @author Artem Bilan
* @author Vedran Pavic
*
* @since 4.0
*
*/
public final class RedisLockRegistry implements ExpirableLockRegistry, DisposableBean {
private static final Log LOGGER = LogFactory.getLog(RedisLockRegistry.class);
private static final long DEFAULT_EXPIRE_AFTER = 60000L;
private static final String OBTAIN_LOCK_SCRIPT =
"local lockClientId = redis.call('GET', KEYS[1])\n" +
"if lockClientId == ARGV[1] then\n" +
" redis.call('PEXPIRE', KEYS[1], ARGV[2])\n" +
" return true\n" +
"elseif not lockClientId then\n" +
" redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])\n" +
" return true\n" +
"end\n" +
"return false";
private final Map<String, RedisLock> locks = new ConcurrentHashMap<>();
private final String clientId = UUID.randomUUID().toString();
private final String registryKey;
private final StringRedisTemplate redisTemplate;
private final RedisScript<Boolean> obtainLockScript;
private final long expireAfter;
/**
* An {@link ExecutorService} to call {@link StringRedisTemplate#delete} in
* the separate thread when the current one is interrupted.
*/
private Executor executor =
Executors.newCachedThreadPool(new CustomizableThreadFactory("redis-lock-registry-"));
/**
* Flag to denote whether the {@link ExecutorService} was provided via the setter and
* thus should not be shutdown when {@link #destroy()} is called
*/
private boolean executorExplicitlySet;
private volatile boolean unlinkAvailable = true;
/**
* Constructs a lock registry with the default (60 second) lock expiration.
* @param connectionFactory The connection factory.
* @param registryKey The key prefix for locks.
*/
public RedisLockRegistry(RedisConnectionFactory connectionFactory, String registryKey) {
this(connectionFactory, registryKey, DEFAULT_EXPIRE_AFTER);
}
/**
* Constructs a lock registry with the supplied lock expiration.
* @param connectionFactory The connection factory.
* @param registryKey The key prefix for locks.
* @param expireAfter The expiration in milliseconds.
*/
public RedisLockRegistry(RedisConnectionFactory connectionFactory, String registryKey, long expireAfter) {
Assert.notNull(connectionFactory, "'connectionFactory' cannot be null");
Assert.notNull(registryKey, "'registryKey' cannot be null");
this.redisTemplate = new StringRedisTemplate(connectionFactory);
this.obtainLockScript = new DefaultRedisScript<>(OBTAIN_LOCK_SCRIPT, Boolean.class);
this.registryKey = registryKey;
this.expireAfter = expireAfter;
}
/**
* Set the {@link Executor}, where is not provided then a default of
* cached thread pool Executor will be used.
* @param executor the executor service
* @since 5.0.5
*/
public void setExecutor(Executor executor) {
this.executor = executor;
this.executorExplicitlySet = true;
}
@Override
public Lock obtain(Object lockKey) {
Assert.isInstanceOf(String.class, lockKey);
String path = (String) lockKey;
return this.locks.computeIfAbsent(path, RedisLock::new);
}
@Override
public void expireUnusedOlderThan(long age) {
long now = System.currentTimeMillis();
this.locks.entrySet()
.removeIf((entry) -> {
RedisLock lock = entry.getValue();
return now - lock.getLockedAt() > age && !lock.isAcquiredInThisProcess();
});
}
@Override
public void destroy() {
if (!this.executorExplicitlySet) {
((ExecutorService) this.executor).shutdown();
}
}
private final class RedisLock implements Lock {
private final String lockKey;
private final ReentrantLock localLock = new ReentrantLock();
private volatile long lockedAt;
private RedisLock(String path) {
this.lockKey = constructLockKey(path);
}
private String constructLockKey(String path) {
return RedisLockRegistry.this.registryKey + ':' + path;
}
public long getLockedAt() {
return this.lockedAt;
}
@Override
public void lock() {
this.localLock.lock();
while (true) {
try {
while (!obtainLock()) {
Thread.sleep(100); //NOSONAR
}
break;
}
catch (InterruptedException e) {
/*
* This method must be uninterruptible so catch and ignore
* interrupts and only break out of the while loop when
* we get the lock.
*/
}
catch (Exception e) {
this.localLock.unlock();
rethrowAsLockException(e);
}
}
}
private void rethrowAsLockException(Exception e) {
throw new CannotAcquireLockException("Failed to lock mutex at " + this.lockKey, e);
}
@Override
public void lockInterruptibly() throws InterruptedException {
this.localLock.lockInterruptibly();
try {
while (!obtainLock()) {
Thread.sleep(100); //NOSONAR
}
}
catch (InterruptedException ie) {
this.localLock.unlock();
Thread.currentThread().interrupt();
throw ie;
}
catch (Exception e) {
this.localLock.unlock();
rethrowAsLockException(e);
}
}
@Override
public boolean tryLock() {
try {
return tryLock(0, TimeUnit.MILLISECONDS);
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
long now = System.currentTimeMillis();
if (!this.localLock.tryLock(time, unit)) {
return false;
}
try {
long expire = now + TimeUnit.MILLISECONDS.convert(time, unit);
boolean acquired;
while (!(acquired = obtainLock()) && System.currentTimeMillis() < expire) { //NOSONAR
Thread.sleep(100); //NOSONAR
}
if (!acquired) {
this.localLock.unlock();
}
return acquired;
}
catch (Exception e) {
this.localLock.unlock();
rethrowAsLockException(e);
}
return false;
}
private boolean obtainLock() {
Boolean success =
RedisLockRegistry.this.redisTemplate.execute(RedisLockRegistry.this.obtainLockScript,
Collections.singletonList(this.lockKey), RedisLockRegistry.this.clientId,
String.valueOf(RedisLockRegistry.this.expireAfter));
boolean result = Boolean.TRUE.equals(success);
if (result) {
this.lockedAt = System.currentTimeMillis();
}
return result;
}
@Override
public void unlock() {
if (!this.localLock.isHeldByCurrentThread()) {
throw new IllegalStateException("You do not own lock at " + this.lockKey);
}
if (this.localLock.getHoldCount() > 1) {
this.localLock.unlock();
return;
}
try {
if (!isAcquiredInThisProcess()) {
throw new IllegalStateException("Lock was released in the store due to expiration. " +
"The integrity of data protected by this lock may have been compromised.");
}
if (Thread.currentThread().isInterrupted()) {
RedisLockRegistry.this.executor.execute(this::removeLockKey);
}
else {
removeLockKey();
}
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Released lock; " + this);
}
}
catch (Exception e) {
ReflectionUtils.rethrowRuntimeException(e);
}
finally {
this.localLock.unlock();
}
}
private void removeLockKey() {
if (RedisLockRegistry.this.unlinkAvailable) {
try {
RedisLockRegistry.this.redisTemplate.unlink(this.lockKey);
}
catch (Exception ex) {
RedisLockRegistry.this.unlinkAvailable = false;
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("The UNLINK command has failed (not supported on the Redis server?); " +
"falling back to the regular DELETE command", ex);
}
else {
LOGGER.warn("The UNLINK command has failed (not supported on the Redis server?); " +
"falling back to the regular DELETE command: " + ex.getMessage());
}
RedisLockRegistry.this.redisTemplate.delete(this.lockKey);
}
}
else {
RedisLockRegistry.this.redisTemplate.delete(this.lockKey);
}
}
@Override
public Condition newCondition() {
throw new UnsupportedOperationException("Conditions are not supported");
}
public boolean isAcquiredInThisProcess() {
return RedisLockRegistry.this.clientId.equals(
RedisLockRegistry.this.redisTemplate.boundValueOps(this.lockKey).get());
}
@Override
public String toString() {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd@HH:mm:ss.SSS");
return "RedisLock [lockKey=" + this.lockKey
+ ",lockedAt=" + dateFormat.format(new Date(this.lockedAt))
+ ", clientId=" + RedisLockRegistry.this.clientId
+ "]";
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + getOuterType().hashCode();
result = prime * result + ((this.lockKey == null) ? 0 : this.lockKey.hashCode());
result = prime * result + (int) (this.lockedAt ^ (this.lockedAt >>> 32));
result = prime * result + RedisLockRegistry.this.clientId.hashCode();
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
RedisLock other = (RedisLock) obj;
if (!getOuterType().equals(other.getOuterType())) {
return false;
}
if (!this.lockKey.equals(other.lockKey)) {
return false;
}
return this.lockedAt == other.lockedAt;
}
private RedisLockRegistry getOuterType() {
return RedisLockRegistry.this;
}
}
}
首先我们看一下该类的javadoc,通过阅读javadoc,可以快速了解作者的意图,与作者实现灵魂上的沟通,我们自己在写代码时,也应该将设计意图使用javadoc的方式来体现,让后继的维护者可以了解当时的设计意图,也方便自己在未来需要重新修改代码时,快速回忆自己的设计方案。
RedisLockRegsitry 实现了ExpirableLockRegistry 接口,基于Redis实现了分布式锁,锁存储在registryKey:lockKey 这个key下,默认的过期时间是60秒,线程解锁一个已经过期的锁会导致IllegalStateException.这应该被视为一个严重错误,因为它可能会导致被保护的资源被破坏。 然而,锁只在registry中有效,如果使用相同的registryKey,注册在不同的registry中,也会被认为是不同的锁。 注意:这不是为低延迟应用设计的分布式锁实现,这是为在不同JVM之间为资源加锁而设计的实现。
RedisLockRegistry 不仅实现了ExpirableLockRegistry接口,还实现了DisposableBean接口,在spring容器销毁时,可以调用destroy方法来释放资源,具体到这里的资源就是线程池了。
@Override
public void destroy() {
if (!this.executorExplicitlySet) {
((ExecutorService) this.executor).shutdown();
}
}
private Executor executor =
Executors.newCachedThreadPool(new CustomizableThreadFactory("redis-lock-registry-"));
该类提供了两个构造方法:
public RedisLockRegistry(RedisConnectionFactory connectionFactory, String registryKey) {
this(connectionFactory, registryKey, DEFAULT_EXPIRE_AFTER);
}
public RedisLockRegistry(RedisConnectionFactory connectionFactory, String registryKey, long expireAfter) {
Assert.notNull(connectionFactory, "'connectionFactory' cannot be null");
Assert.notNull(registryKey, "'registryKey' cannot be null");
this.redisTemplate = new StringRedisTemplate(connectionFactory);
this.obtainLockScript = new DefaultRedisScript<>(OBTAIN_LOCK_SCRIPT, Boolean.class);
this.registryKey = registryKey;
this.expireAfter = expireAfter;
}
构造本类实例,需要提供Redis的连接工厂,registryKey,registryKey的过期时间,过期时间可以使用默认值60秒
private static final long DEFAULT_EXPIRE_AFTER = 60000L;
那么RedisLockRegistry是如何在Redis服务器上设置锁的呢?这就要用到lua脚本的能力了:
private static final String OBTAIN_LOCK_SCRIPT =
"local lockClientId = redis.call('GET', KEYS[1])\n" +
"if lockClientId == ARGV[1] then\n" +
" redis.call('PEXPIRE', KEYS[1], ARGV[2])\n" +
" return true\n" +
"elseif not lockClientId then\n" +
" redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])\n" +
" return true\n" +
"end\n" +
"return false";
我们来解读一下这个脚本,首先要调用lua脚本,从Redis中获取KEYS[1] 指定的这个key的值,如果取到的值与ARGV[1]相等,那么就执行PEXPIRE KEYS[1] , ARGV[2];如果没取到值,那么就执行SET KEYS[1] ,ARGV[1],PX ARGV[2],翻译一下意思就是如果Redis中已经存在了值为ARGV[1]的key,那么就获取到锁,就为该key设置过期时间ARGV[2],如果没有取到值,那么就设置key的值和过期时间。
我们继续看一下如何获取锁:
@Override
public Lock obtain(Object lockKey) {
Assert.isInstanceOf(String.class, lockKey);
String path = (String) lockKey;
return this.locks.computeIfAbsent(path, RedisLock::new);
}
obtain 这个方法是在LockRegistry这个接口中定义的方法,locks 是Map<String, RedisLock> locks = new ConcurrentHashMap<>()的实现,ConcurrentHashMap中存放的V是RedisLock的实例,RedisLock是RedisLockRegistry的内部类,实现了Lock接口,内部又持有一个ReentrantLock,也就J.U.C提供的可重入锁的实现,在获取锁时,通过指定一个lockKey,生成指定的RedisLock:
private RedisLock(String path) {
this.lockKey = constructLockKey(path);
}
通过源码我们可以发现,最终锁在Redis中的key是registryKey+":"+path这种格式,所以在设置registryKey时,不需要添加后缀的*':'*。
this.locks.computeIfAbsent(path, RedisLock::new)这个方法,如果从locks中无法获取到key为path的值,那么就new 一个RedisLock 并将其放入到locks,如果能取到,那么就返回取到的值,因此,key为path的锁,在当前RedisLockRegistry中只会存在一个,保证了锁的唯一性。
获取到了锁的实例之后,我们还需要尝试申请锁,Lock接口定义了三种申请锁的方法:
- lock()
- tryLock()
- tryLock(long time,TimeUnit unit)
第一种方法会一直尝试获取锁,直到获取到为止,第二种会尝试一次获取锁,获取不到就停止,第三种会在指定时间段内一直尝试,直到获取到锁或时间到期,很显然,我们多数情况下都会使用第三种获取锁的方式。我们看一下RedisLock中重写的第三种方法的实现:
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
long now = System.currentTimeMillis();
if (!this.localLock.tryLock(time, unit)) { ①
return false;
}
try {
long expire = now + TimeUnit.MILLISECONDS.convert(time, unit);
boolean acquired;
while (!(acquired = obtainLock()) && System.currentTimeMillis() < expire) { //NOSONAR ②
Thread.sleep(100); //NOSONAR ③
}
if (!acquired) {
this.localLock.unlock(); ④
}
return acquired;
}
catch (Exception e) {
this.localLock.unlock();
rethrowAsLockException(e);
}
return false;
}
首先,先RedisLock持有的ReentrantLock尝试获取锁,如果在指定时间范围内无法获取到锁,直接返回false;获取到锁之后,在指定时间范围内,通过obtainLock方法不停尝试在Redis设置锁,每次尝试的间隔是100毫秒,如果在指定时间范围内无法在Redis设置锁,那么需要将内部持有的ReentrantLock释放,如果在Redis成功设置了锁,则返回true,表示获取锁成功。 我们再看一下obtainLock方法的实现:
private boolean obtainLock() {
Boolean success =
RedisLockRegistry.this.redisTemplate.execute(RedisLockRegistry.this.obtainLockScript,
Collections.singletonList(this.lockKey), RedisLockRegistry.this.clientId,
String.valueOf(RedisLockRegistry.this.expireAfter));
boolean result = Boolean.TRUE.equals(success);
if (result) {
this.lockedAt = System.currentTimeMillis();
}
return result;
}
使用redisTemplate 执行上文提到的OBTAIN_LOCK_SCRIPT这个lua脚本,key的值为RedisLockRegistry.this.clientId,我们可以看到它的实现是UUID.randomUUID().toString();
当前线程使用完资源之后,需要将锁释放,让其他线程有机会获取到锁,我们再看一下释放锁的实现:
@Override
public void unlock() {
if (!this.localLock.isHeldByCurrentThread()) { ①
throw new IllegalStateException("You do not own lock at " + this.lockKey);
}
if (this.localLock.getHoldCount() > 1) { ②
this.localLock.unlock();
return;
}
try {
if (!isAcquiredInThisProcess()) {
throw new IllegalStateException("Lock was released in the store due to expiration. " +
"The integrity of data protected by this lock may have been compromised.");
}
if (Thread.currentThread().isInterrupted()) {
RedisLockRegistry.this.executor.execute(this::removeLockKey);
}
else {
removeLockKey();
}
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Released lock; " + this);
}
}
catch (Exception e) {
ReflectionUtils.rethrowRuntimeException(e);
}
finally {
this.localLock.unlock();
}
}
首先要判断当前要释放锁的线程是否为锁的加锁线程,释放锁与加锁线程必须为同一线程才能释放锁,第二步要判断当前线程持有几个锁(Queries the number of holds on this lock by the current thread.),因为ReentrantLock是可重入锁,如果持有个数大于1,说明当前锁被持有了多次,不能将Redis中的锁释放,只能是将RedisLock中持有的ReentrantLock的持有数减1,如果持有数为1,那么就需要释放Redis锁:
private void removeLockKey() {
if (RedisLockRegistry.this.unlinkAvailable) {
try {
RedisLockRegistry.this.redisTemplate.unlink(this.lockKey);
}
catch (Exception ex) {
RedisLockRegistry.this.unlinkAvailable = false;
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("The UNLINK command has failed (not supported on the Redis server?); " +
"falling back to the regular DELETE command", ex);
}
else {
LOGGER.warn("The UNLINK command has failed (not supported on the Redis server?); " +
"falling back to the regular DELETE command: " + ex.getMessage());
}
RedisLockRegistry.this.redisTemplate.delete(this.lockKey);
}
}
else {
RedisLockRegistry.this.redisTemplate.delete(this.lockKey);
}
}
分析完上锁的流程,释放锁的流程就很好理解了。
文章开头提到了分布式锁的特性中还包括响应中断,大家应该都有了解,线程是否能响应中断,基本需要看造化,我个人不建议在程序中使用中断,程序执行到哪一步,谁能说的清呢,所以这里就不展开说了。
Redis的Java客户端,除了上文提到过的Lettuce和Jedis,还有一个Redisson,这个客户端也非常不错,后面有时间我们再看看Redisson的分布式锁是如何实现的。
另外,为了提高Redis的高可用性,一般都会使用主从模式部署,那么如果master宕机了,Redis中的数据需要异步同步到从节点,从节点需要一边同步数据,一边为客户端提供服务,那么这时候如果锁数据还未同步完成,新的客户端申请锁,就会导致两个客户端同时持有了相同的锁,所以,这种分布式锁的实现方式,不能100%的实现排他性,那要怎么设计分布式锁呢?
其实Redis作者已经给出了答案,在Redis官网上,作者介绍了Redlock,但是Redlock的实现比较复杂,之后我们再详细介绍。