秒杀 :这些问题帮你弄清楚分布式锁应该如何设计
👈👈👈 欢迎点赞收藏关注哟
一. 前言
分布式锁方案很多,但是重在了解其设计的原则
,知道原则后,具体使用什么媒介都没大问题。
👉 本文目标 :确定分布式锁应该满足什么特性,了解常见的思路。
二. 用问题的形式来思考这个领域
2.1 分布式锁需要什么介质?
- ✔️ : 需要一个多个微服务节点都能访问的
存储介质
,需要能保存锁信息
- ✔️ : 该工具上锁时要能保证
原子操作
, 能处理并发
,且能对结果进行感知
- ✔️ : 节点
具有强一致性
,不论几个节点,客户端最终结果一致 - ✔️ : 老生常谈的
高可用
,高性能
结合这几点来看一下市面上的常用方案 :
- Redis : 高性能简单的分布式锁方案
- 本身的
单线程
方式保证了操作的并发性。 - 通过
EVAL 命令
可以保证操作的原子性 - 虽然
没有达到强一致
,但是多节点时可以保证最终一致性
- 本身的
- Zookeeper : 性能略差,但是一致性高
临时节点
和事件监听
机制 ,创建临时有序节点 , 判断是否是最小从而获取到锁- 通过事件监听等待锁的释放
- 解锁则删除节点
- MySQL : 有方案,但是从来没用过
- MySQL 的事务机制和锁机制足够满足上述的基本需求
- 只不过性能不够理想
2.2 分布式锁需要实现哪些功能 ?
- 锁的基本要求 :锁最大的要求就是互斥和可重入。
- 不同的对象不能拿到同一个锁,同一个对象可以再次访问该锁
- 需要避免死锁 :死锁四大条件里面,只要破坏一个就可以避免死锁
互斥条件
: 资源是排他的,一个资源一次只能被一个对象获取请求与保持条件
:当前对象持有这个资源的是时候会请求其他的资源,并且不会放弃持有当前资源不可剥夺条件
: 不可以强行从一个对象手中剥夺资源循环等待条件
: 当前对象需要去等待其他对象的资源,其他对象也要等待当前对象的资源解决方案
: 超时退出最简单
- 锁对象独占 : 能拿到锁 ,能校验锁 ,也能解除锁,保证
锁的独占性
- 尝试获取时间 / 超时自动释放 : 一个是尝试获取锁时,多久超时失败。 一个是拿到锁后 ,多久自动释放。
- 高并发,高可用 : 除了锁介质需要满足这些,实现锁的方案上也有满足。
三. 业务扩展
3.1 分布式锁有哪些业务场景 ?
场景一 :限制资源写入
资源访问限制是一个很宽泛的领域,来细化一下就是 API 的访问
,数据库的访问
等等场景都可以通过分布式锁来控制。
而往业务场景去偏移 ,包括超卖问题
,重复消费问题
,等等也都在分布式锁的解决范围之内。
同时可以在一定程度上避免数据库级别的锁竞争问题。避免同时的数据写入和修改。
场景二 : 限制资源的产生
这种最常见的场景在于缓存过期的问题上 ,当并发到来的时候 ,如果缓存服务器即将过期 ,可能会基于缓存的特性限制缓存的重复读取和写入
。 避免查询重复的数据
。
再就例如分布式ID的场景下 ,也会通过分布式锁类似的方式,来获取一个粗粒度的 ID 范围 ,用于后续ID的细分。
场景三 : 限制触发的频率
这种体现在 Job 定时任务的执行上。 不过如果使用的是类似于 XXL-JOB 这类外部的 Job 组件 ,可能这个特性就用不上上了。
但是如果是单个服务内置的 Job 组件 ,微服务之间没有互相通信 ,那么就需要分布式锁来限制任务触发的频率
。
对应的还包括 API 的访问频率
,也可以在分布式锁的基础上进行扩展(主要就是要求原子性的计数)。
场景四 : 维护资源的一致性
由于分布式场景的特性 ,可能在单机上面被视为原子对象的资源 ,在分布式场景下就变成了多个资源。
分布式锁并不能改变这种状态,但是可以增强一致性 ,维护他们的统一状态
。
常见的场景包括分布式事务
,
四. 常见的设计方式
4.1 关于系统介质的选择
以上的几种实现方式里面 ,我用的最多的还是 Redis
。
- ❓ 为什么我
ZK
用的少 ?- ZK 需要
额外的部署
,有些项目并没有使用 ZK 的场景 。 ZK 在性能上比 Redis 要差
- 对一致性的要求
没想象那么高
,小概率事件
, Redis 基本上可以满足 - 非要强一致 ,
Redis 也有替代的方案
, 比如 RedissonRedLock
- ZK 需要
- ❓ 为什么从来没使用过
数据库
?- 在锁的处理上 ,数据库算是
性能最差
的 ,占用资源最多 - 通常用上分布式锁的时候 ,系统已经比较大了,这个时候大概率已经
分库分表了
,增加了复杂度 - 对于一些复杂的功能,数据库实现不了(解锁,判断锁)
- 用它做分布式锁还不如让它作为
乐观锁
- 在锁的处理上 ,数据库算是
4.2 关于锁的实现要点
- 要实现锁的等待,首先要有个明确的等待时间,然后在业务代码里面等待(比如自旋,Java的锁)
- 锁的主键 :一般情况下我们实现的时候都是通过
类 + 方法 + 参数 + 值
- 锁的重入 :使用 redisson 的情况下 ,它是通过线程ID来实现的重入(
如果同一个应用线程相同,就可能存在问题
)
4.3 关于实现的思路
- 👉 Zookeeper 有提供完整功能的第三方包,例如 Curator ,所以就
不细述了
。 - 👍 Redis 使用更加简单,这一篇会详述 :
基于 Redis 的方案我使用过两种 :
基于 LUA 脚本自定义分布式锁
基于 redisson 的分布式锁
(其实本质上还是 LUA 脚本,只不过它帮你封装了)
一般情况下,没有必要重复造轮子,除非迫不得已。 由于 Redission 本身就是基于 LUA ,这里就看一看其实现原理 ,真要造轮子,抄一抄也能用。
S1 : Redisson 支持的方法和使用
- getLock : 获取普通锁
- getMultiLock : 获取组合锁
- getFairLock :获取公平锁
- getRedLock : 获取读锁
- getReadWriteLock : 获取读写锁
而当获取到 Lock 的时候 ,返回的是一个 RLock 对象 ,该对象可以进行如下操作 :
- boolean tryLock :尝试获取锁, 支持时间设置
- boolean isLocked :判断资源是否被锁
- void unlock() :解除锁
使用起来也很简单, getLock 获取到锁
,执行完成后 unLock 解锁
就行
// S1 : 获取到对应的 RLock
this.redissonClient.getLock(getLockKey("LOCK:" + name + group));
// S2 : 尝试获取锁
- (此时如果已经解锁或者锁过期了,就会抛出一个异常,需要注意)
rLock.tryLock(0, second, TimeUnit.SECONDS);
// S3 : 获取锁失败可以直接报错或者执行相关业务
略
// S4 : 使用完成后释放锁
((RLock) cacheLock).unlock()
S2 : 深入 Redisson 的加锁原理
之前说了 ,Redis 主要通过 LUA 脚本实现的分布式锁,而 Redisson 相当于直接进行了封装,来看下代码 :
- 来看看 Java 侧的处理代码 :核心代码都在
RedissonLock
中
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
// 👉 S1 : 获取锁的基本消息
// - 注意 ThreadID , 这意味着同一个 ID 的情况下 ,锁是可重入的
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
// 👉 S2 : 核心调用 LUA 脚本进行锁的判断
// - 如果返回的时间为 null , 则成功获取到锁
Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
return true;
}
// 👉 S3 : 这里是得到一个剩余等待时间 (此处会减去执行的时间)
// - 时间不够则获取锁失败
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
return false;
}
// 👉 S4 : 核心的订阅逻辑,等待锁的释放
// - 4-1 : 通过 await 等待时间来判断是否超过最大等待时间
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
// 省略具体的订阅逻辑
}
// 👉 S5 : 订阅成功后地处理 , 其中有几个核心点
// - while 循环中拿锁 (PS : 以为可能其他的对象也在竞争)
// - 要么拿到锁 ,要么超过超时时间,符合一个都会退出
while (true) {
// 尝试拿锁,拿到就退出,没拿到就循环
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// - 核心点一 : 不断地执行和 S3 类似的逻辑 ,从而判断是否超过最大等待时间
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
// - 核心点二 : getLatch 拿到了一个 Semaphore ,通过 tryAcquire 尝试拿到锁,会等待超时
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
}
- 上述 LUA 最终会调用到 RedissonLock # tryLockInnerAsync 中 ,最后会执行 LUA 脚本
this.internalLockLeaseTime = unit.toMillis(leaseTime);
// 执行 LUA 脚本命令 (EVAL 命令执行)
return this.evalWriteAsync(
this.getName(),
LongCodec.INSTANCE,
command,
"LUA 脚本如下",
Collections.singletonList(this.getName()),
this.internalLockLeaseTime,
this.getLockName(threadId)
);
// 实际执行的脚本
// 1. 先判断 Key (被锁的资源)是否存在,如果不存在则直接认为获取资源
// - KEYS[1] :锁的主键 Key (业务主键)
// - ARGV[1] :过期时间
// - ARGV[2] : 拿锁的线程
if (redis.call('exists', KEYS[1]) == 0) then // 检查名为 KEYS[1] 的键是否存在于 Redis 数据库中
redis.call('hincrby', KEYS[1], ARGV[2], 1); // 给其中的线程递增1,标识当前线程获取到锁
redis.call('pexpire', KEYS[1], ARGV[1]); // 设置名为 KEYS[1] 的哈希的到期时间
return nil;
end;
// 2. 再判断对应的线程是否获取到锁(判断 KEY 中的字段是否存在)- 这里其实是锁的可重入性
// - 如果锁已经存在,但是线程是一样的,则可以再次获取锁
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then // 检查名为 KEYS[1] 的哈希中是否存在名为 ARGV[2] 的字段
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 3. 没拿到锁则返回过期时间
return redis.call('pttl', KEYS[1]); // 获取键 KEYS[1] 的剩余过期时间
// 最终执行的代码就不看了,往下要翻很多层
- 来看一下最终执行的参数
S4 : 简单看一下解锁原理
核心就是加锁,涉及解锁的地方粗略的看一看就完事了 :
// 如果锁不存在,则直接返回
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
// 若锁存在,且唯一标识(线程ID)匹配:则先将锁重入计数减1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then // 如果锁的持有数还是大于 0 ,则不可以删除锁,只是设置时间
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
redis.call('del', KEYS[1]); // 否则则直接删除锁,锁释放
redis.call('publish', KEYS[2], ARGV[1]); // 广播锁释放消息,唤醒等待的线程
return 1;
end;
return nil;
总结
小知识点 , 用好其乐无穷。
深入 Redisson 的情况下, 知识点还挺多,这一篇就不深入了。
参考文档
转载自:https://juejin.cn/post/7353112518145425460