分布式锁的使用场景
单体架构的应用可以直接使用本地锁(Synchronized)就可以解决多线程资源竞争的问题。如果公司业务发展较快,可以通过部署多个服务节点来提高系统的并行处理能力。由于本地锁的作用范围只限于当前应用的线程。高并发场景下,集群中某个应用的本地锁并不会对其它应用的资源访问产生互斥,就会产生数据不一致的问题,所以分布锁就派上了用场
常见的分布式锁应用场景:秒杀活动、优惠券抢购、接口幂等性校验等
分布式锁的三种实现方案
1、数据库乐观锁
2、基于Redis的分布式锁
3、基于Zookeeper的分布式锁
本篇博客主要介绍基于Redis实现的分布式锁
分布式锁需要满足的条件
1、互斥性
任何时候锁资源只能被一个线程持有
2、死锁
获取锁的客户端由于某些原因未能释放锁,也要保证其他客户端可以获取到锁
3、可重入
同一线程已经获得某个锁,可以再次获取锁而不会出现死锁
4、安全性
锁只能被持有该锁的客户端删除,不能由其它客户端删除
基于Redis手写的分布式锁
在高并发场景下,应用程序在执行过程中往往会受到网络、CPU、内存等因素的影响,所以实现一个线程安全的分布式组件,往往需要考虑很多case,下面我们通过手写的方式来探索一个完美的分布锁方案的复杂性
使用setnx()和UUID
/**
* 获取锁
*/
public static boolean getLock(Jedis jedis, String key, int time) {
Long flag = jedis.setnx(key, "object");
if (flag == 1) {
//如果程序突然宕机,则无法设置过期时间,将发生死锁
jedis.expire(key, time);
return true;
}
return false;
}
/**
* 释放锁
*/
public static void releaseLock(Jedis jedis) {
jedis.del("object");
}
上面的代码存在以下2个问题:
1、由于这是两条Redis命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间,那么锁资源将永远不会被释放
2、jedis.del(“object”)会导致操作超时的线程继续运行时解锁其它正在执行的线程锁资源,可以通过给value赋值为requestId,我们就知道这把锁是哪个请求加的,在解锁的时候就可以区分了
requestId可以使用UUID.randomUUID().toString()方法生成,修改代码如下:
/**
* @param reqId 可用使用UUID生成
*/
public static boolean getLock(Jedis jedis, String reqId, String key, int time) {
String result = jedis.set(key, reqId, "NX", "EX", time);
// set操作成功时返回 OK
if ("OK".equals(result)) {
return true;
}
return false;
}
/**
* 释放锁
*/
public static void releaseLock(Jedis jedis, String key, String reqId) {
if(reqId.equals(jedis.get(key))) {
// 若在此时,这把锁突然不是当前线程的,则会误解锁
jedis.del(reqId);
}
}
public static void main(String[] args) {
Jedis jedis = new Jedis();
String reqId = UUID.randomUUID().toString();
try {
if (getLock(jedis, reqId, "key", 10)) {
// 数据库操作
}
} finally {
releaseLock(jedis, "key", reqId);
}
}
解锁好像还是有问题!
如果调用jedis.del()方法的时候,这把锁已经不属于当前线程的时候会解除其它线程加的锁。
那么是否真的有这种场景?
答案是肯定的,比如线程A在执行jedis.del()之前,锁突然过期了,此时线程B尝试加锁成功,然后线程A再执行del()方法,则将线程B的锁给删除了! 原因是删除操作分两条命令去执行的,考虑使用lua脚本原子操作来解决。关于lua脚本的相关知识可以查看其它博文
修改如下:
public static boolean releaseLock(Jedis jedis, String reqId, String 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(key), Collections.singletonList(reqId));
if ("1".equals(result)) {
return true;
}
return false;
}
使用setnx()和过期时间
执行过程如下:
通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功。如果锁已经存在(其它线程获取成功)则获取锁的过期时间和当前时间比较。如果锁已过期,则设置新的过期时间,返回加锁成功。
public static boolean getLock(Jedis jedis, String key, int time) {
// 过期时间
long expires = System.currentTimeMillis() + time;
String expiresValue = String.valueOf(expires);
String result = jedis.set(key, expiresValue, "NX", "EX", time);
// 加锁成功时返回 OK
if ("OK".equals(result)) {
return true;
}
// 锁存在,获取锁的过期时间
String currentValue = jedis.get(key);
if (currentValue != null && Long.parseLong(currentValue) < System.currentTimeMillis()) {
// 锁已过期,获取上一个锁的过期时间,并设置当前锁的过期时间
String oldValue = jedis.getSet(key, expiresValue);
if (oldValue != null && oldValue.equals(currentValue)) {
// 多线程并发的情况,只有一个线程的设置值和当前值相同时,加锁成功
return true;
}
}
return false;
}
以上方案有如下问题:
1、System.currentTimeMillis()是不同的客户端服务器的系统时间,分布式环境下每个客户端的时间必须同步。
2、锁过期时,如果多个线程同时执行jedis.getSet()方法,虽然只有一个线程可以加锁成功,但这个线程的锁过期时间可能被其他线程所覆盖
3、因为客户端系统时间不同步,根据系统时间删除锁时会误删其它系统的锁
手写分布式锁方案总结
手写方案看似好像很完美,遗憾的是,还是有问题!!!
1、不满足可重入性
2、A线程获锁成功,只是由于一些客观因素(如:数据库I/O偶尔过慢)操作延时,导致key失效,但是业务代码还会是继续往下执行。因为key失效的原因,其它线程可以继续获取到锁,从某种意义上讲还是没有达到从根本上解决资源互斥的效果!
有什么解决方案吗?有,超时时间续期!可以通过定时任务延长失效时间(参考Redisson的Watch Dog),但是实现起来有点麻烦。另外,可重入性问题也可以得到解决,比如:先将获锁成功的线程信息暂存起来,再次请求锁资源时判断一下即可。
(完)
下篇文章我们将介绍Redisson的分布式锁实现原理。