为什么需要锁
在多任务环境下解决并发场景的数据竞争问题
Java常见锁
我们可以根据锁是否包含某一特性来进行分组归类
- 从线程是否对资源加锁,可以将锁分为乐观锁和悲观锁
- 从资源已被锁定时,线程是否阻塞,可以分为自旋锁(JUC下的atomic家族)和阻塞锁(synchronized、ReentrantLock)
- 从多个线程并发访问资源,可以分为无锁、偏向锁、轻量级锁和重量级锁(jdk1.6开始进行锁优化)
- 从锁的公平性进行区分,分为公平锁和非公平锁
- 从锁是否可以重复获取可以分为可重入锁和不可重入锁
- 从多线程能否获得同一把锁分为共享锁和排他锁
为什么需要分布式锁
在单机应用的环境下,所有线程运行在同一个jvm进程中,使用Java中自带的锁足以控制并发;但是在分布式场景下,多个线程运行在不同的机器(jvm进程)上,就需要分布式锁来解决问题了
什么是分布式锁
分布式锁是控制分布式系统不同进程并发访问共享资源的一种锁的实现。如果不同主机之间共享了某个临界资源(例如数据库中的数据),往往需要互斥来防止彼此干扰,以保证一致性。
作用:分布式集群中多个服务请求同一方法或者同一个业务操作(比如秒杀)的情况下,对应的业务逻辑只能被一台机器上的一个线程执行,避免出现并发安全问题。
基于数据库实现的分布式锁
利用select...for update
,数据库行锁来实现悲观锁。注意:如果查询条件用了索引/主键,那么select … for update就会进行行锁;如果是普通字段(没有索引/主键),那么select … for update就会进行锁表。
悲观锁实现
获取锁方法需要声明事务,加数据行锁,事务结束则释放行锁,释放锁的操作应放在finally中
伪代码如下:
//整体流程
try {
if(lock(keyResource)){//加锁
process();//业务逻辑处理
}
} finally {
unlock(keyResource);//释放
}
//锁方法实现
//获取锁
public boolean lock(String keyResource){
resLock = 'select * from resource_lock where key_resource = '#{keySource}' for update';
if(resLock != null && resLock.getLockFlag == 1){
return false;
}
resLock.setLockFlag(1);//上锁
insertOrUpdate(resLock);//提交
return true;
}
//释放锁
public void unlock(String keyResource){
resourceLock.setLockFlag(0);//解锁
update(resourceLock);//提交
}
乐观锁实现
基于CAS思想,在数据库表中添加version字段。
使用时,带着条件去更新(判断version)
mybatis-plus已支持自动配置
特点
- 由于数据库本身的性能瓶颈,基于数据库实现的分布式锁主要应用于并发不高的场景
- 实习方式简单,稳定可靠
基于Redis实现的分布式锁
原始方案
使用SETNX
命令,SETNX
即 SET if Not eXists (对应 Java 中的 setIfAbsent
方法),如果 key 不存在的话,才会设置 key 的值,返回1。如果 key 已经存在, SETNX
啥也不做,返回0。
expire KEY seconds
设置key的过期时间,如果key已过期,将会被自动删除。
del KEY
删除key
伪代码如下:
//setnx加锁
if(jedis.setnx(key,lock_value) == 1){
//设定锁过期时间
expire(key,10);
try{
//业务处理
do();
} catch(){
}finally{
//释放锁
jedis.del(key);
}
}
在这个原始方案中,setnx和expire是两个分开的操作而不是原子操作。如果执行完setnx操作后,在执行expire设置过期时间之前进程挂了,那这个锁就无法释放,其他线程也获取不到锁了。
SET拓展命令(Redis2.6.12版本之后)
使用redis拓展命令SET key value[EX seconds][PX milliseconds][NX|XX]
其中各个参数的含义如下:
-
key
: 要设置的键名。
-
value
: 要设置的值。
-
EX seconds
: 可选参数,表示设置键的过期时间(以秒为单位)。
-
PX milliseconds
: 可选参数,表示设置键的过期时间(以毫秒为单位)。
-
NX
: 可选参数,表示只在键不存在时才设置值。
-
XX
: 可选参数,表示只在键已经存在时才设置值。
举例如下:
SET username alice
这将设置键名为 “username” 的值为 “alice”。
SET session_token 123456 EX 3600
这将设置键名为 “session_token” 的值为 “123456”,并且该键将在 3600 秒(1 小时)后过期。
SET cache_key data123 PX 5000
这将设置键名为 “cache_key” 的值为 “data123”,并且该键将在 5000 毫秒(5 秒)后过期。
SET order_status pending NX
如果键名 “order_status” 不存在,那么它将被设置为 “pending”。如果键名已经存在,则不进行任何操作。
SET login_attempts 3 XX
如果键名 “login_attempts” 已经存在,它的值将被设置为 “3”。如果键名不存在,则不进行任何操作。
了解完这条命令,我们就可以用它来构建分布式锁了
伪代码如下:
if(jedis.set(key,lock_value,"NX","EX",10s) == 1){
try{
do();
}catch(){
}finally{
jedis.del(key);
}
}
这种操作保证了set和expire的原子性,但是仍有其他问题:
- 锁过期释放了,但是业务还没有执行完(后续会提到解决方法:看门狗机制)
- 锁被其他线程误删(后续会提到解决方法:Lua脚本):线程1的锁过期释放后,被其他线程(线程2)获取,但是之前的线程(线程1)在执行结束后又del了锁(即释放了线程2的锁),在高并发情况下这种场景等同于没有加锁
锁误删问题
有的同学就要问了,既然锁可以被其他线程误删,那我们给他加一个唯一标识可以吗?总的来说思路上是没有问题的,但是不能简单的在Java中进行处理
如果我们在Java中进行判断,伪代码如下:
//加锁时设置一个随机id来作为标识,如果释放锁时还是这个id即证明释放了自己的锁(实际上是有逻辑错误的)
if(jedis.set(key,randomId,"NX","EX",10s) == 1){
try{
do();
}catch(){
}finally{
//从redis获取randomId,如果是期望值则释放
if(randomId.equals(jedis.get(key))){
jedis.del(key);
}
}
}
看起来好像没什么问题,但是这里又会出现不是原子操作导致的问题:如果在刚判断完randomId是期望值后,锁过期了,第二个线程创建了自己的锁,这时由于第一个线程已经通过了randomId的判断,那么它还是会释放线程二刚刚创建的锁,锁误删的问题仍然存在…
好消息是,我们还有其他解决方案。
在redis 2.6版本后,允许开发者使用Lua编写脚本来传到redis执行,这样做的好处如下:
- 减少网络开销:本来多次网络请求的操作,可以用一个请求完成,原先多次请求的逻辑放在redis服务器上完成。使用脚本,减少了网络往返时延;
- 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入/打断;
- 替代redis的事务功能:Redis的lua脚本几乎实现了常规的事务功能,支持报错回滚操作,官方推荐如果要用redis事务功能可以用redis lua脚本替代。
Redis Eval 命令基本语法如下
EVAL script numkeys key [key ...] arg [arg ...]
#实例 eval 引号中是脚本内容 key的个数 key[...] arg[...]
127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 username age jack 20
1) "username"
2) "age"
3) "jack"
4) "20"
这时候我们就可以用Lua脚本来保证操作的原子性了
lua脚本:
if redis.call('get',KEYS[1])==ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end;
在redis中:
EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 key value
在Java中:
String key = "key";
String value = "value";
// 定义 Lua 脚本
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// 执行 Lua 脚本
Object result = jedis.eval(luaScript, 1, key, value);
到这里,我们就可以解决锁误删的情况了,但是还有另外的一个问题没有解决,锁过期释放了但是业务还没有执行完怎么办?
Redisson的看门狗机制
Redisson和Jedis类似,是Java操作Redis的客户端,他在解决分布式场景问题比Jedis更加好用,提供了各种分布式对象、分布式锁、分布式同步器、分布式服务等等
Redission分布式锁的实现流程如下
Redisson实现自动续约的实现思路即源码如下:
private void renewExpiration() {
// 获取当前锁的过期时间续约条目
RedissonBaseLock.ExpirationEntry ee = (RedissonBaseLock.ExpirationEntry) EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
// 如果存在续约条目
if (ee != null) {
// 创建定时任务,定时执行续约操作
Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
public void run(Timeout timeout) throws Exception {
// 获取续约条目
RedissonBaseLock.ExpirationEntry ent = (RedissonBaseLock.ExpirationEntry) EXPIRATION_RENEWAL_MAP.get(RedissonBaseLock.this.getEntryName());
// 如果续约条目存在
if (ent != null) {
Long threadId = ent.getFirstThreadId();
// 如果存在线程ID
if (threadId != null) {
// 异步执行续约操作
CompletionStage<Boolean> future = RedissonBaseLock.this.renewExpirationAsync(threadId);
// 当异步操作完成时
future.whenComplete((res, e) -> {
if (e != null) {
// 发生错误时,记录日志并移除续约条目
RedissonBaseLock.log.error("Can't update lock " + RedissonBaseLock.this.getRawName() + " expiration", e);
RedissonBaseLock.EXPIRATION_RENEWAL_MAP.remove(RedissonBaseLock.this.getEntryName());
} else {
// 如果续约成功,递归调用续约操作
if (res) {
RedissonBaseLock.this.renewExpiration();
} else {
// 如果无法续约,取消续约操作
RedissonBaseLock.this.cancelExpirationRenewal((Long)null);
}
}
});
}
}
}
}, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS);
// 设置定时任务到续约条目
ee.setTimeout(task);
}
}
protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {
// 使用 evalWriteAsync 方法执行 Lua 脚本
// 这个脚本会检查锁是否仍然由给定的线程持有,如果是则更新锁的过期时间
return this.evalWriteAsync(
this.getRawName(), // 锁的键名
LongCodec.INSTANCE, // 键的编码器
RedisCommands.EVAL_BOOLEAN, // 使用 EVAL 命令并返回布尔值
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;",
Collections.singletonList(this.getRawName()), // 键名作为 KEYS[1]
this.internalLockLeaseTime, // 锁的过期时间(毫秒)
this.getLockName(threadId) // 获取锁的名称,用于验证锁的持有者
);
}
总结:
- 只有在未指定锁超时时间时,才会使用看门狗
- 如果Redisson实例挂了,看门狗也会跟着crash,那么达到失效时间的这个key会被redis清除,锁也就被释放了,不会出现锁被永久占用的问题。
Redisson的RLock接口继承了JUC的lock接口,所以他是符合Java中的Lock接口规范的,同时Redisson还提供了多种分布式锁的实现类(例如:RedissonFairLock、RedissonRedLock等)可供大家选择
Redis集群数据不一致问题
在部署redis时,为了避免单点问题,我们通常会采用集群方式部署,由于redis集群的数据同步是异步操作,在主节点加锁后就会返回加锁成功;如果一个线程在master节点上拿到了锁,但是加锁的key还没同步到slave节点时master节点就发生了故障,一个slave节点就会升级成master节点,其他线程就也可以获取同个key的锁,又一次相当于没加锁
redis的作者提出了一种高级的分布式锁算法:Redlock,来解决这个问题
Redlock核心思想
搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是在与Redis单实例使用相同方法来获取和释放锁。
Redlock流程步骤
1、按顺序向多个master节点(如上图5个)请求加锁
2、根据设置的超时时间来判断,是不是要跳过该master节点;
3、如果有半数以上节点加锁成功(右图3个成功即可),并且使用的时间小于锁的有效期(设置单个节点超时时间),即可认定加锁成功;
4、如果获取锁失败,给所有的master节点解锁
基于ZooKeeper实现的分布式锁
这个之前已经写过博客辣,大家直接点击链接跳转即可~
基于ZooKeeper临时顺序节点的分布式锁实现_❀always❀的博客-CSDN博客
实现思路如下:
分布式锁实现方案比较
方案 |
思路 |
优点 |
缺点 |
典型场景 |
mysql |
悲观锁、乐观锁 |
实现简单、稳定可靠 |
性能差,不适合高并发 |
分布式定时任务 |
redis |
基于SETNX和Lua脚本保证缓存操作原子化 |
性能好(AP) |
实现相对复杂,不是100%可靠 |
秒杀、抢购、大型抽奖 |
zookeeper |
基于ZK的节点特性和Watcher机制 |
可靠性高(CP) |
实现相对复杂,性能略差 |
秒杀、抢购、大型抽奖 |
分布式锁与高并发
从设计角度来看,分布式锁和高并发本身是矛盾的:分布式锁实际是将并行代码串行化来解决并发问题,对性能是有影响的,但是可以进行优化。
主要方案有:
- 锁粒度最小:尽可能地将最小粒度的有并发安全问题的代码放在锁里面,其他代码都放到锁外面去,这是锁的基本优化原则
- 数据分片:例如ConcurrentHashMap使用分段锁机制提高并发能力,MySQL分库分表(将压力分摊到不同DB上)等
业务场景中分布式锁的应用
-
某事件发生后需要发短信提醒用户,且两小时之内多次发生该事件只在第一次提醒用户
实现思路:在每次发短信之前先获取分布式锁,设定过期时间为2h,若2h内事件再次发生则无法获取到相同的分布式锁,自动跳过发送短信的流程即可
-
保证某表中以 id+当日时间 为唯一标识的数据只有一条
实现思路:在插入或更新时,先获取到分布式锁,成功插入后解锁
-
抢购某总量限定的奖品
实现思路:每个线程抢占分布式锁,抢占成功后判断剩余数量是否满足所需数量,若满足,则抢购成功并释放锁