在单进程环境下,要保证一个代码块的同步执行,直接用synchronized
关键字或ReetrantLock
即可。在分布式环境下,要保证多个节点的线程对代码块的同步访问,就必须要用到分布式锁方案。
分布式锁实现方案有很多,有基于关系型数据库行锁实现的;有基于ZooKeeper临时顺序节点实现的;还有基于 Redis setnx 命令实现的。本文介绍一下基于 Redis 实现的分布式锁方案。
实现分布式锁有几个要求
Redis 服务端命令是单线程串行执行的,天生就是原子的,并且支持执行自定义的 lua 脚本,功能上更加强大。
关于互斥性,我们可以用 setnx 命令实现,Redis 可以保证只会有一个客户端 set 成功。但是由于我们要实现的是一个分布式的可重入锁,数据结构得用 hash,用客户端ID+线程ID作为 field,value 记作锁的重入次数即可。
关于死锁,代码里建议把锁的释放写在 finally 里面确保一定执行,针对客户端抢到锁后宕机的场景,可以给 redis key 设置一个超时时间来解决。
关于误解锁,客户端在释放锁时,必须判断 field 是否当前客户端ID以及线程ID一致,不一致就不执行删除,这里需要用到 lua 脚本判断。
关于释放锁通知,可以利用 Redis 发布订阅模式,给每个锁创建一个频道,释放锁的客户端负责往频道里发送消息通知等待抢锁的客户端。
最后关于高性能和高可用,因为 Redis 是基于内存的,天生就是高性能的。但是 Redis 服务本身一旦出现问题,分布式锁也就不可用了,此时可以多部署几台独立的示例,使用 RedLock 算法来解决高可用的问题。
首先我们定义一个 RedisLock 锁对象的抽象接口,只有尝试加锁和释放锁方法
public interface RedisLock {
boolean tryLock();
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit);
void unlock();
}
然后提供一个默认实现 DefaultRedisLock
public class DefaultRedisLock implements RedisLock {
// 客户端ID UUID
private final String clientId;
private final StringRedisTemplate redisTemplate;
// 锁频道订阅器 接收释放锁通知
private final LockSubscriber lockSubscriber;
// 加锁的key
private final String lockKey;
}
关于tryLock()
,首先执行lua脚本尝试获取锁,如果加锁失败则返回其它客户端持有锁的过期时间,客户端订阅锁对应的频道,然后sleep,直到收到锁释放的通知再继续抢锁。最终不管有没有抢到锁,都会在 finally 取消频道订阅。
@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) {
final long timeout = System.currentTimeMillis() + unit.toMillis(waitTime);
final long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(leaseTime, unit, threadId);
if (ttl == null) {
return true;
}
if (System.currentTimeMillis() >= timeout) {
return false;
}
final Semaphore semaphore = lockSubscriber.subscribe(getChannel(lockKey), threadId);
try {
while (true) {
if (System.currentTimeMillis() >= timeout) {
return false;
}
ttl = tryAcquire(leaseTime, unit, threadId);
if (ttl == null) {
return true;
}
if (System.currentTimeMillis() >= timeout) {
return false;
}
semaphore.tryAcquire(timeout - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
lockSubscriber.unsubscribe(getChannel(lockKey), threadId);
}
return false;
}
tryAcquire()
就是执行lua脚本来加锁,解释一下这段脚本的逻辑:首先判断 lockKey 是否存在,不存在则直接设置 lockKey并且设置过期时间,返回空,表示加锁成功。存在则判断 field 是否和当前客户端ID+线程ID一致,一致则代表锁重入,递增一下value即可,不一致代表加锁失败,返回锁的过期时间
private Long tryAcquire(long leaseTime, TimeUnit timeUnit, long threadId) {
return redisTemplate.execute(RedisScript.of(
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; end;" +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; end;" +
"return redis.call('pttl', KEYS[1]);", Long.class), Collections.singletonList(lockKey),
String.valueOf(timeUnit.toMillis(leaseTime)), getLockName(threadId));
}
lockName是由客户端ID和线程ID组成的:
private String getLockName(long threadId) {
return clientId + ":" + threadId;
}
如果加锁失败,客户端会尝试订阅对应的频道,名称规则是:
private String getChannel(String lockKey) {
return "__lock_channel__:" + lockKey;
}
订阅方法是LockSubscriber#subscribe
,同一个频道无需订阅多个监听器,所以用一个 Map 记录。订阅成功以后,会返回当前线程对应的一个 Semaphore 对象,默认许可数是0,当前线程会调用Semaphore#tryAcquire
等待许可数,监听器在收到锁释放消息后会给 Semaphore 对象增加许可数,唤醒线程继续抢锁。
@Component
public class LockSubscriber {
@Autowired
private RedisMessageListenerContainer messageListenerContainer;
private final Map<String, Map<Long, Semaphore>> channelSemaphores = new HashMap<>();
private final Map<String, MessageListener> listeners = new HashMap<>();
private final StringRedisSerializer serializer = new StringRedisSerializer();
public synchronized Semaphore subscribe(String channelName, long threadId) {
MessageListener old = listeners.put(channelName, new MessageListener() {
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = serializer.deserialize(message.getChannel());
String ignore = serializer.deserialize(message.getBody());
Map<Long, Semaphore> semaphoreMap = channelSemaphores.get(channel);
if (semaphoreMap != null && !semaphoreMap.isEmpty()) {
semaphoreMap.values().stream().findFirst().ifPresent(Semaphore::release);
}
}
});
if (old == null) {
messageListenerContainer.addMessageListener(listeners.get(channelName), new ChannelTopic(channelName));
}
Semaphore semaphore = new Semaphore(0);
Map<Long, Semaphore> semaphoreMap = channelSemaphores.getOrDefault(channelName, new HashMap<>());
semaphoreMap.put(threadId, semaphore);
channelSemaphores.put(channelName, semaphoreMap);
return semaphore;
}
public synchronized void unsubscribe(String channelName, long threadId) {
Map<Long, Semaphore> semaphoreMap = channelSemaphores.get(channelName);
if (semaphoreMap != null) {
semaphoreMap.remove(threadId);
if (semaphoreMap.isEmpty()) {
MessageListener listener = listeners.remove(channelName);
if (listener != null) {
messageListenerContainer.removeMessageListener(listener);
}
}
}
}
}
对于 unlock,就只是一段 lua 脚本,这里解释一下:判断当前客户端ID+线程ID 这个 field 是否存在,存在说明是自己加的锁,可以释放。不存在说明不是自己加的锁,无需做任何处理。因为是可重入锁,每次 unlock 都只是递减一下 value,只有当 value 等于0时才是真正的释放锁。释放锁的时候会 del lockKey,再 publish 发送锁释放通知,让其他客户端可以继续抢锁。
@Override
public void unlock() {
long threadId = Thread.currentThread().getId();
redisTemplate.execute(RedisScript.of(
"if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then " +
"return nil;end;" +
"local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1); " +
"if (counter > 0) then " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], 1); " +
"return 1; " +
"end; " +
"return nil;", Long.class), Arrays.asList(lockKey, getChannel(lockKey)),
getLockName(threadId));
}
最后,我们需要一个 RedisLockFactory 来创建锁对象,它同时会生成客户端ID
@Component
public class RedisLockFactory {
private static final String CLIENT_ID = UUID.randomUUID().toString();
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private LockSubscriber lockSubscriber;
public RedisLock getLock(String lockKey) {
return new DefaultRedisLock(CLIENT_ID, redisTemplate, lockSubscriber, lockKey);
}
}
至此,一个基于 Redis 实现的分布式可重入锁就完成了。
目前这个版本的分布式锁,保证了互斥性、可重入、避免死锁和误解锁、实现了释放锁通知,但是并没有高可用的保证。如果 Redis 是单实例部署,就会存在单点问题,Redis 一旦故障,整个分布式锁将不可用。如果 Redis 是主从集群模式部署,虽然有主从自动切换,但是 Master 和 Slave 之间的数据同步是存在延迟的,分布式锁可能会出现问题。比如:客户端A加锁成功,lockKey 写入了 Master,此时 Master 宕机,其它 Slave 升级成了 Master,但是还没有同步到 lockKey,客户端B来加锁也会成功,这就没有保证互斥性。针对这个问题,可以参考 RedLock 算法,部署多个单独的 Redis 示例,只要一半以上的Redis节点加锁成功就算成功,来尽可能的保证服务高可用。