简单概括
什么是分布式锁
- 分布式锁可用需要满足 3 个条件
- 互斥性: 任意时刻,只有一个客户端能持有锁
- 不会发生死锁: 即使有一个客户端在持有锁的期间因为崩溃等原因没有主动释放锁,也能保证后续其他客户端能够加锁
- 加锁和释放锁都必须是同一个客户端,即当前客户端不能释放其他客户端的锁
如何实现
- 总体上使用
setnx
setex
- 避免锁无法释放,需要保持原子性
- 使用 lua 脚本实现原子性
- 使用命令连用的方式实现原子性
解锁需要注意什么
- 加锁和解锁必须是同一个客户端,即当前客户端不能释放其他客户端的锁
redis 的分布式锁实现
简单版本
- 先
setnx(key, value)
,如果返回1
继续,0
返回 - 进行
setex(key, time, value)
对setnx
的键值对设置过期时间 setnx
的线程需要释放该锁
代码简单示范
private void tryOnce() {
Boolean result = false;
String myValue = getRandomValue();
try {
// setnx
result = redisTemplate.opsForValue().setIfAbsent(KEY, myValue);
// setex
redisTemplate.opsForValue().set(KEY, myValue, 10, TimeUnit.SECONDS);
if (!result) {
// 要立即返回
return;
} else {
System.out.println("ok: " + System.currentTimeMillis());
}
TimeUnit.SECONDS.sleep(1);
} catch (Exception e) {
log.error(e.toString(), e);
} finally {
// 需要判断对应 key 的值是不是当前线程设置的
if (result && myValue.equals(redisTemplate.opsForValue().get(KEY))) {
redisTemplate.delete(KEY + serviceName);
System.out.println("释放锁");
}
}
}
/**
* 获取随机值,生成上可以设置为当前服务的ip
*
* @return {@link java.lang.String} 随机字符串
*/
private String getRandomValue() {
return UUID.randomUUID().toString().replace("-", "");
}
原子性优化点一
- 在
setnx
后,出现下列问题,会导致setnx
的值过期时间为-1
- 当前线程所在服务宕机
redis
不可用
- 此后如果不及时处理,会导致其他服务永远拿不到锁
所以就要让
setnx
和setex
操作作为 原子操作Lua数据类型和redis返回值类型转换规则 | Lua数据类型 | 整数回复(Lua的数字类型会被自动转换成整数) | | —- | —- | | 字符串类型 | 字符串回复 | | table类型(数组形式) | 多行字符串回复 | | table类型(只有一个ok字段存储状态信息) | 状态回复 | | table类型(只有一个err字段存储错误信息) | 错误回复 |
- 在
resources
目录下新建一个one.lua
脚本
-- KEYS... , ARGS... 注意 KEYS 和 ARGS 之间的逗号两边都有空格, 而他们各自的值是用空格分开的
-- 下标从 1 开始
local key = KEYS[1]
local value = KEYS[2]
local expired_time = ARGV[1]
-- 判断结果是否包含指定的键
local checkResultContainKey = function(tableName, keyName)
for key, value in pairs(tableName) do
-- 方式一
if string.find(key, keyName) then
return true
end
-- 方式二
if key == keyName then
return true
end
return false
end
end
-- 开始 setnx setex 操作
local result_setnx = redis.call("SETNX", key, value)
if result_setnx == 1 then -- 注意 setnx 返回 1
-- setnx 成功会进行 setex
local result_setex = redis.call("SETEX", key, expired_time, value)
if result_setnx and checkResultContainKey(result_setex, 'ok') then
return true
else
return false
end
else
return result_setnx
end
代码中读取lua 脚本
/**
* <h2>实现 setnx setex 原子化</h2>
*
* @param key 键
* @param value 值
* @param time 过期时间,目前限制为 seconds
* @return {@link java.lang.Boolean}
*/
private Boolean luaExpress(String key, String value, Integer time) {
// 泛型表示 lua 返回值
DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(
new ResourceScriptSource(new ClassPathResource("one.lua"))
);
// 也可以在这里设置返回值内容
redisScript.setResultType(Boolean.class);
// 封装参数
List<String> keyList = new ArrayList<>();
keyList.add(key);
keyList.add(value);
return (Boolean) redisTemplate.execute(redisScript, keyList, time);
}
修改原来的逻辑
// 将之前代码中的 try {} 中的内容进行替换
try {
// setnx + setex 原子化
result = luaExpress(KEY, serviceName, 30);
if (!result) {
// 要立即返回
return;
} else {
System.out.println("ok: " + System.currentTimeMillis());
}
TimeUnit.SECONDS.sleep(1);
}
命令连用
- redis 2.6 (?不确定) 之后可以实现方法连用
- 代码演示
/**
* 使用 redisConnection 中重写方法实现方法连用
*
* @param key 键
* @param value 值
* @param time 时间,目前单位规定为 seconds
* @return {@link java.lang.Boolean}
*/
private Boolean overwrite(String key, String value, Integer time) {
try {
Boolean result = redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
return connection.set(key.getBytes(StandardCharsets.UTF_8),
value.getBytes(StandardCharsets.UTF_8),
Expiration.seconds(time),
RedisStringCommands.SetOption.ifAbsent()
);
}
});
return result;
} catch (Exception e) {
log.error("出现异常: {}", e.getLocalizedMessage(), e);
}
return false;
}
- 逻辑修改
// 将之前代码中的 try {} 中的内容进行替换
try {
// setnx + setex 原子化
result = overwrite(KEY, myValue, 30);
if (!result) {
// 要立即返回
return;
} else {
System.out.println("ok: " + System.currentTimeMillis());
}
TimeUnit.SECONDS.sleep(1);
}
SET key value [EX seconds] [PX milliseconds] [NX|XX]
- 原子的么?
原子性优化点二
- 在释放锁的时候,由于
setex
设置的过期时间过短,在抢到锁的当前服务执行完毕后将要删除该锁时,该锁已经过期,此时如果别人抢占了锁,那么当前服务可能会删除到了别的服务的锁 - 即在代码中
finally
阶段,需要进行get
、判断是否相同、del
操作- 如果在
get
、判断是否相同,在进行del
时,这时候锁过期,被其他线程抢到锁设置了一个相同key
的锁,此时del
就相当于释放了别人的锁 - 所以在进行上面
finally
三步骤时,要保持 原子性 - 可以说有进行
get
+ operation 的情景,即后续操作是依托于get
的结果的情景,get
和 后续操作要保持原子性
- 如果在
用lua
- 脚本 ```lua local key = KEYS[1] —local myValue = ARGV[1] — ARGV 传入的字符串居然会带双引号!!!!! local myValue = KEYS[2]
— 判断 key 是否为自己设置的 myValue local is_key_exist = redis.call(“GET”, key)
if is_key_exist == myValue then local deleted = redis.call(“DEL”, key) — 返回删除状态 return deleted else — 标明该 key 不是自己删除的 return false end
- 读取lua脚本
```java
/**
* 判断目标值是否为自己设置的值
*
* @param key 键
* @param myValue 当前设置的值
* @return {@link java.lang.Boolean}
*/
private Boolean removeIfSame(String key, String myValue) {
DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(
new ResourceScriptSource(new ClassPathResource("removeIfSame.lua"))
);
redisScript.setResultType(Boolean.class);
List<String> keyList = new ArrayList<>(2);
keyList.add(key);
keyList.add(myValue);
// 这里将 key 和 myValue 当作 KEYS 传入
return redisTemplate.execute(redisScript, keyList);
}
- 修改代码逻辑
finally {
// 判断目标值是否存在,且是否为自己设置的值
// get + del 原子化
if (result && removeIfSame(KEY + serviceName, serviceName)) {
System.out.println("释放了自己设置的锁");
}
}