其实Redis分布式锁的介绍,前面几篇文章中都要介绍到,只是没有独立成篇,今天把其单独摘出来,便于学习和使用。
1、概述
当多个进程不在同一个系统中时,用分布式锁控制多个进程对资源的操作或者访问。
分布式锁的实现要保证几个基本点:
- 1、互斥性:任意时刻,只有一个资源能够获取到锁
- 2、容灾性:能够在未成功释放锁的情况下,一定时限内能够恢复锁的正常功能
- 3、统一性:加锁和解锁保证同一资源来进行操作
分布式锁的实现方式有很多种:
- 1、数据库乐观锁方式(数据库加一个版本号)
- 2、基于Redis的分布式锁
- 3、基于ZK的分布式锁(Zookeeper基础(五):分布式锁)
2、Redis单机实现
2.1 原理
Redisson底层原理简单描述:
先判断一个key存在不存在,如果不存在,则set key,同时设置过期时间和value(1),
这个过程使用lua脚本来实现,可以保证多个命令的原子性,当业务完成以后,删除key;
如果存在说明已经有别的线程获取锁了,那么就循环等待一段时间后再去获取锁
如果是可重入锁呢:
先判断一个key存在不存在,如果不存在,则set key,同时设置过期时间和value(线程id:1),
如果存在,则判断value中的线程id是否是当前线程的id,如果是,说明是可重入锁,则value+1,变成(线程id:2),如果不是,说明是别的线程来获取锁,则获取失败;这个过程同样使用lua脚本一次性提交,保证原子性。
如何防止业务还没执行完,但是锁key过期呢,可以在线程加锁成功后,启动一个后台进程看门狗,去定时检查,如果线程还持有锁,就延长key的生存时间——Redisson就是这样实现的。
其实Jedis也有现成的实现方式,单机、集群、分片都有实现,底层原理是利用连用setnx、setex指令
(Redis从2.6之后支持setnx、setex连用),核心是设置value和设置过期时间包装成一个原子操作
jedis.set(key, value, "NX", "PX", expire)
注:setnx和setex都是原子性的
SETNX key value:
将 key 的值设为 value ,当且仅当 key 不存在;若给定的 key 已经存在,则 SETNX 不做任何动作。
相当于是 EXISTS 、SET 两个命令连用
SETEX key seconds value:
将value关联到key, 并将key的生存时间设为seconds(以秒为单位);如果key 已经存在,SETEX将重写旧值;
相当于是SET、EXPIRE两个命令连用
2.1 实现
public class RedisTool {
private static final String LOCK_SUCCESS = "OK";
//NX|XX, NX -- Only set the key if it does not already exist;
// XX -- Only set the key if it already exist.
private static final String SET_IF_NOT_EXIST = "NX";
//EX|PX, expire time units: EX = seconds; PX = milliseconds
private static final String SET_WITH_EXPIRE_TIME = "PX";
private static volatile JedisPool jedisPool = null;
public static JedisPool getRedisPoolUtil() {
if(null == jedisPool ){
synchronized (RedisTool.class){
if(null == jedisPool){
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
poolConfig.setMaxTotal(100);
poolConfig.setMaxIdle(10);
poolConfig.setMaxWaitMillis(100*1000);
poolConfig.setTestOnBorrow(true);
jedisPool = new JedisPool(poolConfig,"192.168.10.151",6379);
}
}
}
return jedisPool;
}
/**
* 尝试获取分布式锁
* @param lockKey 锁
* @param requestId 请求标识
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(String lockKey, String requestId, int expireTime) {
Jedis jedis = jedisPool.getResource();
try {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}catch (Exception e){
return false;
}finally {
jedisPool.returnResource(jedis);
}
}
private static final Long RELEASE_SUCCESS = 1L;
/**
* 释放分布式锁
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
//如果使用的是切片shardedJedis,那么需要先获取到jedis,
//Jedis jedis = shardedJedis.getShard(key);
Jedis jedis = jedisPool.getResource();
try {
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}catch (Exception e){
return false;
}finally {
jedisPool.returnResource(jedis);
}
}
}
从jedis源码中可以发现上面的加锁/释放锁指令在单机jedis/ShardedJedis/JedisCluster下都能实现(jedis版本为3.0以上),但是ShardedJedis可以直接加锁,但是不能直接释放锁(没有提供eval工具方法),但是可以先
Jedis jedis = shardedJedis.getShard(key) 获得jedis,然后使用jedis.evel()来释放锁。
注:关于redisTool工具类的更优化实现见Java 函数式接口编程实例
3 、Cluster集群实现
上面介绍的分布式锁的实现在Redis Cluster集群模式下,是存在问题的,Redis Cluster集群模式介绍见Redis(四):集群模式
整个过程如下:
- 客户端1在Redis的节点A上拿到了锁;
- 节点A宕机后,客户端2发起获取锁key的请求,这时请求就会落在节点B上;
- 节点B由于之前并没有存储锁key,所以客户端2也可以成功获取锁,即客户端1和客户端2同时持有了同一个资源的锁。
针对这个问题。Redis作者antirez提出了RedLock算法来解决这个问题
3.1 RedLock算法
RedLock算法思路如下:
获取当前时间的毫秒数startTime;
按顺序依次向N个Redis节点执行获取锁的操作,这个获取锁的操作和前面单Redis节点获取锁的过程相同,同时锁超时时间应该远小于锁的过期时间;
如果客户端向某个Redis节点获取锁失败/超时后,应立即尝试下一个Redis节点;
失败包括Redis节点不可用或者该Redis节点上的锁已经被其他客户端持有如果客户端成功获取到超过半数的锁时,记录当前时间endTime,同时计算整个获取锁过程的总耗时costTime = endTime - startTime,如果获取锁总共消耗的时间远小于锁的过期时间(即costTime < expireTime),则认为客户端获取锁成功,否则,认为获取锁失败
如果获取锁成功,需要重新计算锁的过期时间。它等于最初锁的有效时间减去第三步计算出来获取锁消耗的时间,即expireTime - costTime
如果最终获取锁失败,那么客户端立即向所有Redis发起释放锁的操作。(和单机释放锁的逻辑一样)
3.2 缺陷
RedLock算法虽然可以解决单点Redis分布式锁的安全性问题,但如果集群中有节点发生崩溃重启,还是会对锁的安全性有影响的。
假设一共有5个Redis节点:A, B, C, D, E。设想发生了如下的事件序列:
- 客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住);
- 节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了;
- 节点C重启后,客户端2锁住了C, D, E,获取锁成功;
这样,客户端1和客户端2同时获得了锁(针对同一资源)。针对这样场景,解决方式也很简单,也就是让Redis崩溃后延迟重启,并且这个延迟时间大于锁的过期时间就好。这样等节点重启后,所有节点上的锁都已经失效了。也不存在以上出现2个客户端获取同一个资源的情况了
还有一种情况,如果客户端1获取锁后,访问共享资源操作执行任务时间过长(要么逻辑问题,要么发生了GC),导致锁过期了,而后续客户端2获取锁成功了,这样就会导致客户端1和客户端2同时操作共享资源,相当于同一个时刻出现了2个客户端获得了锁的情况。这也就是上面锁过期时间要远远大于加锁消耗的时间的原因。
服务器台数越多,出现不可预期的情况也越多,所以针对分布式锁的应用的时候需要多测试。
如果系统对共享资源有非常严格要求得情况下,还是建议需要做数据库锁的方案来补充,如飞机票或火车票座位得情况。
对于一些抢购获取,针对偶尔出现超卖,后续可以通过人工介入来处理,毕竟redis节点不是天天奔溃,同时数据库锁的方案
性能又低。
3.3 实现
redisson包已经有对redlock算法封装
public interface DistributedLock {
/**
* 获取锁
* @author zhi.li
* @return 锁标识
*/
String acquire();
/**
* 释放锁
* @author zhi.li
* @param indentifier
* @return
*/
boolean release(String indentifier);
}
public class RedisDistributedRedLock implements DistributedLock {
/**
* redis 客户端
*/
private RedissonClient redissonClient;
/**
* 分布式锁的键值
*/
private String lockKey;
private RLock redLock;
/**
* 锁的有效时间 10s
*/
int expireTime = 10 * 1000;
/**
* 获取锁的超时时间
*/
int acquireTimeout = 500;
public RedisDistributedRedLock(RedissonClient redissonClient, String lockKey) {
this.redissonClient = redissonClient;
this.lockKey = lockKey;
}
@Override
public String acquire() {
redLock = redissonClient.getLock(lockKey);
boolean isLock;
try{
isLock = redLock.tryLock(acquireTimeout, expireTime, TimeUnit.MILLISECONDS);
if(isLock){
System.out.println(Thread.currentThread().getName() + " " + lockKey + "获得了锁");
return null;
}
}catch (Exception e){
e.printStackTrace();
}
return null;
}
@Override
public boolean release(String indentifier) {
if(null != redLock){
redLock.unlock();
return true;
}
return false;
}
}
4、项目中调用
RedisTool 中加锁/释放锁实现后,在项目中怎么调用呢,如果直接在业务代码中调用,那一方面太麻烦了,另一方面耦合太多,如果有一天需要改动其中的逻辑,那在项目中需要改动很多地方。
这里我们使用AOP+注解来实现调用,即在需要加锁的方法上添加注解,然后再AOP中,统一加锁,释放锁。
4.1 自定义注解
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLockAnnotation {
int expire() default 5;
String field() default "";
}
4.2 自定义切面
@Aspect
@Service
public class RedisLockAspect {
//方法切点
@Pointcut("@annotation(redisLock.RedisLockAnnotation)")
public void methodAspect() {
}
@Around("methodAspect()")
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
Signature signature = joinPoint.getSignature();
Method method = ((MethodSignature) signature).getMethod();
Method realMethod = joinPoint.getTarget().getClass().getDeclaredMethod(signature.getName(),method.getParameterTypes());
RedisLockAnnotation redisLockAnnotation = realMethod.getAnnotation(RedisLockAnnotation.class);
int expireTime = redisLockAnnotation.expire();
String field = redisLockAnnotation.field();
Map<String, Object> params = getNameAndValue(joinPoint, field);
if (params==null){
throw new RuntimeException("params is not allowed null");
}
String url = method.getDeclaringClass().getSimpleName() + "." + method.getName();
String reqParam = JSONObject.toJSONString(params);
//redis加锁
String localKey = url + ":" + reqParam;
String requestFlag = UUID.randomUUID().toString();
boolean lock = RedisTool.tryGetDistributedLock(localKey, requestFlag, expireTime);
if(!lock){
return "锁已存在";
}
//加锁成功
Object result = null;
try {
//执行方法
result =joinPoint.proceed();
} finally {
//方法执行完之后进行解锁
RedisTool.releaseDistributedLock(localKey, requestFlag);
}
return result;
}
/**
* 获取参数Map集合
*/
private Map<String, Object> getNameAndValue(ProceedingJoinPoint joinPoint, String filedList) {
Map<String, Object> param = new HashMap<String, Object>();
Object[] paramValues = joinPoint.getArgs();
String[] paramNames = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
for (int i = 0; i < paramValues.length; i++) {
List<String> targetFields = Arrays.asList(filedList.split(","));
JSONObject valueDetialsJson = (JSONObject) JSONObject.toJSON(paramValues[i]);
//得到属性
for (int j = 0; j < targetFields.size(); j++) {
if (valueDetialsJson.get(targetFields.get(i))!=null){
param.put(targetFields.get(i), valueDetialsJson.get(targetFields.get(i)));
}
}
}
if (param != null && param.size() > 0) {
return param;
}
for (int i = 0; i < paramNames.length; i++) {
param.put(paramNames[i], paramValues[i]);
}
return param;
}
}
4.3 使用
public class RedidLockTest1 {
@RedisLockAnnotation(field = "userId")
public Object test1(String userId){
return userId+"==";
}
}