前言
分布式锁需要考虑的因素:
- 互斥:只能有一方获取锁
- 同一:解铃还须系铃人
- 可重入
- 是否可续期
- 容错:过期自动释放,防死锁
觉得不错的同学可以加我公众号,会经常分享一些技术干货,以及热点AI和科技新闻
一、redis
1.1 setNx
青铜版本:无续期,没有容错,不可重入,别人可以解锁
boolean lock (String key){
if(setNx(key))) {
// 执行业务
unlock(key);
} else {
// 防止栈溢出
sleep(100);
lock(key);
}
}
白银版本:有容错,无续期,不可重入,别人可以解锁,新问题:setNx和expire不是原子操作
boolean lock (String key){
if(setNx(key))) {
// 设置过期时间
expire(key, 1000);
// 执行业务
unlock(key);
} else {
// 防止栈溢出
sleep(100);
lock(key);
}
}
黄金版本:lua脚本解决原子性,无续期,不可重入,别人可以解锁
boolean lock (String key){
if(luaSetNx(key, 1000))) {
// 执行业务
unlock(key);
} else {
// 防止栈溢出
sleep(100);
lock(key);
}
}
1.2 redission
加锁lua:
/**
* // 1
* KEYS[1] 代表上面的 myLock
* 判断 KEYS[1] 是否存在, 存在返回 1, 不存在返回 0。
* 当 KEYS[1] == 0 时代表当前没有锁
* // 2
* 查找 KEYS[1] 中 key ARGV[2] 是否存在, 存在回返回 1
* // 3
* 使用 hincrby 命令发现 KEYS[1] 不存在并新建一个 hash
* ARGV[2] 就作为 hash 的第一个key, val 为 1
* 相当于执行了 hincrby myLock 91089b45... 1
* // 4
* 设置 KEYS[1] 过期时间, 单位毫秒
* // 5
* 返回 KEYS[1] 过期时间, 单位毫秒
*/
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
"if ((redis.call('exists', KEYS[1]) == 0) " + // 1
"or (redis.call('hexists', KEYS[1], ARGV[2]) == 1)) then " + // 2
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " + // 3
"redis.call('pexpire', KEYS[1], ARGV[1]); " + // 4
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);", // 5
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
解锁lua:
// 判断 KEYS[1] 中是否存在 ARGV[3]
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
// 将 KEYS[1] 中 ARGV[3] Val - 1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
// 如果返回大于0 证明是一把重入锁
"if (counter > 0) then " +
// 重置过期时间
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
// 删除 KEYS[1]
"redis.call('del', KEYS[1]); " +
// 通知阻塞等待线程或进程资源可用
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;"
问题:
在redis主从模式下,锁会丢失
- 线程A从主redis中请求一个分布式锁,获取锁成功;
- 从redis准备从主redis同步锁相关信息时,主redis突然发生宕机,锁丢失了;
- 触发从redis升级为新的主redis;
- 线程B从继任主redis的从redis上申请一个分布式锁,此时也能获取锁成功;
- 导致,同一个分布式锁,被两个客户端同时获取,没有保证独占使用特性;
1.3 redLock
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();
问题1: 宕机重启之后,2个客户端拿到同一把锁。
假设5个节点是A, B, C, D, E,客户端1在A, B, C上面拿到锁,D, E没有拿到锁,客户端1拿锁成功。 此时,C挂了重启,C上面锁的数据丢失(假设机器断电,数据还没来得及刷盘;或者C上面的主节点挂了,从节点未同步)。客户端2去取锁,从C, D, E 3个节点拿到锁,A, B没有拿到(还被客户端1持有),客户端2也超过多数派,也会拿到锁。
问题1解决方案- 延迟重启;但是由于时钟跳变的因素,导致延迟重启时效(无法解决该问题);
问题2:脑裂问题:就是多个客户端同时竞争同一把锁,最后全部失败。
比如有节点1、2、3、4、5,A、B、C同时竞争锁,A获得1、2,B获得3、4,C获得5,最后ABC都没有成功获得锁,没有获得半数以上的锁。
问题2官方的建议是尽量同时并发的向所有节点发送获取锁命令。客户端取得大部分Redis实例锁所花费的时间越短,脑裂出现的概率就会越低。
需要强调,当客户端从大多数Redis实例获取锁失败时,应该尽快地释放(部分)已经成功取到的锁,方便别的客户端去获取锁,假如释放锁失败了,就只能等待锁超时释放了
问题3:效率低,主节点越多,获取锁的时间越长;
二、zookeeper
创建临时顺序节点,比较是否最小,如果不是最小,监听前一个节点,监听前一个节点的主要原因是避免全局查找最小节点带来的性能开销。通过监听前一个节点,系统可以快速响应锁释放的情况,从而减少了不必要的等待和计算。
简单来说,监听前一个节点是为了减少不必要的全局查找和优化获取锁的性能。
临时节点保证了容错
如果需要可重入,可以本地ConcurrentMap维护获取锁的线程
同一也需要自己实现
无续期,这里没有好的解决办法
三、mysql
以下两种方式还是要考量最开始提到的几个要素,显然mysql不是一个很好的分布式锁,这里只是提供一个思路。
3.1 乐观锁
使用版本号来控制,如果更新数量为1,则成功,否则重试
select version
update where version = ?
3.2 悲观锁
悲观锁其实就是先利用mysql的行锁和唯一索引来锁定数据,以此来获取锁
select for update:行锁
唯一索引:插入数据,成功则获取到锁,索引冲突则失败