Spring data jpa 緩存機制講解
Spring data jpa 的使用讓我們操作數據庫變得非常簡單,開發人員只需要編寫repository接口,Spring將自動提供實現,尤其是基礎的的CURD 操作,爲我們封裝好的同時也做了一些性能上的優化。但也正因爲如此,這些基礎的操作的背後並不是那麼簡單,稍有不慎就會得到我們意料之外的結果,接下來列舉一些工作中遇到的問題。
一 案例
項目中遇到過這樣一個問題,repository繼承了CrudRepository接口,直接使用save(S entity) 方法進行數據保存,但是因爲某個字段的唯一約束衝突了,導致保存失敗並拋出了異常,但是save方法後的代碼邏輯卻執行了,將數據保存到redis,這導致了數據庫和redis數據不一致。代碼代碼大概是這樣子:
@Override
@Transactional
public void save(SomeThingVo vo){
SomeThingEntity entity = new SomeThingEntity();
BeanUtils.copyProperties(vo,entity);
//保存至數據庫
someThingRepository.save(entity);
//緩存
cacheSomeThing(entity);
//做一些其他事
doSomeThingElse();
}
然後對這個操作進行了debug,發現到save方法結束,是沒有拋出異常的,然後繼續進行保存redis等操作,直到方法結束才拋出了異常。這時注意到了@Transactional註解加在了這個方法之上,那就是事務提交時纔會報出 唯一約束衝突的異常,再聯想到Spring data Jpa的是用Hibernate實現的 , Hibernate是有緩存機制的,猜想不使用jpa自帶的save方法,就可以在保存時直接拋異常,而不執行之後的代碼,然後進行嘗試,的確如此;還有一種解決方式是使用saveAndFlush方法,立馬將緩存中的實體bean刷入數據庫。
二 分析
Hibernate緩存包括兩大類:一級緩存和二級緩存。
一級緩存又稱爲“Session的緩存”,它是內置的,不能被卸載(不能被卸載的意思就是這種緩存不具有可選性,必須有的功能,不可以取消session緩存)。由於Session對象的生命週期通常對應一個數據庫事務或者一個應用事務,因此它的緩存是事務範圍的緩存在第一級緩存中,持久化類的每個實例都具有唯一的OID。我們使用@Transactional 註解時,JpaTransactionManager會在開啓事務前打開一個session,將事務綁定在這個session上,事務結束session關閉,所以後續內容將以粗略以事務作爲一級緩存的生存時段。
二級緩存又稱爲“SessionFactory的緩存”,由於SessionFactory對象的生命週期和應用程序的整個過程對應,因此二級緩存是進程範圍或者集羣範圍的緩存,有可能出現併發問題,因此需要採用適當的併發訪問策略。第二級緩存是可選的,是一個可配置的插件,在默認情況下,SessionFactory不會啓用這個插件,二級緩存應用場景侷限性比較大,適用於數據要求的實時性和準確性不高、變動很少的情況,此次我們僅針對一級緩存進行詳細說明。
我們使用CrudRepository.save() 方法保存或更新對象的流程如下
從上圖可以看出每次save方法執行時都會用主鍵向數據庫發起一次查詢,來判斷是更新還是插入,此時spring data jpa 不會立馬向數據庫發送命令,而是將這條數據保存在一級緩存之中,然後返回緩存中實體對象,接下來繼續執行後續的代碼。如果想更新這條數據的值,可以直接修改這個實體對象,jpa會在事前提交之前的某個點(具體後面會說明)自動將這些變更的數據保存至數據庫,並且在事務期間查詢這條數據都是優先從緩存中獲取數據。
一級緩存的作用還是很明顯的,在整個事務中,在對同一條數據進行了保存更新查詢操作都會以儘量少地請求數據庫的方式進行優化,降低了網絡io開銷。
三 聯想
有利就有弊,就像第一部分描述的,因爲延遲提交 ,數據的正確性驗證(數據庫限制方面,比如約束)並沒有立馬執行,有時候完全是我們不能承受的,我們想要的效果並不是這樣。接下來設想一下其他場景:
1、 何時會將數據提交至數據庫?
實際上這中情況是不存在的。測試代碼和結果如下:
代碼:
1 @Transactional(rollbackFor = {Exception.class})
2 public SomeThingEntity save(SomeThingVo vo) {
3 SomeThingEntity entity = new SomeThingEntity();
4 BeanUtils.copyProperties(vo,entity);
5 SomeThingEntity someThingEntity = someThingRepository.save(entity);
6 log.info("保存方法結束");
7 String code = "GOODS_" + someThingEntity.getCode() ;
8 someThingEntity.setCode(code);
9 log.info("開始查找");
10 SomeThingEntity searchThing = someThingRepository.searchByCode(code);
11 log.info("查找結果:{}" , searchThing);
12 SomeThingEntity getThing = someThingRepository.getOne(someThingEntity.getId());
13 log.info("執行了一次JPA查詢\n\r" +
14 "someThingEntity == getThing : {}\n\r" +
15 "searchThing == getThing :{}" , someThingEntity == getThing , searchThing == getThing );
16 return someThingEntity;
17 }
打印日誌:
1 Hibernate: select somethinge0_.id as id1_3_0_, somethinge0_.code as code2_3_0_, somethinge0_.description as descript3_3_0_, somethinge0_.price as price4_3_0_ from tb_something somethinge0_ where somethinge0_.id=?
2 保存方法結束
3 開始查找
4 Hibernate: insert into tb_something (code, description, price, id) values (?, ?, ?, ?)
5 Hibernate: update tb_something set code=?, description=?, price=? where id=?
6 Hibernate: select somethinge0_.id as id1_3_, somethinge0_.code as code2_3_, somethinge0_.description as descript3_3_, somethinge0_.price as price4_3_ from tb_something somethinge0_ where somethinge0_.code=?
7 查找結果:SomeThingEntity(id=5, code=GOODS_005, price=100, description=書包)
8 執行了一次JPA查詢
9 someThingEntity == getThing : true
10 searchThing == getThing :true
11 Hibernate: update tb_something set code=?, description=?, price=? where id=?
從日誌可見:
save()方法執行時只打印了一個查詢sql
someThingRepository.searchByCode()方法執行前各打印了一條插入sql和更新sql
someThingRepository.searchByCode() 進行了查詢
getOne()並沒有打印sql,直接獲取緩存中的對象
最後比對這些實體都是同一個對象,即緩存中的對象。
將代碼中someThingRepository.searchByCode方法改爲其他讀寫語句,嘗試多次,得出以下結論:
(1)未提交至數據庫的操作會在下次請求到數據庫時一起提交至數據庫執行
(2)在事務提交前存在未提交的數據,會提交至數據庫執行
2、實體對象加入緩存後,我們寫sql更新數據,再用自己的sql獲取這條數據,得到的是緩存中的數據還是更新後的數據
這次測試代碼和結果如下:
代碼:
1 @Transactional(rollbackFor = {Exception.class})
2 public SomeThingEntity save(SomeThingVo vo) {
3 SomeThingEntity entity = new SomeThingEntity();
4 BeanUtils.copyProperties(vo,entity);
5 SomeThingEntity someThingEntity = someThingRepository.save(entity);
6 log.info("開始更新");
7 Integer fenPrice = entity.getPrice() * 100;
8 someThingRepository.updatePriceByCode(someThingEntity.getCode(),fenPrice);
10 //Session session = (Session) entityManger.getDelegate();
11 //session.clear();
12 SomeThingEntity searchThing = someThingRepository.searchByCode(someThingEntity.getCode());
13 log.info("searchThing = {}",searchThing);
14 log.info("searchThing == someThingEntity {}",searchThing == someThingEntity);
15 //someThingEntity.setDescription("");
16 return someThingEntity;
17 }
傳入參數:{id=20,code='GOODS_020",price=100,description="書包"}
打印日誌:
1Hibernate: select somethinge0_.id as id1_3_0_, somethinge0_.code as code2_3_0_, somethinge0_.description as descript3_3_0_, somethinge0_.price as price4_3_0_ from tb_something somethinge0_ where somethinge0_.id=?
2 開始更新
3 Hibernate: insert into tb_something (code, description, price, id) values (?, ?, ?, ?)
4 Hibernate: update tb_something set price=? where code=?
5 Hibernate: select * from tb_something where code = ?
6 searchThing = SomeThingEntity(id=20, code=GOODS_020, price=100, description=書包)
7 searchThing == someThingEntity true
數據庫結果:
{id=20,code='GOODS_020",price=10000,description="書包"}
從日誌中可見:
someThingRepository.updatePriceByCode(someThingEntity.getCode(),fenPrice) 執行打印了相關更新sql(第4行日誌),目的 將price由100 改爲10000
我們的查詢方法向數據庫發起了查詢;
打印的結果不是我們更新後的結果,price仍然爲100;
查詢的結果對象和緩存中的對象比較,是同一個對象;
測試說明:
執行我們的查詢方法後,jpa返回給我們的仍然是緩存中的值,這樣子的話我們在這個事務中怎麼查詢都拿不到我們變更後的值! jpa不會根據我們的update方法自動刷新緩存,後邊查詢出來的數據也不會覆蓋緩存中的數據。
那麼一些同學可能會把一個事務涵蓋內容的比較多,在頂層的service就加了@Transactional ,就可能在一些操作上進入了這樣的場景,在緩存存在的情況,手動update,後續有去查詢使用,最終使用了錯誤的數據。
如果非要在當前事務中查詢到正確數據的話,那就手動清除session中的緩存吧(上述代碼中 10、11行)。
另外,放開上述代碼中的15行,最終保存在數據庫的結果爲 {id=20,code='GOODS_020",price=100,description=""} ,price的值會被緩存中的覆蓋。
四 總結
Spring data jpa 的這些操作都是簡單常用而又容易忽視的,我們在使用時要考慮一下是否得當。
對於這樣的緩存機制我們要做的是 將事務控制在合適的範圍,將不需要在事務中執行的內容就移出去;在需要sql明確執行好的情況,就主要避開使用會延遲提交的方法。
規範的代碼和設計是質量的一個重要保證之一。