本文是上一篇文章 分布式鎖可以這么簡單? 的續篇,主要是記錄分析在封裝過程中碰到的難點以及對應的解決方案。
注:閱讀本文需要有一定的 面向切面編程 的基礎。
準備工作
要把問題暴露出來,需要先把部分代碼注釋一下。
1. 注釋 TransactionEnhancerAspect 的注入代碼
把 DistributionLockAutoConfiguration
中的以下幾行代碼注釋掉:
2. DistributedLockAspect 不實現接口 Ordered
分布式鎖注解不生效?
先來看一個測試用例:
public class TestItemService {
// 省略其他
@DistributedLock(
lockName = "#{#testItem.id}",
lockNamePre = "item",
checkBefore = "#{#root.target.checkbefore(#testItem)}"
)
@Transactional(rollbackFor = Throwable.class)
public Integer testInoperative(TestItem testItem) {
sleep(100L);
TestItem item = this.getById(testItem.getId());
Integer stock = item.getStock();
System.out.println(String.format("current thread: %s, current stock: %d", getCurrentThreadName(), item.getStock()));
if (stock > 0) {
stock = stock - 1;
item.setStock(stock);
this.saveOrUpdate(item);
} else {
stock = -1;
}
return stock;
}
public void checkbefore(TestItem testItem) {
TestItem item = this.getById(testItem.getId());
System.out.println(String.format("current thread: %s, check before. stock: %d", getCurrentThreadName(), item.getStock()));
}
}
public class DistributedLockTests {
@Test
public void testInoperative() {
Consumer<TestItem> consumer = testItem -> {
Integer stock = testItemService.testInoperative(testItem);
if (stock >= 0) {
System.out.println(Thread.currentThread().getName() + ": rest stock = " + stock);
} else {
System.out.println(Thread.currentThread().getName() + ": sold out.");
}
};
commonTest(consumer);
}
}
啟動測試用例,結果類似如下:
可以看到,所有線程都正常扣庫存了,但全部跑完后,數據庫的庫存還剩下 7,這還得了?
其中 check before. stock: 8
是在方法 checkbefore
中打印的,該方法在獲得鎖之前就會被執行。
那為什么會出現這種情況呢?明明已經加了鎖了呀。
在回答這個問題之前,需要知道一個數據庫的知識點,即數據庫事務的隔離級別。一共有4種隔離級別,分別為:讀未提交(Read uncommitted)、讀已提交(Read committed)、可重復讀(Repeatable read)、串行讀(Serializable),由于這不是本文的重點,這里就不展開說了。
因為本文使用的是 Mysql
,所以就拿它來說明。Mysql
的默認隔離級別為 可重復讀,該隔離級別有什么特點呢?先看下面的圖:(比較熟悉可以跳過這部分)
在數據庫的可視化界面新開2個查詢窗口,分別對應上圖的 Session A
,Session B
,然后按步驟,一次在2個窗口中執行。根據執行的結果,可以看出,事務B
開啟后,無論 事務A
如何操作,id = 1
的庫存一直都為 10,直到 事務B
結束后,才能看到 事務A
提交的數據庫操作結果。
明白了這點之后,我們再來看另一張圖:
上面的圖,是將上面測試用例的重點流程可視化后的結果(只畫出前2個線程)。當有多個切面時,遵循的是先進后出的原則,比如上圖中有2個切面,一前一后分別是 Transactional Aspect
、DistributedLock Aspect
,所以進入對應的方法時,會先執行 Transactional Aspect
的相關邏輯,再執行 DistributedLock Aspect
的相關邏輯。
上圖中,比較有爭議的地方是 步驟12,為什么獲取到的庫存是 8?線程1 明明已經把庫存更新為 7,且事務已經提交。
其實,可以看到,線程2 在等待鎖的時候,已經開啟了一個新事務,再根據 可重復讀 隔離級別的特點,可以輕松得出:無論線程1 怎么操作數據庫并提交,無論增刪改,只要 線程2 的事務沒有提交,對于 線程2 來說都是不可見的,所以 線程2 獲取到的庫存是 8。
這樣一分析下來,很明顯,獲得鎖的邏輯不能放在 事務開啟 之后,即 DistributedLock Aspect
要在 Transactional Aspect
之前,這樣一來,都是拿到分布式鎖后,才去開啟事務,這樣才不會出現上面測試用例的情況。
問題找到了,那要怎么保證這兩個切面的先后順序呢?Spring AOP
已經考慮到這點,可以讓切面類通過實現接口 org.springframework.core.Ordered
,該接口需要實現一個方法 int getOrder()
,Spring AOP
會根據返回的數值去對切面進行排序,數值越小,優先級越高,即越先執行。
所以我們只需要在構造 DistributedLock Aspect
的時候,動態獲取 Transactional Aspect
的 order
數值,然后返回一個比它小的數值即可。問題來了,如何動態獲取 Transactional Aspect
的 order
數值?要解決這個問題,需要對 Spring 事務
的源碼有一定了解,文末有分析源碼的相關文章,有興趣的可以去研究研究。
Spring 事務
的開啟,涉及到一個注解 @EnableTransactionManagement
,該注解有一個屬性 order
,該 order
就是我們想要的,下面來看看如何在 Spring 容器
啟動階段,動態獲取 order
數值。
public class DistributedLockAspect implements ApplicationContextAware, Ordered {
// ... 省略其他
public int getOrder() {
try {
int minOrder = Integer.MAX_VALUE;
Map<String, Object> beansWithAnnotation = applicationContext.getBeansWithAnnotation(EnableTransactionManagement.class);
for (Map.Entry<String, Object> entry : beansWithAnnotation.entrySet()) {
Object value = entry.getValue();
Class<?> proxyTargetClass = ClassUtils.getUserClass(value);
EnableTransactionManagement annotation = proxyTargetClass.getAnnotation(EnableTransactionManagement.class);
int order = annotation.order();
minOrder = Math.min(order, minOrder);
}
return Math.min(0, minOrder) - 1;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
簡單解釋一下上面的代碼,首先,applicationContext.getBeansWithAnnotation
可以獲取 Spring
容器中,所有被給定注解標記過的 Bean
;將這些 Bean
找出來后,接著遍歷所有 Bean
,因為 Spring
容器中的 Bean
大多都是代理對象,所以這里使用 ClassUtils.getUserClass
來獲取所代理的類對象,最后即可找出標記 EnableTransactionManagement
注解的時候,設置的 order
數值,一般都為默認值。最后,我們只要保證比它的 order
數值小并返回就行。
將上面的代碼補充到 DistributedLockAspect
后,再重新啟動上面的測試用例,可以看到類似如下:
終于正常了。
鎖被動釋放后,數據無法正常回滾
先來看一個新的測試用例:
public class TestItemService {
@DistributedLock(
lockName = "#{#testItem.id}",
lockNamePre = "item",
checkBefore = "#{#root.target.checkbefore(#testItem)}"
)
@Transactional(rollbackFor = Throwable.class)
public Integer testRollbackWhenLostTheLock(TestItem testItem) {
TestItem item = this.getById(testItem.getId());
Integer stock = item.getStock();
int ci = i.getAndDecrement();
if (ci == 10) {
System.out.println(String.format("current thread: %s, got the lock first, now sleep a few seconds.", getCurrentThreadName()));
sleep(6000L);
}
System.out.println(String.format("current thread: %s, current stock: %d", getCurrentThreadName(), item.getStock()));
if (stock > 0) {
stock = stock - 1;
item.setStock(stock);
this.saveOrUpdate(item);
} else {
stock = -1;
}
return stock;
}
}
public class DistributedLockTests {
@Test
public void testRollbackWhenLostTheLock() {
Consumer<TestItem> consumer = testItem -> {
Integer stock = testItemService.testRollbackWhenLostTheLock(testItem);
if (stock >= 0) {
System.out.println(Thread.currentThread().getName() + ": rest stock = " + stock);
} else {
System.out.println(Thread.currentThread().getName() + ": sold out.");
}
};
commonTest(consumer);
}
}
啟動測試用例,類似如下:
不出意外的話,這時數據庫中的庫存為 7,很明顯,這又有問題。
上面的測試用例是什么邏輯呢?第1個獲得鎖的線程(線程1),獲得數據后,休眠了 6s
,而鎖的有效時間為 5s
,也就是說休眠恢復后,鎖已經被動釋放,且被其他線程獲得,這個時候 線程1 還以為鎖還是它所持有,然后繼續剩余的流程,最后把庫存更新為 7,但其實這個時候的庫存已經為 0 了,所以這個時候如果把數據更新到數據庫,那大概率是錯誤的。
針對這種情況,應該怎么辦呢,既然提交的數據已經不可信了,那肯定是不能讓它正常 commit
,必須讓它回滾掉。
我們都知道,加上注解 @Transactional(rollbackFor = Throwable.class)
后,如果方法執行過程中拋異常的話,會回滾數據。因此,我們可以借助這一特性,進行一系列改造,讓其在 commit
之前,先檢查一下鎖是否由當前線程持有,如果是,則可以放心 commit
,如果不是,那就大膽回滾吧。
聰明的你,這時候可能注意到了控制臺打印的一行文字:鎖釋放失敗, 當前線程不是鎖的持有者
,如果把打印邏輯換成 拋異常,這樣能否達到我們想要的效果呢?可以嘗試一下,按照下圖進行改造,改造的類名為 RedisDistributedLockTemplate
:
再次運行測試用例,可以看到類似如下:
異常倒是能正常拋出,但看了下數據庫,好像沒達到預想中的效果,庫存還是為 7,為啥呢?
在解決前一個問題的時候,我們已經保證了一點—— 分布式鎖定切面的優先級高于 事務切面,加了這個改造后,會對數據的回滾有什么影響呢?先來看一張圖:
這是改造后的流程圖,可以看到 釋放鎖 的時候,事務其實已經 commit
了,那這個時候去拋異常,顯然是沒辦法觸發回滾的,那怎么辦呢?我們先來簡單分析一下:
這兩個切面的順序肯定是不能調整的,又必須得在事務 commit
前判斷鎖是否由當前線程持有,然后兩個切面的邏輯也是非常獨立的,既然這樣,直接改 Transactional Aspect
的相關源碼肯定是行不通的,那就只能找找 Transactional Aspect
有沒有什么可以擴展的地方了。
在解決這個問題之前,又得先了解幾個知識點,一個是 當有多個切面的時候,每個切面的 Advice
的詳細執行順序,這一點,下面的圖就能很明顯看出來了(其中藍色的 Method
是目標業務方法):
如果把 DistributedLock Aspect
和 Transactional Aspect
套進去的話,切面A 就是 DistributedLock Aspect
,切面B 就是 Transactional Aspect
。
另一個是 事務是在什么時間點開啟和結束的,這里先直接說答案,是在 @Arround
階段就開啟的,而且也是在該階段 commit
的;
最后一個是 Spring 事務 提供的一個接口 TransactionSynchronization
,這個接口有一個方法 void beforeCommit(boolean readOnly)
,該方法會在事務提交前被調用,當然,前提是 在事務開啟后需要將該接口的實現類注冊到 TransactionSynchronizationManager
中,注冊成功后會被存儲在一個 ThreadLocal<Set<TransactionSynchronization>>
中,且僅限當前事務有效。詳細的可研究推薦閱讀給出的參考鏈接:Spring 事務源碼分析。
將這3個知識點關聯起來,基本就可以得出一個解決方案:只有在下圖圈起來的 @Before Advice
將 TransactionSynchronization
的實現類注冊到 TransactionSynchronizationManager
中。具體原因可參考附錄:為什么增強邏輯只能在 Before Advice
。
接下來看具體實現方法。
TransactionEnhancerAspect
定義一個切面類
@Aspect
public class TransactionEnhancerAspect {
private final DistributedLockTemplate distributedLockTemplate;
public TransactionEnhancerAspect(DistributedLockTemplate distributedLockTemplate) {
this.distributedLockTemplate = distributedLockTemplate;
}
@Before(value = "@annotation(transactional)")
public void doBefore(JoinPoint jp, Transactional transactional) {
// 是否開啟了 可寫的事務
if (!isWithinWritableTransaction()) {
return;
}
DistributedLock annotation = PointCutUtils.getAnnotation(jp, DistributedLock.class);
if (annotation == null) {
return;
}
// 該 context 在創建鎖的時候初始化的,并把 lock 對象設置進去
DistributedLockContext context = DistributedLockContextHolder.getContext();
Object lock = context.getLock();
UnlockFailureProcessor unlockFailureProcessor = new UnlockFailureProcessor(distributedLockTemplate, lock);
TransactionSynchronizationManager.registerSynchronization(unlockFailureProcessor);
}
/**
* 是否在一個可寫的事務中
*
* @return
*/
private boolean isWithinWritableTransaction() {
boolean isTransactionActive = TransactionSynchronizationManager.isActualTransactionActive();
boolean isTransactionReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
return isTransactionActive && !isTransactionReadOnly;
}
}
UnlockFailureProcessor
定義一個 TransactionSynchronization
實現類
public class UnlockFailureProcessor implements TransactionSynchronization {
private final Object lock;
private final DistributedLockTemplate distributedLockTemplate;
public UnlockFailureProcessor(DistributedLockTemplate distributedLockTemplate, Object lock) {
this.distributedLockTemplate = distributedLockTemplate;
this.lock = lock;
}
@Override
public void beforeCommit(boolean readOnly) {
boolean heldByCurrentThread = distributedLockTemplate.isHeldByCurrentThread(lock);
if (!heldByCurrentThread) {
ResponseEnum.LOCK_NO_MORE_HOLD.assertFailWithMsg("釋放鎖時, 當前線程不是鎖的持有者");
}
}
}
可以看到,在 commit
之前,判斷是否當前線程持有鎖,如果不是,拋異常,讓數據回滾。
其他
上面介紹的類中,涉及到的 DistributedLockContextHolder
、DistributedLockContext
因為比較簡單也不影響整體代碼邏輯,就不貼源碼了。
最后,只需要在 DistributionLockAutoConfiguration
配置類中,加入 TransactionEnhancerAspect
的注入 Spring 容器
邏輯。
測試驗證
經過這一番改造,基本即可以解決問題,下面再運行一下測試用例。(最好把之前在 RedisDistributedLockTemplate
中添加的拋異常去掉,不然可能會看到拋2個異常)
啟動測試用例,可以看到類似如下:
可以看到,第一個拿到鎖的線程,最后拋異常了,符合預期,再看看數據庫的庫存,如果不出意外,看到的是 0,那就代表成功了。
結語
至此,已經把我認為比較有挑戰的難點列出,并拆解、分析、解決了,至于其他,各位看官如果覺得哪里有疑惑的,可以評論留言,一起討論、學習。
謝謝~~
推薦閱讀
附錄
為什么增強邏輯只能在 Before Advice
源碼 TransactionAspectSupport#invokeWithinTransaction
上圖中,第一個圈中的地方,是創建一個新事務(有必要的話);
第二個圈中的地方,注釋的內容簡單翻譯一下,這里是一個 Around Advice
,當前的位置只是攔截器鏈中的某一個,需要繼續觸發下一個攔截器;
第三個圈中的地方,是目標方法返回后, commit
事務。
所以,Transactional Aspect
在 around advice
階段完成后,事務的相關邏輯基本都 完成了,之后的 After Advice
、AfterReturning Advice
這2個階段都已不在事務中了,所以能注冊 TransactionSynchronization
的階段就只有 Before Advice
了。