其實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+"==";
}
}