基于Spring Boot AOP 實現(xiàn)分布式鎖
AOP
AOP 的全稱為 Aspect Oriented Programming芯侥,譯為面向切面編程肾档。實際上 AOP 就是通過預(yù)編譯和運行期動態(tài)代理實現(xiàn)程序功能的統(tǒng)一維護的一種技術(shù)。在不同的技術(shù)棧中 AOP 有著不同的實現(xiàn)落恼,但是其作用都相差不遠(yuǎn),我們通過 AOP 為既有的程序定義一個切入點,然后在切入點前后插入不同的執(zhí)行內(nèi)容梨熙,以達(dá)到在不修改原有代碼業(yè)務(wù)邏輯的前提下統(tǒng)一處理一些內(nèi)容(比如日志處理、分布式鎖)的目的刀诬。
為什么要使用 AOP
在實際的開發(fā)過程中咽扇,我們的應(yīng)用程序會被分為很多層。通常來講一個 Java 的 Web 程序會擁有以下幾個層次:
- Web 層:主要是暴露一些 Restful API 供前端調(diào)用陕壹。
- 業(yè)務(wù)層:主要是處理具體的業(yè)務(wù)邏輯质欲。
- 數(shù)據(jù)持久層:主要負(fù)責(zé)數(shù)據(jù)庫的相關(guān)操作(增刪改查)。
雖然看起來每一層都做著全然不同的事情糠馆,但是實際上總會有一些類似的代碼嘶伟,比如日志打印和安全驗證等等相關(guān)的代碼。如果我們選擇在每一層都獨立編寫這部分代碼榨惠,那么久而久之代碼將變的很難維護奋早。所以我們提供了另外的一種解決方案: AOP。這樣可以保證這些通用的代碼被聚合在一起維護赠橙,而且我們可以靈活的選擇何處需要使用這些代碼耽装。
AOP 的核心概念
-
切面(Aspect) :通常是一個類,在里面可以定義切入點和通知期揪。(
@Aspect
修飾的類) - 連接點(Joint Point) :被攔截到的點掉奄,因為 Spring 只支持方法類型的連接點,所以在 Spring 中連接點指的就是被攔截的到的方法凤薛,實際上連接點還可以是字段或者構(gòu)造器姓建。
-
切入點(Pointcut) :對連接點進行攔截的定義(在切面類上被
@Pointcut
修飾的方法, @Pointcut("execution(* com.controller.TQueryController.query(..))") )缤苫。 -
通知(Advice) :攔截到連接點之后所要執(zhí)行的代碼速兔,通知分為前置、后置活玲、異常涣狗、最終谍婉、環(huán)繞通知五類。(切面類上被
@Before
镀钓、@After
穗熬、@AfterReturning
、@Around
丁溅、@AfterThrowing
修飾的方法) - AOP 代理 :AOP 框架創(chuàng)建的對象唤蔗,代理就是目標(biāo)對象的加強。Spring 中的 AOP 代理可以使 JDK 動態(tài)代理窟赏,也可以是 CGLIB 代理妓柜,前者基于接口,后者基于子類饰序。
Spring AOP
Spring 中的 AOP 代理還是離不開 Spring 的 IOC 容器领虹,代理的生成,管理及其依賴關(guān)系都是由 IOC 容器負(fù)責(zé)求豫,Spring 默認(rèn)使用 JDK 動態(tài)代理塌衰,在需要代理類而不是代理接口的時候,Spring 會自動切換為使用 CGLIB 代理蝠嘉,不過現(xiàn)在的項目都是面向接口編程最疆,所以 JDK 動態(tài)代理相對來說用的還是多一些。
Spring AOP 相關(guān)注解
-
@Aspect
: 將一個 java 類定義為切面類蚤告。 -
@Pointcut
:定義一個切入點努酸,可以是一個規(guī)則表達(dá)式,比如下例中某個package
下的所有函數(shù)杜恰,也可以是一個注解等获诈。 -
@Before
:在切入點開始處切入內(nèi)容。 -
@After
:在切入點結(jié)尾處切入內(nèi)容心褐。 -
@AfterReturning
:在切入點 return 內(nèi)容之后切入內(nèi)容(可以用來對處理返回值做一些加工處理)舔涎。 -
@Around
:在切入點前后切入內(nèi)容,并自己控制何時執(zhí)行切入點自身的內(nèi)容逗爹。 -
@AfterThrowing
:用來處理當(dāng)切入內(nèi)容部分拋出異常之后的處理邏輯亡嫌。
其中 @Before
、 @After
掘而、 @AfterReturning
挟冠、 @Around
、 @AfterThrowing
都屬于通知袍睡。
AOP 順序問題
在實際情況下知染,我們對同一個接口做多個切面,比如日志打印斑胜、分布式鎖持舆、權(quán)限校驗等等色瘩。這時候我們就會面臨一個優(yōu)先級的問題,這么多的切面該如何告知 Spring 執(zhí)行順序呢逸寓?這就需要我們定義每個切面的優(yōu)先級,我們可以使用 @Order(i)
注解來標(biāo)識切面的優(yōu)先級, i
的值越小覆山,優(yōu)先級越高竹伸。假設(shè)現(xiàn)在我們一共有兩個切面,一個 WebLogAspect
簇宽,我們?yōu)槠湓O(shè)置 @Order(100)
勋篓;而另外一個切面 DistributeLockAspect
設(shè)置為 @Order(99)
,所以 DistributeLockAspect
有更高的優(yōu)先級魏割,這個時候執(zhí)行順序是這樣的:在 @Before
中優(yōu)先執(zhí)行 @Order(99)
的內(nèi)容譬嚣,再執(zhí)行 @Order(100)
的內(nèi)容。而在 @After
和 @AfterReturning
中則優(yōu)先執(zhí)行 @Order(100)
的內(nèi)容钞它,再執(zhí)行 @Order(99)
的內(nèi)容拜银,可以理解為先進后出的原則。
多個AOP執(zhí)行順序是按棧先進后出的原則遭垛。
基于注解的 AOP 配置
使用注解一方面可以減少我們的配置尼桶,另一方面注解在編譯期間就可以驗證正確性,查錯相對比較容易锯仪,而且配置起來也相當(dāng)方便泵督。相信大家也都有所了解,我們現(xiàn)在的 Spring 項目里面使用了非常多的注解替代了之前的 xml 配置庶喜。
官網(wǎng)對 execution 表達(dá)式的介紹
execution(<修飾符模式>?<返回類型模式><方法名模式>(<參數(shù)模式>)<異常模式>?)
其中除了返回類型模式小腊、方法名模式和參數(shù)模式外,其它項都是可選的久窟。這個解釋可能有點難理解秩冈,下面我們通過一個具體的例子來了解一下。在 WebLogAspect
中我們定義了一個切點瘸羡,其 execution
表達(dá)式為 * cn.itweknow.sbaop.controller..*.*(..)
漩仙,下表為該表達(dá)式比較通俗的解析:
表 1. execution()
表達(dá)式解析
標(biāo)識符 | 含義 |
---|---|
execution() |
表達(dá)式的主體 |
第一個 * 符號 |
表示返回值的類型, * 代表所有返回類型 |
cn.itweknow.sbaop.controller |
AOP 所切的服務(wù)的包名犹赖,即需要進行橫切的業(yè)務(wù)類 |
包名后面的 ..
|
表示當(dāng)前包及子包 |
第二個 *
|
表示類名队他, * 表示所有類 |
最后的 .*(..)
|
第一個 .* 表示任何方法名,括號內(nèi)為參數(shù)類型峻村, .. 代表任何類型 |
為什么要使用分布式鎖
我們程序中多多少少會有一些共享的資源或者數(shù)據(jù)麸折,在某些時候我們需要保證同一時間只能有一個線程訪問或者操作它們。在傳統(tǒng)的單機部署的情況下粘昨,我們簡單的使用 Java 提供的并發(fā)相關(guān)的 API 處理即可垢啼。但是現(xiàn)在大多數(shù)服務(wù)都采用分布式的部署方式窜锯,我們就需要提供一個跨進程的互斥機制來控制共享資源的訪問,這種互斥機制就是我們所說的分布式鎖芭析。
注意
- 互斥性锚扎。在任時刻,只有一個客戶端能持有鎖馁启。
- 不會發(fā)生死鎖驾孔。即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證后續(xù)其他客戶端能加鎖惯疙。這個其實只要我們給鎖加上超時時間即可翠勉。
- 具有容錯性。只要大部分的 Redis 節(jié)點正常運行霉颠,客戶端就可以加鎖和解鎖对碌。
- 解鈴還須系鈴人。加鎖和解鎖必須是同一個客戶端蒿偎,客戶端自己不能把別人加的鎖給解了朽们。
注解參數(shù)解析器
由于注解屬性在指定的時候只能為常量,我們無法直接使用方法的參數(shù)酥郭。而在絕大多數(shù)的情況下分布式鎖的 key 值是需要包含方法的一個或者多個參數(shù)的华坦,這就需要我們將這些參數(shù)的位置以某種特殊的字符串表示出來,然后通過參數(shù)解析器去動態(tài)的解析出來這些參數(shù)具體的值不从,然后拼接到 key
上惜姐。在本教程中我也編寫了一個參數(shù)解析器 AnnotationResolver
。需要的讀者可以 查看源碼 椿息。
可以用個約定的獲取方法更討巧方面歹袁。
實例:
pom.xml
<!--web起步依賴-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions><!-- 去掉springboot默認(rèn)配置 -->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--添加 AOP 相關(guān)依賴-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!--添加 Swagger 依賴-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<!--添加 Swagger UI 依賴-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
</dependency>
Redis配置參考Springboot整合redis使用RedisTemplate.
切面類
@Component
@Aspect
@Order(100)
@Slf4j
public class DistributeLockAspect {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private AnnotationResolver annotationResolver;
@Pointcut("execution(* com.self.controller..*.*(..))")
public void distributeLockCut(){
}
@Around(value = "distributeLockCut() && @annotation(distributeLock)")
public Object doDistributeLockAround(ProceedingJoinPoint joinPoint, DistributeLock distributeLock) throws Exception {
String key = annotationResolver.resolver(joinPoint, distributeLock.key());
String keyValue = getLock(key, distributeLock.timeOut(), distributeLock.timeUnit());
if (StringUtil.isNullOrEmpty(keyValue)) {
// 獲取鎖失敗。
return BaseResponse.addError(ErrorCodeEnum.OPERATE_FAILED, "請勿頻繁操作");
}
// 獲取鎖成功
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
return BaseResponse.addError(ErrorCodeEnum.SYSTEM_ERROR, "系統(tǒng)異常");
} finally {
// 釋放鎖寝优。
unLock(key, keyValue);
}
}
/**
* 獲取鎖
* @param key 鎖的key
* @param timeout 鎖超時時間
* @param timeUnit 時間單位
*
* @return 鎖的值
*/
private String getLock(String key, long timeout, TimeUnit timeUnit) {
try {
String value = UUID.randomUUID().toString();
Boolean lockStat = stringRedisTemplate.execute((RedisCallback<Boolean>)connection ->
connection.set(key.getBytes(Charset.forName("UTF-8")), value.getBytes(Charset.forName("UTF-8")),
Expiration.from(timeout, timeUnit), RedisStringCommands.SetOption.SET_IF_ABSENT));
if (!lockStat) {
// 獲取鎖失敗条舔。
return null;
}
return value;
} catch (Exception e) {
log.error("獲取分布式鎖失敗,key={}", key, e);
return null;
}
}
/**
* 釋放鎖
*
* @param key 鎖的key
* @param value 獲取鎖的時候存入的值
*/
private void unLock(String key, String value) {
try {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
boolean unLockStat = stringRedisTemplate.execute((RedisCallback<Boolean>) connection ->
connection.eval(script.getBytes(), ReturnType.BOOLEAN, 1,
key.getBytes(Charset.forName("UTF-8")), value.getBytes(Charset.forName("UTF-8"))));
if (!unLockStat) {
log.error("釋放分布式鎖失敗乏矾,key={}孟抗,已自動超時,其他線程可能已經(jīng)重新獲取鎖", key);
}
} catch (Exception e) {
log.error("釋放分布式鎖失敗钻心,key={}", key, e);
}
}
}
注解
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DistributeLock {
/**
* 鎖名稱
*/
String key();
/**
* 超時時間
*/
long timeOut() default 3;
/**
* 時間單位
*/
TimeUnit timeUnit() default TimeUnit.HOURS;
}
測試類
@RequestMapping("/post-test")
@DistributeLock(key = "post_test_#{baseRequest.channel}", timeOut = 10)
public BaseResponse postTest(@RequestBody @Valid BaseRequest baseRequest, BindingResult bindingResult) {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return BaseResponse.addResult();
}
基于Spring AOP 實現(xiàn)分布式鎖——Jedis
分布式鎖一般有數(shù)據(jù)庫樂觀鎖(服務(wù)端是集群凄硼,數(shù)據(jù)庫是單例或者讀寫分離庫)、基于Redis的分布式鎖以及基于ZooKeeper的分布式鎖三種實現(xiàn)方式捷沸。
pom.xml文件加入下面的代碼:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
加鎖代碼
正確代碼
public class RedisTool {
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
* 嘗試獲取分布式鎖
* @param jedis Redis客戶端
* @param lockKey 鎖
* @param requestId 請求標(biāo)識
* @param expireTime 超期時間
* @return 是否獲取成功
*/
public static boolean tryLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
可以看到摊沉,我們加鎖就一行代碼:jedis.set(String key, String value, String nxxx, String expx, int time),這個set()方法一共有五個形參:
第一個為key痒给,我們使用key來當(dāng)鎖说墨,因為key是唯一的骏全。
第二個為value,我們傳的是requestId尼斧,很多童鞋可能不明白姜贡,有key作為鎖不就夠了嗎,為什么還要用到value棺棵?原因就是我們在上面講到可靠性時鲁豪,分布式鎖要滿足第四個條件解鈴還須系鈴人,通過給value賦值為requestId律秃,我們就知道這把鎖是哪個請求加的了,在解鎖的時候就可以有依據(jù)治唤。requestId可以使用UUID.randomUUID().toString()方法生成棒动。
第三個為nxxx,這個參數(shù)我們填的是NX宾添,意思是SET IF NOT EXIST船惨,即當(dāng)key不存在時,我們進行set操作缕陕;若key已經(jīng)存在粱锐,則不做任何操作;
第四個為expx扛邑,這個參數(shù)我們傳的是PX怜浅,意思是我們要給這個key加一個過期的設(shè)置,具體時間由第五個參數(shù)決定蔬崩。
第五個為time恶座,與第四個參數(shù)相呼應(yīng),代表key的過期時間沥阳。
總的來說跨琳,執(zhí)行上面的set()方法就只會導(dǎo)致兩種結(jié)果:
當(dāng)前沒有鎖(key不存在),那么就進行加鎖操作桐罕,并對鎖設(shè)置個有效期脉让,同時value表示加鎖的客戶端。
已有鎖存在功炮,不做任何操作溅潜。
錯誤示例1
比較常見的錯誤示例就是使用jedis.setnx()和jedis.expire()組合實現(xiàn)加鎖,代碼如下:
public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
// 若在這里程序突然崩潰死宣,則無法設(shè)置過期時間伟恶,將發(fā)生死鎖
jedis.expire(lockKey, expireTime);
}
}
setnx()方法作用就是SET IF NOT EXIST,expire()方法就是給鎖加一個過期時間毅该。乍一看好像和前面的set()方法結(jié)果一樣博秫,然而由于這是兩條Redis命令潦牛,不具有原子性,如果程序在執(zhí)行完setnx()之后突然崩潰挡育,導(dǎo)致鎖沒有設(shè)置過期時間巴碗。那么將會發(fā)生死鎖。網(wǎng)上之所以有人這樣實現(xiàn)即寒,是因為低版本的jedis并不支持多參數(shù)的set()方法橡淆。
錯誤示例2
這一種錯誤示例就比較難以發(fā)現(xiàn)問題,而且實現(xiàn)也比較復(fù)雜母赵。實現(xiàn)思路:使用jedis.setnx()命令實現(xiàn)加鎖逸爵,其中key是鎖,value是鎖的過期時間凹嘲。執(zhí)行過程:1. 通過setnx()方法嘗試加鎖师倔,如果當(dāng)前鎖不存在,返回加鎖成功周蹭。2. 如果鎖已經(jīng)存在則獲取鎖的過期時間趋艘,和當(dāng)前時間比較,如果鎖已經(jīng)過期凶朗,則設(shè)置新的過期時間瓷胧,返回加鎖成功。代碼如下:
public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {
long expires = System.currentTimeMillis() + expireTime;
String expiresStr = String.valueOf(expires);
// 如果當(dāng)前鎖不存在棚愤,返回加鎖成功
if (jedis.setnx(lockKey, expiresStr) == 1) {
return true;
}
// 如果鎖存在搓萧,獲取鎖的過期時間
String currentValueStr = jedis.get(lockKey);
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
// 鎖已過期,獲取上一個鎖的過期時間遇八,并設(shè)置現(xiàn)在鎖的過期時間
String oldValueStr = jedis.getSet(lockKey, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考慮多線程并發(fā)的情況矛绘,只有一個線程的設(shè)置值和當(dāng)前值相同,它才有權(quán)利加鎖
return true;
}
}
// 其他情況刃永,一律返回加鎖失敗
return false;
}
這段代碼的錯誤之處在于:
- 由于是客戶端自己生成過期時間货矮,所以需要強制要求分布式下每個客戶端的時間必須同步。
- 當(dāng)鎖過期的時候斯够,如果多個客戶端同時執(zhí)行jedis.getSet()方法囚玫,那么雖然最終只有一個客戶端可以加鎖,但是這個客戶端的鎖的過期時間可能被其他客戶端覆蓋读规。
- 鎖不具備擁有者標(biāo)識抓督,即任何客戶端都可以解鎖。
解鎖代碼
正確代碼
public class RedisTool {
private static final Long RELEASE_SUCCESS = 1L;
/**
* 釋放分布式鎖
* @param jedis Redis客戶端
* @param lockKey 鎖
* @param requestId 請求標(biāo)識
* @return 是否釋放成功
*/
public static boolean unLock(Jedis jedis, 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";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
可以看到束亏,我們解鎖只需要兩行代碼就搞定了铃在!第一行代碼,我們寫了一個簡單的Lua腳本代碼,第二行代碼,我們將Lua代碼傳到j(luò)edis.eval()方法里定铜,并使參數(shù)KEYS[1]賦值為lockKey阳液,ARGV[1]賦值為requestId。eval()方法是將Lua代碼交給Redis服務(wù)端執(zhí)行揣炕。
那么這段Lua代碼的功能是什么呢帘皿?其實很簡單,首先獲取鎖對應(yīng)的value值畸陡,檢查是否與requestId相等鹰溜,如果相等則刪除鎖(解鎖)。那么為什么要使用Lua語言來實現(xiàn)呢丁恭?因為要確保上述操作是原子性的曹动。那么為什么執(zhí)行eval()方法可以確保原子性,源于Redis的特性牲览,簡單來說仁期,就是在eval命令執(zhí)行Lua代碼的時候,Lua代碼將被當(dāng)成一個命令去執(zhí)行竭恬,并且直到eval命令執(zhí)行完成,Redis才會執(zhí)行其他命令熬的。
錯誤示例1
最常見的解鎖代碼就是直接使用jedis.del()方法刪除鎖痊硕,這種不先判斷鎖的擁有者而直接解鎖的方式,會導(dǎo)致任何客戶端都可以隨時進行解鎖押框,即使這把鎖不是它的岔绸。
public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
jedis.del(lockKey);
}
錯誤示例2
這種解鎖代碼乍一看也是沒問題,甚至我之前也差點這樣實現(xiàn)橡伞,與正確姿勢差不多盒揉,唯一區(qū)別的是分成兩條命令去執(zhí)行,代碼如下:
public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
// 判斷加鎖與解鎖是不是同一個客戶端
if (requestId.equals(jedis.get(lockKey))) {
// 若在此時兑徘,這把鎖突然不是這個客戶端的刚盈,則會誤解鎖.鎖過期情況
jedis.del(lockKey);
}
}
如代碼注釋,這個代碼的問題在于如果調(diào)用jedis.del()方法的時候挂脑,這把鎖已經(jīng)不屬于當(dāng)前客戶端的時候會解除他人加的鎖藕漱。那么是否真的有這種場景?答案是肯定的崭闲,比如客戶端A加鎖肋联,一段時間之后客戶端A解鎖,在執(zhí)行jedis.del()之前刁俭,鎖突然過期了橄仍,此時客戶端B嘗試加鎖成功,然后客戶端A再執(zhí)行del()方法,則將客戶端B的鎖給解除了侮繁。
總結(jié)
本文介紹的Redis分布式鎖都是用JAVA實現(xiàn)虑粥,對于加鎖和解鎖的方法也分別給出了錯誤示例供大家參考。其實想要通過Redis實現(xiàn)分布式鎖難度并不高鼎天,只要能滿足上面給出的四個可靠性條件即可舀奶。