日常开发中,秒杀下单、抢红包等等业务场景,都需要用到分布式锁。而Redis非常适合作为分布式锁使用。大系统,多系统迎来的是多应用之间的一致性。
// 获取锁 基于 setnx 和 expire 此方法不会保证原子性 可以使用lua脚本(redis 又演变出 set加过期时间的方式)
public boolean getLockNx(Jedis jedis, String lockeKey, String requestId, Long expireTime) {
Long setnx = jedis.setnx(lockeKey, requestId);
if (Objects.equals(setnx, 1)) {
jedis.expire(lockeKey, new Long(expireTime).intValue());
return true;
}
return false;
}
问题:
操作化为两步操作,导致形成了非原子性。假如setnx执行后,机器挂机了,就会导致锁一直在锁,无法释放,形成死锁。
/**
* 最终加强分布式锁
*
* @param lock key值
* @return 是否获取到
*/
public boolean lock(String lock, long expireTime){
// 利用lambda表达式
return (Boolean) redisTemplate.execute((RedisCallback) connection -> {
long expireAt = System.currentTimeMillis() + expireTime + 1;
Boolean acquire = connection.setNX(lock.getBytes(), String.valueOf(expireAt).getBytes());
if (acquire) {
return true;
} else {
//锁存在 查看是否过期时间
byte[] value = connection.get(lock.getBytes());
if (Objects.nonNull(value) && value.length > 0) {
long expire = Long.parseLong(new String(value));
// 如果锁已经过期 判断过期时间是否超时
if (expire < System.currentTimeMillis()) {
// 重新加锁,防止死锁
//锁超时 重新加锁 使用getset 原子操作
//重新加锁 防止死锁 判断重新加锁时间戳,确定是否加锁成功 old
//查看老的时间值是否被其他线程修改 值是否被其他线程修改
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁
// 该锁没有保存持有者的唯一标识,可能被别的客户端释放/解锁
//到这一步其实还是有问题的,如果一个请求更新缓存的时间比锁的有效期还要长,导致在缓存更新过程中锁就失效了,此时另一个请求就会获取到锁,但前一个请求在缓存更新完毕的时候,直接删除锁的话就会出现误删其它请求创建的锁的情况。
byte[] oldValue = connection.getSet(lock.getBytes(), String.valueOf(System.currentTimeMillis() + expireTime + 1).getBytes());
return Long.parseLong(new String(oldValue)) < System.currentTimeMillis();
}
}
}
return false;
});
}
问题:
1.「锁被别的线程误删」。假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没执行完呢。
2.「锁过期释放了,业务还没执行完」。假设线程a获取锁成功,一直在执行临界区的代码。但是100s过去后,它还没执行完。但是,这时候锁已经过期了,此时线程b又请求过来。显然线程b就可以获得锁成功,也开始执行临界区的代码。那么问题就来了,临界区的业务代码都不是严格串行执行的啦。
/**
* 释放分布式锁 基于lua脚本释放 保证了原子性 和释放锁是符合自己的 解决并发问题
*
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
问题:
例如,操作共享资源的时间「最慢」可能需要 15s,而我们却只设置了 10s 过期,那这就存在锁提前过期的风险。
过期时间太短,那增大冗余时间,例如设置过期时间为 20s,这样总可以了吧?
这样确实可以「缓解」这个问题,降低出问题的概率,但依旧无法「彻底解决」问题。
为什么?
原因在于,客户端在拿到锁之后,在操作共享资源时,遇到的场景有可能是很复杂的,例如,程序内部发生异常、网络请求超时等等。
既然是「预估」时间,也只能是大致计算,除非你能预料并覆盖到所有导致耗时变长的场景,但这其实很难。
有什么更好的解决方案吗?
别急,关于这个问题,我会在后面详细来讲对应的解决方案。
保证SETNX + EXPIRE两条指令的原子性,我们还可以巧用Redis的SET指令扩展参数!(SET key value[EX seconds][PX milliseconds][NX|XX]),它也是原子性的!
/**
* set 扩展命令执行
* 问题一:「锁过期释放了,业务还没执行完」。假设线程a获取锁成功,一直在执行临界区的代码。但是100s过去后,它还没执行完。但是,这时候锁已经过期了,此时线程b又请求过来。显然线程b就可以获得锁成功,也开始执行临界区的代码。那么问题就来了,临界区的业务代码都不是严格串行执行的啦。
* 问题二:「锁被别的线程误删」。假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没执行完呢。
* @param lock
* @param value
* @param expireTime
* @return
*/
public Boolean lock(String lock, String value, long expireTime){
return (Boolean)redisTemplate.execute((RedisCallback<Boolean>) connection -> {
RedisSerializer keySerializer = redisTemplate.getKeySerializer();
RedisSerializer valueSerializer = redisTemplate.getValueSerializer();
Object execute = connection.execute("set",
keySerializer.serialize(lock),
valueSerializer.serialize(value),
SafeEncoder.encode("NX"),
SafeEncoder.encode("EX"),
Protocol.toByteArray(expireTime)
);
return Objects.nonNull(execute);
});
}
业务执行
if(jedis.set(key_resource_id, lock_value, "NX", "EX", 100s) == 1){ //加锁
try {
do something //业务处理
}catch(){
}
finally {
jedis.del(key_resource_id); //释放锁
}
}
问题:
问题一:「锁过期释放了,业务还没执行完」。假设线程a获取锁成功,一直在执行临界区的代码。但是100s过去后,它还没执行完。但是,这时候锁已经过期了,此时线程b又请求过来。显然线程b就可以获得锁成功,也开始执行临界区的代码。那么问题就来了,临界区的业务代码都不是严格串行执行的啦。
问题二:「锁被别的线程误删」。假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没执行完呢。
伪代码
if(jedis.set(key_resource_id, uni_request_id, "NX", "EX", 100s) == 1){ //加锁
try {
do something //业务处理
}catch(){
}
finally {
//判断是不是当前线程加的锁,是才释放
if (uni_request_id.equals(jedis.get(key_resource_id))) {
jedis.del(lockKey); //释放锁
}
}
}
问题:「判断是不是当前线程加的锁」和「释放锁」不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。
伪代码
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end;
问题:
「锁过期释放了,业务还没执行完」。假设线程a获取锁成功,一直在执行临界区的代码。但是100s过去后,它还没执行完。但是,这时候锁已经过期了,此时线程b又请求过来。显然线程b就可以获得锁成功,也开始执行临界区的代码。那么问题就来了,临界区的业务代码都不是严格串行执行的啦。
还是可能存在「锁过期释放,业务没执行完」的问题。有些小伙伴认为,稍微把锁过期时间设置长一些就可以啦。其实我们设想一下,是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。
只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了「锁过期释放,业务没执行完」问题。
这个不会
Redisson实现了redLock版本的锁,有兴趣的小伙伴,可以去了解一下哈~