redis的set命令可以将键key设置为指定的值,如果 key 已经保存了一个值,那么这个操作会直接覆盖原来的值,并且忽略原始类型。当set命令执行成功之后,之前设置的过期时间都将失效。
从2.6.12版本开始,redis为SET命令增加了一系列选项:
EX seconds – 设置键key的过期时间,单位时秒
PX milliseconds – 设置键key的过期时间,单位时毫秒
NX – 只有键key不存在的时候才会设置key的值
XX – 只有键key存在的时候才会设置key的值
获取键为foo的锁,可以使用以下操作:
SETNX lock.foo <current Unix time + lock timeout + 1>
如果客户端获得锁,SETNX返回1,lock.foo键的值设置为一个时间戳。具体业务操作完成后可以使用DEL lock.foo去释放该锁。如果SETNX返回0,那么该键已经被其他的客户端锁定。这时可以立刻返回给调用者加锁失败,或者尝试重新获取该锁,直到成功或者超时。
如果客户端出现故障,崩溃或者其他情况无法释放该锁时,可以使用该锁键值中设置的那个Unix时间戳来判断该锁是否过期,如果当前时间大于等于该时间戳,说明该锁过期了。
但是当发生以下这种情况发生时,需要注意一个问题:
- C3 加锁成功持有锁,并且 C3 崩溃。
- C1 和 C2 尝试获取锁,执行SETNX都返回0。获取锁失败。检查时间戳发现锁过期。
- C1 发送DEL lock.foo
- C1 发送SETNX lock.foo命令并且成功返回
- C2 发送DEL lock.foo
- C2 发送SETNX lock.foo命令并且成功返回
错误:由于竞态条件 C2 删除了 C1 加的锁导致 C1 和 C2 都获取到了锁
所以当多个客户端察觉到一个过期的锁并且都尝试去释放它时,不能直接调用DEL来删除该锁后执行SETNX。如何避免这种情况呢?
- C3 加锁成功持有锁,并且 C3 崩溃。
- C4 尝试获取锁,执行SETNX返回0,获取锁失败。检查时间戳发现锁过期
- C4 尝试执行以下的操作:
GETSET lock.foo <current Unix timestamp + lock timeout + 1>
GETSET 命令用于设置指定 key 的值,并返回 key 的旧值,当 key 没有旧值时,即 key 不存 在 时,返回 nil 。
- C4 检查已经过期的旧值是否仍然存储在lock.foo中。如果是的话,C4 获得锁。
如果另一个客户端C5 ,比 C4 更快的通过GETSET操作获取到锁,那么 C4 执行GETSET操作会被返回一个未过期的时间戳,立即返回获取锁失败或者重试。
以上具体实现为:
//加锁过期时间,毫秒
public static final int LOCK_EXPIRE = 600; // ms
private RedisTemplate redisTemplate;
/**
* 加锁
* @param lock
* @return
*/
public boolean lock(String lock) {
Boolean execute = (Boolean) redisTemplate.execute((RedisCallback) connection -> {
//获取时间毫秒值
long expireAt = System.currentTimeMillis() + LOCK_EXPIRE + 1;
//获取锁
Boolean acquire = connection.setNX(lock.getBytes(), String.valueOf(expireAt).getBytes());
if (Boolean.TRUE.equals(acquire)) {
return true;
} else {
byte[] bytes = connection.get(lock.getBytes());
//非空判断
if (Objects.nonNull(bytes) && bytes.length > 0) {
long expireTime = Long.parseLong(new String(bytes));
// 如果锁已经过期
if (expireTime < System.currentTimeMillis()) {
// 重新加锁,防止死锁
byte[] set = connection.getSet(lock.getBytes(), String.valueOf(System.currentTimeMillis() + LOCK_EXPIRE + 1).getBytes());
return Optional.ofNullable(set).map(String::new)
.map(Long::parseLong)
.map(m -> m < System.currentTimeMillis())
.orElse(false);
}
}
}
return false;
});
return Boolean.TRUE.equals(execute);
}
/**
* 解锁锁
*/
public void release(String key) {
redisTemplate.delete(key);
}
此方法可以实现一个简单的分布式锁,但还是会有几个小问题:
- 键值设置为过期时间,这就要求各个客户端之间要有严格的时钟同步
- 锁未标志是那个客户端设置的,解锁时不能保证和加锁的是同一个客户端。不是自己加锁的,也会被删除。再极端情况下可能会导致以下问题:
- C3 加锁成功持有锁,并且 C3 阻塞导致锁过期
- C4 尝试获取锁,发现锁过期使用GETSET命令获取锁,这时 C3、C4 都持有锁
- C3 恢复执行完业务释放锁
- C5 尝试获取锁由于锁已释放,加锁成功,此时 C4 和 C5 都持有锁
- .....
要解决上述问题可以使用以下方法:
SET resource-name anystring NX PX max-lock-time
加锁时将键值设置为唯一id,同时设置过期时间,键key过期后会被redis自动删除。解锁时使用Lua脚本:
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
通过键值的唯一id判断锁是否是自己加的,再删除并且同时保证了原子性:
//加锁过期时间,毫秒
public static final int LOCK_EXPIRE = 600; // ms
private RedisTemplate redisTemplate;
/**
* 加锁
*
* @param key 键名
* @param lockId 锁id,键值
* @param lockWait 加锁等待时间,毫秒
* @return
*/
public boolean lock(String key, String lockId, long lockWait) {
try {
long waitEnd = System.currentTimeMillis() + lockWait;
while (System.currentTimeMillis() < waitEnd) {
Boolean isLocked = (Boolean) redisTemplate.execute((RedisCallback<Boolean>) connection -> {
Object execute = connection.execute("set",
redisTemplate.getKeySerializer().serialize(key),
redisTemplate.getValueSerializer().serialize(lockId),
"NX".getBytes(StandardCharsets.UTF_8),
"PX".getBytes(StandardCharsets.UTF_8),
String.valueOf(LOCK_EXPIRE).getBytes(StandardCharsets.UTF_8));
return Optional.ofNullable(execute).map(Object::toString).map("OK"::equals).orElse(false);
});
if (Boolean.TRUE.equals(isLocked)) {
return true;
}
TimeUnit.MICROSECONDS.sleep(10L);
}
} catch (Exception e) {
log.error("lock error", e);
throw new BizException(e.getMessage());
}
return false;
}
/**
* 解锁
*
* @param key 键名
* @param lockId 锁id,键值
* @return
*/
public boolean release(String key, String lockId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object execute = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(key), lockId);
return Optional.ofNullable(execute).map(m -> (long) m).map(m -> m == 1).orElse(false);
}