當@Transactional遇到@CacheEvict,你的代碼還運行正常嗎?

本文首發於我的微信公衆號:Coder小黑html

有bug嗎

如上圖所示,當@Transactional 遇到@CacheEvict,緩存放在 redis 中,這樣寫代碼會有什麼問題呢?大家的程序中是否寫着這樣的代碼呢?若是是,請你馬上修改!java

思考 🤔

首先,@Transactional是給當前方法添加事務支持,是經過 AOP 動態代理實現的,在方法執行完以後才提交事務。其次,@CacheEvict是在該方法執行完以後,清除 redis 中的緩存,也是使用 AOP 動態代理實現的。mysql

那麼,上述方法想表達語義應該是:先保存對象,提交事務,而後清除緩存。可是,這樣寫真的能達到這個語義嗎?redis

Debug 尋找真相 👣

首先,執行清除緩存的是org.springframework.cache.Cache#evict方法,此處又是使用 redis 做爲緩存的提供者,因此在清除緩存時,必然會調用 redis 緩存實現類的方法,即:org.springframework.data.redis.cache.RedisCache#evict。因而,在該方法處加一個斷點。spring

org.springframework.data.redis.cache.RedisCache#evict

對於 JDBC 事務而言,想要提交事務,那就必需要調用java.sql.Connection#commit方法。因爲筆者此處使用的是 MySQL 數據庫,因此這裏對應的實現類爲com.mysql.jdbc.ConnectionImpl#commit。因而,一樣在該方法加一個斷點。sql

com.mysql.jdbc.ConnectionImpl#commit

打上斷點以後,讓咱們來運行程序。數據庫

demo程序

在執行 save 方法以前,經過調用 getById 方法已經將對應的數據緩存到了 redis 中。同時,數據庫中 countNumber 的值爲 1。編程

添加緩存到redis中

程序再向下運行,能夠發現,首先命中了org.springframework.data.redis.cache.RedisCache#evict方法的斷點,執行完該方法以後,能夠看到,對應的緩存數據已被清除。緩存

緩存已被清除

由於尚未中事務提交的斷點,因此此時很明顯數據庫中對應 id 爲 1 的記錄的 countNumber 值依舊爲 1。微信

數據庫中的記錄

程序再向下執行,則執行事務提交。

提交事務

執行完 commit 方法以後,事務提交,對應記錄更新成功。

更新成功

到這裏也就解決了本文開篇所提到的問題,咱們但願程序是先提交事務,而後更新緩存。而真正的執行順序是,先清除緩存,而後提交事務

那這樣會有什麼問題呢?先清除緩存,而後在事務尚未提交以前,程序就收到了用戶的請求,發現緩存中沒有數據,則去數據庫中獲取數據(事務尚未提交則獲取到舊值),同時將獲取的數據添加到緩存中。此時會致使數據庫和緩存數據不一致。

如何解決 👀

方案 1:修改代碼,縮小事務範圍

事務是一個很容易出問題的操做,@Transactional事務不要濫用 ,用的時候要儘量的縮小事務範圍,在事務方法中只作事務相關的操做。引用阿里巴巴 Java 開發手冊的一句話:

image.png

縮小事務範圍

方案 2:修改 AOP 執行順序

若是能夠改爲先提交事務,再清除緩存,同樣能夠解決這個問題。那 Spring 中有沒有什麼方法能夠去修改 AOP 的執行順序呢?

@Transactional@CacheEvict都是經過動態代理來實現的,在執行 save 方法處打一個斷點,命中斷點以後,點擊Step Into,就能夠進入到代理對象的執行方法內。

step into

CglibAopProxy.DynamicAdvisedInterceptor#intercept

能夠看到,執行 save 方法以前,被CglibAopProxy.DynamicAdvisedInterceptor#intercept方法所攔截了。

在 SpringBoot2.0 以後,SpringBoot 中 AOP 的默認實現被設置成了默認使用 CGLIB 來實現了。具體能夠閱讀筆者以前的文章:

mp.weixin.qq.com/s/oyH4GVwJe…

Spring5 AOP 默認使用 CGLIB ?從現象到源碼的深度分析

image.png

經過 debug 能夠發現:advised.advisors是一個 List,List 中的兩個 Advisor 分別爲:

