SET操作会覆盖原有值,SETEX虽然可设置key过期时间,但也会覆盖原有值,所以考虑可以使用SETNX
使用redis的setnx「SET if Not eXists」实现。
只在key不存在的情况下,将key值设为value
key存在,不做任何操作
redis> EXISTS job # job 不存在
(integer) 0
redis> SETNX job "programmer" # job 设置成功
(integer) 1
redis> SETNX job "code-farmer" # 尝试覆盖 job ,失败
(integer) 0
redis> GET job # 没有被覆盖
"programmer"
处理死锁
当一个客户端获取锁成功之后,假如它崩溃了导致它再也无法和 Redis 节点通信,那么它就会一直持有这个锁,导致其它客户端永远无法获得锁了,因此锁必须要有一个自动释放的时间。
通常我们会把获取锁的操作分成两个 Redis 命令:
redis> setnx LOCK 7978ff8a-170c-4422-ab17-6a5d846acd92
(integer) 1
redis> expire LOCK 30
(integer) 1
如果客户端在执行完 setnx LOCK 7978ff8a-170c-4422-ab17-6a5d846acd92
命令后由于某种原因,客户端宕机了,那么这时这把锁并没有过期时间,导致其它客户端永远无法获得锁了。
因此对于锁的过期时间设置不能分为两步操作,Spring Boot 的 StringRedisTemplate 并没提供原子性操作,一条命令设置 key、value、expire,Redis 官方提供的 Jedis 客户端中的 JedisCommands 接口就可以实现这个操作,如下:
package com.rmb.monitor.util;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.util.Assert;
import redis.clients.jedis.JedisCommands;
/**
* @author cby
*/
public class Test {
/**
* 当且仅当 key 不存在时设置 value ,等效于 SETNX
*/
public static final String NX = "NX";
/**
* 以秒为单位设置 key 的过期时间,等效于 EXPIRE key seconds
*/
public static final String EX = "EX";
@Autowired
private StringRedisTemplate redisTemplate;
private String setNx(final String key, final String value, final long seconds) {
Assert.isTrue(!StringUtils.isEmpty(key), "Invalid key");
return redisTemplate.execute((RedisCallback<String>) connection -> {
Object nativeConnection = connection.getNativeConnection();
String result = null;
if (nativeConnection instanceof JedisCommands) {
result = ((JedisCommands) nativeConnection).set(key, value, NX, EX, seconds);
}
return result;
});
}
/**
* 获取redis里面的值
*
* @param key
* @param clazz
* @return T
*/
private <T> T get(final String key, Class<T> clazz) {
Assert.isTrue(!StringUtils.isEmpty(key), "Invalid key");
return redisTemplate.execute((RedisConnection connection) -> {
Object nativeConnection = connection.getNativeConnection();
Object result = null;
if (nativeConnection instanceof JedisCommands) {
result = ((JedisCommands) nativeConnection).get(key);
}
return clazz.cast(result);
});
}
}
关于redisTemplate.execute(),
execute
@Nullable
public <T> T execute(RedisCallback<T> action)
该方法执行给定的Action在一次redis连接中。执行完成之后可以返回多个结果(List)。但是需要注意的是,它本身不支持解决事务。
executePipelined
public List<Object> executePipelined(SessionCallback<?> session)
executePipelined
是可以允许我们执行事务的。executePipelined
还有一个需要注意的点,就是虽然重写了回调函数,但是回调函数还是有可能返回空值的。
关于JedisCommands相关接口,可查看上篇文章
那么,解锁呢
分布式锁应用中,使用lua脚本删除redis中匹配value的key。可以避免由于方法执行时间过长而redis锁自动过期失效的时候误删其他线程的锁,spring自带的执行脚本方法中,集群模式直接抛出不支持执行脚本的异常,所以只能拿到原redis的connection来执行脚本。
(1)减少网络开销,在Lua脚本中可以把多个命令放在同一个脚本中运行
(2)原子操作,redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。
(3)复用性,客户端发送的脚本会永远存储在redis中,这意味着其他客户端可以复用这一脚本来完成同样的逻辑
/**
* 解锁的lua脚本
*/
public static final String UNLOCK_LUA = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then return redis.call(\"del\",KEYS[1]) else return 0 end";
/**
* 锁标记
*/
private volatile boolean locked = false;
/**
* 锁标志对应的key
*/
private String lockKey;
/**
* 锁对应的值
*/
private String lockValue;
/**
* 解锁
* <p>
* 防止持有过期锁的客户端误删现有锁的情况出现,可以通过以下修改:
* <p>
* 1. 不使用固定的字符串作为 value,而是使用随机的 UUID 作为 value 。
* 2. 不使用 DEL 命令来释放锁,而是发送一个 Lua 脚本,这个脚本只在客户端传入的值和键的口令串相匹配时,才对键进行删除。
*/
public Boolean unlock() {
// 只有加锁成功并且锁还有效才去释放锁
if (locked) {
try {
return redisTemplate.execute((RedisConnection connection) -> {
Object nativeConnection = connection.getNativeConnection();
Long result = 0L;
List<String> keys = new ArrayList<>();
keys.add(lockKey);
List<String> values = new ArrayList<>();
values.add(lockValue);
// 集群模式
if (nativeConnection instanceof JedisCluster) {
result = (Long) ((JedisCluster) nativeConnection)
.eval(UNLOCK_LUA, keys, values);
}
// 单机模式
if (nativeConnection instanceof Jedis) {
result = (Long) ((Jedis) nativeConnection).eval(UNLOCK_LUA, keys, values);
}
if (result == 0 && !StringUtils.isEmpty(lockKey)) {
log.debug("Unlock failed! key={}, time={}", lockKey,
System.currentTimeMillis());
}
locked = result == 0;
return result == 1;
});
} catch (Throwable e) {
log.warn(
"The redis you are using dose NOT support EVAL. Use downgrade method to unlock. {}",
e.getMessage());
String value = this.get(lockKey, String.class);
if (lockValue.equals(value)) {
redisTemplate.delete(lockKey);
return true;
}
return false;
}
}
return true;
}
锁被其他线程释放
如果不加任何处理即简单使用 SETNX 实现 Redis 分布式锁,就会遇到一个问题:如果线程 C1 获得锁,但由于业务处理时间过长,锁在线程 C1 还未处理完业务之前已经过期了,这时线程 C2 获得锁,在线程 C2 处理业务期间线程 C1 完成业务执行释放锁操作,但这时线程 C2 仍在处理业务线程 C1 释放了线程 C2 的锁,导致线程 C2 业务处理实际上没有锁提供保护机制;同理线程 C2 可能释放线程 C3 的锁,从而导致严重的问题。
因此每个线程释放锁的时候只能释放自己的锁,即锁必须要有一个拥有者的标记,并且也需要保证释放锁的原子性操作。
锁拥有着的标志我们可以用 UUID 在实现,将其在获取锁的时候作为 value 值 set 到 Redis中。释放锁的时候先判断锁对应的 UUID 是否与线程中的 UUID 相同,相同时才做删除操作。
从 Redis 2.6.0 版本开始,通过内置的 Lua 解释器,可以使用 EVAL 命令对 Lua 脚本进行求值,通过 Lua 脚本来达到释放锁的原子操作。
可重入锁
可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,如果没有可重入锁的支持,在第二次尝试获得锁时将会进入死锁状态。
ThreadLocal 适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景。
在这里,我们可以利用在 Redis 保存的 value 值进行判断,获得锁后我们将 UUID 存入 ThreadLocal 中,同一线程再次尝试获取锁的时候将 ThreadLocal 中的 UUID 与 Redis 的 value 比较,如果相同则表示这把锁所以该线程,即实现可重入锁。