1.Redis分佈式鎖理論
Redis有一系列的命令,特點是以NX結尾,NX是Not eXists的縮寫,如SETNX命令就應該理解爲:SET if Not eXists。
設置成功,返回 1 。 設置失敗,返回 0
由於Redis爲單進程單線程模式,採用隊列模式將併發訪問變成串行訪問,命令是一條一條執行的所以可以利用setNx可以實現分佈式鎖。
方法執行前請求redis 進行setnx命令。如果返回1,則表示此時該線程獲得鎖,執行方法,如果返回0,表示鎖已經被佔用了,等待重新獲取或者超時處理。
項目依賴:spring-boot 版本2.3.0
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
2.Redis整合 封裝服務類
基於spring boot 封裝的 RedisTemplate 實現redis 服務。
- Redis 配置:主要設置了redis 序列化的一些配置
@Configuration
public class RedisConfig {
@Bean
@SuppressWarnings("all")
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<String, Object>();
template.setConnectionFactory(factory);
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// key採用String的序列化方式
template.setKeySerializer(stringRedisSerializer);
// hash的key也採用String的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
// value序列化方式採用jackson
template.setValueSerializer(jackson2JsonRedisSerializer);
// hash的value序列化方式採用jackson
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}
- service層編寫: service 只提供2個方法加鎖和解鎖,加鎖需要幾個參數redis 裏的key、value,鎖的過期時間,獲取鎖的重試間隔時間,獲取不到鎖的超時時間。 解鎖要兩個參數鎖的key 和value值
public interface RedisService {
/**
* 加鎖
* @param key redis key
* @param value redis value
* @param expireTime 過期時間
* @param timeout 獲取不到鎖超時時間
* @param interval 重試間隔
* @return
*/
boolean tryLock(String key, String value, long expireTime, long timeout, long interval);
/**
* 解鎖
* @param key
* @param value
*/
void unLock(String key, String value);
}
-
service實現層
加鎖方法: 首先判斷 獲取不到鎖的等待時間如果小於等於0 給一個默認時間30毫秒。
如果獲取不到鎖超時時間大於0 就獲取當前時間進行判斷,是否超時
獲取到鎖直接返回true 獲取不到鎖則鎖定當前線程進行等待。這裏使用redisTemplate裏的方法進行redis 命令操作,需要考慮的問題:鎖必須有超時時間,如果A線程獲取了鎖,A線程在執行過程中異常,導致永遠也不會執行結束 這時候鎖被A線程佔用,其他線程永遠獲取不到鎖,造成死鎖。所以要根據業務處理估算時間 進行設置過期時間,如果A線程異常 則超時自動釋放鎖。由於setnx和expire不具備原子性,假設 用戶setnx後 在expire前線程異常,則鎖的過期時沒有設置上,所以此處必須保證原子性。 redis版本升級到2.1以上,直接在setIfAbsent中設置過期時間,也可以是用lua腳本實現。
/**
* 加鎖
* @param key redis key
* @param value redis value
* @param expireTime 過期時間
* @param timeout 獲取不到鎖超時時間
* @param interval 重試間隔
* @return
*/
@Override
public boolean tryLock(String key, String value, long expireTime, long timeout, long interval) {
if(interval<=0){
//默認等待時間 30 毫秒
interval = 30L;
}
try {
if (timeout > 0) {
long begin = System.currentTimeMillis();
while (System.currentTimeMillis() - begin < timeout) {
if (redisTemplate.opsForValue().setIfAbsent(key, value, expireTime, TimeUnit.MILLISECONDS)) {
return true;
}
//等待
synchronized (Thread.currentThread()) {
Thread.currentThread().wait(interval);
}
}
return false;
} else {
return redisTemplate.opsForValue().setIfAbsent(key, value, expireTime, TimeUnit.MILLISECONDS);
}
}catch (Exception e){
return false;
}
}
解鎖方法:解鎖需要注意一點,解鈴還須繫鈴人,假設A線程獲取到了鎖,但是正常執行了,但是執行方法耗時太長了導致超時了,鎖自動釋放了,此時B線程獲取到了鎖,B執行方法中,A執行結束 進行了釋放鎖, 由於沒有判斷 鎖是否是A加的鎖,進行了刪除,所以B在正常未執行結束的時候鎖已經被A釋放了,這就造成了併發問題。所以A在解鎖前要判斷鎖是否爲A加的鎖,利用redis命令存儲時設置的value 進行判斷是否爲A加的鎖,刪除鎖之前先獲取判斷後如果值和A設置的值相等進行刪除操作。需要注意的是判斷和刪除操作必須保持原子性,在高併發情況下,如果兩條命令不是原子性操作,在判斷後,鎖超時釋放了,其他線程獲取到了鎖,就會被誤刪除。
/**
* 解鎖
* @param key
* @param value
*/
@Override
public void unLock(String key, String value) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
DefaultRedisScript<Long> defaultRedisScript = new DefaultRedisScript();
defaultRedisScript.setScriptText(script);
defaultRedisScript.setResultType(Long.class);
//執行 腳本 刪除 key ,必須使用lua 腳本實現 保證原子性
Long res = redisTemplate.execute(defaultRedisScript, Collections.singletonList(key), value);
if(res!=1L){
System.err.println("釋放失敗");
}
}
注意:使用redisTemplate執行腳本 和使用Jedis 執行腳本參數不一致。開始沒注意,導致測試的時候 一直刪除不掉鎖。
看下代碼:
jedis 參數爲 腳本和兩個集合
public Object eval(String script, List<String> keys, List<String> args) {
return this.eval(script, keys.size(), getParams(keys, args));
}
redisTemplate參數爲一個集合 和多個參數代替第二個集合
@Override
public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
return scriptExecutor.execute(script, keys, args);
}
3.自定義註解
自定義註解:需要幾個參數 鎖的key,獲取鎖的間隔、失效時間、超時時間
/**
* redis 分佈式鎖註解
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RedisLock {
/**
* key 默認爲類名+方法名
* 使用方法:
* 1.String 字符串
* 2.#args[]變量
* 例如: #args[0]
* #args[1].getName() 只支持無參方法調用
*/
String key() default "";
/**
* 重新獲取鎖的間隔時間,默認100ms
*/
long interval() default 100L;
/**
* 失效時間,默認10秒
*/
long expireTime() default 10*1000L;
/**
* 阻塞時間,超時獲取不到鎖,拋異常 或走回調方法
*/
long timeout() default 5*1000L;
}
4.Aop實現註解環繞通知、獲取註解參數、加鎖解鎖
Aop裏需要做的事情:在方法執行前,獲取鎖的註解值,進行加鎖,如果加鎖成功進行方法執行,如果加鎖失敗 拋出異常,可以自定義異常使用統一異常處理。
大概是:切入註解 RedisLock ,獲得註解的參數,使用uuid作爲redis value,解鎖的時候傳入認證 封裝獲取key的方法,反射根據註解 執行方法 獲得參數裏的key值,默認爲類名+方法名
/**
* 分佈式鎖AOP
*/
@Aspect
@Component
public class LockAspect {
@Autowired
RedisService redisService;
/**
* 環繞通知 加鎖 解鎖
* @param joinPoint
* @return
* @throws Throwable
*/
@Around("@annotation(RedisLock)")
public Object redisLockAop(ProceedingJoinPoint joinPoint) throws Throwable {
Object res = null;
RedisLock lock = ((MethodSignature)joinPoint.getSignature()).getMethod().getAnnotation(RedisLock.class);
String uuid = UUID.randomUUID().toString();
String key = getKey(joinPoint, lock.key());
System.err.println("[KEY] :"+key);
if(ThreadLocalUtil.get(key)!=null){
//當前線程已經獲取到鎖 不需要重複獲取鎖。保證可重入性
return joinPoint.proceed();
}
if(redisService.tryLock(key, uuid, lock.expireTime(), lock.timeout(), lock.interval())){
//獲取到鎖進行標記 執行方法
ThreadLocalUtil.put(key,"");
res = joinPoint.proceed();
//方法執行結束 釋放鎖
ThreadLocalUtil.clear(key);
redisService.unLock(key, uuid);
return res;
}else{
//獲取不到鎖 拋出異常 進入統一異常處理
throw new Exception();
}
}
/**
* 根據參數 和註解 獲取 redis key值
* @param joinPoint
* @param key
* @return
*/
public String getKey(ProceedingJoinPoint joinPoint,String key){
String className = joinPoint.getSignature().getDeclaringTypeName();
String methodName = joinPoint.getSignature().getName();
try {
if("".equals(key)){
//默認類名 + 方法名
return className+methodName;
}
if(key.startsWith("#args")){
//包含 #args 讀取參數 設置key 不包含直接返回
//獲取參數
Object[] args = joinPoint.getArgs();
//獲取註解下標 例如:#args[0] 或者 #args[1].getName()
Integer index = Integer.parseInt(key.substring(key.indexOf("[") + 1, key.indexOf("]")));
Object keyArgs = args[index];
if(key.split("\\.").length<=1){
return keyArgs.toString();
}
//反射執行方法 拿到返回值 返回key
Class clas = keyArgs.getClass();
Method method = clas.getMethod(key.split("\\.")[1].split("\\(")[0]);
return method.invoke(keyArgs).toString();
}
return key;
}catch (Exception e){
return className+methodName;
}
}
}
5.ThreadLocal實現可重入鎖
可重入性:假設a方法需要 testlock鎖,b方法也需要testlock鎖,a方法調用了b方法,此時鎖被a方法獲取,b方法獲取不到鎖永遠等待,所以 如果線程有一個方法獲取到了鎖,則其他方法不需要獲取鎖就可以執行了。
ThreadLocal是解決線程安全問題一個很好的思路,它通過爲每個線程提供一個獨立的變量副本解決了變量併發訪問的衝突問題
使用ThreadLocal,在獲取到鎖的時候 標記一下,方法獲取鎖之前先判斷線程是否已經獲取到鎖了。
提供一個ThreadLocal 幫助類
public class ThreadLocalUtil {
private static ThreadLocal tlContext = new ThreadLocal();
/**
* 放入緩存
* @param key 鍵
* @param value 數值
*/
public static void put(Object key,Object value){
Map m=(Map)tlContext.get();
if(m==null){
m=new HashMap();
tlContext.set(m);
}
m.put(key, value);
}
/**
* 獲取緩存
* @param key 鍵
*/
public static Object get(Object key){
Map m=(Map)tlContext.get();
if(m==null) return null;
return m.get(key);
}
/**
* 清理
* @param key 鍵
*/
public static void clear(Object key){
Map m=(Map)tlContext.get();
if(m==null) return;
m.remove(key);
}
}
測試: