什么是分布式锁
首先介绍下什么是分布式锁,分布式锁是针对不同系统多进程之间数据同步的一种解决方案。在不同进程需要互斥地访问共享资源时,分布式锁是一种非常有用的技术手段。
安全和可靠性要求
先提出三个属性,这三个属性,是实现高效分布式锁的基础。
- 安全属性A:互斥,不管任何时候,只有一个客户端能持有同一个锁。
- 效率属性A:不会死锁,最终一定会得到锁,就算一个持有锁的客户端宕掉或者发生网络分区。
- 效率属性B:容错,只要大多数Redis节点正常工作,客户端应该都能获取和释放锁。
场景
当前目标是在多个tomcat上实现关单操作。所谓关单,就是把那些超期未支付的订单给关闭掉,这里用的是spring schedule实现,关单部分不在这里详述,仅就分布式锁问题进行探索。
这里是多个tomcat共享一个redis集群。分布式锁用Redis实现。同一时间只有一个tomcat可以获取锁,进行关单。
初始版本(多重防死锁,存在瑕疵,仅此场景可用)
原理
这个初始版本理论上不是一个真正的分布式锁,但放在这里是可以实现分布式锁的功能的。所以我也放在这里做一个例子吧。为什么这个不好呢,之后再说。
几个命令:
- setnx :set if not exists。如果存在此key,就不会set。
- expire:expire key seconds。为某个key设定存活时间,存活时间到了就自动删除key。
首先设定一个锁名+锁过期时间
的键值对到redis中,用setnx
来实现锁功能。setnx,即"set if not exists",就是只有在不存在这个key的时候才进行set,契合锁的定义。
如果set成功,也就是没有这个key,那么就获取锁成功。还要再把锁expire一下,防止set之后当前系统挂了之后一直没解锁,发生死锁。在这段时间里该进程可以对共享资源进行操作,进行关单,其他进程无法插手。
如果set失败,说明redis中已经有这个key了,但是并不代表这个锁还有效,此时仍需判断,如果锁过期了,获取锁。
下面是此过程的的流程图:
要使用redis,需要引入redis相关组件,这里采用jedis,在pom.xml中添加:
<!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.6.0</version>
</dependency>
具体实现代码如下:
@Scheduled(cron = "0 */1 * * * ?")//每1分钟执行一次(每个1分钟的整数倍)
public void closeOrderTask() {
log.info("关闭订单定时任务启动");
long lockTimeout = Long.parseLong(PropertiesUtil.getProperty("lock.timeout", "5000")); //分布式锁失效时间
Long setnxResult = RedisShardedPoolUtil.setnx(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, String.valueOf(System.currentTimeMillis()+lockTimeout));
if (setnxResult != null && setnxResult.intValue() == 1) {
//如果key不存在,获取锁成功
closeOrder(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
} else {
//key存在,未获取到锁,继续判断时间戳,看是否可以重置并获取到锁
String lockValueStr = RedisShardedPoolUtil.get(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);//key的旧值
if (lockValueStr != null && System.currentTimeMillis() > Long.parseLong(lockValueStr)) {
//如果当前key存在,且根据时间戳已经已经过时
//返回给定的key的旧值,->旧值判断,若已过时,就可以获取锁
//当key没有旧值时,即key不存在时,返回nil -> 获取锁
//再次用当前时间戳 getSet
//这里设置了一个新的 value值 getSetResult,获取旧的值
String getSetResult = RedisShardedPoolUtil.getSet(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, String.valueOf(System.currentTimeMillis() + lockTimeout));//key的旧值
if (getSetResult == null || (getSetResult != null && StringUtils.equals(lockValueStr, getSetResult))) {
//若key没有旧值,或存在且前后一致,获取锁
closeOrder(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
} else {
log.info("没有获取到分布式锁:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
}
} else {
log.info("没有获取到分布式锁:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
}
}
log.info("关闭订单定时任务结束");
}
/**
* 关闭订单,解锁
* @param lockName :redis中存储的锁
*/
private void closeOrder(String lockName) {
RedisShardedPoolUtil.expire(lockName, 50); //有效期 50s,防止死锁
log.info("获取{}, ThreadName:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
int hour = Integer.parseInt(PropertiesUtil.getProperty("close.order.task.time.hour"));
iOrderService.closeOrder(hour);
//解锁
RedisShardedPoolUtil.del(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
log.info("获取{}, ThreadName:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
log.info("============================");
}
这是无业务代码的简化理解版:
public static boolean getLock(Jedis jedis, String lockKey, int timeout) {
long expireTime = System.currentTimeMillis() + timeout;
String expiresStr = String.valueOf(expireTime);
// 如果当前锁不存在,加锁成功
if (jedis.setnx(lockKey, expiresStr) == 1) {
return true;
}
// 如果锁存在,获取锁的过期时间
String currentValueStr = jedis.get(lockKey);
if (currentValueStr != null && System.currentTimeMillis() > Long.parseLong(currentValueStr)) {
// 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
String oldValueStr = jedis.getSet(lockKey, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
return true;
}
}
// 其他情况,一律返回加锁失败
return false;
}
问题分析
这里会出现什么问题呢?
- 首先可以很清楚地看出,这种实现方式麻烦,比较难懂,出错概率不小;
- 如果有多个进程同时进行getset操作,可能会出现A设置的value值覆盖B的value值的情况,此时A和B同时进行了getset,尽管最终获得锁的只有一个进程,假设为B,但是如果A的getset是在B之后的话,那么就会覆盖掉B的value值。即:锁是B的,业务是B在处理,但是过期时间是A的;
但是放在这里还是OK的,因为A、B的过期时间相差几乎没有,既然都是基于当前时间+timeout,两条指令先后处理,相差时间是微秒级的; - 可以看到代码中是有给锁设置有效时间的,而且既在redis中设置了ttl,还将过期时间设为value值。但是还有一种情况:如果锁已过期失效,但进程任务仍然没有完成,此时有另一个进程获取锁,那么就有两个进程在同时执行此任务,违背了分布式锁的规则;
要设置一个合理的时间要考虑到执行时间问题、阻塞的问题,同时要兼顾效率。在我这个情境下执行时间是可以预测,因此这个问题可以不用考虑; - 对时间同步性的要求高。锁过期时间是由进程自己生成的,那么多机部署的时候就要求时间是同步的,否则会出现某个锁时间过长/过短的情况。
- setnx和expire是两步操作,不具有原子性。在并发中原子性是是十分重要的一个属性。
对初始版本的分析就完了,总结起来就是仅仅可以实现当前这种情景,功能简易实现麻烦…是否可以有一种完美的,适合所有情况的分布式锁呢?
第二版
原子性命令:
set key value [EX seconds] [PX milliseconds] [NX|XX]
其实前面逻辑判断那么多就为了无冲突地set,jedis自带了一个多参数set:jedis.set(String key, String value, String nxxx, String expx, long time)
。还是原子性的。
这几个参数的意思是:
- key,键,使用唯一的key来实现唯一的锁
- value,设置有效时间
- nxxx,可以传NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作
- expx,可以传PX,意思是给这个key加ttl,具体时间由time决定
- time,代表key的过期时间
第三版
Redisson框架也可以十分简单的实现分布式锁。代码如下:
@Scheduled(cron = "0 */1 * * * ?")//每1分钟执行一次(每个1分钟的整数倍)
public void closeOrderTask() {
RLock lock = redissonManager.getRedisson().getLock(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
boolean getLock = false; //是否获取锁
try {
//tryLock参数:等待时间,锁释放时间,时间单位
if (getLock = lock.tryLock(0, 5, TimeUnit.SECONDS)) {
log.info("Redisson获取分布式锁:{}, ThreadName:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
int hour = Integer.parseInt(PropertiesUtil.getProperty("close.order.task.time.hour"));
//执行关单业务
// iOrderService.closeOrder(hour);
} else {
log.info("Redisson没有获取到分布式锁:{}, ThreadName:{}", Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK, Thread.currentThread().getName());
}
} catch (InterruptedException e) {
log.error("Redisson分布式锁获取异常");
} finally{
if (!getLock) {
//如果未获得锁
return;
}
//如果获得了锁,将其释放
lock.unlock(); //释放锁
log.info("Redisson分布式锁释放");
}
}
有几个需要注意的点:
- 这里redis中存入的锁是hash类型;
- 注意要在finally中释放锁。
总结
目前来说最好的redis分布式方案是Redis作者antirez 的RedLock,这种方案也只是做到了更好,远远没有到完美的程度。