使用背景
在一个新开发的项目中我需要写一个定时器去触发一个定时任务,但是考虑到项目是集群部署,在某个时间点所有定时器都会触发,但只能有一个去执行这个定时任务,这就需要用到锁去实现。
目前分布式锁一般有三种实现方式:1. 数据库乐观锁;2. 基于Redis的分布式锁;3. 基于ZooKeeper的分布式锁。因为项目中本身就有一个Redis数据库,所以我就基于Redis去实现分布式锁。
锁应该是怎样的
- 1.互斥
- 2.避免死锁
- 3.容错性(只要大部分Redis节点正常运行就能加锁解锁)
- 4.获取锁和释放锁(释放锁分为自动释放和主动释放)
先说下思路吧
首先肯定是要互斥,不能每个服务器都能去执行这个任务,所以我们要有唯一的key存入Redis(存入时不能有覆盖),同时键的值存入一个随机字符串作为口令串(token),这个token解锁的时候会用到。我是通过设置key 的生存时间Expire_Time 避免死锁,这个生存时间不能太短,要足够服务器去执行完任务,但也不能太长,key必须在下次执行任务前过期。在服务器执行完任务之后,必须由执行任务的服务器去主动释放锁,所以我没有直接使用del 去删除key,而是发送一个 Lua 脚本,这个脚本只在服务器传入的值和键的口令串相匹配时,才对键进行删除。至于容错性,除非有人拉闸,不会有问题。
Java代码
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.scheduling.annotation.Scheduled;
import redis.clients.jedis.Jedis;
import java.util.*;
public class RedisLock {
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 = "EX";
private static final int Expire_Time = 60 * 5;
/**
* 每天凌晨1点执行
*/
@Scheduled(cron = "0 0 1 * * ?")
public void scheduleTask(){
String lockKey = "REDIS_LOCK";
//不使用固定的字符串作为键的值,而是设置一个不可猜测(non-guessable)的长随机字符串,作为口令串(token)
String token = getRandomString(8);
boolean isGetLock = getLock(lockKey, token, Expire_Time);
if (isGetLock){
System.out.println("=======获取到锁 开始执行=======");
//TODO
//业务完成之后释放锁
releaseLock(lockKey, token);
}else {
System.out.println("=======没有获取到锁=======");
}
System.out.println("=======定时任务结束=======");
}
/**
* 获取锁
* @param lockKey
* @param token
* @param expireTime
* @return
*/
private static boolean getLock(String lockKey, String token, int expireTime){
String result = "";
Jedis jedis = getJedis();
try {
/**执行这个命令
* 如果返回 OK ,那么这个线程获得锁
* 如果返回 NIL ,那么线程获取锁失败,可以在稍后再重试
* 设置的过期时间到达之后,锁将自动释放
*/
result = jedis.set(lockKey, token, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
}catch (Exception e) {
e.printStackTrace();
} finally {
returnResource(jedis);
}
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
/**
* 释放锁
* @param lockKey
* @param token
* @return
*/
private static boolean releaseLock(String lockKey, String token) {
Jedis jedis = getJedis();
Object result = 0L;
try {
/**
* 不使用DEL命令来释放锁,而是发送一个 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(token));
}catch (Exception e) {
e.printStackTrace();
} finally {
returnResource(jedis);
}
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
/**
* 随机字符串
* @param length
* @return
*/
public static String getRandomString(int length) {
return RandomStringUtils.random(length,"0987654321abcdef");
}
//我是用jedis操作redis数据库,jedisPool需要自己配置,下列代码只是列出了上面调用的方法
//不一定要用jedis,可以根据自己的需求进行一定的更改
/**
* 释放jedis资源
* @param jedis
*/
public static void returnResource(final Jedis jedis) {
if (jedis != null) {
jedis.close();
//jedisPool.returnResource(jedis);
}
}
/**
* 获取Jedis实例
* @return
*/
public static Jedis getJedis() {
try {
if (jedisPool != null) {
Jedis resource = jedisPool.getResource();
return resource;
} else {
return null;
}
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}
总结
因为我是用定时器去实现查数据然后发邮件通知的功能,不可能所有集群服务器都发一遍邮件,所以每天执行一次就好了。
这个代码还有不完善的地方,比如我选择性无视了获取到锁的服务器在执行完任务之前就挂掉的可能,因为在我这个业务场景中服务器挂掉的概率非常小,而且即便是当天定时任务没有执行也并不会影响到其他业务操作,所以我在服务器没有获取到锁之后就把定时任务结束了。但是在某些场景中获取到锁的服务器突然挂掉,需要其他服务器代替挂掉的服务器去执行任务,这种情况下我建议使用ZooKeeper。
当然,Redis实现分布式锁还是很可靠的,服务器突然挂掉的概率微乎其微,即便服务器突然挂掉也不会产生死锁,但是要把握好lockKey 的过期时间设置,在下一次执行前让这个lockKey 失效。