当服务采用集群方式部署的时候,本地锁无法发挥作用,所以需要分布式锁来实现加锁。
实现
Redis主要运用setnx命令进行锁操作
- 加锁: SETINX key value, 当锁不存在的时候,成功设置锁并返回
- 解锁: DEL key, 通过删除键值对释放锁,以便其他线程可以通过SETINX来获取锁
- 锁超时:EXPIRE key timeout, 设置超时时间,以便即使锁没有被及时释放,也可以在一定时间内自动释放,避免资源被永远锁住
Redis使用lua脚本的好处
- 减少网络开销。可以将多个请求通过脚本形式一次发送,减少网络时延。
- 原子操作。Redis会将整个脚本作为整体执行,中间不会被其他命令插入。
- 复用。客户端发送的脚本会永久存储在redis中,这样其他客户端就可以复用而不需要使用代码完成相同的逻辑
如何使用lua
EVAL
eval "return {KEYS[1], KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
-- 第一个参数是lua程序脚本
-- 第二个参数是lua脚本后面的那个参数,表示KEYS参数的个数
-- 第三个参数是Redis键名。lua脚本可以访问有KEYS全局变量组成的一维数据参数
-- 第四个参数是相应KEYS所对应的值,并且lua脚本可以通过ARGV访问其值
redis.call
可以使用redis.call(),redis.pcall()从lua脚本调用Redis命令。
redis.call()与redis.pcall()唯一的区别在于Redis命令调用错误时,redis.call抛出Lua类型的错误,再强制EVAL将错误返回给命令的调用者,而redis.pcall将捕获错误并返回表示错误的Lua表类型。
问题
SETINX和EXPIRE的非原子性
在设置锁成功后,而设置超时时间时因为服务器挂掉、重启、网络等问题而没有执行成功
解决方法:
public boolean tryLock_with_lua(String key, String UniqueId, int seconds) {
String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
"redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
List<String> keys = new ArrayList<>();
List<String> values = new ArrayList<>();
keys.add(key);
values.add(UniqueId);
values.add(String.valueOf(seconds));
Object result = jedis.eval(lua_scripts, keys, values);
//判断是否成功
return result.equals(1L);
}
锁误解除
如果线程A获取到锁并设置过期时间为30s,然后过期时间到了线程B获取到了锁,随后线程A执行完毕,就会使用DEL误释放B的锁。
解决方法:
通过在value中设置当前锁的标识,并在删除之前判断是否为当前线程所持有。
// 设置锁
public boolean tryLock_with_set(String key, String uniqueId, int seconds){
// u niqueId具有唯一性
// N X
return "Ok".equals(jedis.set(key,uniqueId,"NX","EX",seconds));
}
// 释放锁
public boolean releaseLock_with_lua(String key, String value){
// 使用lua脚本尽量保持原子性
String luaScript= "if redis.call('get', KEYS[1])==ARGV[1] then "+
"return redis.call('del',KEYS[1]) else return 0 end";
return jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(value)).equals(1L);
}
超时解锁导致并发
如果线程A获取到了锁并设置了过期时间30s,但线程A执行时间超过了30s,这个时候线程B获取到了锁,就会导致A,B并发执行。
为了解决这个问题,我们可以
- 将过期时间设置的足够长,保证代码逻辑在锁释放前执行完成。但是这很难设置,要么设置时间过长影响性能,要么比较短还是会导致并发执行
- 为获取锁的线程添加守护线程,为将要过期但并未释放的锁增加有效时间。
不可重入
通过本地记录重入次数在分布式锁保证可重入性,由于考虑到过期时间、本地以及Redis一致性的问题,会增加代码的复杂性。
解决方法:
// 如果lock_key不存在
if (redis.call('exists', KEYS[1]==0))
then
// 设置lock_key线程标识1进行加锁
redis.call('hset',KEYS[1],ARGV[2],1);
// 设置过期时间
redis.call('pexpire',KEYS[1],ARGV[1]);
// 如果lock_key存在且线程标识是当前欲加锁线程的标识
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
// 重入次数自增
then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
// 重置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 如果加锁失败,返回锁剩余时间
return redis.call('pttl', KEYS[1]);
无法等待锁释放
我们可以通过两种方式来等待锁的释放,第一种是比较传统的客户端轮询的方式,当未获取到锁的时候会等待一段时间后重新去获取锁,直到成功获取锁或等待超时。可是这种方式比较消耗服务器资源
另一种是使用redis的发布订阅功能,当获取锁失败的时候,订阅锁释放消息
Ref
-
https://www.jianshu.com/p/366d1b4f0d13
-
https://www.cnblogs.com/PatrickLiu/p/8656675.html
-
https://xiaomi-info.github.io/2019/12/17/redis-distributed-lock/
-
https://juejin.cn/post/6844903830442737671