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 新闻邮件,保持联系