簡述
利用Redis的Setnx命令,來實現一個分布式的加鎖方案。利用注解,在擁有該注解的方法上,進行切面處理,在方法執行前,進行加鎖,執行結束后,根據是否自動釋放鎖,進行解鎖。
將該注解用在定時任務的方法上,即可實現分布式定時任務,即獲取到鎖的方法,才會執行。
1 redis命令
- 1.1 setnx命令
Redis setnx(SET if Not eXists) 命令在指定的 key 不存在時,為 key 設置指定的值。(該命令無法設置過期時間)
Redis為單進程單線程模式,采用隊列模式將并發訪問變成串行訪問,且多客戶端對Redis的連接并不存在競爭關系。redis的SETNX命令可以方便的實現分布式鎖。
當某一個客戶端將key的值設置成功后,其他的客戶端再進行設置,將返回失敗,保證同一時間,只有一個客戶端能夠設置成功。 - Redis事務
watch key1 key2 ... : 監視一或多個key,如果在事務執行之前,被監視的key被其他命令改動,則事務被打斷 ( 類似樂觀鎖 )
multi : 標記一個事務塊的開始( queued )
exec : 執行所有事務塊的命令 ( 一旦執行exec后,之前加的監控鎖都會被取消掉 )
discard : 取消事務,放棄事務塊中的所有命令
unwatch : 取消watch對所有key的監控
事務正常使用
127.0.0.1:6379> multi
127.0.0.1:6379> set name jack
127.0.0.1:6379> exec
取消事務
127.0.0.1:6379> multi
127.0.0.1:6379> set name jack
127.0.0.1:6379> discard
watch使用
number初始為10
127.0.0.1:6379> watch number
127.0.0.1:6379> multi
127.0.0.1:6379> set number 11
127.0.0.1:6379> exec
如果在執行exec時,number沒有被其他客戶端修改,還是10,則事務執行成功;
如果被其他客戶端修改了,number不是10了,則事務執行失敗,這時候就需求程序自行處理,進行再次提交或者其他操作
在spring boot 中,我們用StringRedisTemplate來操作Redis,它的方法:stringRedisTemplate.opsForValue().setIfAbsent()方法即對應setnx命令,這個方法有兩個重載的方法:
1、Boolean setIfAbsent(K key, V value); 設置key value,返回成功/失敗
2、Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit); 設置key value,返回成功/失敗,同時設置過期時間,redisTemplate 會調用 EXPIRE進行過期時間的設定,同時在設置值和過期時間時,會開啟事務,保存全部成功。
```// org.springframework.data.redis.core 中實現的方法
@Override
public Boolean setIfAbsent(K key, V value) {
byte[] rawKey = rawKey(key);
byte[] rawValue = rawValue(value);
return execute(connection -> connection.setNX(rawKey, rawValue), true);
}
@Override
public Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit) {
byte[] rawKey = rawKey(key);
byte[] rawValue = rawValue(value);
Expiration expiration = Expiration.from(timeout, unit);
return execute(connection -> connection.set(rawKey, rawValue, expiration, SetOption.ifAbsent()), true);
}
1.2 DEL命令、lua腳本
在加鎖之后,解鎖時,需要判斷鎖,是否是當前線程所擁有的,如果是當前線程擁有的,則刪除該key,刪除key,用del命令。
del key_name
我們會先取出key對應的值,然后判斷是否和當前線程的定義的值一致。如果一致,則說明是該線程擁有的key。如果我們在代碼中取出key的值,然后判斷通過后,調用redis del 刪除key,這就不是一個原子操作了。如果在我們取出key的值后,然后在刪除前,其他線程獲取了鎖,當前線程刪除的動作,就會導致刪除其他線程擁有的鎖。所以釋放鎖,需要利用lua腳本進行,將判斷和刪除,這兩個動作,合為一個原子性的操作。
所以我們會利用代碼去執行下面的lua腳本,保證判斷和刪除的原子性。
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
一般教程中,利用RedisTemplate來執行lua腳本時,會將lua腳本放到靜態資源目錄中。而在下面的代碼中,利用ByteArrayResource直接從String字符串中讀取了lua腳本內容:
/*
* 保存lua腳本
*/
private DefaultRedisScript<List> getRedisScript;
@PostConstruct
public void init(){
// 定義lua腳本資源
// 也可以放到文件中,加載進來: new ResourceScriptSource(new ClassPathResource("redis/demo.lua"))
String luaStr = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
ByteArrayResource resource = new ByteArrayResource(luaStr.getBytes());
getRedisScript = new DefaultRedisScript<>();
getRedisScript.setResultType(List.class);
getRedisScript.setScriptSource(new ResourceScriptSource(resource));
}
2 分布式鎖實現
下面是實現的核心類:
RedisLock: reids分布式鎖工具類
EmLock: 分布式鎖注解
LockRangeEnum: 分布式鎖的范圍枚舉
EmLockAspect: 分布式鎖切面
2.1 RedisLock,reids分布式鎖工具類
代碼如下:
package com.emdata.lowvis.common.redislock;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.concurrent.TimeUnit;
/**
* reids分布式鎖工具類
*
* @version 1.0
* @date 2020/12/8 14:37
*/
@Slf4j
@Component
public class RedisLock {
private static final String SPLIT = "_";
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 加鎖解鎖工具類
* @param lockKey 加鎖的key
* @param uuid 線程的標志
* @param timeout 超時時間
* @param timeUnit 超時時間粒度
* @return true:獲取成功
*/
public boolean lock(String lockKey, String uuid, long timeout, TimeUnit timeUnit) {
// 根據key獲取值
String currentLock = stringRedisTemplate.opsForValue().get(lockKey);
// 值為:uuid_時間
String value = uuid + SPLIT + (timeUnit.toMillis(timeout) + System.currentTimeMillis());
// 如果為空,則設置值
if (StringUtils.isEmpty(currentLock)) {
if (stringRedisTemplate.opsForValue().setIfAbsent(lockKey, value, timeout, timeUnit)) {
// 對應setnx命令,可以成功設置,也就是key不存在,獲得鎖成功
return true;
} else {
return false;
}
} else {
// 可重入鎖,如果是這個uuid持有的鎖,則更新時間
if (currentLock.startsWith(uuid)) {
stringRedisTemplate.opsForValue().set(lockKey, value, timeout, timeUnit);
return true;
} else {
return false;
}
}
}
/*
* 保存lua腳本
*/
private DefaultRedisScript<List> getRedisScript;
@PostConstruct
public void init(){
// 定義lua腳本資源
// 也可以放到文件中,加載進來: new ResourceScriptSource(new ClassPathResource("redis/demo.lua"))
String luaStr = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
ByteArrayResource resource = new ByteArrayResource(luaStr.getBytes());
getRedisScript = new DefaultRedisScript<>();
getRedisScript.setResultType(List.class);
getRedisScript.setScriptSource(new ResourceScriptSource(resource));
}
/**
* 釋放鎖
*
* @param lockKey 加鎖的key
* @param uuid 線程的標志
*/
public void release(String lockKey, String uuid) {
try {
List<Integer> execute = stringRedisTemplate.execute(getRedisScript, Collections.singletonList(lockKey), uuid);
log.debug("解鎖結果: {}", execute.get(0) == 0);
} catch (Exception e) {
log.error("解鎖異常, key: {}, uuid: {}", lockKey, uuid);
log.error("", e);
}
}
}
2.2 EmLock,分布式鎖注解
package com.emdata.lowvis.common.redislock;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* 分布式鎖注解
*
* @version 1.0
* @date 2020/12/8 17:59
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface EmLock {
/**
* 鎖的范圍,默認應用級別
* @return 鎖的范圍
*/
LockRangeEnum lockRange() default LockRangeEnum.APPLICATION;
/**
* 鎖對應的key
* @return key
*/
String key();
/**
* 鎖超時時間
* @return 時間
*/
int timeout() default 5;
/**
* 鎖超時時間粒度
* @return 粒度
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 是否自動釋放鎖
* @return true: 方法完成后,自動釋放
*/
boolean autoRelease() default true;
}
2.3 LockRangeEnum, 分布式鎖的范圍枚舉
package com.emdata.lowvis.common.redislock;
/**
* 分布式鎖的范圍枚舉
*
* @author pupengfei
* @version 1.0
* @date 2020/12/10 13:46
*/
public enum LockRangeEnum {
/**
* 應用級別,鎖的級別在整個應用容器內
*/
APPLICATION,
/**
* 線程級別,鎖的級別在每個線程
*/
THREAD
}
2.4 EmLockAspect,分布式鎖切面
package com.emdata.lowvis.common.redislock;
import com.emdata.lowvis.common.utils.UUIDUtils;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;
/**
* 分布式鎖切面
*
* @version 1.0
* @date 2020/12/8 17:59
*/
@Slf4j
@Component
@Aspect
@Configuration
public class EmLockAspect {
@Autowired
private RedisLock redisLock;
/**
* 應用級別的容器的id
*/
private final String appUUID = UUIDUtils.get();
/**
* 線程級別的線程的id
*/
private final ThreadLocal<String> threadUUID = ThreadLocal.withInitial(UUIDUtils::get);
/**
* 定義切點
*/
@Pointcut("@annotation(com.emdata.lowvis.common.redislock.EmLock)")
public void lockAop() {
}
@Around("lockAop()")
public Object doAround(ProceedingJoinPoint point) throws Throwable {
// 獲取方法
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
// 看有沒有日志注解
EmLock emLock = method.getAnnotation(EmLock.class);
if (emLock == null) {
return point.proceed();
}
// 獲取鎖的級別
LockRangeEnum lockRangeEnum = emLock.lockRange();
String uuid = lockRangeEnum == LockRangeEnum.APPLICATION ? appUUID : threadUUID.get();
// 獲取鎖的key和超時時間
String key = emLock.key();
int timeout = emLock.timeout();
TimeUnit timeUnit = emLock.timeUnit();
// 加鎖
boolean lock = redisLock.lock(key, uuid, timeout, timeUnit);
Object proceed = null;
try {
if (lock) {
log.info("獲取到鎖,繼續執行...");
// 繼續執行
proceed = point.proceed();
}
} finally {
// 自動釋放,則釋放鎖
if (emLock.autoRelease()) {
redisLock.release(key, uuid);
}
}
return proceed;
}
}
3 使用示例
3.1 使用RedisLock
@Autowired
private RedisLock redisLock;
public void useLock() {
// 定義鎖的key
String lockKey = "camera_update_key";
String uuid = UUIDUtils.get();
// 定義超時時間
long timeout = 5;
TimeUnit timeUnit = TimeUnit.SECONDS;
// 加鎖
boolean lock = redisLock.lock(lockKey, uuid, timeout, timeUnit);
try {
if (lock) {
log.info("執行...");
} else {
throw new IllegalStateException("未獲取到鎖,放棄執行");
}
} finally {
// 在finally里面進行解鎖
redisLock.release(lockKey, uuid);
}
}
3.2 使用EmLock
@Component
@Slf4j
public class ScheduleTask {
/**
* 用在定時任務方法上,鎖的key為test_lock,指定了超時時間為2秒鐘
* 鎖的級別為默認的應用級別(LockRangeEnum.APPLICATION),在這個如果應用啟動了多個容器運行,在只會有一個容器獲取到鎖,
* 自動釋放鎖為false,即方法執行完成后,也不會自動釋放鎖,只有到超時時間了,鎖才會釋放
*/
@Scheduled(cron = "0 0/1 * * * ? ")
@EmLock(key = "test_lock", timeout = 2, timeUnit = TimeUnit.SECONDS, autoRelease = false)
public void recordUpdateTask() {
log.info("執行任務.......");
}
/**
* 用在普通的方法上,鎖的key為method_Lock,指定了超時時間為1分鐘,
* 鎖的級別為默認的線程級別,在該應用內多個線程執行該方法,則只會有一個線程獲取到鎖
* 如果啟動了多個應用容器,同樣多個容器內的所有線程,也只會有一個線程獲取到鎖
*/
@EmLock(key = "method_Lock", timeout = 1, timeUnit = TimeUnit.MINUTES, lockRange = LockRangeEnum.THREAD)
public void recordUpdate() {
log.info("執行任務2.......");
}
}
4 使用注意
使用Redis作為分布式鎖的實現,依賴于Redis服務,如果Redis服務無法正常訪問,則會導致整個方法無法執行。
如果EmLock注解用在定時任務上時,如果應用運行在不同的服務器上,或者不同的docker容器里面時,必須保證運行環境的時間一致。
如果設置了定時任務上面的鎖,不是自動釋放的,則運行環境的時間,相差不大于鎖超時時間的時候,也可以保證定時任務,唯一執行。因為在超時時間范圍內,某個應用容器持有該鎖,其他應用來獲取鎖時,同樣獲取不到,方法不會執行。
作者:Knight_9
鏈接:http://www.lxweimin.com/p/5190600e44c1
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。