GORM 陷阱(第三部分)

工程 | Peter Ledbrook | 2010 年 7 月 28 日 | ...

很高興聽到大家覺得這些文章有用,因此我非常樂意為本系列再添一篇。這次我將再次討論關聯,但重點在於它們何時被載入到記憶體中。

更新 2010 年 8 月 2 日 我增加了關於一對多關係中急切載入(eager fetching)的更多資訊,因為存在一些你需要注意的問題。

懶惰是件好事

人們學習 GORM 關係時首先了解的一點是,它們預設是延遲載入(lazily loaded)的。換句話說,當你從資料庫中獲取一個域例項時,它的任何關聯都不會被載入。相反,GORM 只會在你實際使用關聯時才載入它。

讓我們結合前一篇文章中的示例,讓這更具體一些。

class Location {
    String city
}

class Book {
    String title

    static constraints = {
        title(blank: false)
    }
}

class Author {
    String name
    Location location

    static hasMany = [ books: Book ]
}

如果我們獲取一個Author例項,無需執行額外查詢即可使用的唯一資訊是作者的名字。當我們嘗試獲取關聯的地理位置或書籍時,會觸發更多查詢來獲取我們所需的額外資料。

這確實是唯一合理的預設選項,特別是對於具有長關聯鏈的複雜模型。如果急切載入是預設設定,你很可能只需要獲取一個例項,最終卻拉取了資料庫中的一半資料。

儘管如此,這個選項並非沒有代價。我將探討延遲關聯的三個副作用,以便你知道它們是什麼,能夠識別症狀,並修復由這些副作用導致的任何問題。

代理

關聯的延遲載入涉及一些“魔術”。畢竟,你不希望上面的location屬性返回null,對吧?所以 Hibernate 使用代理和自定義集合類來提供對延遲載入的集合和關聯的透明訪問——你無需擔心它們尚未在記憶體中。通常這些代理能很好地隱藏幕後的工作,但偶爾實現細節會暴露出來。

舉個例子,考慮這個領域模型

class Pet {
    String name
}

class Dog extends Pet {
}

這是一個非常簡單的繼承層級,所以你不會期望有什麼不好的意外。現在想象一下,我們在資料庫中有一個 ID 為 1 的Dog例項。你認為以下程式碼會發生什麼?

def pet = Pet.load(1)
assert pet instanceof Dog

直觀上,這應該能工作。畢竟,ID 為 1 的寵物是一個Dogload()方法返回一個代理,該代理根據需要執行所需的查詢,例如當你嘗試訪問除id之外的屬性時。Pet而不是Dog,所以instanceof檢查失敗。即使例項從資料庫載入後,它仍然失敗!用圖表示就是

Pet.load()改為Dog.load()將解決這個問題,因為代理將成為Dog的動態子類。load()替換為get(),因為後者的實現會自動解包代理並返回底層的Dog例項。實際上,Grails 在許多其他情況下都努力執行這種自動解包,所以你不太可能遇到這個問題。這也是當你遇到它時會感到如此驚訝的原因之一。

還有另一種可能引起一些麻煩的情況,儘管它應該相當罕見。想象一下你有另一個類,Person,它與Pet像這樣有一個關係

class Person {
    String name
    Pet pet
}

這個pet關係是延遲載入的,所以當你獲取Person例項時,pet屬性將是一個代理。通常 GORM 會為你隱藏這一點,但請看以下程式碼的行為

def p = Person.get(1)
assert p.pet instanceof Dog
assert Pet.get(1) instanceof Dog
assert Pet.findById(1) instanceof Dog
assert Pet.list()[0] instanceof Dog

假設我們有一個Person例項和一個Pet例項,它是一個Dog,並且假設兩者透過pet屬性關聯,前三個斷言會成功,但最後一個不會。去掉其他行程式碼,突然那個斷言就成功了。這是怎麼回事?

這種行為無疑令人困惑,但其根源在於 Hibernate 會話。當你從資料庫中檢索Person時,它的pet屬性是一個代理。該代理儲存在會話中,代表 ID 為 1 的Pet例項。現在,Hibernate 會話保證,無論你在單個會話中多少次檢索特定的域例項,Hibernate 都會返回完全相同的物件。所以當我們呼叫Pet.get(1)時,Hibernate 會給我們代理。對應的斷言之所以成功,是因為 GORM 會自動解包代理。對於findBy*()和任何其他只能返回單個例項的查詢,情況也是如此。

然而,GORM 不會解包list(), findAllBy*()以及其他可以返回多個結果的查詢的結果中的代理。所以Pet.list()[0]返回給我們未解包的代理例項。如果Person沒有先被獲取,Pet.list()將返回真實的例項:這次代理不在會話中,所以查詢沒有義務返回它。

你可以通過幾種方式保護自己免受此問題的影響。首先,你可以使用動態的instanceOf()方法,而不是使用instanceof運算子。它適用於所有 GORM 域例項,並且是代理感知的Pet.get(1).instanceOf(Dog)def而不是靜態域類型別,否則你可能會看到類轉換異常。所以,與其使用

