Redis分布式锁
Redis分布式锁
在分布式系统中,由于redis分布式锁相对于更简单和高效,成为了分布式锁的首先,被我们用到了很多实际业务场景当中。
Redis分布式锁常见问题:
- 非原子操作
- 忘记释放锁
- 释放了其他人的锁
- 大量失败请求
- 锁重入问题
- 锁竞争问题
- 锁超时问题
- 主从复制问题
加锁:
// 此方式setNx命令设置锁和设置超时时间是分开的,非原子操作
if (jedis.setnx(lockKey, val) == 1) {
jedis.expire(lockKey, timeout);
}
// 使用set命令结合多个参数,该操作为原子操作
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;
其中:
lockKey
:锁的标识requestId
:请求idNX
:只在键不存在时,才对键进行设置操作。PX
:设置键的过期时间为 millisecond 毫秒。expireTime
:过期时间
分布式锁的合理使用方式:
- 手动加锁
- 业务操作
- 手动释放锁
- 如果手动释放锁失败了,则达到超时时间,redis会自动释放锁。
释放锁
// 在finally块里释放锁,即使因系统宕机锁也会因设置的超时时间而释放
try{
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;
} finally {
unlock(lockKey);
}
但仍可能会出现释放了别人的锁的问题:
假如线程A和线程B,都使用lockKey加锁。线程A加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间。这时候,redis会自动释放lockKey锁。此时,线程B就能给lockKey加锁成功了,接下来执行它的业务操作。恰好这个时候,线程A执行完了业务功能,接下来,在finally方法中释放了锁lockKey。这不就出问题了,线程B的锁,被线程A释放了。
解决方案:根据业务场景确定requestId,使用requestId来设置lockKey.(自己只能释放自己的锁)
lua脚本加锁操作:
// redisson框架加锁代码:
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]);
大量失败请求
场景1:秒杀场景:每1W个请求,有1个成功,再1W个请求,有1个成功;(不合理,合理场景应该是:1W个请求,成功1个,失败的部分应继续参与竞争)
解决方案:自旋锁,失败后休眠一段时间继续发起新一轮尝试(根据业务场景设置休眠时间和尝试次数)
锁重入问题
递归加锁场景中的问题需使用可重入锁解决
// redisson可重入锁使用伪代码
private int expireTime = 1000;
public void run(String lockKey) {
RLock lock = redisson.getLock(lockKey);
this.fun(lock,1);
}
public void fun(RLock lock,int level){
try{
lock.lock(5, TimeUnit.SECONDS);
if(level<=10){
this.fun(lock,++level);
} else {
return;
}
} finally {
lock.unlock();
}
}
redisson可重入锁lua脚本:
if (redis.call('exists', KEYS[1]) == 0)
then
redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
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]);
其中:
- KEYS[1]:锁名
- ARGV[1]:过期时间
- ARGV[2]:uuid + ":" + threadId,可认为是requestId
- 先判断如果锁名不存在,则加锁。
- 接下来,判断如果锁名和requestId值都存在,则使用hincrby命令给该锁名和requestId值计数,每次都加1。注意一下,这里就是重入锁的关键,锁重入一次值就加1。
- 如果锁名存在,但值不是requestId,则返回过期时间。
redisson释放锁lua脚本:
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0)
then
return nil
end
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0)
then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil
- 先判断如果锁名和requestId值不存在,则直接返回。
- 如果锁名和requestId值存在,则重入锁减1。
- 如果减1后,重入锁的value值还大于0,说明还有引用,则重试设置过期时间。
- 如果减1后,重入锁的value值还等于0,则可以删除锁,然后发消息通知等待线程抢锁。
锁竞争问题
通过控制锁的粒度来提升redis分布式锁性能:读写锁,锁分段
redisson中的读写锁示例:
// 读锁
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");
RLock rLock = readWriteLock.readLock();
try {
rLock.lock();
//业务操作
} catch (Exception e) {
log.error(e);
} finally {
rLock.unlock();
}
// 写锁
RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");
RLock rLock = readWriteLock.writeLock();
try {
rLock.lock();
//业务操作
} catch (InterruptedException e) {
log.error(e);
} finally {
rLock.unlock();
}
锁超时问题
线程A获取锁执行业务由于耗时过多导致超时释放了锁,线程B开始执行,此时线程A仍在执行,会导致意想不到的情况
解决方案:锁在达到超时时间后需要给锁自动续期
// 可以使用TimerTask类来实现自动续期
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
//自动续期逻辑
}
}, 10000, TimeUnit.MILLISECONDS);
获取锁之后,自动开启一个定时任务,每隔10秒钟,自动刷新一次过期时间。这种机制在redisson框架中,有个比较霸气的名字:watch dog
,即传说中的看门狗
。
自动续期操作的lua脚本实现:
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end;
return 0;
需要注意的地方是:在实现自动续期功能时,还需要设置一个总的过期时间,可以跟redisson保持一致,设置成30秒。如果业务代码到了这个总的过期时间,还没有执行完,就不再自动续期了。
主从复制问题
对于哨兵模式的redis使用分布式锁问题:
刚加上锁后master节点还未来得及同步到从节点就挂了
redisson框架为了解决这个问题,提供了一个专门的类:RedissonRedLock
,使用了Redlock算法。
RedissonRedLock解决问题的思路如下:
- 需要搭建几套相互独立的redis环境,假如我们在这里搭建了5套。
- 每套环境都有一个redisson node节点。
- 多个redisson node节点组成了RedissonRedLock。
- 环境包含:单机、主从、哨兵和集群模式,可以是一种或者多种混合。
RedissonRedLock加锁过程如下:
- 获取所有的redisson node节点信息,循环向所有的redisson node节点加锁,假设节点数为N,例子中N等于5。
- 如果在N个节点当中,有N/2 + 1个节点加锁成功了,那么整个RedissonRedLock加锁是成功的。
- 如果在N个节点当中,小于N/2 + 1个节点加锁成功,那么整个RedissonRedLock加锁是失败的。
- 如果中途发现各个节点加锁的总耗时,大于等于设置的最大等待时间,则直接返回失败。
不过也引出了一些新的问题:
- 要额外搭建多套环境,申请更多的资源,需要评估一下成本和性价比。
- 如果有N个redisson node节点,需要加锁N次,最少也需要加锁N/2+1次,才知道redlock加锁是否成功。显然,增加了额外的时间成本,有点得不偿失。
在分布式环境中,CAP是绕不过去的。
CAP指的是在一个分布式系统中:
- 一致性(Consistency)
- 可用性(Availability)
- 分区容错性(Partition tolerance)
这三个要素最多只能同时实现两点,不可能三者兼顾。
如果你的实际业务场景,更需要的是保证数据一致性。那么请使用CP类型的分布式锁,比如:zookeeper,它是基于磁盘的,性能可能没那么好,但数据一般不会丢。
如果你的实际业务场景,更需要的是保证数据高可用性。那么请使用AP类型的分布式锁,比如:redis,它是基于内存的,性能比较好,但有丢失数据的风险。