最近工作涉及到一個需求是關於禁止重複操作的後端校驗,當時通過一種與業務耦合的redis加鎖方式暫時滿足了功能需求,後來在大佬的指點下將該功能抽離出來單獨做一個組件,公司目前的項目是典型的分佈式系統,自己之前接觸的很少,中途也遇到了一些困難,在此做下總結。
什麼是分佈式鎖
要介紹分佈式鎖,首先要提到與分佈式鎖相對應的是線程鎖、進程鎖。
① 線程鎖:主要用來給方法、代碼塊加鎖。當某個方法或代碼使用鎖,在同一時刻僅有一個線程執行該方法或該代碼段。線程鎖只在同一JVM中有效果,因爲線程鎖的實現在根本上是依靠線程之間共享內存實現的,比如synchronized是共享對象頭,顯示鎖Lock是共享某個變量(state)。
② 進程鎖:爲了控制同一操作系統中多個進程訪問某個共享資源,因爲進程具有獨立性,各個進程無法訪問其他進程的資源,因此無法通過synchronized等線程鎖實現進程鎖。
分佈式鎖:當多個進程不在同一個系統中,用分佈式鎖控制多個進程對資源的訪問。
爲什麼需要分佈式鎖(即要解決什麼問題)
隨着業務越來越複雜,應用服務都會朝着分佈式、集羣方向部署,而分佈式CAP原則告訴我們,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分區容錯性),三者不可得兼。實踐中一般滿足CP或AP。
很多場景中,需要使用分佈式事務、分佈式鎖等技術來保證數據最終一致性。有的時候,我們需要保證某一方法同一時刻只能被一個線程執行。
在單機環境中,多個線程對共享變量進行訪問時,我們可以簡單的通過Java自身的同步操作來協調同一時刻對變量的串行訪問。然而在分佈式環境中,進程的獨立性,進程之間無法訪問相互之間的資源,無法像之前那樣的方式實現進程鎖,故需要一個獨立的中心節點,以協調多個系統對共享變量的訪問,所有進程在訪問該變量時,都從同一個地方進行取值並控制,從而實現在類似於單機環境中同步控制的效果。
需要滿足什麼條件
① 可以保證在分佈式部署的應用集羣中,同一個方法在同一時間只能被一臺機器上的一個線程執行
② 這把鎖要是一把可重入鎖(避免死鎖)
③ 這把鎖最好是一把阻塞鎖
④ 有高可用的獲取鎖和釋放鎖功能
⑤ 獲取鎖和釋放鎖的性能要好
實現方式
主要有三種實現方式:①基於數據庫(樂觀鎖);②基於緩存(redis,memcached等)③基於zookeeper
本次實踐主要選用第二種,基於redis進行實現。
實現思路
粗粒度的大致思路:
① 在對共享變量(key)操作前,判斷該變量值在redis中是否有對應的key
② 若無,則設置到redis中,並設置該key的超時時間,返回true;若有,則說明該變量已經被別的線程/進程持有,不做任何動作,返回false
大致的思路相對簡單,實際實現會遇到很多困難,如實現方式,加鎖位置,操作原子性,死鎖問題,性能問題等。
實現方案
以下方式沒有誰好誰壞之分,需要看實際的業務情況取捨,性能,或是跟業務代碼的耦合度,代碼結構設計等等
① 以服務的方式提供分佈式鎖
這種方式主要是將加鎖解鎖操作封裝成一個服務提供給業務使用,將鎖服務耦合到業務中,能夠較細粒度的控制好變量的訪問,實現較爲靈活,性能相對較好,但是技術需求對於業務代碼的入侵相對較多。
② 以切面的方式提供分佈式鎖
主要通過自定義註解,集成spring提供的aop切面,在方法執行前後進行攔截,對要加鎖的變量進行協調,這種方式對業務代碼的入侵較低,可以方便的對業務的加鎖操作進行擴展。不過隨之帶來的有性能問題,因爲在方法攔截後需要對當前方法進行反射,從而獲取當前註解變量和方法參數,反射是一種耗性能的操作方式。
自定義註解+Spring AOP+Redis實現分佈式鎖
本次採用第二種方式,通過自定義註解+Spring AOP+Redis實現
① 自定義註解 RedisLock
package cn.jyycode.annotition;
import java.lang.annotation.*;
/** redis鎖
* @author [email protected]
* @date 2019/1/29 19:23
* @since 1.0.0
*/
@Documented
@Target({ElementType.METHOD,ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLock {
/**
* 要鎖定的屬性參數值(key值)索引
* @return
*/
int argsIndex();
}
這裏的argsIndex是指形參中參數的索引值,即第0個參數,第1個參數,第2個參數......
因爲我們需要獲取加鎖的變量值(非固定鎖),這個值在方法參數中提供(若要加鎖的對象在業務邏輯中提供而不是方法參數,就應當採用以服務的方式提供分佈式鎖而不是現在這種方法),所以本方式也是具有它的侷限性的。
② RedisLockAspect
@Slf4j
@Aspect
@Component
public class RedisLockAspect {
@Autowired
private StringRedisTemplate stringRedisTemplate;
集成spring aop的方式,聲明RedisLock註解的攔截切面。並將redis服務通過spring bean的方式注入進來。
③ 設置超時時間
/**
* 設置key超時時間,默認爲5s
*/
private static final int LOCK_EXPIRE_SECONDS = 5;
具體超時時間根據業務需求而定了,這裏默認5s
④ 設置攔截切點
/**
* 要鎖定的操作切點
*/
@Pointcut("@annotation(cn.jyycode.annotition.RedisLock)")
public void lockAspect() {
}
對註解了RedisLock的方法進行攔截
⑤ 方法執行前
/**
* 用於攔截操作,在方法執行前執行
*
* @param joinPoint 切點
*/
@Before(value = "lockAspect()")
public void before(JoinPoint joinPoint) throws Throwable {
this.handle(joinPoint);
}
對攔截的方法進行代理增強,進行相關邏輯的操作
⑥ 加鎖判斷
/**
* redis分佈式鎖
*
* @param joinPoint 連接點
* @return
*/
private void handle(JoinPoint joinPoint) throws Throwable {
RedisLock annotation = this.getAnnotation(joinPoint);
Object[] args = joinPoint.getArgs();
boolean result = false;
try {
key = String.valueOf(args[Optional.ofNullable(annotation.argsIndex()).orElse(0)]);
result = this.lock(key, LOCK_EXPIRE_SECONDS);
if (!result) {
log.info("【鎖定失敗】:{}", "要操作的屬性值已被鎖定");
throw new RedisLockException("【鎖定失敗】:要操作的屬性值已被鎖定");
} else {
log.info("【成功加鎖】");
}
} catch (Exception e) {
}
}
1. 獲取方法上的註解,將要加鎖對象的參數索引值取出,通過切入點的參數數組獲取要加鎖對象的值;
2. 執行lock加鎖操作,若加鎖成功則返回true,失敗則返回false
⑦ 加鎖操作(重點)
/**
* 加鎖
*
* @param key
* @param expire 過期時間,單位秒
* @return true:加鎖成功,false:加鎖失敗
*/
private boolean lock(String key, int expire) {
String oldValue = stringRedisTemplate.opsForValue().getAndSet(key, String.valueOf(expire));
stringRedisTemplate.expire(key, expire, TimeUnit.SECONDS);
if (oldValue != null) {
return false;
}
return true;
}
1.對redis進行getAndSet操作,該操作是原子性的,獲取舊值並設置新值
2.對獲取的舊值進行判斷,若爲空,說明是第一次加鎖(即前面沒線程/進程進來設置value值),則表明加鎖成功,返回true;若舊值不爲空,則說明上一個線程/進程已經在使用該變量,並且還在使用或是未超時,則本次應當返回加鎖失敗(雖然設了新值進去,但是)返回false
⑧ 獲取方法上的註解
/**
* 獲取方法上的註解
*
* @param joinPoint 連接點
* @return 返回方法註解
* @throws Exception
*/
private RedisLock getAnnotation(JoinPoint joinPoint) {
Method method = null;
try {
method = Optional.ofNullable(((MethodSignature) joinPoint.
getSignature()).
getMethod()).orElse(null);
} catch (Exception ignore) {
log.info("【反射當前方法失敗】:{}", ignore);
ignore.printStackTrace();
throw new RedisLockException("【反射當前方法失敗】");
}
if (method == null) {
throw new RedisLockException("【獲取方法註解異常】");
}
return method.getAnnotation(RedisLock.class);
}
⑨ 自定義異常
/** redis鎖異常
* @author [email protected]
* @date 2019/1/30 10:00
* @since 1.0.0
*/
@NoArgsConstructor
public class RedisLockException extends RuntimeException {
public RedisLockException(String message){
super(message);
}
}
用於統一處理此類異常
存在的問題及不足(時間倉促,待完善)
① 上述寫的很粗糙,需要完善,大致完善點如下
② 對上面寫的內容進行補充和review
③ 需要更合乎邏輯的加鎖處理(如鎖失效條件,超時還是自己手動解鎖等),操作原子性等
④ 潛在的死鎖問題分析
⑤ 本次是悲觀鎖實現,性能差,需要尋求樂觀鎖實現(配合分佈式事務)
⑥ 實踐spring官方提供的一種解決方案redisson
⑦ 遇到的問題總結及改進方式,例utils->自定義註解+aop攔截,方式的轉變,非固定鎖值的獲取(方法註解,參數註解等),異常處理(空指針,數組越界,邊界條件等)