GORM 陷阱 (第三部分)

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

很高兴听到大家发现这些文章很有用,因此我非常乐意在这个系列中添加另一篇文章。这次我将再次讨论关联,但重点关注它们何时加载到内存中。

2010 年 8 月 2 日更新 我添加了更多关于一对多关系中急切加载的信息,因为您需要注意一些问题。

懒惰是件好事

人们学习 GORM 关系的第一件事之一就是它们默认情况下是延迟加载的。换句话说,当您从数据库中获取一个域实例时,它的任何关系都不会被加载。相反,GORM 只有在您实际使用关系时才会加载它。

让我们通过考虑上一篇文章中的示例来更具体地说明这一点

class Location {
    String city
}

class Book {
    String title

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

class Author {
    String name
    Location location

    static hasMany = [ books: Book ]
}

如果我们获取一个作者实例,我们唯一无需执行另一个查询即可使用的信息是作者的姓名。当我们尝试获取关联的位置或书籍时,会启动更多查询以获取我们需要的额外数据。

这确实是唯一明智的默认选项,尤其是在具有长关联链的复杂模型中。如果急切加载是默认设置,则您只需获取一个实例即可轻松地最终拉取数据库中一半的数据。

尽管如此,此选项并非没有成本。我将探讨延迟关联的三个副作用,以便您知道它们是什么,能够识别症状,并能够修复由这些副作用引起的任何问题。

代理

关联的延迟加载涉及一些“魔法”。毕竟,您不希望上面的位置属性返回null,对吧?因此,Hibernate 使用代理和自定义集合类来提供对延迟加载的集合和关联的透明访问 - 您不必担心它们尚未在内存中。通常,这些代理在隐藏幕后工作方面做得很好,但偶尔实现会泄漏。

例如,考虑以下域模型

class Pet {
    String name
}

class Dog extends Pet {
}

这是一个非常简单的继承层次结构,因此您不会期望任何令人不快的意外。现在假设我们数据库中有一个实例,其 ID 为 1。您认为以下代码会发生什么?

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

直觉上,这应该可以工作。毕竟,ID 为 1 的宠物是。那么为什么断言失败了呢?而不是从数据库中获取底层实例,load()方法返回一个代理,该代理根据需要执行所需的查询,例如当您尝试访问除id以外的属性时。此代理是宠物的动态子类,而不是,因此instanceof检查失败。即使从数据库中加载实例后,它也会继续失败!以图解形式

更改Pet.load()Dog.load()将解决此问题,因为代理将成为的动态子类。您也可以通过替换load()get()使其工作,因为后者的实现会自动解包代理并返回底层实例。事实上,Grails 努力在许多其他情况下执行此自动解包,因此您不太可能遇到此问题。这就是当您遇到此问题时,它为何会令人惊讶的原因之一。

还有一种情况可能会导致一些烦恼,尽管这种情况应该相当罕见。假设您还有另一个类,,它与宠物有如下关系

class Person {
    String name
    Pet 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

假设我们有一个实例和一个宠物实例,它是一个,并假设这两个实例通过宠物属性相关联,则前三个断言将成功,但最后一个断言将失败。删除其他代码行,突然该断言将成功。嗯?

这种行为无疑令人困惑,但其根源在于 Hibernate 会话。当您从数据库中检索时,其宠物属性是一个代理。该代理存储在会话中,并表示宠物实例,其 ID 为 1。现在,Hibernate 会话保证,无论您在单个会话中从会话中检索特定域实例多少次,Hibernate 都会返回完全相同的对象。因此,当我们调用Pet.get(1)时,Hibernate 会给我们提供代理。相应的断言成功的原因是 GORM 会自动解包代理。对于findBy*()以及任何只能返回单个实例的其他查询,也会发生这种情况。

但是,GORM 不会为list(), findAllBy*()以及其他可能返回多个结果的查询的结果解包代理。所以Pet.list()[0]返回给我们解包的代理实例。如果未先获取,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]

使用这种方法,您仍然能够访问特定于的任何属性或方法,即使您正在使用代理。

必须承认,GORM 在保护开发人员免受代理的影响方面做得非常出色。它们很少泄漏到您的应用程序代码中,尤其是在更新版本的 Grails 中。尽管如此,有些人仍会遇到与它们相关的问题,因此了解症状及其发生原因非常有用。

我在最后一个示例中展示了会话的行为如何与延迟加载相结合产生一些有趣的结果。这种组合也隐藏着一个更常见的错误:org.hibernate.LazyInitializationException.

延迟加载和会话

正如我之前提到的,当您有一个延迟加载的关系时,如果稍后要导航该关系,Hibernate 必须执行额外的查询。在正常情况下,这不是问题(除非您担心性能),因为 Hibernate 会透明地执行此操作。但是,如果您尝试在不同的会话中访问该关系会发生什么?

假设您已在控制器操作中加载了 ID 为 1 的作者实例,并将其存储在 HTTP 会话中。此时,没有代码触及书籍集合。在下一个请求中,用户转到与该控制器操作相对应的 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 会话包含一个作者变量,则该操作将呈现该作者书籍的标题。除了在这种情况下它不会这样做。它抛出一个LazyInitializationException

问题在于作者实例是我们所说的分离对象。它在一个 Hibernate 会话中加载,但在请求结束时该会话被关闭。一旦对象的会话关闭,它就会变成分离的,并且您无法访问会导致查询的任何属性。

“但是我的操作中有一个会话处于打开状态,那么为什么会出现问题呢?”我听到您在哭喊。这是一个好问题。不幸的是,这是一个新的 Hibernate 会话,它对我们的作者实例一无所知。只有在将对象显式附加到新会话后,您才能访问其延迟关联。有几种方法可以做到这一点

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中启用 Hibernate 日志记录,这确保所有查询都记录到标准输出

dataSource {
    ...
    loggingSql = true
}

当然,您可以根据每个环境启用它。另一种方法是使用像P6Spy这样的特殊数据库驱动程序,它会拦截查询并记录它们。

那么如何避免这些额外的查询呢?通过急切而不是延迟地获取关联。此方法还可以解决我提到的与延迟加载相关的其他问题。

急切

GORM 允许您在每个关系的基础上覆盖默认的延迟加载行为。例如,我们可以通过此映射配置 GORM 始终与作者一起加载作者的位置

class Author {
    String name
    Location location

