剛準備下班走人,被一開發同事叫住,讓幫看一個比較奇怪的問題:Mybatis同一個Mapper介面的查詢方法,第一次返回與第二次返回結果不一樣,百思不得其解!
問題
Talk is cheap. Show me the code. 該問題涉及的主要程式碼實現包括
mapper介面定義
public interface GoodsTrackMapper extends BaseMapper<GoodsTrack> { List<GoodsTrackDTO> listGoodsTrack(@Param("criteria") GoodsTrackQueryCriteria criteria); }複製程式碼
xml定義
<select id="listGoodsTrack" resultType="xxx.GoodsTrackDTO"> SELECT ... </select>複製程式碼
service定義
@Service
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true, rollbackFor = Exception.class)
public class GoodsTrackService extends BaseService<GoodsTrack, GoodsTrackDTO> {
@Autowired
private GoodsTrackMapper goodsTrackMapper;
public List<GoodsTrackDTO> listGoodsTrack(GoodsTrackQueryCriteria criteria){
return goodsTrackMapper.listGoodsTrack(criteria);
}
public List<GoodsTrackDTO> goodsTrackList(GoodsTrackQueryCriteria criteria){
List<GoodsTrackDTO> listGoodsTrack = goodsTrackMapper.listGoodsTrack(criteria);
Map<String, GoodsTrackDTO> goodsTrackDTOMap = new HashMap<String, GoodsTrackDTO>();
for (GoodsTrackDTO goodsTrackDTO : listGoodsTrack){
String goodsId = String.valueOf(goodsTrackDTO.getGoodsId());
if (!goodsTrackDTOMap.containsKey(goodsId)){
goodsTrackDTOMap.put(goodsId, goodsTrackDTO);
}else {
GoodsTrackDTO goodsTrack = goodsTrackDTOMap.get(goodsId);
int num = goodsTrack.getGoodsNum() + goodsTrackDTO.getGoodsNum();
goodsTrack.setGoodsNum(num);
}
}
List<GoodsTrackDTO> list = new ArrayList(goodsTrackDTOMap.values());
return list;
}
}
@Service
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true, rollbackFor = Exception.class)
public class GoodsOrderService extends BaseService<GoodsOrder, GoodsOrderDTO> {
@Autowired
private GoodsTrackService goodsTrackService;
@Override
public GoodsOrderDTO create(GoodsOrderDTO goodsOrderDTO) {
//...
List<GoodsTrackDTO> rs1 = goodsTrackList(criteria);
//...
List<GoodsTrackDTO> rs2 = listGoodsTrack(criteria);
//...
}
}複製程式碼
大致邏輯就是在 GoodsTrackService
定義了兩個查詢方法,一個是直接從資料庫中獲取資料,第二個是從資料庫中獲取資料後進行了一些加工(通過某個欄位進行合併累加,類似sum group by),然後在GoodsOrderService
的同一個方法(該方法是一個事務方法 )中呼叫這兩個查詢,發現rs2中的資料存在問題, 期望是都應該與資料庫表的資料一致,但其中部分資料卻與查出後進行了修改的rs1中的一致。
定位
初步看,listGoodsTrack
方法直接呼叫的mapper方法 goodsTrackMapper.listGoodsTrack(criteria)
沒做任何應用層的處理,第一反應是快取的原因。 我問前面的查詢有沒有改變查詢返回的結果(一開始沒細看具體實現),答曰沒有。折騰一陣後,返過去細看 goodsTrackList
的實現,果然還是眼見為實、耳聽為虛。在該方法中,通過goodsId對返回的列表進行分組,對goodsNum進行累加,最後返回累加後的幾個物件。但是在累加的時候,是直接作用於返回結果物件的,明明就是改變了查詢結果(居然說沒有?!!)。 這就是問題所在了,mybatis在同一個事務中,對同一個查詢(同樣的sql,同樣的引數)的返回結果進行了快取(稱為一級快取),下一次做同樣的查詢時,如果中間沒有任何更新操作,則直接返回快取的資料,而在本例中因為對快取資料做了人為的修改,所以最後導致查出的資料與資料庫不一致。
mybatis快取機制
簡單介紹下mybatis的兩級快取機制
一級快取:一級快取包括SqlSession與STATEMENT兩種級別,預設在 SqlSession 中實現。在一次會話中,如果兩次查詢sql相同,引數相同,且中間沒有任何更新操作,則第二次查詢會直接返回第一次查詢快取的結果,不再請求資料庫。如果中間存在更新操作,則更新操作會清除掉快取,後面的查詢就會訪問資料庫了。STATEMENT級別則每次查詢都會清掉一級快取,每次查詢都會進行資料庫訪問。
二級快取:二級快取則是在同一個namesapce的多個 SqlSession 間共享的快取,預設未開啟。當開啟二級快取後,資料查詢的流程就是 二級快取 ——> 一級快取 ——> 資料庫, 同一個namespace下的更新操作,會影響同一個Cache。
如何開啟二級快取
- 需要在mybatis-config.xml中設定:
<settings> <setting name="cacheEnabled" value="true"/> </settings>複製程式碼
- 然後在mapper的xml檔案的<mapper>下設定cache相關配置:
<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/> 複製程式碼
支援的屬性:
- type:cache使用的型別,預設是PerpetualCache
- eviction: 回收的策略,常見的有LRU,FIFO
- flushInterval: 配置一定時間自動重新整理快取,單位毫秒
- size: 最多快取的物件個數
- readOnly: 是否只讀,若配置為可讀寫,則需要對應的實體類實現Serializable介面
- blocking: 如果快取中找不到對應的key,是否會一直blocking,直到有對應的資料進入快取
也可以使用 <cache-ref namespace="mapper.UserMapper"/>
來與另一個mapper共享二級快取
解決
已經定位到是由於mybatis的一級快取導致,那如何解決本文提到的問題呢? 基本上有三個解決方向。
- 使用快取的方案
既然要使用快取,那就不能更改快取的資料,此時我們可以在需要更改資料的地方把資料做一次副本拷貝,使其不改變快取資料本身, 如
for (GoodsTrackDTO goodsTrackDTO : listGoodsTrack){
String goodsId = String.valueOf(goodsTrackDTO.getGoodsId());
if (!goodsTrackDTOMap.containsKey(goodsId)){
goodsTrackDTOMap.put(goodsId, ObjectUtil.clone(goodsTrackDTO));
}else {
GoodsTrackDTO goodsTrack = goodsTrackDTOMap.get(goodsId);
int num = goodsTrack.getGoodsNum() + goodsTrackDTO.getGoodsNum();
goodsTrack.setGoodsNum(num);
}
}複製程式碼
使用ObjectUtil.clone()方法(hutool工具包中提供)對需要更改的資料做副本拷貝。
- 禁用快取的方案
在xml的sql定義中新增 flushCache="true" 的配置,使該查詢不使用快取,如下
<select id="listGoodsTrack" resultType="xxx.GoodsTrackDTO" flushCache="true">
SELECT ...
</select>複製程式碼
禁用快取的另一種方案是將一級快取直接設定為STATEMENT來進行全域性禁用,在mybatis-config.xml中配置:
<settings>
<setting name="localCacheScope" value="STATEMENT"/>
</settings>複製程式碼
- 避開快取的方案
再定義一個實現相同查詢的mapper方法,id不一樣來避開使用相同的快取,這種做法就不怎麼優雅了。
<select id="listGoodsTrack2" resultType="xxx.GoodsTrackDTO" flushCache="true">
SELECT ...
</select>複製程式碼
避開快取的另一種做法是不使用事務,使兩個查詢不在一個SqlSession中,但有時候事務是必須的,所以得分場景來。
另外由於mybatis的快取都是基於本地的,在分散式環境下可能導致讀取的資料與資料庫不一致,比如一個服務例項兩次讀取中間,另一個服務例項對資料進行了更新,則後一次讀取由於快取還是讀取的舊資料,而不是更新後的資料,可能導致問題。這時可以通過將快取設定為STATEMENT級別來禁用mybatis快取,通過Redis,MemCached等來提供分散式的全域性快取。
作者:空山新雨,一枚仍在學習路上的IT老兵
近期作者寫了幾十篇技術部落格,內容包括Java、Spring Boot、Spring Cloud、Docker,技術管理心得等
歡迎關注作者微信公眾號:空山新雨的技術空間,一起學習成長