題全解析:從原理到實(shí)戰(zhàn),徹底告別SQL爆炸)
先說(shuō)一個(gè)真實(shí)場(chǎng)景。某天深夜我負(fù)責(zé)的服務(wù)突然被數(shù)據(jù)庫(kù)CPU告警刷屏上去一看一條列表接口在高峰期每秒打出幾百條一模一樣的SQL骨架只是IN參數(shù)不同。翻代碼就是Hibernate的默認(rèn)懶加載配上循環(huán)遍歷把訂單查出來(lái)后逐條去get買(mǎi)家、get商品、get收貨地址。當(dāng)時(shí)我就意識(shí)到N1這個(gè)問(wèn)題不踩一次永遠(yuǎn)記不牢踩過(guò)一次就會(huì)在寫(xiě)每一個(gè)關(guān)聯(lián)映射之前先問(wèn)自己三遍這里到底會(huì)發(fā)幾條SQL這篇文章我把Hibernate里N1問(wèn)題的來(lái)龍去脈、排查套路、常用解法和實(shí)戰(zhàn)取舍一次性講透。適合正在用JPA/Hibernate做持久層開(kāi)發(fā)的同學(xué)尤其適合被列表接口性能問(wèn)題折磨過(guò)、或者面試前想把這個(gè)點(diǎn)徹底搞清楚的朋友。文中所有示例我都基于一個(gè)模擬電商項(xiàng)目X的訂單域模型來(lái)講代碼風(fēng)格貼近日常業(yè)務(wù)開(kāi)發(fā)不是教科書(shū)版簡(jiǎn)化Demo。1. N1問(wèn)題的本質(zhì)不是查詢慢而是查詢次數(shù)失控1.1 一個(gè)最簡(jiǎn)單的復(fù)現(xiàn)場(chǎng)景先定義兩個(gè)實(shí)體。訂單OrderEntity和訂單項(xiàng)OrderItemEntity一對(duì)多關(guān)系這是電商系統(tǒng)里最常見(jiàn)的模型。Entity Table(name t_order) public class OrderEntity { Id private Long id; private String orderNo; private Long buyerId; OneToMany(mappedBy order) private ListOrderItemEntity items new ArrayList(); } Entity Table(name t_order_item) public class OrderItemEntity { Id private Long id; private Long productId; private Integer quantity; ManyToOne(fetch FetchType.LAZY) JoinColumn(name order_id) private OrderEntity order; }需求很簡(jiǎn)單在訂單列表頁(yè)展示10個(gè)訂單同時(shí)要顯示每個(gè)訂單里有多少個(gè)商品項(xiàng)。于是代碼這樣寫(xiě)ListOrderEntity orders orderRepository.findLatestOrders(10); for (OrderEntity order : orders) { int itemCount order.getItems().size(); ... }只要items上的抓取策略是默認(rèn)的LAZY這10個(gè)訂單就會(huì)觸發(fā)10次額外的select * from t_order_item where order_id ?。再加上最開(kāi)始的1次訂單查詢一共是1 10 11條SQL。列表只有10條數(shù)據(jù)體感不明顯但當(dāng)你把列表改成100條、每頁(yè)50條時(shí)就是1 100條SQL其中99條都是重復(fù)結(jié)構(gòu)。這就是N1的經(jīng)典形態(tài)一次查詢得到N條主數(shù)據(jù)隨后逐條訪問(wèn)關(guān)聯(lián)屬性又發(fā)出N條查詢。N越大問(wèn)題越猙獰而且它不會(huì)在開(kāi)發(fā)環(huán)境暴露因?yàn)殚_(kāi)發(fā)時(shí)數(shù)據(jù)量少性能差異完全被網(wǎng)絡(luò)延遲和本地?cái)?shù)據(jù)庫(kù)的快速響應(yīng)掩蓋了。1.2 為什么Hibernate特別容易踩中這個(gè)坑很多從MyBatis轉(zhuǎn)過(guò)來(lái)的同學(xué)覺(jué)得奇怪我寫(xiě)個(gè)關(guān)聯(lián)查詢把結(jié)果一次性取出來(lái)不就行了嗎問(wèn)題的根源在于Hibernate的實(shí)體加載模型。Hibernate引入了“持久化上下文”和“代理對(duì)象”這套機(jī)制。懶加載關(guān)聯(lián)屬性時(shí)返回的不是真實(shí)集合而是一個(gè)被委托的PersistentBag。你對(duì)這個(gè)集合調(diào)用size()、iterator()、contains()時(shí)Hibernate才臨時(shí)決定哦還沒(méi)初始化我發(fā)一條SQL現(xiàn)取。也就是說(shuō)N1不是Hibernate的bug而是懶加載機(jī)制在“循環(huán)遍歷”這種代碼形態(tài)下的必然產(chǎn)物。我之前在某公司排查過(guò)一個(gè)線上問(wèn)題代碼里只是把List 的查詢結(jié)果做了stream().map(Order::getItems).count()這類(lèi)操作結(jié)果SQL數(shù)量直接爆炸。表面上看是框架的問(wèn)題實(shí)際是開(kāi)發(fā)者對(duì)“什么時(shí)候觸發(fā)查詢”完全無(wú)感。Hibernate把這條邊界藏得太深你寫(xiě)了order.getItems()根本看不出來(lái)這里會(huì)打DB。另一個(gè)容易忽略的點(diǎn)是事務(wù)邊界。只要Session還開(kāi)著懶加載隨時(shí)可能觸發(fā)額外查詢Session關(guān)了再訪問(wèn)直接拋LazyInitializationException。很多人為了解決這個(gè)異常粗暴地改成EAGER這就把N1問(wèn)題從“可能發(fā)生”變成了“必然發(fā)生”。后面會(huì)詳細(xì)講為什么EAGER是飲鴆止渴。1.3 怎么量化你到底發(fā)了多少條SQL不把SQL數(shù)量量化優(yōu)化就無(wú)從談起。我有兩個(gè)常用手段都是低成本高收益的。先看Hibernate自己的日志。在application.yml里把show_sql和format_sql打開(kāi)同時(shí)把日志級(jí)別調(diào)到DEBUG或者TRACEspring: jpa: show-sql: true properties: hibernate.format_sql: true hibernate.use_sql_comments: true其中use_sql_comments非常有用它會(huì)在生成的SQL里附帶一段Hibernate內(nèi)部注釋標(biāo)注這條SQL是哪一行Java代碼觸發(fā)的。線上排查時(shí)能直接定位到service層的某個(gè)方法省去逐行打斷點(diǎn)的麻煩。不過(guò)show_sql輸出到控制臺(tái)有時(shí)候會(huì)混在業(yè)務(wù)日志里很難看我在正式環(huán)境會(huì)引入數(shù)據(jù)源代理工具比如p6spy。它的思路是攔截所有JDBC執(zhí)行統(tǒng)一格式化打印還能統(tǒng)計(jì)每條SQL的耗時(shí)。配置很簡(jiǎn)單引入依賴后替換driverClassName和jdbcUrl前綴就行。p6spy還支持自定義LogMessageFormat把SQL和執(zhí)行時(shí)間拼在一起排查N1時(shí)非常直觀。還有一個(gè)偏門(mén)但很好用的辦法查數(shù)據(jù)庫(kù)端的連接會(huì)話數(shù)。如果某個(gè)接口在短時(shí)間內(nèi)對(duì)同一張表發(fā)起了幾十條結(jié)構(gòu)相似的查詢數(shù)據(jù)庫(kù)的information_schema.processlist里能看到一長(zhǎng)串同類(lèi)SQL。這也是快速確認(rèn)N1是否存在的鐵證。2. 先搞清楚映射設(shè)計(jì)再談優(yōu)化關(guān)聯(lián)關(guān)系是怎么變成SQL的2.1 FetchType的默認(rèn)值是個(gè)陷阱很多開(kāi)發(fā)者對(duì)ManyToOne、OneToOne、OneToMany、ManyToMany四種關(guān)聯(lián)的默認(rèn)fetch策略記不準(zhǔn)確這里我把它整理成一張表關(guān)聯(lián)類(lèi)型默認(rèn)Fetch策略默認(rèn)情況下訪問(wèn)關(guān)聯(lián)屬性時(shí)的行為ManyToOneEAGER主查詢時(shí)立即JOIN查詢OneToOneEAGER主查詢時(shí)立即JOIN查詢OneToManyLAZY首次訪問(wèn)集合時(shí)額外查詢ManyToManyLAZY首次訪問(wèn)集合時(shí)額外查詢看到?jīng)]有ManyToOne默認(rèn)是立即抓取也就是說(shuō)你查訂單列表時(shí)訂單上那個(gè)ManyToOne的買(mǎi)家對(duì)象已經(jīng)跟著一起查出來(lái)了。這不是N1但它會(huì)帶來(lái)另一個(gè)問(wèn)題多余的、可能根本用不到的大字段關(guān)聯(lián)被強(qiáng)行加載。更糟糕的是如果多個(gè)ManyToOne互相指來(lái)指去Hibernate生成SQL時(shí)可能產(chǎn)生笛卡爾積式的多表JOIN行數(shù)膨脹非常嚇人。所以我個(gè)人的鐵律是無(wú)論哪種關(guān)聯(lián)一律顯式寫(xiě)成fetch FetchType.LAZY。好處有兩個(gè)。第一避免默認(rèn)EAGER導(dǎo)致的無(wú)謂查詢和超大JOIN第二把“什么時(shí)候加載關(guān)聯(lián)數(shù)據(jù)”的決定權(quán)從映射配置移交到業(yè)務(wù)代碼中配合JOIN FETCH或者EntityGraph精確控制思路才清晰。你可能擔(dān)心全改成LAZY之后某些地方訪問(wèn)關(guān)聯(lián)屬性會(huì)報(bào)LazyInitializationException。這個(gè)問(wèn)題的正確解法不是改回EAGER而是保證關(guān)聯(lián)屬性在事務(wù)范圍內(nèi)被初始化或者用實(shí)體圖明確指定要查詢的關(guān)聯(lián)。這點(diǎn)放在后面方案部分細(xì)說(shuō)。2.2 集合委托機(jī)制為什么getItems().size()不觸發(fā)查詢這里有個(gè)很有意思的細(xì)節(jié)。對(duì)于被懶加載的集合屬性Hibernate返回的不是ArrayList而是PersistentBag。這個(gè)類(lèi)繼承了AbstractCollection但它內(nèi)部維護(hù)了一個(gè)尚未初始化的狀態(tài)。當(dāng)你調(diào)用size()時(shí)它可能不需要訪問(wèn)數(shù)據(jù)庫(kù)——因?yàn)闊o(wú)論集合里有多少元素一個(gè)空的或者未初始化的PersistentBag的size()都可以憑內(nèi)部的queuedOperations判斷。但當(dāng)你調(diào)用iterator()、toArray()或者stream()時(shí)它就必須確定真實(shí)數(shù)據(jù)從而觸發(fā)初始化SQL。這個(gè)機(jī)制解釋了為什么有些代碼看起來(lái)遍歷了集合卻沒(méi)發(fā)SQL另一些代碼明明只是取個(gè)數(shù)卻發(fā)了SQL。這也引出一個(gè)優(yōu)化小技巧如果你只需要判斷集合是否為空優(yōu)先用!order.getItems().isEmpty()而不是order.getItems().size() 0在某些情況下能少接一次數(shù)據(jù)庫(kù)往返。不過(guò)這只是錦上添花真正解決N1還是得靠下面的方案。理解了委托機(jī)制后你再看N1問(wèn)題本質(zhì)上就是“代碼觸發(fā)了一個(gè)又一個(gè)集合/實(shí)體的初始化請(qǐng)求”。優(yōu)化的方向其實(shí)就兩條路要么一次性把需要的關(guān)聯(lián)數(shù)據(jù)查出來(lái)讓初始化不發(fā)生在循環(huán)里要么減少初始化發(fā)生的次數(shù)延遲到批量場(chǎng)景統(tǒng)一處理。2.3 事務(wù)邊界是放大器同一個(gè)代碼塊事務(wù)邊界不同N1的嚴(yán)重程度完全不同。如果循環(huán)訪問(wèn)集合屬性的代碼在一個(gè)長(zhǎng)事務(wù)里那么N次額外查詢都發(fā)生在同一條數(shù)據(jù)庫(kù)連接上占用連接時(shí)間變長(zhǎng)連接池壓力驟增最終影響整個(gè)應(yīng)用的吞吐。更隱蔽的是如果配合了讀寫(xiě)分離一次請(qǐng)求里發(fā)出的每一條SQL都可能被路由到不同的只讀副本N1會(huì)讓各副本之間的負(fù)載嚴(yán)重不均。事務(wù)還會(huì)放大另一個(gè)問(wèn)題延遲加載不只是查詢還可能是數(shù)據(jù)不一致。比如你先查出訂單列表事務(wù)中途其他線程修改了某個(gè)訂單的items數(shù)據(jù)你再訪問(wèn)該訂單的items集合時(shí)Hibernate發(fā)出的查詢可能拿到不同的數(shù)據(jù)造成同一個(gè)事務(wù)內(nèi)讀到“前后不一致”的結(jié)果。這也是我建議在事務(wù)邊界內(nèi)盡早把關(guān)聯(lián)數(shù)據(jù)初始化完整的原因之一。3. 解決方案實(shí)戰(zhàn)對(duì)比JOIN FETCH、實(shí)體圖和批量抓取3.1 第一板斧JOIN FETCH先看最直觀的方案用JPQL寫(xiě)顯式關(guān)聯(lián)抓取Query(value select distinct o from OrderEntity o left join fetch o.items order by o.id desc) ListOrderEntity findOrdersWithItems();這條JPQL翻譯成SQL后會(huì)一次性把訂單和訂單項(xiàng)通過(guò)LEFT JOIN查出來(lái)Hibernate根據(jù)結(jié)果集里的OrderEntity主鍵去重把items正確組裝到每個(gè)訂單上。最終SQL只有1條N1直接消失。JOIN FETCH的適用場(chǎng)景非常廣但它有幾個(gè)必須知道的注意事項(xiàng)。第一分頁(yè)問(wèn)題。left join fetch一對(duì)多關(guān)聯(lián)時(shí)如果同時(shí)使用setFirstResult/setMaxResultsHibernate會(huì)直接在內(nèi)存中分頁(yè)而不是走數(shù)據(jù)庫(kù)的LIMIT。因?yàn)榻Y(jié)果集的行數(shù)已經(jīng)被關(guān)聯(lián)表放大數(shù)據(jù)庫(kù)層面的分頁(yè)語(yǔ)義會(huì)錯(cuò)亂。如果你在訂單列表頁(yè)既要一次性查出items又要分頁(yè)JOIN FETCH并不合適這種情況我后文會(huì)講替代方案。第二distinct的去重語(yǔ)義。不加distinct如果關(guān)聯(lián)子表有多條記錄同一個(gè)訂單實(shí)體在結(jié)果集里會(huì)出現(xiàn)多行Hibernate雖然能根據(jù)主鍵識(shí)別為同一實(shí)體但如果你用的是普通JPQL返回可能得到重復(fù)的訂單引用。distinct能保證結(jié)果集層面的去重但要注意distinct不是SQL層面的行去重而是Hibernate實(shí)體去重的語(yǔ)義。第三多級(jí)關(guān)聯(lián)。如果訂單還要關(guān)聯(lián)買(mǎi)家信息可以繼續(xù)在JPQL里加left join fetch o.buyer。但如果同時(shí)fetch兩個(gè)集合結(jié)果集行數(shù)會(huì)變成兩個(gè)集合行數(shù)的笛卡爾積數(shù)據(jù)量立刻膨脹。我的經(jīng)驗(yàn)是同一查詢中fetch的集合不要超過(guò)1個(gè)實(shí)在要多個(gè)集合時(shí)優(yōu)先考慮改為分批查詢或二級(jí)緩存。3.2 第二板斧EntityGraph更優(yōu)雅的聲明式方案EntityGraph是JPA 2.1引入的規(guī)范Spring Data JPA對(duì)它支持得非常好。它的思路和JOIN FETCH類(lèi)似都是通過(guò)一條SQL把關(guān)聯(lián)數(shù)據(jù)join出來(lái)但它是聲明式的不需要寫(xiě)JPQL對(duì)代碼侵入更小。EntityGraph(attributePaths {items}) Query(select o from OrderEntity o order by o.id) ListOrderEntity findOrdersWithItems();也可以在Repository方法名上直接加EntityGraph或者定義在實(shí)體類(lèi)上。用起來(lái)非常靈活。它和JOIN FETCH的基本原理一致我在實(shí)際項(xiàng)目中通常優(yōu)先用EntityGraph原因是維護(hù)成本低避免一堆JPQL字符串散落在代碼里。EntityGraph也支持子圖比如同時(shí)抓取訂單的itemsitems又關(guān)聯(lián)product可以這么寫(xiě)EntityGraph(attributePaths {items, items.product})這個(gè)場(chǎng)景如果某個(gè)產(chǎn)品的ManyToOne默認(rèn)是LAZY那么把product加進(jìn)attributePaths后會(huì)在一條SQL里把兩層關(guān)系全部join出來(lái)。但同樣不要濫用子圖。我見(jiàn)過(guò)一個(gè)同事把整條對(duì)象鏈全都塞進(jìn)attributePaths結(jié)果一條查詢join了五張表返回的數(shù)據(jù)行數(shù)呈笛卡爾積爆炸接口從幾十毫秒變成了幾秒。記住EntityGraph是精確按需加載不是把所有可能用到的關(guān)系都塞進(jìn)去。還有一個(gè)隱藏知識(shí)點(diǎn)當(dāng)EntityGraph和實(shí)體上的fetch配置沖突時(shí)以EntityGraph的配置為準(zhǔn)。所以哪怕你在實(shí)體里全設(shè)了LAZY也不影響EntityGraph工作。這也是我推薦全LAZY策略的一個(gè)支撐點(diǎn)。3.3 第三板斧BatchSize批量初始化的黑馬有些場(chǎng)景你根本不想動(dòng)JPQL和Repository只想讓現(xiàn)有的懶加載代碼少發(fā)幾條SQL。這時(shí)候BatchSize就派上用場(chǎng)了。Entity Table(name t_order) public class OrderEntity { OneToMany(mappedBy order) BatchSize(size 50) private ListOrderItemEntity items new ArrayList(); }當(dāng)Hibernate檢測(cè)到多個(gè)未初始化的集合需要加載時(shí)它會(huì)根據(jù)BatchSize的大小一次性把多個(gè)order_id拼到IN條件里select * from t_order_item where order_id in (?, ?, ?, ...)。假設(shè)你有10個(gè)訂單的items需要初始化BatchSize50時(shí)就會(huì)把10個(gè)訂單的id放進(jìn)同一個(gè)INSQL從10條變成1條。BatchSize真正厲害的地方在于它不需要改變調(diào)用方代碼。循環(huán)遍歷、stream操作這些代碼原封不動(dòng)Hibernate在幕后自動(dòng)幫你合并查詢。對(duì)于存量項(xiàng)目改造這是侵入性最小的方案。但要注意BatchSize生效的前提是Hibernate能推斷出“這些集合要一起加載”通常發(fā)生在一次flush/per-rollback的懶加載初始化階段。如果業(yè)務(wù)代碼在每次訪問(wèn)集合前手動(dòng)清空了SessionBatchSize就失效了。BatchSize也有一個(gè)局限它不會(huì)減少訪問(wèn)集本身時(shí)發(fā)起的查詢邏輯只是把N次查詢合并成N/size次。如果N1000BatchSize50仍然有20條SQL。它能極大緩解問(wèn)題但不能根除。所以更徹底的做法還是讓業(yè)務(wù)代碼不觸發(fā)額外的集合訪問(wèn)。3.4 第四板斧二級(jí)緩存和Cacheable如果N1場(chǎng)景是“同一批訂單的數(shù)據(jù)在短時(shí)間內(nèi)被反復(fù)查詢”比如緩存熱點(diǎn)數(shù)據(jù)、配置類(lèi)數(shù)據(jù)二級(jí)緩存可以有效地把重復(fù)SQL攔在應(yīng)用層之外。Spring Data JPA里配合Cacheable或者Hibernate的二級(jí)緩存可以讓相同的查詢走緩存不查數(shù)據(jù)庫(kù)。二級(jí)緩存的坑在于它是弱一致性緩存如果數(shù)據(jù)更新了緩存沒(méi)有及時(shí)失效就會(huì)讀到臟數(shù)據(jù)。所以我一般只把“很少變化、被高頻讀取”的數(shù)據(jù)放進(jìn)二級(jí)緩存比如商品分類(lèi)、地區(qū)表。訂單這種高頻變更的數(shù)據(jù)強(qiáng)行放二級(jí)緩存只會(huì)帶來(lái)一致性災(zāi)難。另外二級(jí)緩存需要你顯式配置CacheConcurrencyStrategy不同策略的讀寫(xiě)行為完全不同新手很容易用錯(cuò)。3.5 四種方案的選型建議我把這幾種方案的使用場(chǎng)景歸納成一張表方便你做決策方案最佳場(chǎng)景注意事項(xiàng)JOIN FETCH列表頁(yè)需要展示單層關(guān)聯(lián)明細(xì)可接受1條大SQL一對(duì)多分頁(yè)時(shí)謹(jǐn)慎多集合fetch會(huì)導(dǎo)致行膨脹EntityGraph多層關(guān)聯(lián)、按需指定查詢路徑代碼可維護(hù)性要求高attributePaths不要塞太多按需選擇BatchSize存量代碼改造、無(wú)法修改業(yè)務(wù)遍歷邏輯只能減少SQL條數(shù)不能根除批量大小需測(cè)試二級(jí)緩存高頻只讀、極少變更的數(shù)據(jù)如字典配置注意一致性不適用頻繁寫(xiě)的數(shù)據(jù)沒(méi)有什么銀彈。我見(jiàn)過(guò)一些團(tuán)隊(duì)一刀切要求所有列表查詢必須JOIN FETCH結(jié)果分頁(yè)接口反而出現(xiàn)內(nèi)存分頁(yè)OOM也有人迷信BatchSize結(jié)果寫(xiě)入場(chǎng)景下集合初始化時(shí)機(jī)不受控性能依然抖動(dòng)。正確做法是先看清楚這個(gè)接口的IO模式再?zèng)Q定用哪一種。4. 分頁(yè)場(chǎng)景下的N1一對(duì)多查詢的正確打開(kāi)方式4.1 為什么JOIN FETCH和分頁(yè)水火不容前面埋了一個(gè)伏筆現(xiàn)在展開(kāi)講。訂單列表頁(yè)通常需求是這樣的每頁(yè)展示20條訂單每條訂單下面要顯示items數(shù)量。用firstResult(page * size)配合JOIN FETCH會(huì)出什么問(wèn)題假設(shè)第2頁(yè)的20條訂單每個(gè)訂單有5個(gè)訂單項(xiàng)。數(shù)據(jù)庫(kù)LEFT JOIN后產(chǎn)生的行數(shù)是20×5100行。Hibernate拿到結(jié)果集后要先把100行拆成20個(gè)訂單實(shí)體再對(duì)items賦值。問(wèn)題是分頁(yè)的LIMIT應(yīng)該加在哪個(gè)SQL上正確的語(yǔ)義是先找20個(gè)訂單再把這些訂單的items查出來(lái)。但如果SQL本身是JOIN形態(tài)LIMIT就會(huì)被應(yīng)用到100行上你得到的前N行可能只包含幾個(gè)完整訂單甚至某個(gè)訂單被截?cái)嗔艘话氲膇tems。Hibernate在這條路上的實(shí)現(xiàn)選擇是如果它檢測(cè)到JPQL中有一對(duì)多fetch且有分頁(yè)參數(shù)它干脆不做數(shù)據(jù)庫(kù)分頁(yè)把所有數(shù)據(jù)查出來(lái)后在內(nèi)存里做分頁(yè)。這個(gè)行為在數(shù)據(jù)量大時(shí)是致命的——一張百萬(wàn)行的訂單表你要查第100頁(yè)它會(huì)把所有訂單及所有items全部加載到堆內(nèi)存然后丟棄掉前面1999條。你會(huì)看到接口OOM或者GC頻繁導(dǎo)致Full GC。4.2 方案一先分頁(yè)主實(shí)體再批量初始化關(guān)聯(lián)既然JOIN FETCH不能和分頁(yè)共存那就拆成兩步第一步用普通查詢只查訂單本身分頁(yè)交給數(shù)據(jù)庫(kù)第二步拿到當(dāng)前頁(yè)的訂單ID集合用IN查詢把關(guān)聯(lián)數(shù)據(jù)一次性裝回來(lái)。PageOrderEntity orderPage orderRepository.findByStatus(OrderStatus.PAID, pageable); ListLong orderIds orderPage.getContent().stream() .map(OrderEntity::getId) .toList(); ListOrderItemEntity items orderItemRepository.findByOrderIdIn(orderIds); MapLong, ListOrderItemEntity itemsGrouped items.stream() .collect(Collectors.groupingBy(item - item.getOrder().getId()));這里有一個(gè)細(xì)節(jié)findByOrderIdIn返回的關(guān)聯(lián)對(duì)象它的order屬性如果還是懶加載代理你可能在后續(xù)訪問(wèn)item.getOrder()時(shí)發(fā)起額外的查詢。但因?yàn)槟阋呀?jīng)有orderIds了某些場(chǎng)景下可以直接用id構(gòu)造一個(gè)引用Entity或者用loadById批量預(yù)置到當(dāng)前事務(wù)的持久化上下文里Hibernate就不會(huì)發(fā)SQL了。這也是“會(huì)話內(nèi)先占坑”的思路。我實(shí)際項(xiàng)目中更常用的方式是在主實(shí)體分頁(yè)查詢時(shí)就用EntityGraph抓單層關(guān)聯(lián)再單獨(dú)做一層明細(xì)匯總。這兩種操作各自可控不會(huì)在內(nèi)存里做大集合的笛卡爾積。4.3 方案二冗余字段或統(tǒng)計(jì)字段電商訂單列表這個(gè)場(chǎng)景真正要顯示的往往不是完整的訂單項(xiàng)列表而是“有多少種商品、共幾件、總金額”。強(qiáng)行把items實(shí)體加載出來(lái)只是為了算個(gè)數(shù)非常不劃算。我見(jiàn)過(guò)不少架構(gòu)為了省事就這么做了結(jié)果列表接口成了DB的噩夢(mèng)。合理設(shè)計(jì)是在訂單主表增加冗余字段比如item_total_count、item_sku_summary事務(wù)中寫(xiě)訂單項(xiàng)時(shí)同步維護(hù)冗余字段。查詢列表時(shí)直接查主表不需要碰關(guān)聯(lián)集合。這個(gè)方案的代價(jià)是寫(xiě)入邏輯必須保證冗余數(shù)據(jù)一致但在絕大多數(shù)業(yè)務(wù)里訂單項(xiàng)一旦寫(xiě)入數(shù)量和金額不會(huì)頻繁變化一致性風(fēng)險(xiǎn)完全可控。這也算是用空間換時(shí)間的經(jīng)典思路。如果連冗余字段都不想設(shè)計(jì)還有一個(gè)退路單獨(dú)寫(xiě)統(tǒng)計(jì)查詢不做實(shí)體關(guān)聯(lián)。Query(select o.id, count(i.id) from OrderEntity o left join o.items i where o.status :status group by o.id) ListObject[] findItemCountGroupByStatus(Param(status) String status);這種查詢返回的是標(biāo)量數(shù)據(jù)不會(huì)觸發(fā)任何懶加載也不會(huì)產(chǎn)生實(shí)體快照性能很好。缺點(diǎn)是拿到的數(shù)據(jù)要自己組裝到DTO里但相比N1帶來(lái)的痛苦這點(diǎn)麻煩不值一提。4.4 超大結(jié)果集游標(biāo)是最后手段有些報(bào)表類(lèi)接口一次要導(dǎo)出幾萬(wàn)條訂單并附帶每條訂單的明細(xì)。這類(lèi)場(chǎng)景就算用IN批量查詢幾萬(wàn)條實(shí)體的加載和組裝也會(huì)讓內(nèi)存吃緊而且Hibernate的一級(jí)緩存會(huì)持續(xù)膨脹。這時(shí)可以考慮用Stream配合readOnly和fetchSize逐批讀取或者直接用原生的FORWARD_ONLYJDBC游標(biāo)。Hibernate里可以這樣QueryHints(QueryHint(name org.hibernate.fetchSize, value 500)) Query(select o from OrderEntity o join fetch o.items where o.status :status) StreamOrderEntity streamOrdersWithItems(Param(status) String status);注意流式查詢要求事務(wù)保持打開(kāi)狀態(tài)連接不能被提前釋放。另外如果一對(duì)多join fetch加stream結(jié)果集會(huì)因?yàn)閖oin而放大所以要確保沒(méi)有分頁(yè)。這類(lèi)查詢適合后臺(tái)任務(wù)不適合同步接口。我建議不到萬(wàn)不得已不要上這一層先把前幾種方案做扎實(shí)。5. 實(shí)戰(zhàn)案例一個(gè)訂單列表接口的完整優(yōu)化過(guò)程5.1 現(xiàn)場(chǎng)問(wèn)題模擬項(xiàng)目X上線第二周訂單列表接口開(kāi)始出現(xiàn)偶發(fā)超時(shí)。監(jiān)控?cái)?shù)據(jù)顯示該接口打到數(shù)據(jù)庫(kù)的SQL總量占整個(gè)服務(wù)SQL總數(shù)的65%其中大量SQL形如select * from t_order_item where order_id 123 select * from t_order_item where order_id 456 select * from t_order_item where order_id 789一個(gè)典型的N1。負(fù)責(zé)這個(gè)接口的是一個(gè)剛轉(zhuǎn)JPA不久的同學(xué)他在Repository里寫(xiě)了這樣一個(gè)方法Query(select o from OrderEntity o where o.status :status) PageOrderEntity findByStatusWithPage(Param(status) String status, Pageable pageable);然后在Service層遍歷Page里的訂單打印訂單項(xiàng)數(shù)量。就這么簡(jiǎn)單知識(shí)盲區(qū)直接變成生產(chǎn)事故。5.2 排查鏈路我當(dāng)時(shí)按下面幾步排查花了一整個(gè)下午現(xiàn)在把步驟復(fù)盤(pán)給你。第一步開(kāi)SQL日志。一開(kāi)始show_sql和format_sql打開(kāi)發(fā)現(xiàn)日志里大量重復(fù)SQL。第二步確認(rèn)是否為N1統(tǒng)計(jì)單次請(qǐng)求SQL總數(shù)。用p6spy打印帶耗時(shí)的SQL日志確認(rèn)一次分頁(yè)請(qǐng)求每頁(yè)20條發(fā)出1條分頁(yè)SQL 20條order_item查詢。第三步定位觸發(fā)點(diǎn)。開(kāi)啟use_sql_comments能看到類(lèi)似/* load one-to-many OrderEntity.items */的注釋直接鎖定到Service層的for循環(huán)。第四步分析數(shù)據(jù)模型。發(fā)現(xiàn)OrderEntity.items用的是默認(rèn)LAZY沒(méi)有被任何fetch策略覆蓋。第五步確認(rèn)分頁(yè)場(chǎng)景。這一步最關(guān)鍵因?yàn)榉猪?yè)場(chǎng)景不能直接用join fetch否則會(huì)觸發(fā)Hibernate內(nèi)存分頁(yè)我當(dāng)時(shí)測(cè)過(guò)把上面那個(gè)分頁(yè)查詢直接改成join fetch items后Hibernate直接把所有訂單讀入內(nèi)存再分頁(yè)一次請(qǐng)求加載了全表數(shù)據(jù)數(shù)據(jù)庫(kù)的臨時(shí)連接瞬間滿了。5.3 優(yōu)化落地方案最終我采用了兩步走策略。第一步主頁(yè)面分頁(yè)查詢保持普通查詢不加join fetch分頁(yè)交給數(shù)據(jù)庫(kù)LIMIT。第二步拿到當(dāng)前頁(yè)的訂單列表后收集所有orderId用一條IN查詢批量查出所有items然后在Service層按orderId分組組裝成Map。public ListOrderListVO getOrderList(int page, int size) { Pageable pageable PageRequest.of(page, size); PageOrderEntity orderPage orderRepository.findByStatus(OrderStatus.PAID, pageable); if (orderPage.isEmpty()) { return Collections.emptyList(); } ListLong orderIds orderPage.getContent().stream() .map(OrderEntity::getId) .collect(Collectors.toList()); ListOrderItemEntity itemList orderItemRepository.findByOrderIdIn(orderIds); MapLong, Long countMap itemList.stream() .collect(Collectors.groupingBy(i - i.getOrder().getId(), Collectors.counting())); return orderPage.getContent().stream() .map(order - new OrderListVO(order.getId(), order.getOrderNo(), countMap.getOrDefault(order.getId(), 0L))) .toList(); }優(yōu)化后一個(gè)完整請(qǐng)求的SQL數(shù)量從21條降為2條接口P99從800ms降到120ms。5.4 這類(lèi)排查的通用套路事后我把這套排查總結(jié)成四條經(jīng)驗(yàn)也分享給你。第一N1問(wèn)題一定是在“主查詢 循環(huán)訪問(wèn)”這個(gè)結(jié)構(gòu)下發(fā)生。見(jiàn)到for循環(huán)、stream、mapgetXxx、集合訪問(wèn)這類(lèi)代碼就要本能警惕。第二SQL日志里重復(fù)率超高的SQL是最大嫌疑。用p6spy的耗時(shí)輸出一眼就能看出累計(jì)耗時(shí)最多的SQL模板是什么。第三不要一上來(lái)就改代碼。先在紙上畫(huà)清楚這個(gè)接口要查幾層實(shí)體哪些層真正需要哪些層只是“順便”。N1很多時(shí)候是“用不到的數(shù)據(jù)被強(qiáng)行加載”造成的。第四優(yōu)化完一定要用日志驗(yàn)證SQL條數(shù)。很多同事改完說(shuō)“應(yīng)該好了”我問(wèn)一句“日志里幾條SQL”答不上來(lái)。沒(méi)有SQL條數(shù)對(duì)比的優(yōu)化都是自我安慰。6. 容易忽略的N1變種與常見(jiàn)誤區(qū)6.1 單向關(guān)聯(lián)也能引發(fā)N1很多資料講N1都拿雙向關(guān)聯(lián)舉例容易讓人以為單向關(guān)聯(lián)就安全。實(shí)際完全不是。比如你有一個(gè)Service類(lèi)只依賴OrderItemRepository通過(guò)ListOrderItemEntity findByOrderId(String orderId)查出訂單項(xiàng)每個(gè)item上都有ManyToOne private OrderEntity order當(dāng)你遍歷item并訪問(wèn)item.getOrder().getOrderNo()時(shí)同樣會(huì)產(chǎn)生N1。這里的order是懶加載代理每訪問(wèn)一個(gè)都會(huì)觸發(fā)查詢。解決辦法同樣簡(jiǎn)單要么在這個(gè)查詢的EntityGraph里抓取order要么先把訂單信息按id批量查出來(lái)放一個(gè)Map里再替換引用。重點(diǎn)是單向關(guān)聯(lián)只是減少了映射的書(shū)寫(xiě)量并不減少懶加載觸發(fā)次數(shù)。6.2 Open Session in View是N1的幫兇很多Spring Boot項(xiàng)目默認(rèn)開(kāi)啟了spring.jpa.open-in-viewtrue。這個(gè)配置的意思是每個(gè)Web請(qǐng)求的整個(gè)生命周期都保持同一個(gè)Hibernate Session打開(kāi)。好處是Controller層訪問(wèn)懶加載屬性不會(huì)報(bào)LazyInitializationException代價(jià)是你會(huì)無(wú)意識(shí)地在任何地方觸發(fā)懶加載N1問(wèn)題被這個(gè)開(kāi)關(guān)徹底掩蓋。我在檢查項(xiàng)目時(shí)有個(gè)習(xí)慣新項(xiàng)目強(qiáng)烈建議把open-in-view設(shè)為false徹底斷了“偷懶”的念想。這樣在Service層不把關(guān)聯(lián)數(shù)據(jù)查全到Controller層就必然報(bào)錯(cuò)逼著你在數(shù)據(jù)訪問(wèn)層把關(guān)聯(lián)關(guān)系設(shè)計(jì)清楚。對(duì)于存量項(xiàng)目逐接口排查比一鍵改配置更穩(wěn)妥因?yàn)楫?dāng)你把open-in-view關(guān)掉后可能出現(xiàn)一大堆LazyInitializationException需要逐個(gè)修復(fù)。6.3 誤區(qū)把LAZY改成EAGER就萬(wàn)事大吉我前文已經(jīng)說(shuō)過(guò)這是飲鴆止渴。原因有三。第一EAGER導(dǎo)致查詢持久化實(shí)體時(shí)無(wú)論你需不需要關(guān)聯(lián)數(shù)據(jù)都會(huì)JOIN查詢SQL可能變成多表大JOINcase量翻倍。第二多個(gè)EAGER集合疊加時(shí)結(jié)果集行數(shù)會(huì)指數(shù)膨脹產(chǎn)生笛卡爾積。第三列表頁(yè)、詳情頁(yè)、導(dǎo)出任務(wù)對(duì)關(guān)聯(lián)數(shù)據(jù)的需求完全不同EAGER是“全都要”沒(méi)有任何取舍余地。Hibernate官方文檔其實(shí)早就建議盡量避免EAGER默認(rèn)全部LAZY在查詢時(shí)精確控制。記住一句話映射層只定義關(guān)系不定義查詢計(jì)劃查詢計(jì)劃應(yīng)該由Repository上的fetch策略決定。6.4 分批查詢之后又出現(xiàn)的二級(jí)N1有時(shí)候你以為優(yōu)化完了結(jié)果在“批量初始化”這一步里又踩了新坑。舉個(gè)例子我用findByOrderIdIn批量查出訂單項(xiàng)后為了給前端返回商品名稱遍歷item訪問(wèn)item.getProduct().getName()。如果product的關(guān)聯(lián)是LAZY這里馬上又觸發(fā)N1只是目標(biāo)從t_order_item變成了t_product。這類(lèi)問(wèn)題很好復(fù)現(xiàn)優(yōu)化方案本身引入了新的實(shí)體訪問(wèn)路徑但沒(méi)有把新路徑一次性加載全。解決方式還是在批量查詢上做文章把product也加進(jìn)EntityGraph或者用另一個(gè)批量查詢把product全部查出來(lái)做Map。我總結(jié)了一個(gè)實(shí)用經(jīng)驗(yàn)法則每次對(duì)一個(gè)實(shí)體的List做關(guān)聯(lián)屬性訪問(wèn)前都問(wèn)一下“這個(gè)關(guān)聯(lián)屬性背后的實(shí)體我是不是已經(jīng)把它在當(dāng)前上下文里弄齊了”齊了就是安全的沒(méi)齊就是N1溫床。6.5 二級(jí)緩存誤用帶來(lái)的混亂最后提一個(gè)常見(jiàn)翻車(chē)現(xiàn)場(chǎng)為了優(yōu)化N1有人把訂單、訂單項(xiàng)都配上了Cacheable。結(jié)果訂單更新后關(guān)聯(lián)的訂單項(xiàng)緩存沒(méi)失效用戶看到的是“改了訂單狀態(tài)但明細(xì)還是舊的”。因?yàn)镠ibernate二級(jí)緩存默認(rèn)是基于實(shí)體主鍵和集合的更新主表時(shí)不一定級(jí)聯(lián)清理關(guān)聯(lián)集合的緩存。這種數(shù)據(jù)一致性問(wèn)題比性能問(wèn)題嚴(yán)重得多查起來(lái)又是一整天的活。二級(jí)緩存存在的意義是緩存“不太會(huì)變化”的數(shù)據(jù)。訂單、庫(kù)存、余額這類(lèi)數(shù)據(jù)我從來(lái)不放二級(jí)緩存。寧可多查幾次數(shù)據(jù)庫(kù)也不要讓用戶看到臟數(shù)據(jù)。7. 從框架層面根治組織層面的防御手段7.1 Repository層設(shè)計(jì)規(guī)范N1問(wèn)題不是靠某一招就能永遠(yuǎn)避免的它更多是編碼習(xí)慣問(wèn)題。我所在團(tuán)隊(duì)沉淀了一套持久層開(kāi)發(fā)規(guī)范這里分享給你參考。第一Repository方法上禁止直接返回實(shí)體集合統(tǒng)一返回DTO/VO或帶EntityGraph的投影。這樣做能強(qiáng)制讓開(kāi)發(fā)者思考“我需要哪些字段”而不是“把整個(gè)實(shí)體給我”。很多字段根本用不上加載進(jìn)來(lái)純屬浪費(fèi)。第二所有關(guān)聯(lián)的fetch策略一律顯式LAZY禁止出現(xiàn)默認(rèn)EAGER。這個(gè)可以通過(guò)Code Review守住也建議在實(shí)體基類(lèi)上寫(xiě)統(tǒng)一注釋。第三如果Service層要在同一個(gè)事務(wù)里訪問(wèn)多個(gè)關(guān)聯(lián)屬性盡量在查詢階段就用EntityGraph抓全。實(shí)在做不到就用批量查詢Map組裝。7.2 SQL審計(jì)自動(dòng)化代碼審查防不住每一個(gè)漏網(wǎng)之魚(yú)更可靠的是自動(dòng)化監(jiān)控。我有兩個(gè)比較推薦的做法。第一個(gè)在測(cè)試環(huán)境或預(yù)發(fā)環(huán)境打開(kāi)SQL日志統(tǒng)計(jì)對(duì)單接口做SQL數(shù)量斷言。比如用JUnit的DataJpaTest集成測(cè)試在測(cè)試結(jié)束后統(tǒng)計(jì)Hibernate的SessionFactory.getStatistics().getEntityLoadCount()或者getCollectionLoadCount()超過(guò)閾值就報(bào)錯(cuò)。這能保證新增代碼不會(huì)引入無(wú)休止的N1。第二個(gè)對(duì)關(guān)鍵接口做流量錄制或網(wǎng)關(guān)層的SQL監(jiān)控。把接口每分鐘的SQL總次數(shù)畫(huà)成曲線如果某個(gè)發(fā)布版本后曲線突然升高大概率引入了N1或大循環(huán)加載。我在項(xiàng)目中用過(guò)數(shù)據(jù)庫(kù)中間件的SQL審計(jì)能力效果很好能精確定位到機(jī)器和接口URL。7.3 團(tuán)隊(duì)培訓(xùn)的價(jià)值看似是技術(shù)問(wèn)題實(shí)際上是知識(shí)盲區(qū)。Hibernate的初學(xué)門(mén)檻低于MyBatis很多新手上手特別快但對(duì)懶加載機(jī)制缺乏敏感度。我建議團(tuán)隊(duì)里定期做一次“SQL日志閱讀”的workshop拿一段真實(shí)日志讓大家猜這個(gè)接口里循環(huán)了多少次、查了幾次庫(kù)。這個(gè)訓(xùn)練做三輪之后團(tuán)隊(duì)至少會(huì)養(yǎng)成“寫(xiě)完查詢先看日志”的習(xí)慣。有一句話我在Code Review時(shí)經(jīng)常說(shuō)你以為你寫(xiě)的是一行Java代碼其實(shí)你寫(xiě)的是三條SQL。把這句話內(nèi)化成團(tuán)隊(duì)的默認(rèn)認(rèn)知N1的復(fù)發(fā)率會(huì)大幅下降。7.4 兜底方案全局懶加載監(jiān)控開(kāi)關(guān)如果團(tuán)隊(duì)規(guī)模較大、水平參差可以在框架層面加一個(gè)兜底保險(xiǎn)。比如實(shí)現(xiàn)一個(gè)Spring AOP切面監(jiān)控Service方法執(zhí)行期間發(fā)起的SQL數(shù)量當(dāng)單方法SQL數(shù)超過(guò)閾值時(shí)打印告警日志并拋異常。這樣即使有人不小心寫(xiě)了循環(huán)查庫(kù)的代碼也不會(huì)無(wú)聲無(wú)息上線。但這類(lèi)兜底方案不能作為常規(guī)手段依賴。因?yàn)椤癝QL多”不等于“有問(wèn)題”有些合法查詢本身就涉及很多子查詢。建議把閾值設(shè)得寬一些只做告警不做阻斷避免誤殺正常業(yè)務(wù)。8. 我踩過(guò)這些坑之后的一些最終體會(huì)N1問(wèn)題看起來(lái)是個(gè)性能話題本質(zhì)上是個(gè)“控制意識(shí)”的問(wèn)題。Hibernate給了你很大的自由度同時(shí)也把查詢計(jì)劃的責(zé)任移交給了你。你用MyBatis寫(xiě)關(guān)聯(lián)查詢時(shí)每一條SQL都是你自己寫(xiě)出來(lái)的代價(jià)直觀用JPA/Hibernate時(shí)SQL是隱式生成的代價(jià)藏在方法調(diào)用后面。這意味著你的心智模型必須跟上框架的抽象層。我個(gè)人的建議是剛接觸Hibernate時(shí)一定把所有懶加載信息摸透把SQL日志當(dāng)成日常開(kāi)發(fā)的一部分不要等到線上告警才追悔莫及。我甚至?xí)诒镜亻_(kāi)發(fā)時(shí)把show_sql常開(kāi)配合一個(gè)不錯(cuò)的日志格式化工具每寫(xiě)一個(gè)查詢就停下來(lái)看一眼SQL條數(shù)。這個(gè)習(xí)慣幫我在很多項(xiàng)目里提前擋住了潛在問(wèn)題。最后分享一個(gè)小技巧如果你的接口確實(shí)需要完整的實(shí)體圖數(shù)據(jù)又不方便使用EntityGraph可以考慮用DTO投影加對(duì)象組裝。不要總覺(jué)得DTO麻煩在性能問(wèn)題面前一點(diǎn)類(lèi)型轉(zhuǎn)換的開(kāi)銷(xiāo)遠(yuǎn)比幾十條網(wǎng)絡(luò)往返便宜。把“少發(fā)SQL”作為指標(biāo)代碼多寫(xiě)幾行完全值得。希望這篇經(jīng)驗(yàn)對(duì)你正在處理的N1問(wèn)題有實(shí)際幫助。如果在優(yōu)化過(guò)程中也遇到了奇怪的SQL膨脹問(wèn)題歡迎多交流Hibernate這潭水多踩踩總會(huì)摸清底。