今天來聊聊分佈式鎖的解決方案,就從什麼是分佈式鎖和分佈式鎖的解決方案以及具體實現來進行分析,內容純屬個人見解,如有紕漏及錯誤還請指正!
什麼是分佈式鎖
- 傳統的單機應用中,需求在一臺jvm上如果有線程併發的安全問題使用jvm自帶的鎖機制就能很好的解決;但是在微服務的分佈式部署下理論上會有N臺JVM集羣,顯然單機鎖已經解決不了,那麼如何保證他們對共有資源的訪問安全呢?這就需要引入分佈式鎖;分佈式鎖同樣能保證在某一時刻內共有資源只被一個應用的某個線程所佔用,其他資源無法搶佔,除非該線程執行完自己的業務操作主動釋放鎖,那麼分佈式鎖有哪些主流解決方案呢?
分佈式鎖的三種解決方案
通過數據庫實現
實現思路
- 通過創建一個具有uk、開始時間、過期時間的表,然後業務側拿到資源後將資源id作爲uk插入該表,如果插入成功即成功搶佔鎖;爲了防止搶佔到鎖的資源宕機導致釋放鎖失敗還需要新建定時任務固定時間內刪除已經失效的鎖記錄
表結構參考
create table LOCK_INFO
(
ID VARCHAR2(100),
busi_Id VARCHAR2(255),
CREATE_TIME DATE,
EXPIRE_TIME DATE
);
create unique index UK_LOCK on LOCK_INFO (busi_Id);
comment on table LOCK_INFO is '分佈式鎖信息表';
comment on column LOCK_INFO.ID is '自增主鍵';
comment on column LOCK_INFO.busi_Id is '共享資源的id';
comment on column LOCK_INFO.CREATE_TIME is '鎖的創建時間';
comment on column LOCK_INFO.EXPIRE_TIME is '鎖的過期時間';
加鎖
insert into LOCK_INFO values('10020201','BUSI_0001',sysdate,sysdate+numtodsinterval(5,'minute'))
說明:加鎖只強調了insert代碼的sql,實際應用中是需要在代碼中trycatch捕獲異常的,不然萬一加鎖失敗整個業務都進行不下去了
釋放鎖
- 業務正常執行完畢的釋放
delete from LOCK_INFO where busi_id = 'BUSI_0001';
- 定時任務的釋放
delete from LOCK_INFO where expire_time < sysdate
缺點
- 按照上述思路實現還有一個問題即拿到鎖的程序執行時間大於刪除鎖的定時任務的時間該怎麼辦?這樣會導致定時任務把鎖刪掉了別的程序會搶佔到該鎖但是原程序仍在執行,這就會造成數據不一致的問題了
- 解決方案
當拿鎖的程序執行時間過長開啓異步程序去數據庫續期即可
實際運用
由於工作中我們的分佈式鎖用的就是數據庫實現的,所以這裏聊下我們的業務場景以及具體實現
業務場景
系統內的定時任務需要使用分佈式鎖
具體實現
- 1、自定義註解@Lock,需要使用鎖的方法直接加上該註解即可,註解類如下
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Lock {
/**
* 鎖名稱
* @return
*/
String name() default CommonConstant.EMPLY;
/**
* 鎖類型
* @return
*/
String type() default "DB";
}
- 2、利用AOP攔截方法上有@Lock註解的請求,執行加鎖、執行業務、解鎖的操作,關鍵代碼如下:
@Aspect
@Component
public class LockAop {
@Resource
private LockService lockService;
private final static Logger log = LoggerFactory.getLogger(LockAop.class);
@Pointcut("@annotation(com.darling.annotation.Lock)")
public void lockPointCut(){}
@Around("lockPointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
Signature signature = point.getSignature();
MethodSignature methodSignature = (MethodSignature)signature;
Method method = methodSignature.getMethod();
Method targetMethod = point.getTarget().getClass().getMethod(method.getName(), method.getParameterTypes());
String className = method.getDeclaringClass().getName()+"."+method.getName();
Lock lock = targetMethod.getAnnotation(Lock.class);
Object result = null;
if (Objects.nonNull(lock)) {
String name = lock.name();
if (StringUtils.isBlank(name)) {
name = className;
}
try {
lockService.lock(name);
try {
result = point.proceed();
} finally {
lockService.unLock(name);
}
} catch (Exception e) {
log.info("搶佔失敗,name = " + name + e);
}
}
return result;
}
}
說明:LockService裏封裝的就是加減鎖對應的insert、delete操作
- 3、開發定時任務定期刪除已過期的鎖,這裏是五分鐘刪除一次,釋放鎖的邏輯在lockService裏實現
@Scheduled(cron = "0 0/5 * * * ?")
@ScheProfile(value = PRD)
private void autoUnlock() {
lockService.autoUnLock();
}
通過redis實現
實現原理
通過redis的 setnx這個原子操作命令來實現,爲了降低數據一致性風險建議設置key的時候添加過期時間,如set busiId 999 nx ex 300;但是需要注意的是業務側一定要保證每次加鎖的命令一定是標準化的,因爲如果像上面的命令加鎖成功後其他業務設置了set busiId 888 ex 300不僅能設置成功而且還會覆蓋掉原值,這樣鎖就失效了,如下所示:
優缺點
- 優點:redis基於內存所以效率上肯定比操作數據庫快,並且redis可以自己管理過期時間不用寫任務去刪除
- 缺點:單點故障、續期問題
單點故障的解決方案-紅鎖
- 一臺redis的單點故障問題很容易會想到用主從複製的架構來解決,如果當應用在master上拿到鎖執行業務後master掛掉了,那麼其他應用很容易會在slave上拿到鎖,這樣就又破壞了數據的一致性;所以紅鎖的解決方案應運而生;
- 紅鎖即部署由用戶可知個數和順序的redis集羣,集羣內各個實例互不影響,這裏提到的個數建議爲奇數個,應用側按照順序依次對集羣內的redis實例進行setnx ex操作,如果成功的實例數大於集羣內總實例數的一半加一時即認爲加鎖成功;這種方案明顯提升了redis作爲分佈式鎖的可靠性,但是顯然增加了維護和部署的難度;
- 缺點:當應用拿到鎖的某個redis實例宕機需要重啓的時候其內存是沒有對應key值的,這時候就又會被其他程序搶鎖成功而先搶到鎖的應用還沒有執行完業務;這就又產生了一致性的問題,解決方案就是延緩宕機實例的重啓時間,可以設置1小時或者更久後重啓,理論上1小時先搶到鎖的應用業務肯定也執行完畢了
通過ZK+數據庫樂觀鎖實現
思考 假設應用側是一個部署了10臺JVM應用的集羣,其中一個JVM應用(稱之爲A應用)利用紅鎖拿到了鎖去執行業務了,假設該應用A在執行過程中做了一次很長時間的io或者乾脆STW了,時間長到大於redis裏設置的過期時間了,此時redis裏的key過期失效了,而另一個應用B搶到了該鎖去執行任務了,此時應用A恢復正常響應繼續執行業務,那麼就又造成了數據一致性的問題了!
- 上面問題可以通過ZK的臨時順序節點+mysql的樂觀鎖來實現,具體的實現步驟我就不用文字一一描述了,這裏貼上我畫的流程圖: