制詳解:HQL、Criteria與原生SQL的映射規(guī)則)
有人問“hibernate還有人用嗎”這個(gè)問題我?guī)缀趺磕甓紩?huì)在技術(shù)群里看到一次。我的回答一直很直接只要你的Spring Boot項(xiàng)目還在用Spring Data JPA那底層跑的就是Hibernate。所謂“Hibernate過氣”無非是大家不用再天天面對(duì)SessionFactory和HQL的細(xì)節(jié)了框架本身反而活得好好的。也正因如此很多開發(fā)者在寫JPA的時(shí)候遇到一個(gè)看似簡(jiǎn)單的問題反而卡住了Hibernate里的別名Alias到底是什么這個(gè)問題的答案其實(shí)貫穿了HQL、JPQL、Criteria查詢和原生SQL映射的方方面面。如果你總是不清楚select u.name from User u里的這個(gè)u是干嘛的或者原生SQL把列映射回實(shí)體的規(guī)則是什么那么這篇內(nèi)容就是沖著你來的。我會(huì)從別名要解決什么問題講起再分場(chǎng)景拆解HQL、Criteria、原生SQL里別名的用法和坑最后給一個(gè)可以直接收藏的踩坑速查表。1. 別名到底解決什么問題一張關(guān)系表上的三個(gè)角色1.1 一句話說清別名就是一個(gè)引用標(biāo)簽先說最樸素的定義。在SQL里別名就是給表或列起的一個(gè)臨時(shí)名字作用是在一條語句里指代這個(gè)對(duì)象省得每次都寫完整的表名和列名。select u.name from t_user u這里的u就是t_user這張表的別名。到了Hibernate這里含義又?jǐn)U展了一點(diǎn)。HQL里from User uu是實(shí)體別名原生SQL里select u.id as user_iduser_id是列別名Criteria里root.join(pet, JoinType.LEFT)的返回值本質(zhì)上也是Hibernate幫你管理的路徑引用。這三個(gè)東西都叫別名但作用位置不同混在一起想就亂了。我用一個(gè)生活類比幫新手理解別名相當(dāng)于微信里的備注名。你不必每次都說“那個(gè)頭像很酷、上個(gè)月一起吃過飯、做后端開發(fā)的朋友”只需要說“老張”對(duì)方就知道是誰。在關(guān)系數(shù)據(jù)庫(kù)的查詢里也一樣一張表一旦出現(xiàn)在多條子句里你不能每次都重復(fù)全名得給它一個(gè)“老張”式的稱呼這就是別名。1.2 三種別名別搞混實(shí)體別名、列別名、路徑別名把三種別名放在一起對(duì)比一次后面就不會(huì)混淆了。實(shí)體別名作用于HQL/JPQL代表的是持久化實(shí)體對(duì)象本身列別名作用于SQL結(jié)果集代表查詢出的某個(gè)字段也是原生SQL映射回實(shí)體屬性的關(guān)鍵路徑別名則是Criteria查詢里對(duì)關(guān)聯(lián)對(duì)象引用的一種封裝。有個(gè)印象特別深的例子。同事在排一個(gè)查詢問題看到日志里Hibernate生成的SQL是select u1_0.id, p1_0.name from t_user u1_0 left join t_pet p1_0 on u1_0.idp1_0.user_id他問我“這個(gè)u1_0是什么我沒寫過這個(gè)別名”。這就是Hibernate在把HQL解析成SQL時(shí)自動(dòng)生成的實(shí)體別名規(guī)則是實(shí)體首字母加數(shù)字序號(hào)用來保證一條復(fù)雜SQL里同名實(shí)體不打架。這個(gè)概念理解清楚了以后看Hibernate打印的SQL日志就不會(huì)覺得是一堆亂碼了。2. HQL中的別名查詢邏輯的骨架2.1 from子句的實(shí)體別名與where引用HQL寫出來的第一步就是決定給核心實(shí)體一個(gè)什么別名。from User u是整個(gè)查詢的主語后續(xù)所有對(duì)User字段的限制都通過u.xxx來寫。比如要查年齡大于18的用戶就寫where u.age 18按名字排序就寫order by u.name。這里有一個(gè)常見誤區(qū)很多人以為HQL里的別名是給數(shù)據(jù)庫(kù)表用的所以誤寫成了from t_user u。HQL的對(duì)象不是表而是實(shí)體所以必須是from User u這里的User是Java類名。如果你用原生的t_user寫HQLHibernate直接拋org.hibernate.hql.internal.ast.QuerySyntaxException。實(shí)體的別名隨意起但建議短且有辨識(shí)度u、p、o這類就夠用不需要起一長(zhǎng)串。還有一點(diǎn)值得注意HQL里別名可以不出現(xiàn)在select里只出現(xiàn)在where里。from User u where u.age 20select其實(shí)是整行實(shí)體這里別名u只負(fù)責(zé)限定條件。這時(shí)候如果打印SQL日志你會(huì)看到Hibernate生成的SQL里select了所有字段這是正常的因?yàn)镠ibernate需要把整個(gè)實(shí)體組裝出來。2.2 隱式路徑表達(dá)式Hibernate幫你起名有時(shí)候你不需要顯式聲明別名也能寫出帶關(guān)聯(lián)字段的HQL。比如查所有有寵物的用戶from User u where u.pet is not null。這里的u.pet是路徑表達(dá)式Hibernate會(huì)在內(nèi)部創(chuàng)建一個(gè)關(guān)于pet關(guān)聯(lián)的隱式別名并自動(dòng)生成一個(gè)inner join的SQL。這種寫法省事但有兩個(gè)風(fēng)險(xiǎn)。一是在一個(gè)較長(zhǎng)的HQL里如果多次通過路徑表達(dá)式訪問同一個(gè)關(guān)聯(lián)的屬性Hibernate內(nèi)部可能會(huì)生成多個(gè)隱式j(luò)oin雖然語義上等價(jià)但生成的SQL可能會(huì)有重復(fù)join的情況性能和可讀性都受影響。二是在select子句里想同時(shí)返回User和Pet卻不顯式聲明別名寫法會(huì)變得非常繞。所以我在實(shí)際項(xiàng)目里有一條約定只要一條HQL里出現(xiàn)了兩個(gè)或以上的實(shí)體就明確給每個(gè)實(shí)體起別名并且把join關(guān)系寫清楚不要依賴隱式路徑。比如from User u left join u.pets p where p.name like %球%。這樣代碼的可讀性、可維護(hù)性都強(qiáng)得多排查問題時(shí)也能一眼看出是哪張表和哪張表在join。2.3 select子句投影和聚合上下文別名在select子句里還有一個(gè)重要任務(wù)承載投影結(jié)果。select u.name from User u返回的是字符串列表select u from User u返回的是User實(shí)體列表。別名放的位置不同結(jié)果類型完全不同。更常見的是DTO投影寫法這是Hibernate用戶必須掌握的select new com.example.UserDTO(u.id, u.name, u.age) from User u where u.age 18這里UserDTO是一個(gè)普通Java類必須有對(duì)應(yīng)的構(gòu)造函數(shù)。Hibernate會(huì)按位置把別名列出的字段值填入構(gòu)造函數(shù)。這就是我們通常說的“select new”動(dòng)態(tài)實(shí)例化。它比返回Object[]再手動(dòng)轉(zhuǎn)DTO要優(yōu)雅得多。聚合場(chǎng)景下別名的角色更微妙。select u.age, count(u) from User u group by u.age having count(u) 5這里count(u)中的u不是值本身而是代表“整行實(shí)體的數(shù)量”。初學(xué)者容易把u當(dāng)成某個(gè)字段來理解其實(shí)它指向的是查詢的主體實(shí)體。還有一個(gè)官方文檔里明確寫的規(guī)則select子句出現(xiàn)的別名、屬性表達(dá)式必須在group by或聚合函數(shù)中保持一致否則數(shù)據(jù)庫(kù)報(bào)錯(cuò)只是時(shí)間問題。比如按u.age分組同時(shí)select了u.name大多數(shù)關(guān)系型數(shù)據(jù)庫(kù)會(huì)直接拒絕執(zhí)行。3. Criteria和API層里的Alias連接與命名的藝術(shù)3.1 用createAlias關(guān)聯(lián)查詢路徑Hibernate的舊版Criteria API里有一個(gè)專門為別名設(shè)計(jì)的方法createAlias(String associationPath, String alias)。很多老項(xiàng)目里的代碼是這樣寫的Criteria criteria session.createCriteria(User.class); criteria.createAlias(pets, p); criteria.add(Restrictions.eq(p.name, 旺財(cái))); ListUser users criteria.list();這里pets是User實(shí)體里的關(guān)聯(lián)屬性名p是賦給這個(gè)關(guān)聯(lián)路徑的別名。創(chuàng)建后后續(xù)所有關(guān)于Pet的條件都通過p.xxx來寫不再需要寫pets.name這種長(zhǎng)路徑。這樣寫的好處是一方面省字另一方面當(dāng)你需要多次操作同一個(gè)關(guān)聯(lián)對(duì)象時(shí)別名能保證Hibernate只生成一個(gè)join。有人會(huì)問能不能直接寫criteria.add(Restrictions.eq(pets.name, 旺財(cái)))能功能上沒問題但一旦你需要在同一個(gè)條件里多次引用pets或者要限制join的類型left join、inner join沒有別名就非常痛苦。更麻煩的是老版Criteria里如果多次用字符串路徑pets.nameHibernate內(nèi)部會(huì)為同一個(gè)屬性路徑生成多個(gè)不同的隱式別名最后查出來的結(jié)果可能正確但生成的SQL又臭又長(zhǎng)。3.2 Root.join與fetch join的別名到了JPA的CriteriaBuilder時(shí)代老朋友createAlias變成了Root.join。例如CriteriaBuilder cb entityManager.getCriteriaBuilder(); CriteriaQueryUser query cb.createQuery(User.class); RootUser root query.from(User.class); JoinUser, Pet petJoin root.join(pets, JoinType.LEFT); query.where(cb.equal(petJoin.get(name), 旺財(cái)));這里的petJoin就是路徑的持柄它和Hibernate老API里的字符串別名不太一樣它是類型安全的對(duì)象別名所有后續(xù)條件都通過petJoin.get(...)來寫。好處是編譯期能檢查路徑是否存在缺點(diǎn)是沒有老版那么直觀。真正的坑在fetch join上。root.fetch(pets, JoinType.LEFT)返回的是Fetch而不是Join但它也會(huì)在底層創(chuàng)建別名。如果你同時(shí)想對(duì)pets加條件還另外寫了一個(gè)root.join(pets)Hibernate不會(huì)自動(dòng)幫你把fetch的join和條件的join合并最后可能生成兩個(gè)獨(dú)立的join結(jié)果出現(xiàn)重復(fù)記錄。我的處理習(xí)慣是需要過濾關(guān)聯(lián)對(duì)象時(shí)只做join不加fetch只是為了避免N1查詢時(shí)只用fetch不join。兩條路盡量分開走別交叉。3.3 老坑路徑字符串和別名覆蓋這里說一個(gè)非常有代表性的老坑。舊版Criteria里createAlias(pets, p)之后再調(diào)用createAlias(pets.tags, t)第一次的p別名依然有效。但如果兩次分別創(chuàng)建了指向同一路徑的不同別名比如createAlias(pets, p)和createAlias(pets, pet)Hibernate會(huì)產(chǎn)生兩個(gè)獨(dú)立的join條件分別加到各自的別名上。這個(gè)行為看著沒毛病卻會(huì)導(dǎo)致結(jié)果集會(huì)比預(yù)期多出很多行。排查時(shí)看到SQL日志里出現(xiàn)兩次對(duì)t_pet表的join第一反應(yīng)就應(yīng)該是某處重復(fù)創(chuàng)建了別名。另外字符串路徑不存在時(shí)會(huì)拋org.hibernate.QueryException: could not resolve property: pets2 of: com.example.User。這類報(bào)錯(cuò)信息其實(shí)已經(jīng)把問題點(diǎn)說得很清楚了但網(wǎng)絡(luò)上的中文回答有時(shí)會(huì)繞到別名機(jī)制本身。我建議排查時(shí)直接把HQL或Criteria路徑拆開逐級(jí)驗(yàn)證先確認(rèn)User里有pets再確認(rèn)Pet里有name基本十分鐘內(nèi)能定位。4. 原生SQL查詢列別名是結(jié)果映射的唯一橋梁4.1 傳統(tǒng)createSQLQuery映射到實(shí)體當(dāng)查詢需求用HQL或Criteria不好表達(dá)時(shí)我們會(huì)選擇寫原生SQL。原生SQL返回的是扁平的列Hibernate要靠“列名與屬性名的對(duì)應(yīng)關(guān)系”把行數(shù)據(jù)組裝成實(shí)體。這個(gè)對(duì)應(yīng)關(guān)系就是列別名。舊版Hibernate的經(jīng)典寫法是這樣的Query query session.createSQLQuery( select u.id, u.name, u.age from t_user u where u.age 18) .addEntity(User.class); ListUser users query.list();這里u.id這一列Hibernate在沒有任何列別名提示的情況下會(huì)拿列名id去匹配User實(shí)體標(biāo)注的Column(name id)。一旦表字段和實(shí)體屬性命名不一致比如數(shù)據(jù)庫(kù)列叫user_name實(shí)體屬性叫name就必須在SQL里顯式起別名select u.user_name as name, u.age as age from t_user uas后面的別名name必須和實(shí)體User的屬性名完全一致。這就是為什么很多老項(xiàng)目的原生SQL看起來特別啰嗦滿眼都是as xxx——那不是作者有強(qiáng)迫癥是被映射規(guī)則逼的。4.2 Spring Data JPA的nativeQuery與DTO投影現(xiàn)代項(xiàng)目用Spring Data JPA寫原生SQL更常見。原生查詢加DTO接口投影的組合值得單獨(dú)說。例如public interface UserNameView { String getName(); Integer getAge(); } Query(value select u.user_name as name, u.age as age from t_user u, nativeQuery true) ListUserNameView findAllNames();這里的as name和as age就是Spring Data的代理對(duì)象用來生成接口方法返回值的依據(jù)。如果列別名和接口方法名對(duì)不上Spring會(huì)拋出轉(zhuǎn)換異常說“無法找到名為name的屬性”甚至直接返回null但不報(bào)錯(cuò)。我建議寫這種查詢的時(shí)候在注解下方保留一條SQL注釋標(biāo)明每個(gè)別名對(duì)應(yīng)接口的哪個(gè)方法方便后來人維護(hù)。HQL版本里做DTO投影用的是select new原生SQL用的是別名匹配兩種方式背后的理念完全不一樣HQL是面向?qū)ο竽P蚐QL是面向列結(jié)構(gòu)。理解了這點(diǎn)你就不會(huì)在原生SQL里寫select new com.example.DTO(...)了因?yàn)槟莻€(gè)語法只在HQL里有效。4.3 標(biāo)量查詢讓無名列變成有名字的列原生SQL里還有一種情況查詢兩個(gè)字段做統(tǒng)計(jì)比如select count(*) from t_user。這個(gè)列本身沒有名字Hibernate返回的Object[]里只有一個(gè)元素類型是BigInteger或Long看日志時(shí)根本不知道對(duì)應(yīng)什么。所以標(biāo)量查詢時(shí)給列起別名也有現(xiàn)實(shí)意義Query query session.createSQLQuery( select u.age as age, count(*) as cnt from t_user u group by u.age); ListObject[] rows query.list(); for (Object[] row : rows) { Integer age (Integer) row[0]; Long cnt (Long) row[1]; }對(duì)象數(shù)組里的下標(biāo)順序?qū)?yīng)select子句里的列順序這個(gè)不依賴別名。但如果你用了setResultTransformer(Transformers.ALIAS_TO_ENTITY_MAP)列別名age和cnt就會(huì)變成Map的key取數(shù)據(jù)時(shí)直接map.get(cnt)可讀性就上來了。新版Hibernate里推薦用原生查詢加NativeQuery接口配合ResultTransformer思路是一樣的只是API換了殼。5. 別名踩坑速查表與排查思路5.1 別名問題速查表這些年處理過的Hibernate別名相關(guān)問題我整理成了一張速查表。碰到類似報(bào)錯(cuò)先對(duì)著查一下通常能省下半個(gè)小時(shí)試錯(cuò)?,F(xiàn)象報(bào)錯(cuò)/表現(xiàn)原因處理方式HQL里寫了表名QuerySyntaxException: t_user is not mapped把表名當(dāng)實(shí)體名用了改成實(shí)體類名User原生SQL查詢返回null字段字段為null但不報(bào)錯(cuò)列名與實(shí)體屬性名不匹配在SQL里加as 實(shí)體屬性名Criteria關(guān)聯(lián)路徑報(bào)錯(cuò)could not resolve property: xxx關(guān)聯(lián)屬性名拼寫錯(cuò)誤或不存在檢查實(shí)體類字段名逐級(jí)驗(yàn)證查詢結(jié)果出現(xiàn)大量重復(fù)行結(jié)果集行數(shù)遠(yuǎn)超預(yù)期同一路徑重復(fù)創(chuàng)建別名導(dǎo)致多次join只創(chuàng)建一個(gè)別名并復(fù)用同一個(gè)引用DTO投影字段全null接口方法返回null列別名和DTO接口方法名不一致保持as別名與接口方法名一致聚合查詢報(bào)錯(cuò)數(shù)據(jù)庫(kù)提示“列必須出現(xiàn)在GROUP BY中”select的字段沒有包含在group by檢查select與group by字段清單這張表雖然簡(jiǎn)單但項(xiàng)目里真正出問題的場(chǎng)景絕大多數(shù)都能歸到這幾類中。5.2 一個(gè)實(shí)用的排查技巧最后分享一個(gè)排查技巧遇到任何疑似別名導(dǎo)致的查詢問題第一件事不是看代碼而是打開Hibernate的SQL日志輸出。把hibernate.show_sqltrue打開找到那條被執(zhí)行的SQL對(duì)照一下where條件、join數(shù)量和select列。舉個(gè)例子如果你明明只想查5個(gè)用戶Hibernate卻多出了一次對(duì)寵物表的join日志里就能看到兩個(gè)join t_pet這時(shí)候可以百分百斷定是隱性重復(fù)join或別名覆蓋問題。重點(diǎn)要看的是Hibernate生成的SQL別名像u1_0、p1_0這種與你定義的別名不是一回事。日志里出現(xiàn)u1_0不代表你的別名寫法錯(cuò)了那只是Hibernate的“內(nèi)部代號(hào)”。許多開發(fā)者在這里被誤導(dǎo)以為自己寫的別名導(dǎo)致SQL異常其實(shí)自己寫的別名早就被解析成內(nèi)部代號(hào)了。我個(gè)人在實(shí)際操作中的體會(huì)是寫HQL前先給實(shí)體起好別名并且從第一條where到最后一條order都只引用這個(gè)別名絕不混用另一個(gè)臨時(shí)稱呼寫原生SQL時(shí)凡是結(jié)果要映射回實(shí)體或DTO的列全部顯式加as 目標(biāo)屬性名寫Criteria時(shí)堅(jiān)持用一個(gè)join變量貫穿查詢不重復(fù)創(chuàng)建關(guān)聯(lián)路徑。這套規(guī)矩看著樸素但能避免掉絕大多數(shù)別名相關(guān)的坑比記住什么“隱式別名”“顯式別名”的概念都管用。