代理與兩級緩存源碼解析)
面試官拋來一個很基礎(chǔ)的問題你 Service 里注入了一個 Mapper 接口方法名寫好了SQL 寫在 XML 里但接口根本沒有實現(xiàn)類這一調(diào)怎么就執(zhí)行了緊接著又問同一個事務(wù)里同一個查詢調(diào)兩次走緩存嗎一級緩存和二級緩存誰在前大多數(shù)平時寫業(yè)務(wù)的人能用但真被問到就有點卡殼。這篇文章不繞圈子直接從源碼拆 MyBatis 的 Mapper 動態(tài)代理、一級緩存和二級緩存的完整鏈路最后再給幾個實戰(zhàn)中排查緩存、打印 SQL、自定義緩存的操作既能當(dāng)面試復(fù)習(xí)材料也能當(dāng)項目排坑手冊。1. Mapper 動態(tài)代理為什么接口沒有實現(xiàn)類也能干活1.1 從 JDBC 到 Mapper 接口你少寫了的那層膠水回想沒用 MyBatis 的時候?qū)懸粋€數(shù)據(jù)庫操作要經(jīng)歷什么獲取 Connection創(chuàng)建 PreparedStatement手動 setParameter執(zhí)行 executeQuery再手動遍歷 ResultSet 轉(zhuǎn)成對象最后還要關(guān)掉一堆資源。后來用 MyBatis你只需要定義接口方法、寫一個 XML 里的 SQL或者用注解寫一行 SQL然后在 Service 里直接注入 Mapper 調(diào)方法。中間那幾千行樣板代碼全部消失了。但這引出第一個疑問接口不能 newMyBatis 到底往接口里塞了什么答案不是字節(jié)碼增強插樁而是 JDK 自帶的一個通行做法——動態(tài)代理。MyBatis 在啟動階段掃描 Mapper 接口給每個接口生成一個代理對象你調(diào)用接口方法實際上是調(diào)到了代理對象的 invoke 方法上。代理內(nèi)部再做兩件事定位這條 SQL然后交給 SqlSession 去執(zhí)行。1.2 MapperProxy 與 Proxy.newProxyInstance動態(tài)代理的入口所有 Mapper 接口代理的源頭在MapperRegistry。MyBatis 啟動解析配置文件時會把你配置的每個 Mapper 接口通過registry.addMapper(Class)注冊進去每個接口對應(yīng)生成一個MapperProxyFactory。后面你需要這個接口的實例時工廠調(diào)用public T newInstance(SqlSession sqlSession) { MapperProxyT mapperProxy new MapperProxy(sqlSession, mapperInterface, methodCache); return newInstance(mapperProxy); } protected T newInstance(MapperProxyT mapperProxy) { return (T) Proxy.newProxyInstance(mapperInterface.getClassLoader(), new Class[] { mapperInterface }, mapperProxy); }關(guān)鍵就在Proxy.newProxyInstance它要求目標(biāo)必須是接口Mapper 恰好是接口然后動態(tài)生成一個實現(xiàn)這些接口的代理類。以后你調(diào)用userMapper.selectById(1)其實是調(diào)用到MapperProxy.invoke上。很多初學(xué)者會把 Spring AOP 和這個搞混。這里沒有 CGLIB、沒有 Aspect 切面就是 JDK 原生動態(tài)代理。也正因如此Mapper 接口在設(shè)計上天生不能被寫成普通類只能接口否則就無法走 JDK 代理這條路。1.3 MapperMethod 的分發(fā)邏輯一個方法如何找到對應(yīng)的 SQLinvoke方法并不直接執(zhí)行 SQL它先過濾掉Object類的方法比如toString、hashCode、equals剩下的業(yè)務(wù)方法交給MapperMethod處理public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } return cachedInvoker(method).invoke(proxy, method, args, sqlSession); }MapperMethod構(gòu)造時會解析兩樣?xùn)|西SqlCommand和MethodSignature。SqlCommand做的是從方法名去Configuration.mappedStatements里找對應(yīng)的MappedStatement并根據(jù) SQL 類型分成 UNKNOWN、INSERT、UPDATE、DELETE、SELECT。MethodSignature做的是解析方法參數(shù)搞清楚有多少個參數(shù)、是否需要Param注解、返回類型是 List 還是單個對象、有沒有分頁參數(shù)等。真正執(zhí)行時execute方法會按 SQL 命令類型分發(fā)switch (command.getType()) { case INSERT: { return sqlSession.insert(command.getName(), param); } case SELECT: { if (method.returnsVoid() method.hasResultHandler()) { sqlSession.select(command.getName(), param, method.getResultHandler()); return null; } else if (method.returnsMany()) { return sqlSession.selectList(command.getName(), param); } else { return sqlSession.selectOne(command.getName(), param); } } ... }到這里一個 Mapper 方法被映射成了一次普通的 SqlSession 調(diào)用。剩下的查詢過程就進入 Executor 鏈路也就是緩存發(fā)揮作用的地方。提示如果你在自定義攔截器里想拿到 Mapper 方法的注解或方法名通常是通過Invocation.getArgs()[0]即 mappedStatement.getId()反推Mapper接口的完全限定名和方法名原理就是這里講的動態(tài)代理鏈路。2. 一級緩存藏在 Executor 里的那張查重表2.1 一級緩存的數(shù)據(jù)結(jié)構(gòu)PerpetualCache 與 CacheKey一級緩存官方叫 Local Cache作用域是 SqlSession 級別。大多數(shù)人知道它存在但不知道它長什么樣。其實 MyBatis 的緩存實現(xiàn)很樸素緩存對象統(tǒng)一實現(xiàn)Cache接口一級緩存用的是PerpetualCache內(nèi)部就是一個普通HashMappublic class PerpetualCache implements Cache { private final String id; private final MapObject, Object cache new HashMap(); }注意這個 HashMap 沒有任何加鎖保護所以一級緩存不是線程安全的它天然只服務(wù)于當(dāng)前 SqlSession。那緩存 key 是什么不能簡單拿 SQL 字符串做 key因為同一個 SQL 傳不同參數(shù)就是不同結(jié)果。MyBatis 封裝了一個CacheKey核心字段包括MappedStatement 的 id、查詢 SQL、參數(shù)對象、RowBounds 偏移量甚至還會往里追加一些環(huán)境標(biāo)識。最終生成一個 hash 值作為 HashMap 的 key。所以“同一條 SQL 但參數(shù)不同”“同一個方法但 offset/limit 不同”都會被識別為不同緩存項。2.2 什么情況命中、什么情況失效一次完整查詢的判定過程一級緩存的查詢動作發(fā)生在BaseExecutor.query。這段邏輯是整個緩存體系的心臟值得記清楚先從 BoundSql 和參數(shù)構(gòu)建出 CacheKey。查一級緩存localCache.getObject(key)。如果有值直接返回如果沒值進入數(shù)據(jù)庫查詢queryFromDatabase查到結(jié)果后放進localCache。返回結(jié)果前如果當(dāng)前處于嵌套查詢中還要把結(jié)果保存到localOutputParameterCache這是處理存儲過程輸出參數(shù)的細節(jié)。那么什么時候清空四個時機執(zhí)行了任意 insert/update/delete。因為寫操作后數(shù)據(jù)變了再拿著舊緩存沒意義MyBatis 默認在寫操作前執(zhí)行clearLocalCache()。手動調(diào)用sqlSession.commit()、rollback()或close()。在select標(biāo)簽上顯式設(shè)置flushCachetrue每次查詢前先清緩存。查詢涉及嵌套結(jié)果映射且localCacheScope設(shè)置為 STATEMENT 時每條語句結(jié)束后清緩存。這里有個容易踩的坑一級緩存默認就是開啟的不需要任何配置。同一個 SqlSession 里前一個 SQL 查過的數(shù)據(jù)下一次完全相同的查詢直接命中緩存根本不走數(shù)據(jù)庫。看起來是好事但如果你的業(yè)務(wù)場景是“同一個事務(wù)里先查一次然后別的系統(tǒng)改了庫你再查一次想拿最新數(shù)據(jù)”拿到的還是舊值。這不是 bug是緩存語義如此。2.3 最容易誤解的一點Spring 整合后一級緩存并不是全程有效單獨用 MyBatis 時SqlSession 生命周期由你控制一級緩存的行為很好理解。但到了 Spring/Spring Boot 中事情就變了你的 Service 通常沒開事務(wù)也沒手動拿 SqlSession一切靠SqlSessionTemplate和SqlSessionInterceptor代理。SqlSessionTemplate內(nèi)部每次調(diào)用方法會通過SqlSessionUtils.getSqlSession()獲取 SqlSession。如果你當(dāng)前沒有 Spring 事務(wù)這個方法會新建一個 SqlSession用完后立刻 close。兩次獨立的 Mapper 調(diào)用其實是兩個不同的 SqlSession一級緩存完全不共享等于每次查詢都重新查庫。一旦 Service 方法加上Transactional情況就不同了Spring 事務(wù)管理器會把 SqlSession 和事務(wù)綁定在當(dāng)前線程上整個事務(wù)范圍內(nèi)的多次查詢復(fù)用同一個 SqlSession一級緩存這才真正生效。所以你在網(wǎng)上會看到兩種矛盾的說法有人說 MyBatis 一級緩存默認開啟很好用有人說自己試了發(fā)現(xiàn)沒用。兩種說法都對區(qū)別就在于是不是處在 Spring 事務(wù)中。這也是一個非常經(jīng)典的面試考點。提示如果在 Spring 環(huán)境里你確實想模擬“每次查詢強制走庫”最干凈的辦法是在對應(yīng)的 select 標(biāo)簽上加flushCachetrue而不是幻想著關(guān)掉一級緩存本身。MyBatis 雖然給了localCacheScopeSTATEMENT這個配置但它清緩存的動作在 Spring 無事務(wù)環(huán)境下意義不大因為 SqlSession 本身已經(jīng)是全新的了。3. 二級緩存namespace 邊界上的共享倉庫3.1 開啟二級緩存的條件cacheEnabled 與 標(biāo)簽缺一不可二級緩存作用域是 namespace也就是一個 Mapper 接口/XML 文件對應(yīng)一個緩存?zhèn)}庫。網(wǎng)上不少人搞混一件事mybatis.configuration.cache-enabled默認是 true就以為默認開了二級緩存。實際上cacheEnabled只決定Configuration.newExecutor時要不要給你的 Executor 套上CachingExecutor裝飾器。真正的開關(guān)還有一道你的 Mapper XML 里必須寫了cache標(biāo)簽或者通過注解CacheNamespace啟用。兩道閘都打開后查詢鏈路就變成CachingExecutor先查二級緩存。二級緩存沒命中進入BaseExecutor查一級緩存。一級緩存也沒有查數(shù)據(jù)庫。兩個緩存的作用域差異決定了一級緩存是會話私有的二級緩存是 namespace 內(nèi)多個 SqlSession 共享的。這也是它名字里“二級”的意義——跨會話共享。3.2 TransactionalCache為什么要等事務(wù)提交才真正寫入二級緩存最容易讓人想當(dāng)然的地方是寫入時機??碈achingExecutor.query源碼命中二級緩存直接返回沒命中查詢數(shù)據(jù)庫后它并不會立刻把結(jié)果放進二級緩存而是先放進一個TransactionalCache的臨時區(qū)。TransactionalCache內(nèi)部有兩個關(guān)鍵結(jié)構(gòu)entriesToAddOnCommit本事務(wù)內(nèi)準(zhǔn)備寫入二級緩存的數(shù)據(jù)先放這里。entriesMissedInCache本事務(wù)內(nèi)查過但沒命中的 key 也先記下來提交時把這些 key 標(biāo)記為“緩存了空值”。只有當(dāng) SqlSession 提交時TransactionalCacheManager.commit()才會把entriesToAddOnCommit真正刷進二級緩存?zhèn)}庫。為什么這樣設(shè)計因為如果你的查詢還沒提交就寫進二級緩存另一個 SqlSession 可能讀到一條事務(wù)未提交的數(shù)據(jù)這就是臟讀。MyBatis 用一個“延遲寫入提交時落庫”的機制規(guī)避了這個問題。同時事務(wù)回滾時會調(diào)用rollback()把臨時區(qū)數(shù)據(jù)直接丟棄避免已回滾的數(shù)據(jù)進入共享緩存。理解了這一點你就明白為什么有人遇到“明明查了數(shù)據(jù)二級緩存命中率卻不高”——大概率是 SqlSession 一直沒提交數(shù)據(jù)始終壓在臨時區(qū)里。3.3 readOnly、flushInterval、eviction 這些參數(shù)到底改了什么cache標(biāo)簽的典型配置長這樣cache evictionLRU flushInterval60000 size512 readOnlytrue /每個參數(shù)背后都對應(yīng)一個裝飾器參數(shù)默認值作用對應(yīng)實現(xiàn)evictionLRU緩存淘汰策略控制容量滿時移除誰LruCache、FifoCacheflushInterval無定時清空緩存的毫秒數(shù)到期后下次查詢會刷新倉庫ScheduledCachesize1024最多緩存多少個對象引用外層裝飾器控制readOnlyfalse返回緩存對象的策略SerializedCachereadOnly是最值得展開的參數(shù)。默認readOnlyfalse時二級緩存返回的對象不是原來那個實例而是通過序列化反序列化復(fù)制出來的副本。這就強制要求所有放進二級緩存的對象實現(xiàn)Serializable接口。好處是安全調(diào)用方隨便改返回對象都不會污染緩存?zhèn)}庫里的原數(shù)據(jù)。壞處是性能開銷大每次命中都要做一次序列化拷貝。readOnlytrue時MyBatis 直接返回緩存里那個對象引用性能高很多但調(diào)用方一旦修改對象倉庫里的數(shù)據(jù)也跟著變。業(yè)務(wù)代碼如果沒意識到這一點很容易出現(xiàn)“緩存被悄悄改掉”的詭異問題。我的建議是只有確認對象不會被修改、且讀取極頻繁的場景才用readOnlytrue否則老老實實保持默認。3.4 多表關(guān)聯(lián)場景下的臟數(shù)據(jù)為什么大家都說二級緩存要慎用二級緩存有一個先天短板它只知道 namespace不知道你 SQL 里到底碰了哪些表。舉個例子OrderMapper里有個查詢 join 了user表查出來的訂單列表帶著用戶昵稱這些數(shù)據(jù)被放進了OrderMapper的二級緩存。另一個UserMapper執(zhí)行更新MyBatis 只清UserMapper自己的緩存OrderMapper緩存里那些舊昵稱還在。下次再查訂單命中的是臟數(shù)據(jù)。這就是二級緩存臟讀的經(jīng)典場景。官方文檔對此的表述很含蓄但業(yè)內(nèi)實踐早就形成共識二級緩存適合字典表、配置表這類極少更新的只讀數(shù)據(jù)不適合頻繁變更的業(yè)務(wù)主表尤其不適合 join 查詢很多的接口。如果一定要在多 Mapper 間共享一塊緩存可以用cache-ref namespace其他Mapper全限定名/讓兩個 namespace 指向同一個緩存?zhèn)}庫但你要在業(yè)務(wù)上保證所有變更語句都會刷到同一個倉庫否則還是躲不過臟讀。提示項目里遇到“改了數(shù)據(jù)庫但查詢結(jié)果沒變”的線上事故第一反應(yīng)應(yīng)該是看二級緩存。尤其是你最近給某個 Mapper 加了cache標(biāo)簽但沒細想它 SQL 涉及哪些表事故率非常高。4. 實戰(zhàn)排查緩存命中率、SQL 打印與自定義緩存擴展4.1 配置 SQL 打印日志實現(xiàn)類的選擇與效果對比排查緩存問題第一步永遠是看清 SQL 到底有沒有真正發(fā)到數(shù)據(jù)庫。MyBatis 的日志輸出靠LogImpl配置常見兩種# 控制臺直接輸出適合本地調(diào)試 mybatis.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl # 接入 Slf4j適合生產(chǎn)環(huán)境按級別過濾 mybatis.configuration.log-implorg.apache.ibatis.logging.slf4j.Slf4jImpl logging.level.你的Mapper包路徑debug選 Slf4j 更合理因為可以配合logback或log4j2把 SQL 日志單獨輸出到文件不至于刷爆應(yīng)用日志。StdOutImpl就是往System.out直接 println本地調(diào)試方便上生產(chǎn)最好不要用日志格式不可控。日志打印的 SQL 是預(yù)處理語句能看到類似Preparing: SELECT * FROM user WHERE id ?以及Parameters: 1(String)。如果兩次相同查詢只有第一次出現(xiàn)Preparing第二次沒了說明走了緩存每次都出現(xiàn)Preparing說明緩存沒有命中或者當(dāng)前會話一級緩存已經(jīng)失效。4.2 手寫一個帶命中率統(tǒng)計的 Cache覆蓋官方實現(xiàn)我排查緩存問題時最喜歡先看一眼命中率。MyBatis 自帶的LoggingCache其實會記錄命中率信息只要在配置里把緩存對應(yīng) namespace 的日志級別調(diào)到 DEBUG日志里就會輸出Cache Hit Ratio。如果你不想依賴日志級別也可以自己寫一個統(tǒng)計包裝器public class HitRateCache implements Cache { private final Cache delegate; private final AtomicLong hits new AtomicLong(); private final AtomicLong total new AtomicLong(); public HitRateCache(Cache delegate) { this.delegate delegate; } Override public Object getObject(Object key) { total.incrementAndGet(); Object value delegate.getObject(key); if (value ! null) { hits.incrementAndGet(); } return value; } Override public void putObject(Object key, Object value) { delegate.putObject(key, value); } Override public double getHitRate() { long t total.get(); return t 0 ? 0 : (double) hits.get() / t; } Override public String getId() { return delegate.getId(); } // removeObject、clear、getSize 直接透傳 }然后在cache配置里用cache type你的類全限定名/替換默認實現(xiàn)。不過要注意用type指定后MyBatis 默認那一套 LRU、Serialized 裝飾器就被跳過了你需要自己在HitRateCache里把需要的策略組合起來比如再包一層LruCache和SerializedCache。最簡單的做法是讓自定義類內(nèi)部持有一個組裝好的Cache鏈條而不是全部自己重寫。4.3 自定義 Redis 二級緩存Cache 接口的落地細節(jié)單機多線程下用內(nèi)置緩存沒問題但應(yīng)用一上多實例問題立刻暴露每個實例的二級緩存互相獨立一個實例更新了數(shù)據(jù)其他實例還是舊值。這需求本質(zhì)是“分布式緩存”很多人都會動手實現(xiàn)一個 Redis 版本。MyBatis 的Cache接口非常小實現(xiàn)思路是public class RedisCache implements Cache { private final String id; private final RedisTemplateObject, Object redisTemplate; public RedisCache(String id) { this.id id; this.redisTemplate SpringContextHolder.getBean(RedisTemplate.class); } Override public void putObject(Object key, Object value) { redisTemplate.opsForValue().set(getKey(key), value, 30, TimeUnit.MINUTES); } Override public Object getObject(Object key) { return redisTemplate.opsForValue().get(getKey(key)); } Override public void clear() { // 按 namespace 前綴刪除注意性能 } // 其他方法略 }落地時有幾個坑值得提前說構(gòu)造函數(shù)必須只有一個String id參數(shù)MyBatis 解析cache時靠反射調(diào)用它。普通RedisTemplate序列化方案要統(tǒng)一建議用 JSON 序列化或自定義序列化器否則存入的對象變更字段后反序列化容易出兼容性問題。Cache接口沒有批量刪除能力clear()只能按前綴掃描刪除量大會有性能隱患。事務(wù)未提交時數(shù)據(jù)不寫真實緩存這個語義由TransactionalCache保證自定義RedisCache只需要實現(xiàn)最基本的存取即可。待定如果你同時在配置里設(shè)置了flushIntervalScheduledCache裝飾器依然會生效要留意定時清空和 Redis 過期時間會雙重疊加。5. 面試與源碼閱讀常見問題背后的源碼證據(jù)5.1 高頻面試題盤點從動態(tài)代理到緩存失效這幾年 MyBatis 相關(guān)的面試題翻來翻去就是下面幾個把源碼位置記清楚回答就能落到底為什么 Mapper 接口不需要實現(xiàn)類答案是 JDK 動態(tài)代理入口在MapperProxyFactory和MapperProxy核心是Proxy.newProxyInstance。一個 Mapper 方法怎么找到對應(yīng)的 SQL入口是MapperMethod它會根據(jù)方法名從Configuration.mappedStatements里找到MappedStatement再根據(jù) SQL 類型調(diào)用SqlSession對應(yīng)方法。一級緩存和二級緩存有什么區(qū)別一級緩存作用域是 SqlSession存儲結(jié)構(gòu)是PerpetualCacheHashMap執(zhí)行 insert/update/delete 或 commit/rollback/close 時清空二級緩存作用域是 namespace要cacheEnabled和cache同時開啟才有效。二級緩存為什么會有臟數(shù)據(jù)緩存只按 namespace 組織無法感知 SQL 涉及的真實表多表 join 和跨 Mapper 更新時其他 namespace 的緩存不會主動失效。Spring 事務(wù)下緩存為什么表現(xiàn)不同無事務(wù)時每次 Mapper 調(diào)用新建和關(guān)閉 SqlSession一級緩存失效有事務(wù)時整個事務(wù)復(fù)用同一個 SqlSession一級緩存能命中。還有一道容易被問倒的緩存命中后返回對象和原對象是同一個嗎取決于二級緩存的readOnly。readOnlyfalse時對象經(jīng)過序列化拷貝不是同一個引用readOnlytrue時是同一個引用。一級緩存則直接返回原對象引用所以通過一級緩存拿到的對象被修改會影響下次命中結(jié)果。5.2 源碼閱讀路徑從 Configuration 到 Executor 的調(diào)用鏈如果你只想讀一遍關(guān)鍵源碼就能把整個查詢鏈路串起來我建議按這個方法走拿到源碼后別從頭讀直接從一個真實查詢跟蹤調(diào)用鏈。步驟如下先看SqlSessionTemplate.selectList這里是 Spring 整合后進入 MyBatis 的入口。順著SqlSessionUtils.getSqlSession看 Spring 怎么管理 SqlSession 生命周期。進入DefaultSqlSession.selectList它調(diào)用Executor.query。看CachingExecutor.query這里判斷二級緩存是否存在、flushCache 是否開啟。沒命中后落到BaseExecutor.query這里操作一級緩存CacheKey 在這里構(gòu)建。一級緩存也沒命中進入SimpleExecutor.doQuery到這里才真正創(chuàng)建 PreparedStatement。整個鏈路一層層剝下來比單獨看任何一個類都有效。建議自己寫兩個 Mapper、一個帶cache、一個不帶然后在CachingExecutor.query和BaseExecutor.query打上斷點看兩次相同查詢分別停在哪里緩存行為立刻一目了然。提示讀源碼時有個很容易忽略的小地方——Configuration.newExecutor決定是否包上CachingExecutor而這個方法是被SqlSessionFactory創(chuàng)建時調(diào)用的。Spring Boot 里SqlSessionFactoryBean裝配完畢后這個 executor 鏈就固定了后續(xù)改cacheEnabled配置需要重啟才生效。最后再分享一個排查心得我自己的經(jīng)驗是遇到 MyBatis 緩存相關(guān)問題先別急著懷疑框架 bug按“二級緩存是否開啟→當(dāng)前有沒有事務(wù)→SQL 是否真的發(fā)出→對象是否被修改”這個順序排查。很多“緩存不生效”其實是 Spring 事務(wù)沒開導(dǎo)致一級緩存作用域不符合預(yù)期很多“緩存臟數(shù)據(jù)”其實是多表操作撞上了 namespace 隔離的墻。另外給團隊定一條規(guī)矩默認不開啟二級緩存除非這個 Mapper 的 SQL 足夠簡單、數(shù)據(jù)更新頻率足夠低并且在代碼評審時明確說明涉及哪些表、為什么安全。MyBatis 的緩存機制本身設(shè)計得相當(dāng)精巧但精巧的默認值并不等于放之四海而皆準(zhǔn)業(yè)務(wù)場景才是最終的尺子。希望這篇文章能把這條鏈路講透下次再有人問起動態(tài)代理和兩級緩存你至少能直接告訴他答案在源碼的哪個位置。