    static hasMany = [ books: Book ]

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

在这种情况下,不仅与作者一起加载位置,而且使用 SQL 联接在同一查询中检索位置。因此,此代码

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

只会导致单个查询。您也可以使用lazy: false选项代替fetch: 'join',但这会导致额外的查询来加载位置。换句话说,关联是急切加载的,但使用单独的 SQL 选择。大多数情况下,您可能希望使用fetch: 'join'为了最大程度减少执行的查询数量,但这有时可能是更昂贵的方法。这真的取决于你的模型。

还有其他选择,但这里我不会深入探讨。如果你想了解更多信息,可以在 Grails 用户指南 的第 5.3.4 和 5.5.2.8 节中找到完整的文档(不过我建议等待 Grails 1.3.4 版本的发布,该版本将包含一些重要的文档更新)。

在域类映射中配置急切加载的缺点是关联将始终被急切加载。但是,如果你只是偶尔需要这些信息呢?任何只想显示作者姓名的页面都会不必要地减慢速度,因为位置也必须加载。对于像这样的简单关联,成本可能很低,但对于集合来说,成本会更高。因此,你还可以选择在每个查询的基础上急切加载关联。

查询是上下文相关的,因此它们是指定是否应该急切加载特定关联的理想位置。假设我们已恢复为的默认行为作者现在我们想获取所有作者并显示他们的城市。在这种情况下,我们显然希望在获取作者时检索位置。方法如下

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

我们所做的只是添加了一个fetch参数到查询中,其中包含关联名称 -> 获取模式的映射。如果代码还显示了作者书籍的标题,我们也会将书籍关联添加到映射中。动态查找器支持完全相同的fetch选项

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

我们也可以使用 criteria 查询实现相同的功能

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

以上所有内容也适用于一对多关系,但你需要考虑一些额外的因素。

一对多关系的急切加载

我在上面说过,在急切获取关联时通常会使用连接,但这个经验法则对于一对多关系并不适用。为了理解原因,请考虑以下查询

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

很可能,这只会返回一个作者实例。这可能不是你期望或想要的行为。那么发生了什么事呢?

在后台,Hibernate 使用左外部连接来获取每个作者的书籍。这意味着你获得了重复的作者实例:每个作者关联的每本书一个。如果你没有max选项,你将看不到这些重复项,因为 GORM 会删除它们。但问题是max选项在删除重复项之前应用于结果。因此,在上面的示例中,Hibernate 仅返回两个结果,这两个结果都可能具有相同的作者。然后 GORM 删除重复项,你最终得到一个作者实例。

此问题同时出现在域类映射配置和 criteria 查询中。事实上,criteria 查询默认情况下不会从结果中删除重复项!对此混乱只有一个明智的解决方案:始终对一对多关系使用“select”模式。例如,在域映射中使用lazy: false:

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

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

在查询中,根据你使用的是动态查找器还是 criteria 查询,使用相应的设置

import org.hibernate.FetchMode

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

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

是的,你最终会得到一个额外的查询来获取集合,但它只有一个,并且你获得了一致性和简单性。如果你发现确实需要减少查询数量,那么你始终可以回退到 HQL。

除了与一对多关系的情况外,GORM 中的急切获取非常简单,如果你遵循对一对多关系使用“select”获取模式的原则,则同样适用于它们。主要工作在于分析应用程序的数据库访问,以确定应该急切获取关联还是专门使用连接获取关联。只需注意过早优化!

总结

如你所见,关联的延迟加载会引发各种问题,尤其是在与 Hibernate 会话结合使用时。尽管存在这些问题,但延迟加载是一个重要的特性,仍然是对象图的合理默认值。一旦你了解了这些问题,就会很容易识别出容易出现的这些问题,并且通常也易于解决。如果没有任何想法,你始终可以回退到谨慎使用急切加载。

尽管如此,随着 Grails 版本号的增加,用户遇到这些问题的可能性越来越小。当你考虑到 Hibernate 幕后发生的事情时,这是一个非常了不起的技巧!

获取 Spring 新闻通讯

与 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,以加速你的进步。

了解更多

获取支持

Tanzu Spring 在一个简单的订阅中提供对 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

查看 Spring 社区中所有即将举行的活动。

查看全部