分布式锁介绍
分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。如果不同的系统或同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性。
需要满足一下几点:
- 「互斥性」: 任意时刻,只有一个客户端能持有锁。
- 「锁超时释放」:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁。
- 「可重入性」:一个线程如果获取了锁之后,可以再次对其请求加锁。
- 「高性能和高可用」:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效。
- 「安全性」:锁只能被持有的客户端删除,不能被其他客户端删除
目前主流的分布式锁主要是通过redis和zookeeper去实现
Redis分布式锁
核心代码
核心代码LUA原子性代码,保证了互斥、超时释放,安全性,通过并发map可以实现可重入性
#获取锁(unique_value可以是UUID等)
SET resource_name unique_value NX PX 30000
#说明
SET resource_name(资源) unique_value(线程ID) NX(不存在设置成功) PX(超时) 30000
# 释放锁(lua脚本中,一定要比较value,防止误解锁)
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
非可重入
@Component
public class RedisLock {
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 加锁
*
* @param key 加锁key
* @param value 加锁value,释放锁时需要根据value进行判断
* @param expire 过期时间,单位:秒
* @return true:加锁成功,获取失败还想获取需要使用循环
*/
public boolean lock(String key, String value, @NonNull Long expire) {
if (StringUtils.isBlank(key)) {
throw new IllegalArgumentException("class:RedisLock,method:public boolean lock(String key),error message:key is blank");
}
Boolean result = stringRedisTemplate.boundValueOps(key).setIfAbsent(value, expire, TimeUnit.SECONDS);
return Optional.ofNullable(result).orElse(false);
}
/**
* 释放锁
*
* @param key 释放锁key
* @param value 当释放锁value与加锁value相等时才会释放锁
* @return true:释放锁成功
*/
public void unLock(String key, String value) {
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
RedisScript<Integer> script = new DefaultRedisScript<>(luaScript, Integer.class);
stringRedisTemplate.execute(script, Stream.of(key).collect(Collectors.toList()), value);
}
}
非可重入 展开源码
public class RedisReentrantLock {
private static final ThreadLocal<Map<String, Integer>> threadLocal = new ThreadLocal<>();
private RedisTemplate<String, String> redisTemplate;
public RedisReentrantLock(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 加锁
*
* @param key 加锁key
* @return true:加锁成功; false:加锁失败
*/
public boolean lock(String key) {
boolean lockFlag = false;
Map<String, Integer> currentLocks = this.currentLocks();
Integer lockCount = currentLocks.get(key);
if (Objects.nonNull(lockCount) && lockCount.compareTo(0) > 0) {
currentLocks.put(key, lockCount + 1);
lockFlag = true;
} else if (Optional.ofNullable(redisTemplate.opsForValue().setIfAbsent(key, "", 5, TimeUnit.SECONDS)).orElse(false)) {
currentLocks.put(key, 1);
lockFlag = true;
}
return lockFlag;
}
/**
* 释放锁
*
* @param key 加锁key
* @return true:释放成功; false:释放失败
*/
public boolean unLock(String key) {
boolean unLockFlag = false;
Map<String, Integer> currentLocks = this.currentLocks();
Integer lockCount = currentLocks.get(key);
if (Objects.nonNull(lockCount)) {
lockCount -= 1;
if (lockCount.compareTo(0) > 0) {
currentLocks.put(key, lockCount);
} else {
threadLocal.remove();
redisTemplate.delete(key);
}
unLockFlag = true;
}
return unLockFlag;
}
/**
* 获取当前线程所持有的锁
*
* @return
*/
private Map<String, Integer> currentLocks() {
Map<String, Integer> map = threadLocal.get();
if (Objects.isNull(map)) {
map = new HashMap<>(0);
threadLocal.set(map);
}
return map;
}
}
存在问题
失效时间如何设置
这个问题的场景是,假设设置失效时间10秒,如果由于某些原因导致10秒还没执行完任务,这时候锁自动失效,导致其他线程也会拿到分布式锁。锁不设置超时时间会出现死锁问题,如果设置超时间,则会出现任务未执行完毕,锁被释放的问题。
高可用问题
Redis的高可用是异步复制,如果在主的加锁,未同步到cluster,主节点挂掉,从节点变为主的,此时锁可能被其他的线程拿到。
解决办法
使用Redisson来实现redis的分布式锁。
关于失效时间的问题,redisson帮我们解决了,锁的核心逻辑还是上面的原子性的lua实现,但在里面有对应的监控程序,监控加锁的线程是否结束,如果锁未结束,则自动续期,我们不用关心锁的过期时间直接用就好。
关于高可用问题,redisson有RedissonRedLock 实现了redlock,可以用redlock去实现。
优缺点及选型
优点:如果引入的话,使用redis的redisson可以很简单的实现分布式锁,它对分布式锁进行了封装很容易操作和替换,另外锁的效率很高。
主要缺点:资源争抢时,其他争抢资源的线程需要不停地尝试获取锁,消耗CPU。分布式高可用的问题,这个对于互联网公司不是问题,但是对于咱们就不太现实,redlock其实还是依靠共识算法,每次加锁对多个集群进行顺序操作,只有成功N/2+1的才算成功,否则失败,它依赖多个redis高可用集群,对于咱们有一个高可用集群就不错了。
所以继续调研。
Redlock介绍
Redis作者antirez提出的redlock算法大概是这样的:
在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:
l 获取当前Unix时间,以毫秒为单位。
l 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
l 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
l 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
l 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
参考资料
实现: https://www.jianshu.com/p/7e47a4503b87
Redis客户端框架对比:https://blog.csdn.net/guyue35/article/details/107381429
Redlock 介绍:https://blog.csdn.net/qq_33449307/article/details/119919965
Zookeeper分布式锁
原理
ZooKeeper节点类型中,有一种临时顺序节点(EPHEMERAL_SEQUENTIAL),在创建这种节点时,Zookeeper会自动为新创建的节点加上一个次序编号,而这个生成的次序编号,是上一个的节点的次序编号加一。Zookeeper的分布式锁就是基于这种临时顺序节点实现的。
框架选型
Zookeeper API
(1)Zookeeper的Watcher是一次性的,每次触发之后都需要重新进行注册; (2)Session超时之后没有实现重连机制; (3)异常处理繁琐,Zookeeper提供了很多异常,对于开发人员来说可能根本不知道该如何处理这些异常信息; (4)只提供了简单的byte[]数组的接口,没有提供针对对象级别的序列化; (5)创建节点时如果节点存在抛出异常,需要自行检查节点是否存在; (6)删除节点无法实现级联删除;
ZkClient简介
ZkClient是一个开源客户端,在Zookeeper原生API接口的基础上进行了包装,更便于开发人员使用。内部实现了Session超时重连,Watcher反复注册等功能。像dubbo等框架对其也进行了集成使用。
虽然ZkClient对原生API进行了封装,但也有它自身的不足之处:
几乎没有参考文档;
异常处理简化(抛出RuntimeException);
重试机制比较难用;
没有提供各种使用场景的实现;
Curator简介
Curator是Netflix公司开源的一套Zookeeper客户端框架,和ZkClient一样,解决了非常底层的细节开发工作,包括连接重连、反复注册Watcher和NodeExistsException异常等。目前已经成为Apache的顶级项目。另外还提供了一套易用性和可读性更强的Fluent风格的客户端API框架。
除此之外,Curator中还提供了Zookeeper各种应用场景(Recipe,如共享锁服务、Master选举机制和分布式计算器等)的抽象封装。
所以我们选择Curator。
连接代码
创建Curator连接
@Configuration
public class ZookeeperConfiguration {
private final Logger logger = LoggerFactory.getLogger(getClass());
@Value("${zookeeper.connectionString}")
private String connectionString;
@Value("${curator.retry.max.retries:3}")
private String maxRetries;
@Value("${curator.retry.base.sleep.time.ms:1000}")
private String baseSleepTimeMs;
@Value("${curator.session.timeout:60000}")
private String sessionTimeOut;
@Value("${curator.connection.timeout:15000}")
private String connectionTimeOut;
/**
* 初始化
*/
@Bean(initMethod = "start", destroyMethod = "close")
public CuratorFramework getCuratorFramework() {
RetryPolicy retryPolicy = new ExponentialBackoffRetry(Integer.valueOf(baseSleepTimeMs), Integer.valueOf(maxRetries));
try (CuratorFramework client = CuratorFrameworkFactory.builder().namespace("lock").connectString(connectionString).retryPolicy(retryPolicy).sessionTimeoutMs(Integer.valueOf(sessionTimeOut)).connectionTimeoutMs(Integer.valueOf(connectionTimeOut)).build()) {
client.getConnectionStateListenable().addListener(new ConnectionStateListener() {
@Override
public void stateChanged(CuratorFramework curatorFramework, ConnectionState connectionState) {
logger.info("CuratorFramework state changed: {}", connectionState);
}
});
return client;
} catch (Exception e) {
logger.error("init CuratorFramework error {}", e);
}
return null;
}
}
锁的类型
/**
* zookeeper lock https://curator.apache.org/curator-recipes/index.html Created by
* if has not lock release IllegalMonitorStateException " You do not own the lock"
* InterProcessMutex 是公平锁
*/
@Component
public class DistributedLock {
private final static String PATH_PARENT = "/lock";
@Resource
CuratorFramework curatorFramework;
/**
* Shared Reentrant Lock 可重入锁
*
*/
public InterProcessMutex getInterProcessMutex(String path) {
return new InterProcessMutex(curatorFramework, getPath(path));
}
/**
* Shared Lock 排它锁
*/
public InterProcessSemaphoreMutex getInterProcessSemaphoreMutex(String path) {
return new InterProcessSemaphoreMutex(curatorFramework, getPath(path));
}
/**
* Shared Reentrant Read Write Lock 读写锁
*/
public InterProcessReadWriteLock getInterProcessReadWriteLock(String path) {
return new InterProcessReadWriteLock(curatorFramework, getPath(path));
}
/**
* Shared Semaphore
*/
public InterProcessSemaphoreV2 getInterProcessSemaphoreV2(String path, int maxLeases) {
return new InterProcessSemaphoreV2(curatorFramework, getPath(path), maxLeases);
}
/**
* Shared Semaphore
*/
public InterProcessSemaphoreV2 getInterProcessSemaphoreV2(String path, SharedCountReader count) {
return new InterProcessSemaphoreV2(curatorFramework, getPath(path), count);
}
/**
* Multi Shared Lock 将多个锁作为单个实体管理的容器
*/
public InterProcessMultiLock getInterProcessMultiLock(List<String> paths) {
return new InterProcessMultiLock(curatorFramework, paths.stream().map(path -> getPath(path)).collect(Collectors.toList()));
}
/**
* lock parent path
* default /lock
* @return
*/
public String getPathParent(){
return PATH_PARENT;
}
private String getPath(String path) {
return PATH_PARENT.concat(path);
}
}
优势分析
使用顺序临时结点的实现分布式锁,可重入也可以通过并发map实现,因为没有超时时间也没有锁过期的说法,用完释放就好,如果服务挂掉也不会死锁,zookeeper自动释放。Zookeeper集群的paxos同步机制保障了节点间数据一致性,即使主节点挂掉,也可以保障数据不丢,可以很方便解决高可用的问题,还有缺点吞吐量比较低,但是对于咱们这些不是问题。
参考资料
选型 https://cloud.tencent.com/developer/article/1015372
curator官网 https://curator.apache.org/
版本选择 https://curator.apache.org/zk-compatibility-34.html
锁的详情:https://curator.apache.org/curator-recipes/index.html
总结
类型\特性 | 互斥性 | 锁超时释放 | 可重入性 | 高性能和高可用 | 安全性 |
---|---|---|---|---|---|
Redis | Setnx 实现,加循环 | 设置超时 | 使用并发map | Redis多结点或者多集群 | 用redlock,使用lua解锁的时候判断value |
zookeeper | 顺序临时结点,加监听 | 连接断开自动释放 | 使用并发map | 使用zookeeper集群即可 | 只有自己能删除自己的结点 |
- 易用性:使用Redssion和Curator框架去实现代码的复杂度相差不大,里面都已经封装好了相关的锁
- 高性能:性能上redis的吞吐量比zookeeper大很多,但是锁互斥和锁续期redssion比较消耗资源(cpu)
- 高可用:redis 使用redlock,需要多个master
redis结点,也就是多个集群,硬件资源消耗比较大,zookeeper直接使用集群就好,它本身很好的paxos同步机制
结合咱们的场景,redis和zookeeper都有,但是后续的消息队列逐渐都会迁移到kafka,kafka使用zookeeper,zk以后会继续加强,所以建议使用zookeeper的Curator。
风险:后续的Kafka 2.8.0,移除了对Zookeeper的依赖,自身实现了Raft一致性,这样的话,kafka不再用zookeeper,组件zookeeper就只有实现分布式锁的功能了