Person p = Person.get(1)
Dog dog = Pet.list()[0]    // Throws ClassCastException!

不如使用

def p = Person.get(1)
def dog = Pet.list()[0]

採用這種方法,即使你在使用代理,你仍然可以訪問任何特定於Dog的屬性或方法。

必須說,GORM 在遮蔽開發者免受代理影響方面做得非常出色。它們很少會暴露到你的應用程式程式碼中,特別是對於 Grails 的較新版本。不過,有些人仍然會遇到與代理相關的問題,因此瞭解這些問題的症狀以及它們發生的原因是很有用的。

我在最後一個例子中展示了會話行為與延遲載入相結合如何產生一些有趣的結果。這種組合也是更常見錯誤背後的原因:org.hibernate.LazyInitializationException.

延遲載入與會話

正如我之前提到的,當你有一個延遲載入的關係時,如果你想稍後導航該關係,Hibernate 必須執行一個額外的查詢。在正常情況下,這並不是問題(除非你擔心效能),因為 Hibernate 會透明地完成它。但是如果你嘗試在不同的會話中訪問該關係會發生什麼呢?

假設你在一個控制器動作中載入了 ID 為 1 的Author例項並將其儲存在 HTTP 會話中。此時,還沒有程式碼接觸過books集合。在下一個請求中,使用者訪問與此控制器動作對應的 URL

class MyController {
    def index = {
        if (session.author) {
            render "Author ${session.author.name} has written the books: ${session.author.books*.title}"
        else {
            render "No author in session"
        }
    }
    ...
}

這裡的意圖是,如果我們的 HTTP 會話包含一個author變數,該動作會渲染該作者書籍的標題。LazyInitializationException取而代之的是丟擲了一個 LazyInitializationException。

問題在於Author例項就是我們所謂的遊離物件(detached object)。它在一個 Hibernate 會話中載入,但該會話在請求結束時關閉了。一旦物件的會話關閉,它就變成遊離狀態,你無法訪問其任何會導致查詢的屬性。

“但是在我的動作中有一個會話是開啟的,為什麼還有問題?”我聽到你這樣問。這是個好問題。不幸的是,這是一個新的 Hibernate 會話,它對我們的Author例項一無所知。只有當物件明確地附加到新會話時,你才能訪問其延遲關聯。有幾種技術可以做到這一點

def author = session.author

// Re-attach object to session, but don't sync the data with the database.
author.attach()

// Re-attach object, but merge any changes with the data in the database.
// You *must* use the instance returned by the merge() method.
author = author.merge()

這個attach()方法在域例項自檢索為遊離物件後在資料庫中不太可能發生變化的情況下很有用。如果資料可能已經改變,那麼你需要小心。請查閱Grails 參考指南,瞭解merge()refresh().

的行為。LazyInitializationException現在如果你遇到

,你就知道這是因為你的域物件沒有附加到 Hibernate 會話。你也會知道如何解決這個問題,儘管我很快會介紹另一種解決問題的方法。在此之前,我想看看延遲初始化另一個經典的副作用:N + 1 選擇問題。

N + 1 選擇問題

Author.list().each { author ->
    println author.location.city
}

讓我們回到本文前面提到的作者/書籍/地理位置示例。假設資料庫中有四位作者,我們執行以下程式碼

會執行多少個查詢?答案是五個:一個用於獲取所有作者,然後每個作者一個查詢來檢索對應的地理位置。這就是所謂的 N + 1 選擇問題,很容易寫出受其影響的程式碼。上面的例子乍一看確實無害。在開發過程中這不是大問題,但是執行如此多的查詢會損害你的應用程式在生產環境部署時的響應速度。因此,在應用程式對終端使用者開放之前,分析其資料庫使用情況是個好主意。最簡單的方法是在grails-app/conf/DataSource.groovy

dataSource {
    ...
    loggingSql = true
}

中啟用 Hibernate 日誌記錄,它能確保所有查詢都記錄到標準輸出(stdout)。

當然,你可以在每個環境基礎上啟用它。另一種方法是使用像 P6Spy 這樣的特殊資料庫驅動程式,它可以攔截查詢並記錄它們。

那麼如何避免這些額外的查詢呢?透過急切地(eagerly)而不是延遲地(lazily)獲取關聯。這種方法也解決了前面提到的與延遲載入相關的其他問題。

急切地載入

class Author {
    String name
    Location location

    static hasMany = [ books: Book ]

