分布式锁
为什么使用分布式锁:
- 加锁的目的是为了防止代码的重复执行,在单机情况下,可以使用 jvm的锁:lock和synchronized进行加锁
- 但是在分布式系统下,每个jvm是相互隔离的,JVM锁没有互斥性,所以需要引入第三方进行加锁
常用的分布式锁实现方案:
-
mysql
- 利用mysql 表的主键或者唯一索引不能重复,插入数据成功代表加锁成功,插入失败代表获取锁失败,执行完成删除表中数据就是释放锁
- 但因为是磁盘IO,代价比较大,不适合大型项目
-
Redis
-
zookeeper
Redis分布式锁
redis分布式锁的要求
- 锁需要有独占性,任何时刻有且只能有一个线程持有
- 并且要保证高可用,redis集群情况下,不能因为某个节点挂了,就出现获取锁和释放锁失败的情况
- 要有超时机制或者撤销操作,防止死锁
- 防止锁被其他线程误删
- 锁需要保证重入性
Redis分布式锁的实现
- 简单来说Redis实现锁机制其实就是在Redis中设置一个key-value,当key存在时,即上锁,删除key即解锁。
- 使用 setnx + del ,先加锁用完后释放锁,这种情况如果执行过程中出现异常,可能导致del指令没有被调用,锁会永远得不到释放,就会产生死锁
- 所以需要给锁增加过期时间,保证锁可以自动释放,但是setnx 和expire 是两条指令,它们不是原子的,如果在设置过期时间时,出现异常,同样会导致锁得不到释放
- 所以redis 在2.8 版本增加了,set的扩展命令(set ex px nx),使得setnx 和expire 可以一起执行,如果redis版本较低,可以使用lua脚本保证两个指令的原子性
- 如果没有获取到锁,锁的重试需要暂停一段时间,以减少不必要的空转,但是等待时间需要根据时间业务进行评估,等待时间过长容易造成业务阻塞,时间过短,可能会造成大量的空转,浪费系统资源
- 此外还有可能会遇到超时问题,也就是锁过期释放了,但业务还没执行完,此时就会有其他线程获取到新的锁
- 这时需要增加一个看门狗,定期检查业务线程有没有执行完,如果没有就要续锁,防止业务没有执行完,锁被释放掉了
- 但是如果在需要续锁的时候,jvm进行垃圾回收了,触发STW机制,导致续锁不及时,key仍然有过期风险,可以通过 zookeeper 解决
- 也有可能原本的程序执行完了,去删除锁时,自身的锁过期了,从而误删除了其他线程加的锁
- 所以设置一个唯一的value值,在删除锁时,需要先判断value值是不是自己的,如果不是就不能删除,防止删除了其他线程的锁
- 但是匹配value和删除key又不是一个指令,这里就需要使用lua脚本了
- 此外还要保证锁的重入性,但是并不推荐redis使用可重入锁,它加重了客户端的复杂性,调整业务结构完全可以避免可重入锁
锁的重入性
- 一个线程获取到锁后,再进入该线程的内部方法如果还需要获得相同的锁,就会自动获得锁,不会因为之前获得过还没有释放而阻塞,可重入锁能再一定程度上避免死锁
- redis分布式锁想要保证可重入性,就需要使用hash结构,使用String无法保证可重入性,加锁解锁操作都需要使用lua脚本保证原子性
看门狗
- 锁过期释放了,但业务还没执行完,就需要锁的续期,防止任务没有执行完,锁被释放掉了,同样需要lua脚本
如果redis客户端加锁请求失败:
- 可以直接抛出异常,这种适合由用户发起的操作,用户看到错误后,可以点击重试
- 或者让线程休眠一会儿,然后在重试,但是这种会阻塞消息处理线程,如果队列里的消息很多,sleep并不合适
- 或者将请求转移到延时队列,过一会儿在试
lua
-
轻量级的脚本语言,C语言编写,嵌入应用程序中,为程序提供灵活的扩展和定制功能
-
redis调用lua脚本,通过eval命令保证redis命令的原子性,用return返回脚本执行后的结果,例如:
-- eval后面跟的是脚本, 一个redis.call()代表一个redis命令 eval " redis.call('set','test','1234') redis.call('expire','test','60') return redis.call('get','test') " 0 -- 上面的脚本是写死的,用动态参数代替后为: eval " redis.call('set',KEYS[1],ARGV[1]) redis.call('expire',KEYS[1],'60') return redis.call('get',KEYS[1]) " 1 test 1234
-
lua脚本参数说明
eval "return redis.call('mset','k1','v1','k2','v2') " 0 -- 上面的脚本是写死的,用动态参数代替后为: -- KEYS代表key, ARGV代表value,,下标都是从1开始 -- 2代表参数的个数,后面的key和value要一一对应 eval "return redis.call('mset',KEYS[1],ARGV[1],KEYS[2],ARGV[2]) " 2 k1 k2 v1 v2 -- if语法说明 ,有if一定有end,除了最后一个else都有then if (布尔条件) then 业务代码 elseif (布尔条件) then 业务代码 else 业务代码 end
-
redis官网的lua脚本如下:
-- 获取key的value值,和输入的value进行比较,相同就删除key并返回删除结果,否则返回0 if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
改写后为:注意单双引号
eval " if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 testLock 1234
hash类型的分布式锁 lua脚本
-
加锁
- 先判断key是否存在,不存在就加锁,存在就判断锁是否是自己的
- hash做分布式锁,<key,<vaule,加锁的次数> >
- 返回1说明加锁成功,返回0加锁失败
-- 第一个条件是锁存在,第二条件是锁的value存在
if redis.call('exists',KEYS[1])==0 or redis.call('hexists',KEYS[1],ARGV[1])==1 then
-- hincrby包含了hset, key2是uuid ,1是增长的步长值
redis.call('hincrby',KEYS[1],ARGV[1],1)
--设置过期时间
redis.call('expire',KEYS[1],ARGV[2])
return 1
else
return 0
end
-- 缩进后:
eval "if redis.call('exists',KEYS[1])==0 or redis.call('hexists',KEYS[1],ARGV[1])==1 then redis.call('hincrby',KEYS[1],ARGV[1],1) redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end" 1 testhashLock 1111 60
- 解锁
- 先判断有没有锁,没有锁返回null
- 有锁,就减少锁的加锁次数,返回0
- 如果加锁的次数为0,就删除锁 ,删除锁后返回1
-- 锁不存在
if redis.call('hexists',KEYS[1],ARGV[1])==0 then
return nil
--减少锁的加锁次数,如果加锁的次数为0,就删除锁
elseif redis.call('hincrby',KEYS[1],ARGV[1],-1)==0 then
return redis.call('del',KEYS[1])
else
return 0
end
eval "if redis.call('hexists',KEYS[1],ARGV[1])==0 then return nil elseif redis.call('hincrby',KEYS[1],ARGV[1],-1)==0 then return redis.call('del',KEYS[1]) else return 0 end" 1 testhashLock 1111
自动续期 lua 脚本
-- 过了一段时间后,发现锁还在,就要续期
if redis.call('hexists',KEYS[1],ARGV[1])==1 then
return redis.call('expire',KEYS[1],ARGV[2])
else
return 0
end
--测试,先加一个key,设置过期时间再续期
hset testhashLock 1111 1
expire testhashLock 60
ttl testhashLock
eval "if redis.call('hexists',KEYS[1],ARGV[1])==1 then return redis.call('expire',KEYS[1],ARGV[2]) else return 0 end" 1 testhashLock 1111 60
Redlock
redis的红锁方案(Redlock):
-
redis分布式锁,在主从情况下是不可靠的,因为redis的复制是异步的,所以即使是在集群模式下运行,如果在加锁时 redis 主节点故障,从节点还未来得及同步锁就升级为主节点,此时其他线程就可以再次加锁,这样就会导致多个线程都持有锁,可以通过红锁解决
-
红锁本质上就是使用多个Redis做锁,官网的示例采用的是5个节点,这些节点完全互相独立,没有主从关系,和很多分布式算法一样,红锁也采用大多数机制
-
在奇数台redis上加锁,一次锁的获取,会对每个请求都获取一遍,如果获取锁成功的数量超过一半,则获取锁成功,反之失败;
-
红锁的问题:
- 如果有节点宕机了,需要等待红锁的key过期或者释放掉才能重启,否则也有可能加锁失败代码重复执行
- 会增加系统复杂度,而且会降低效率
redisson的git地址:
https://github.com/redisson/redisson
红锁的设计理念
- 多个节点完全互相独立,没有主从关系,也不使用复制和其他隐式协调系统
- 获取客户端锁时,首先获取当前时间,
- 依此获取5个实例,使用相同的key和随机值获取锁,客户端设置一个超时时间,超时时间应该小于锁的失效时间,可以防止客户端与一个宕机的redis节点长时间处于阻塞状态,如果一个redis服务器不可用,就要去请求其他redis获取锁
- 客户端通过当前时间减去最开始记录的时间,来计算获取锁使用的时间,只有大多数的redis节点(N/2 +1)都获取到锁,并且获取锁的时间小于锁的失效时间,才算锁获取成功
- 所以锁的有效时间是,初始有效时间减去获取锁使用的时间
- 如果没能获得锁,需要在所有的redis实例上进行解锁,因为可能部分加锁成功,部分加锁失败
红锁节点个数:N = 2 X +1
- N是最终部署的机器数
- X是容错机器数,也就是宕机多少台红锁依然可用
- 因为要保证过半的节点可用,所以奇数台是经历的方法
redisson
- redis官方给的红锁实现,封装了分布式锁的实现,底层使用的是hash结构保证了锁的可重入性,还使用了lua脚本保证原子性,锁的超时时间默认是30s
- redisson支持MultLock机制,可以将多个锁合并为一个大锁,然后禁止统一管理,加锁和解锁
- 加锁流程:
- 通过exists判断,锁如果不存在,就设置值和过期时间,加锁成功
- 如果锁已存在,通过hexists判断,锁的是当前线程,就是可重入锁,加锁成功
- 如果锁已存在,通过hexists判断,锁的不是当前线程,加锁失败,返回锁的过期时间
- 解锁流程:
- 通过hexists判断,释放锁的线程和持有锁的线程是不是同一个,不是就返回null,
- 是就减少锁的加锁次数,并刷新锁的过期时间
- 如果加锁的次数为0,就删除锁 ,并发布解锁的消息后
- 还会额外启动一个线程,也就是看门狗,它的作用是在redisson实例关闭前,来定期检查线程是否还持有锁,如果持有锁,就刷新锁的过期时间(30s),看门狗刷新的频率是锁过期时间的1/3,也就是10s刷新一次
- 此外redisson还提供了参数,指定加锁时间,超过这个时间后锁会自动解开,即使不释放锁也会解开,防止了死锁
- 多机情况下,ReadLock已被弃用,推荐使用 MultLock,MultLock符合juc的lock规范
示例:
@Resource
private Redisson redisson;
public BaseResultModel reduce() {
RLock lock = redisson.getLock(COMMODITY_KEY_LOCK_REDISSON);
//加redis分布式锁
lock.lock();
try {
} catch (Exception e) {
e.printStackTrace();
} finally {
//判断锁是否是当前线程的
if (lock.isLocked()&&lock.isHeldByCurrentThread()){
//释放redis分布式锁
lock.unlock();
}
}
return BaseResultModel.success("执行成功“);
}