本文所說的JPA都是默認指Spirng Data JPA,它是Spring提供的一套簡化JPA開發(fā)的ORM框架,目前在很多互聯(lián)網(wǎng)公司中也比較流行使用。
Spirng Data JPA與MyBatis類似,都是作為一種DAO層的解決方案,只不過,在對sql編寫的靈活性、對sql性能的直接優(yōu)化上,MyBatis要更方便一些,但在開發(fā)效率以及擴展性、可維護性上,Spirng Data JPA則可能更勝一籌。
至于兩者更多的區(qū)別,其實網(wǎng)上已有很多文章,這里不再過多敘述,本系列文章主要是總結個人在使用JPA過程中遇到的各種坑,供參考借鑒。
前言
Spring Data JPA 可以理解為 JPA 規(guī)范的再次封裝抽象,底層還是基于Hibernate 實現(xiàn)。
Spring Data JPA給開發(fā)人員帶來了簡便的同時,也埋了一些“坑”,如果沒弄明白其內(nèi)部機制,有時可能就會莫名的踩坑,今天就說下JPA的save方法在使用時要特別注意的地方。
數(shù)據(jù)的更新操作在JPA中有兩種實現(xiàn)方式,一種是使用Repository的save方法,一種是基于Repository的@Query注解直接寫update hql或sql(nativeQuery)。后者基本上就是寫sql,與MyBatis使用類似,本文主要談談前者(save方式)在更新數(shù)據(jù)時,要特別注意的2個地方(坑)。
踩坑一:默認先select,再update
save方法在更新DB時一個最大的特點是:默認每次都會先去查一遍DB,對查出來的DB數(shù)據(jù)與要保存的數(shù)據(jù)進行比較,看是否有變化,若有變化,才會將數(shù)據(jù)持久化至DB(執(zhí)行數(shù)據(jù)的update),否則,就不會進行數(shù)據(jù)的持久化。
對于JPA的這個設計,我一直比較困惑,因為結合個人對JPA在多個項目中的實踐體會,這種設計能帶來的收益其實很有限(有點像食之無味的雞肋),且在某些場景下反而會給性能帶來不必要的損耗。
個人覺得,save方式下這種“先select,再update”的設計,除非是需要使用到“JPA樂觀鎖”,尚有其合理性,但在其他方面則是沒有必要的。
尤其是需要逐條處理千萬級甚至上億級別的數(shù)據(jù)量時,每次都要去與DB交互一次,意味著除了update外,還要多出千萬上億次DB查詢交互,這些大量的查詢DB帶來的性能損耗是否值得?針對這種大數(shù)據(jù)量下逐條處理的場景,目前的解決方案是有2種:
如前面提到的,不使用save方式,改為使用@Query方式,即直接寫update hql/sql語句來更新數(shù)據(jù)。
自定義save的底層實現(xiàn),這需要更改Spring Data JPA的源碼或者擴展JPA的Hibernate實現(xiàn)。
其實JPA的save方式還是非常方便的,因為不用拼接sql,只操作POJO即可,進一步簡化了開發(fā),而且它可以自動識別是要insert,還是update。但由于它默認會先select,所以在對性能有較高要求的場景下,save方式可能要慎重考慮,還需評估其對性能的影響。
為什么要這樣設計?
JPA這樣設計的初衷不知道是不是出于樂觀鎖的考慮,如果是的話,這種設計尚可理解,畢竟樂觀鎖一般都是要比對內(nèi)存數(shù)據(jù)與DB數(shù)據(jù)在更新前的版本號是否一致,所以一般要先select。
注:JPA的樂觀鎖實現(xiàn)很簡單,因為其內(nèi)部已經(jīng)封裝好了,只要Repository類繼承JpaRepository,且實體的樂觀鎖字段加上@Version注解即可輕松實現(xiàn)樂觀鎖,一旦檢測到樂觀鎖沖突就會拋出ObjectOptimisticLockingFailureException異常,外層只要捕獲該異常即可。
但問題是,即使沒有采用樂觀鎖時,save方式下也是默認會先select,再update,
除非不使用save方式,改為在@Query注解里寫update hql/sql來更新DB,倒是可以解決這個問題。但是如果代碼里大部分的update操作都是采用@Query語句方式來更新DB,則就浪費了JPA本身的優(yōu)勢,可維護性差,而且@Query方式只適用于較為簡單的sql,并不靈活,還不如使用MyBatis來寫upate sql更方便。
又或者JPA是為了二級緩存的數(shù)據(jù)一致性?二級緩存僅限于進程級別(JVM進程緩存),即只針對單個應用實例內(nèi)所有線程來說,是全局的緩存。JPA框架層的這種“先select,再update”的機制,似乎可以在對DB進行update后,自動同步更新本應用實例內(nèi)的二級緩存。但問題是,實際需要用到二級緩存的場景又有多少呢?除非是超高并發(fā)的實時應用(要達到這種量級并發(fā)的公司是鳳毛麟角),可能會用到多級緩存(如redis緩存+jvm緩存),大部分情況下,使用分布式緩存(如redis)已足夠滿足需要。
又或者JPA是想盡量減少數(shù)據(jù)無變化時的無謂更新?但在實際的代碼編寫時,開發(fā)人員很少會去故意更新屬性值沒有任何變化的實體,而且這種更新前的數(shù)據(jù)比對需求并不迫切也不普遍,另一方面,即使有,開發(fā)人員也完全可在業(yè)務代碼上來自主根據(jù)實際情況來控制,沒必要作為框架層的一個默認約束。
又或者,JPA這樣設計是想避免無謂的級聯(lián)更新?Hibernate雖然支持級聯(lián)對象屬性,但在目前實際的生產(chǎn)級別的項目中,核心業(yè)務實體基本上不會設計級聯(lián)對象屬性,即該實體類的某個屬性的類型是另一個實體類,而且DB層也不推薦設計外鍵(級聯(lián)屬性最終是基于外鍵),Hibernate誕生之初推崇的這種級聯(lián)查詢、級聯(lián)更新機制,在目前越來越盛行的分布式實時應用中,已經(jīng)失去了其使用價值。
想來想去,最有可能的原因,是JPA底層實現(xiàn)-Hibernate的限制。Hibernate的Session中,對象實例有3種狀態(tài)(transient、persistent、detached),實例的狀態(tài)只能為這三者之一,且如果是要更新實例,則該實例必須要在Session緩存中,即要為persistent狀態(tài),而通過get()或者load()方法獲取的實例都是persistent實例,因此先select,再update極有可能是為了讓要更新的實例達到persistent狀態(tài),否則無法使用Hibernate Session的更新機制。
翻開Spring Data JPA的源碼,save方法的源碼實現(xiàn)會根據(jù)主鍵id是否為空,來判斷是要調(diào)用persist方法(可理解為insert),還是merge方法(可理解為update),再一步步跟進merge的具體實現(xiàn)中,可看出如果MergeContext中,要更新的實例對象不存在,則會先去調(diào)用load(),將該實例對象加載至Session緩存:
踩坑二:默認更新所有的字段
save方式下的更新數(shù)據(jù),會默認更新該條記錄的所有字段,即使你原本只更改了一個字段值,但最后更新DB時,JPA依舊會對該條記錄的所有字段進行更新(具體如何驗證該特性,可詳見本文后面的附例二)。這個特性可能會造成2個后果:
后果一帶來并發(fā)更新下數(shù)據(jù)不一致及產(chǎn)生不必要的性能浪費。對于DB來說,只更新一個字段,肯定是比更新十個字段的性能消耗小很多,比如原本只是更改了該記錄的一個狀態(tài)字段,但DB卻把該記錄的所有字段都更新了一遍。
后果二:可能會把空值更新到DB。由于save方法的入?yún)⑹且粋€實體對象,如果傳入的實體對象的某些屬性值為空(null),則最后JPA在更新時會把對應的字段也嘗試更新為空(null),而實際上你可能只想更新所更改過的字段,對于空值字段你希望自動忽略不更新。
針對后果一,JPA提供了@Query、@DynamicUpdate注解可以解決該問題,只需要在實體類上加上該注解,即可實現(xiàn)只更新有變化的字段:
對于后果二,目前是建議先從DB上查出要更新的實例,再在該實例上set要更新的屬性,最后再保存該實例。因為Spring Data JPA并不能自動忽略值為null的字段,也不支持根據(jù)配置來決定是否要忽略值為null的字段的更新,除非去更改JPA的源碼或者擴展JPA的Hibernate實現(xiàn),暫未找到其他的辦法。
附例一:驗證save方式下,默認先select,再update
1.在MySQL中,建好User表,主鍵字段名為id,表結構如下所示:
2.給User表初始化一條數(shù)據(jù),主鍵id為1,如:
3.生成對應的實體類和Repository類:
4.編寫一個Service,設置name屬性(要更新的屬性):
5.在JPA配置中要設置show_sql屬性為true,以便觀察日志中的sql語句:
6.再通過單元測試運行下,在日志中便可看到:會先打印select語句,再打印update語句。
結論:save方式下,會先select,再update。
這里也稍微說下是否在事務方法中調(diào)用save方法的區(qū)別:
如果在同個線程內(nèi),傳給save的實例對象是從DB查詢出來的,且該方法要加了事務,也即給上面的UserService的saveOrUpdate方法加上@Transactional注解:
則執(zhí)行save時不會再去select ,因為該實例對象再更新前已經(jīng)達到了persistent狀態(tài)(因為在執(zhí)行save方法前已經(jīng)先查詢了一遍,則會將該實例對象加載到Session緩存中),最后會看到:一條select,一條update。
當然如果數(shù)據(jù)在更新前后,完全沒有任何變化,比如將上面的代碼執(zhí)行2次,則再第2次時,由于Session緩存中的數(shù)據(jù)與DB數(shù)據(jù)完全相同,即沒有更改任何屬性值,則最后打印出來的sql中,只有select,沒有update,如下所示:
而如果沒有加事務,則在執(zhí)行save方法時,依舊會打印出select語句:
最后會看到:兩條select,一條update語句。
附例二:驗證save方式下,默認更新所有字段
先驗證空值更新的問題:
1.該特性很好驗證,其實前面的附例一已經(jīng)間接驗證了,沿用附例一的代碼,執(zhí)行后會發(fā)現(xiàn)sql中對所有字段都set(更新)了一遍:
2.比對更新前后的數(shù)據(jù)
更新前的數(shù)據(jù)是:
更新后的數(shù)據(jù)是:
結論:
若傳給save的實體對象包含了空值(null)則也會被更新到目標記錄上,而實際上你可能只想更新所更改的字段。
再驗證如何實現(xiàn)上面提到的“只想更新所更改的字段”
1.依舊基于前面的代碼,做點小改動:
2.執(zhí)行后,會發(fā)現(xiàn),雖然代碼里只更改了name這一個屬性,但最后DB卻依舊是會更新該記錄的所有字段:
3.給要更新的User實體類加上@DynamicUpdate注解后,再試試看下:
4.執(zhí)行之后,會發(fā)現(xiàn)打印出的sql語句中,只set了name字段:
查看數(shù)據(jù):
結論:
通過@DynamicUpdate可以只更新所更改的字段,未更改的字段并不會被更新。