    static mapping = {
        location fetch: 'join'
    }
}

GORM 允許你針對每個關係覆蓋預設的延遲載入行為。例如,我們可以透過以下對映配置 GORM,使其始終與作者一起載入作者的地理位置

Author.list().each { a ->
    println a.location.city
}

在這種情況下,地理位置不僅與作者一起載入,而且在同一個查詢中使用 SQL 連線來檢索。所以這段程式碼將只導致一個查詢。你也可以使用lazy: false選項來代替fetch: 'join'選項來代替,但這將導致一個額外的查詢來載入地理位置。換句話說,關聯是急切載入的,但使用單獨的 SQL select 語句。大多數情況下,你可能希望使用

以最小化執行的查詢數量,但有時這可能是更耗費資源的方法。這取決於你的模型。

還有其他選項,但我在這裡不再贅述。如果你想了解更多,它們在 Grails 使用者指南 的 5.3.4 和 5.5.2.8 節有完整的文件(儘管我建議等待 Grails 的 1.3.4 版本釋出,它會包含一些重要的文件更新)。

在領域類對映中配置急切載入的缺點是,該關聯將總是被急切載入。但如果你只需要偶爾使用該資訊呢?任何只想顯示作者名字的頁面都會不必要地變慢,因為地理位置也必須被載入。對於這樣一個簡單的關聯來說,成本可能很低,但對於集合來說會更高。這就是為什麼你還可以選擇按查詢粒度來急切載入關聯。Author查詢是上下文敏感的,因此它們是指定是否應急切載入特定關聯的理想位置。假設我們已將

Author.list(fetch: [location: 'join']).each { a ->
    println a.location.city
}

恢復到預設行為,現在我們想獲取所有作者並顯示他們的城市。在這種情況下,當我們獲取作者時,我們顯然希望檢索地理位置。方法如下我們所做的就是向查詢中添加了一個fetchbooks引數,該引數是一個關聯名稱 -> 獲取模式的對映。如果程式碼還需要顯示作者的書籍標題,我們也會將我們所做的就是向查詢中添加了一個關聯新增到對映中。動態查詢器支援完全相同的

Author.findAllByNameLike("John%", [ sort: 'name', order: 'asc', fetch: [location: 'join'] ]).each { a->
    ...
}

選項

def authors = Author.withCriteria {
    like("name", "John%")
    join "location"
}

我們也可以用 Criteria 查詢實現同樣的功能

以上所有內容也適用於一對多關係,但你需要考慮一些額外的因素。

一對多關係的急切載入

Author.list(max: 2, fetch: [ books: 'join' ])

我上面說過,在急切獲取關聯時,你通常會想使用連線(joins),但這條經驗法則對一對多關係效果不好。要理解原因,請看這個查詢Author很可能,這將只返回一個

例項。這可能不是你期望或想要的行為。那麼發生了什麼呢?Author在底層,Hibernate 使用左外連線來獲取每個作者的書籍。這意味著你會得到重複的例項:每個作者關聯的書籍對應一個。如果你沒有設定max例項:每個作者關聯的書籍對應一個。如果你沒有設定選項,你不會看到這些重複項,因為 GORM 會移除它們。但問題在於Author選項是在重複項被移除之前應用於結果的。所以在上面的例子中,Hibernate 只返回了兩個結果,這兩個結果很可能有相同的作者。然後 GORM 移除重複項,你最終只得到一個

例項。將只導致一個查詢。你也可以使用:

class Author {
    ...
    static hasMany = [ books: Book ]

    static mapping = {
        location fetch: 'join'
        books lazy: false
    }
}

這個問題在領域類對映配置和 Criteria 查詢中都會出現。事實上,Criteria 查詢預設不會從結果中移除重複項!解決這種混亂局面唯一合理的方法是:對於一對多關係,始終使用 'select' 模式。例如,在領域對映中使用

import org.hibernate.FetchMode

Author.list(fetch: [ books: 'select' ])

Author.withCriteria {
    fetchMode "books", FetchMode.SELECT
}

在查詢中,根據你是使用動態查詢器還是 Criteria 查詢,使用適當的設定

是的,你最終會有一個額外的查詢來獲取集合,但這只是一個,而且你會獲得一致性和簡單性。如果你發現你確實需要減少查詢數量,那麼你可以隨時回退到 HQL。

除了一對多的情況外,GORM 中的急切載入是直接的,如果你遵循一對多關係使用 'select' 獲取模式的原則,同樣也適用於它們。主要的精力在於分析應用程式的資料庫訪問,以確定哪些關聯應該急切載入,或者特別透過連線載入。只是要注意不要過早最佳化!

總結

如你所見,關聯的延遲載入會引發各種問題,特別是與 Hibernate 會話結合使用時。儘管存在這些問題,延遲載入仍然是一個重要的特性,對於物件圖來說,它仍然是合理的預設設定。一旦你瞭解了這些問題,它們就很容易識別,而且通常也很容易解決。如果沒有其他想法,你總是可以退回到明智地使用急切載入。

話雖如此,隨著 Grails 版本號的提升,使用者遇到這些問題的可能性越來越小。考慮到 Hibernate 在幕後所做的工作,這確實是一個令人印象深刻的技巧!

獲取 Spring 新聞郵件

訂閱 Spring 新聞郵件,保持聯絡