分布式锁的实现方式主要有三种,分别通过redis、zookeeper和mysql,本文是关于redis如何实现分布式锁。
在聊redis的分布式锁之前,首先看看redis中的两条命令,这两条命令对redis分布式锁的实现有至关重要的作用。
setnx key value
setnx是SET if Not eXists(如果不存在,则 SET)
的缩写,用法如下。
当我们要设置的key不存在时,会返回1表示设置成功;如果设置的key已经存在,会返回0表示设置失败。
setex key seconds value
ex表示生存时间,setex命令就是设置一个含有过期时间的键值对,如果key已经存在,那么value会覆盖旧值。
setex是一个原子性操作,即设置键值对
和过期时间
是同时完成的。
2.实际场景
以电商秒杀场景来说,为了减轻数据库的压力,我们往往会将库存存在redis中进行预减库存。尽管我们在update减少库存时,往往会在代码中判断库存量是否大于0,但是考虑这样一种场景:
假设现在redis中显示商品只有一件库存了,服务器A获取库存数量为1,经判断大于0,于是准备对其进行-1操作;这时服务器B获取库存数量,由于A还未修改,所以此时B获得的值也为1,如果不加限制,就会出现超卖的问题。
为了对共享数据进行控制,就要通过分布式锁。
3.分布式锁的实现
(1)setnx(会死锁)
//使用,加锁实现减库存操作
Jedis jedis = getJedis();
jedis.setnx("lock","update");
/*
业务逻辑部分:
这里实现获取库存数量,并对其执行-1操作;
*/
jedis.del("lock")
如上所示,当服务器A获取到锁,也就是执行了setnx语句后,服务器B再想获取锁时就会被阻塞,只有当A对库存操作完并释放锁之后,服务器B才能对库存进行操作。
但是以上有一个很明显的问题就是,如果服务器A获取到锁之后宕机了怎么办?这时其他所有想要获取该数据的服务器会永远得不到该锁!这就造成了死锁。
为了解决这个问题,可以对set的值加上一个过期时间
来进行改善,也就是通过setex命令
。
(2)setnx + setex(可行)
通过setex增加一个过期时间,这样就算服务器A宕机了,经过一段时间后锁也会自动释放,很好的避免了死锁问题。具体实现如下:
public class Test{
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
private static final Long RELEASE_SUCCESS = 1L;
//加锁的方法
public boolean tryGetDistributedLock(String lockKey, String requestId, int expireTime) {
Jedis jedis = getJedis();
//真正加锁是通过jedis的set方法,这里要用到"NX"和"PX"参数
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
//如果设置成功返回true
return true;
}
return false;
}
//释放锁的方法
public boolean releaseDistributedLock(String lockKey, String requestId) {
Jedis jedis = getJedis();
//通过lua脚本进行删除key
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 (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
//使用,加锁实现减库存操作
Test test=new Test();
//尝试获取锁
boolean getRedisKey = test.tryGetDistributedLock("lock", "update", 5 * 60);
if(getRedisKey){
/*
获取库存并对其进行操作
update...
*/
}
test.releaseDistributedLock("lock", "update");
获取锁
由于setnx+setex,也就是设置键值
和过期时间
不是原子性的,为了保证原子性redis提供了"NX"和"PX"参数。
在redis中是这样实现的:
SET resource_key value NX PX 30000
而我们操作redis往往是通过java来实现的,在java中,我们通过jedis.set()方法,传入"NX"和"PX"参数即可。
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
释放锁
释放锁的时候,我们并没有直接进行del lock
,而是通过lua脚本
进行判断后再删除key,为什么呢?看看下面这种场景:
从上图可以很清晰的看到,线程1释放了线程2的锁!这不就乱了套了。因为我们在加锁的时候,key和value是一样的,也就意味着钥匙是一样的。因此,我们需要安全的释放锁——“不是我的锁,我不能瞎释放”。
故我们引入了lua脚本对参数进行判断,只有参数匹配时才释放对应的锁。在java中,通过往jedis.eval()方法中传入luascript(表示要执行的语句)
key
value
三个值来执行lua脚本。
Tips:redis使用lua为什么能保证原子性?
1.Redis 使用相同的 Lua 解释器来运行所有命令。
2.Redis 还保证以原子方式执行脚本: 在执行脚本时不会执行其他脚本或 Redis 命令,类似于事务。从所有其他客户端的角度来看,脚本的效果要么仍然不可见,要么已经完成。
但是存在的另一个问题是,它在执行的过程中如果一个命令报错不会回滚已执行的命令,所以要保证lua脚本的正确性。
(3)redisson
这里先提一下redisTemplate
、jedis
、lettuce
和redisson
的关系
1.Jedis是Redis官方推荐的面向Java的操作Redis的老牌客户端,效率高
2.RedisTemplate是SpringDataRedis中对JedisApi的高度封装,基于SpringBooot自动配置原理
3.在SpringBoot2.x时,lettuce替代了jedis,属于新式redis客户端
4.redission是redis的分布式客户端,提供了分布式操作和一些高级功能
总结:单机低并发选择jedis,分布式高并发选择redission
以上我们通过setnx + setex的方式,并没有实现可重入锁,以及,我们设置了一个过期时间,如果已经超过了过期时间,但是任务还没有执行完成怎么办?这些在redisson中都有实现,可以供我们进行参考。
redisson已经封装好了,我们直接使用即可,下面简单看几段代码:
获取锁
如果一个线程持有锁且任务还没执行完时,会通过一个定时任务不断刷新锁的过期时间。
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
//如果带有过期时间,则按照普通方式获取锁
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
//先按照30秒的过期时间来执行获取锁的方法
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
//如果还持有这个锁,则开启定时任务不断刷新该锁的过期时间
ttlRemainingFuture.addListener(new FutureListener<Long>() {
@Override
public void operationComplete(Future<Long> future) throws Exception {
if (!future.isSuccess()) {
return;
}
Long ttlRemaining = future.getNow();
// lock acquired
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
释放锁
释放锁时会有一个值对锁进行记录,类似一个计数器。计数器的值大于0,表示可重入,每释放一次,就刷新过期时间。和AQS是十分类似的。
这里就不贴代码了,感兴趣的同学可以自己去看一看。
问题
以上我们所讨论的方案,都是单点redis的情况。如果我们有多台redis构成一个集群,那么肯定会有一些问题。
在集群中,往往主库负责写操作
,然后同步到从库。从库负责读操作
,以减轻主库压力。
当我们申请一个锁的时候,对应就是一条命令 setnx mykey myvalue
,在redis sentinel集群中,这条命令先是落到了主库。假设这时主库down了,而这条数据还没来得及同步到从库,sentinel将从库中的一台选举为主库了。这时,我们的新主库中并没有mykey这条数据,若此时另外一个client执行 setnx mykey hisvalue
, 也会成功,即也能得到锁。这就意味着,此时有两个client获得了锁。这不是我们希望看到的。
解决方案
既然有问题就会有解决的办法。
为了解决故障转移情况下的缺陷,Antirez 发明了 Redlock 算法
。加锁的时候,它会想多半节点发送 setex mykey myvalue
命令,只要过半节点成功了,那么就算加锁成功了。释放锁的时候需要想所有节点发送del命令。这是一种基于大多数都同意
的一种机制。在java中,可以使用redisson的redlock。
使用redlock解决问题的同时,也会带来一些性能上的亏损,毕竟没有十全十美的解决方案。