org.springframework.cache.interceptor.BeanFactoryCacheOperationSourceAdvisor: advice org.springframework.cache.interceptor.CacheInterceptor@4b2e3e8f

org.springframework.transaction.interceptor.BeanFactoryTransactionAttributeSourceAdvisor: advice org.springframework.transaction.interceptor.TransactionInterceptor@27a97e08

那咱們要怎麼樣去修改 List 內元素的順序呢?

經過查看BeanFactoryCacheOperationSourceAdvisorBeanFactoryTransactionAttributeSourceAdvisor的源碼可知,這兩個類均繼承了org.springframework.aop.support.AbstractPointcutAdvisor,而AbstractPointcutAdvisor這個抽象類實現了org.springframework.core.Ordered接口。

猜測:那咱們是否是能夠經過修改 getOrder()方法的返回值來影響 List 中的排序呢?

org.springframework.aop.support.AbstractPointcutAdvisor

BeanFactoryTransactionAttributeSourceAdvisor爲例,order 的值來自於AnnotationAttributes enableTx對象的某個屬性。

ProxyTransactionManagementConfiguration#transactionAdvisor

經過源碼能夠發現,AnnotationAttributes enableTx的屬性所有都來自於@EnableTransactionManagement註解。

AbstractTransactionManagementConfiguration#setImportMetadata

@EnableTransactionManagement

同理,@EnableCaching註解上也能夠配置 order,這裏不在贅述。

下面,咱們就來嘗試解決這個問題,看可否經過配置 order 來修改 AOP 的執行順序。

修改AOP執行順序

經過@EnableCaching(order = Ordered.HIGHEST_PRECEDENCE)這個屬性值的配置,運行程序以後,的確作到了先提交事務,再清理緩存的效果,bug 修復成功~~

至於這個 order 設置是怎麼生效的,本文就不在此進行相關說明了。感興趣的讀者能夠自行參閱相關源碼,對應的源碼在org.springframework.aop.framework.autoproxy.AbstractAdvisorAutoProxyCreator#getAdvicesAndAdvisorsForBean,同時使用的比較器爲:org.springframework.core.annotation.AnnotationAwareOrderComparator

Advice Ordering

看到這裏不知道讀者有沒有疑問,優先級越高不是應該越先執行嗎?!緩存 AOP 的優先級最高怎麼比事務提交 AOP 執行的時機要晚呢?

咱們來查閱一下 Spring 的官方文檔:

docs.spring.io/spring/docs…

Advice Ordering

簡單翻譯一下:(這個英文翻譯有點難,建議你們閱讀原文)

當多個 advice 運行在同一個 join point 時會怎麼樣呢? Spring AOP 遵循與 AspectJ 相同的優先級規則來肯定建議執行的順序。能夠經過實現org.springframework.core.Ordered接口或者使用@Order註解來控制其執行順序。優先級最高的 advice 首先「在入口」運行,從 join point「出來」時,優先級最高的 advice 將最後運行。

那應該怎麼理解呢?

能夠把 Spring AOP 想象成一個同心圓。被加強的原始方法在圓心,每一層 AOP 就是增長一個新的同心圓。同時,優先級最高的在最外層。方法被調用時,從最外層按照 AOP一、AOP2 的順序依次執行 around、before 方法,而後執行 method 方法,最後按照 AOP二、AOP1 的順序依次執行 after 方法

AOP

總結

當@Transactional 遇到@CacheEvict,默認設置的狀況下,可能會由於先清除緩存後提交事務,從而產生緩存和數據庫數據不一致的問題。

同時,文本也提出了兩種解決方案。可是,筆者更建議使用方案 1,由於方案 1 更多的是體現了一種編程思想,讓事務方法儘量的小。

做業

閱讀下面源碼:

@Transactional
public synchronized void increment(Integer id) {
  Counter counter = counterRepository.getOne(id);
  counter.setCountNumber(counter.getCountNumber() + 1);
  counterRepository.save(counter);
}
複製代碼

思考:在單 JVM 的多線程環境下,該方法是會產生什麼問題?


歡迎關注公衆號,一塊兒學習成長。

Coder小黑
相關文章
相關標籤/搜索