使用redis实现分布式锁,相对于使用数据库锁或者使用ZooKeeper,简单方便,相对可靠,是最常用的方式,本文上一个实现demo。
在写代码之前,先抛出几个常见问题,带着问题去实现代码,逻辑更清晰完整。
redis实现分布式锁,几个常见经典问题:
问题一:锁不被释放
就是说一个服务在获取到分布式锁后,在释放锁之前,由于某种原因比如服务挂掉了,导致锁一直不会被释放,那么其他服务自然也就再也拿不到锁了。针对这个问题。解决办法一般都是加锁时,同步设置锁的过期时间。
问题二:服务A释放了服务B的锁,导致问题
比如,服务A在拿到锁之后,设置过期时间1s,但是服务A由于自身某种原因,业务执行了2s才结束;那么,在锁过期后,1.5s的时候,服务B正好来拿锁,并且拿到了,然后执行B的业务1s,那么B业务还没执行结束,A结束了,然后去释放锁,这个时候释放的就是B拿到的锁。
为了避免这个问题,需要为每个服务拿锁的请求进行标记,避免分不清锁是谁的。释放锁的时候,判断此刻redis中的锁是不是自己档时获取到的。
问题三:释放锁过程要保证原子性
针对问题二,说到释放锁的时候,要进行判断是不是自己的锁,这个判断+释放的过程,必须是原子性的,否则同样会产生释放别人锁的问题。
比如,服务A解锁时刚判断锁是自己的,于是下一步就是释放锁,结果释放锁之前,锁正好过期,并且服务B刚好申请到了此锁,那么服务A接下来释放的锁,必然是服务B的。
问题四:多个服务同时获取到了锁
业务中,分布式锁的目的肯定是只希望同时只有一个服务拿到锁,不能多个服务同时拿到锁,不然就失去了锁的意义。
但是,有一种场景,比如A服务拿到了锁,由于A业务执行时间过长,在解锁之前锁早已经被释放,同时又被服务B获取到,这样实际上就是服务A和服务B都获取到了锁并且在执行业务逻辑,这是有问题的。
我们可能会想到,把锁的过期时间设置的足够长,比如1min,保证不少于服务A的业务执行时间,这样的确可以,但是这样又产生了别的问题,比如服务A挂掉了,那么其他服务就需要等1min的时间才能拿到锁,这个等待时间未免太久;
那么,过期时间到底设置多久呢,这个不好设定,只能说设置为服务A业务大多数执行的时长,比如服务A的业务大多数执行时间是200ms,那么就设置为1s,这个应该足够了,但是万一服务A某次业务由于特殊原因,执行了2s呢,还是会有上述问题。
那么,我们会想,既然服务执行时间不是那么稳定,这个锁的过期时间是否能根据业务执行时间动态变化呢?答案是肯定的,本问Demo中,我们使用守护线程来动态延长锁的过期时间。
问题五:redis服务宕机,如何保证锁正常使用
此问题是针对单机版的redis做分布式锁,如果此单机redis服务挂掉,那么redis锁将会不可用。解决方式是使用redis集群,但是,在集群环境下,我们的分布式锁的加锁策略是怎样的呢?
原理是对redis集群的每个节点都加锁,然后判断超过半数的节点返回true,表示加锁成功。这里推荐使用Redisson框架,它实现的RedLock就是解决这种场景的。
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();
Redissonh实现了可重入锁,公平锁等各种java中定义的锁类型,可以解决上述的5个问题,相关资料可参考官方文档:https://github.com/redisson/redisson/wiki/目录
Demo:
以上前4个问题,在本文Demo中都有解决,并添加了注释,下面看代码。
先上主线程:
public class RedisLockDemo {
//随便弄个key的名字
private static final String LOCK_KEY = "distributedLock:key";
//主线程
public static void main(String[] args) {
//获取redis客户端
RedisClient redisClient = RedisClient.getInstance();
//开启两个工作线程,模拟分布式服务中的两个服务
for (int i = 0; i < 1; i++) {
startAWork(redisClient, String.valueOf(i), 10);
}
}
/**
* 开启一个工作线程,模拟分布式中的一个服务,抢分布式锁
*
* @param redisClient redis客户端
* @param threadName 线程名称
* @param lengthOfWork 工作时长 秒
*/
public static void startAWork(RedisClient redisClient, String threadName, int lengthOfWork) {
new Thread(() -> {
try {
//生成并保存 获取分布式锁的 请求id,解决问题二
String requestId = UUID.randomUUID().toString();
RedisLockThreadLocalContext.getThreadLocal().set(requestId);
//获取分布式锁,设置过期时间2s,解决问题一
boolean result = RedisTool.tryGetDistributedLock(redisClient.getJedis(), LOCK_KEY, requestId, 2000);
if (result) {//如果成功获取到锁
//开一个守护线程延长锁的过期时间
Thread thread = new Thread(() -> {
while (true) {
Jedis jedis = redisClient.getJedis();
try {
TimeUnit.SECONDS.sleep(1);
System.out.println("守护线程延长锁的过期时间1s");
jedis.setex(LOCK_KEY, 1, requestId);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
}
});
thread.setDaemon(true);
thread.start();
System.out.println("线程" + threadName + "拿到锁,干点事情");
//睡眠一定时间,模拟业务耗时
TimeUnit.SECONDS.sleep(lengthOfWork);
} else {
System.out.println("线程" + threadName + "没有拿到锁");
}
} catch (Exception e) {
//
} finally {
//释放分布式锁
String requestId = RedisLockThreadLocalContext.getThreadLocal().get();
boolean result = RedisTool.releaseDistributedLock(redisClient.getJedis(), LOCK_KEY, requestId);
if (result) {
System.out.println("线程" + threadName + "释放锁");
} else {
System.out.println("线程" + threadName + "释放锁失败");
}
}
System.out.println("线程" + threadName + "结束");
}).start();
}
}
主线程说明:
- 主线程比较简单,只开启了两个工作线程,模拟抢分布式锁的过程;
- 具体的startAWork()方法中,新建了工作线程,使用睡眠时间来模拟执行业务逻辑的耗时;
- 在 RedisTool#tryGetDistributedLock()方法中,传入了过期时间参数,方法内容看下问代码。这个参数解决了问题一;
- 在 RedisTool#tryGetDistributedLock()方法中,传入了requestId参数,这个是一个随机UUID,用来标识每一次加锁的线程,同时这个参数保存在了线程本地变量ThreadLocal中,解决了问题二。
- 在开启工作线程后,代码中紧接着又开启另外一个线程,并使用thread.setDaemon(true);标识为守护线程;这个守护线程的任务就是死循环延长锁的过期时间;当业务线程执行完毕后,这个守护线程会自动销毁。注意循环的时间间隔要小于锁的过期时间,一般设置为过期时间的一半即可。
其他辅助类:
添加jedis依赖包:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
使用JedisPool初始化一个Jedis客户端:
/**
* Description:Redis客户端
*/
public class RedisClient {
private static final Logger LOGGER = LoggerFactory.getLogger(RedisClient.class);
private static RedisClient instance = new RedisClient();
private JedisPool pool;
private RedisClient() {
init();
}
public static RedisClient getInstance() {
return instance;
}
public Jedis getJedis() {
return pool.getResource();
}
/**
* 初始化redis连接池
*/
private void init() {
int maxTotal = 10;
String ip = "redis IP";
String pwd = "redis 密码";
int port = 6379;
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(maxTotal);
jedisPoolConfig.setMaxIdle(20);
jedisPoolConfig.setMaxWaitMillis(6000);
pool = new JedisPool(jedisPoolConfig, ip, port, 5000, pwd);
LOGGER.info("连接池初始化成功 ip={}, port={}, maxTotal={}", ip, port, maxTotal);
}
}
上述代码初始化了redis连接信息,属于固定代码,没啥好解释的,继续往下看代码。
/**
* Description:redis分布式锁访问工具类,提供具体的获取锁,释放锁方法
*/
public class RedisTool {
private static final String LOCK_SUCCESS = "OK";
private static final Long RELEASE_SUCCESS = 1L;
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
* 尝试获取分布式锁
*
* @param jedis Redis客户端
* @param lockKey 锁的key
* @param requestId 锁的Value,值是个唯一标识,用来标记加锁的线程请求;可以使用UUID.randomUUID().toString()方法生成
* @param expireTime 过期时间 ms
* @return 是否获取成功,成功返回true,否则false
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = null;
try {
result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
return LOCK_SUCCESS.equals(result);
}
/**
* 释放分布式锁
*
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识,锁的Value
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
Object result = null;
try {
//使用lua脚本保证原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
return RELEASE_SUCCESS.equals(result);
}
}
- RedisTool工具类,提供了加锁和解锁的两个方法;
- tryGetDistributedLock()加锁方法设置了过期时间,解决了问题一;
- releaseDistributedLock()解锁方法中使用了lua脚本,具备原子性,解锁时先判断key的value值,也就是当初加锁保存的requestId是不是和自己线程保存的一致,一致才说明是自己当初加的锁,方可进行解锁;不一致说明自己加锁已经自动过期,无需解锁;这个解决了问题二和问题三。
/**
* Description:保存redis分布式锁的请求id
*/
public class RedisLockThreadLocalContext {
private static ThreadLocal<String> threadLocal = new NamedThreadLocal<>("REDIS-LOCK-LOCAL-CONTEXT");
public static ThreadLocal<String> getThreadLocal() {
return threadLocal;
}
}
上述RedisLockThreadLocalContext中创建了一个threadLocal单例,用于保存加锁时设置的requestId。当然在使用线程池时,get完数据要注意清除里面的保存信息,这里就不写那么详细了。
以上就是本文全部内容,特别要注意本文开头的那几个问题。