Spring的open-in-view(Open Session In View)指南

1. Overview

每个请求一个会话是一种将持久性会话和请求生命周期联系在一起的事务模式。 不出所料, Spring 自带这种模式的实现,名为 OpenSessionInViewInterceptor, 方便使用惰性关联,从而提高开发人员的工作效率.

在本教程中,首先,我们将学习拦截器如何在内部工作,然后,我们将看到这个有争议的模式如何成为我们的应用程序的一把双刃剑!

2. 介绍 Open Session in View

为了更好地理解 Open Session in View (OSIV) 的作用,假设我们有一个传入请求:

  1. Spring 在请求开始时打开一个新的 Hibernate Session。 这些 Sessions 不一定连接到数据库。
  2. 每次应用程序需要一个Session 时,它都会重用已经存在的一个。
  3. 在请求结束时,同一个拦截器关闭 Session.

乍一看,启用此功能可能很有意义。 毕竟,框架负责处理会话的创建和终止,因此开发人员不会关心这些看似低级的细节。 这反过来又提高了开发人员的生产力。

然而,有时, 在生产中,OSIV可能会导致细微的性能问题. 通常,这类问题是很难诊断。

2.1. Spring Boot

默认情况下,OSIV 在 Spring Boot 应用程序中处于启用状态. 尽管如此,从 Spring Boot 2.0 开始,如果我们没有明确配置它,它会警告我们在应用程序启动时启用它:

spring.jpa.open-in-view is enabled by default. Therefore, database 
queries may be performed during view rendering.Explicitly configure 
spring.jpa.open-in-view to disable this warning

无论如何,我们可以使用 spring.jpa.open-in-view 配置属性禁用 OSIV:

spring.jpa.open-in-view=false

2.2. Pattern or Anti-Pattern(模式或反模式)?

人们对OSIV的反应总是褒贬不一. 支持OSIV阵营的主要论点是开发人员的生产力,特别是在处理懒惰关联时.

另一方面,数据库性能问题是反 OSIV 活动的主要论点。 稍后,我们将详细评估这两个论点。

3. Lazy Initialization Hero(懒惰初始化 Hero)

由于 OSIV 将 Session 生命周期绑定到每个请求, 即使从显式 @Transactional 服务返回后,Hibernate 也可以解决惰性关联.

为了更好地理解这一点,假设我们正在对用户及其安全权限进行建模:

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue
    private Long id;

    private String username;

    @ElementCollection
    private Set<String> permissions;

    // getters and setters
}

WS注释: @ElementCollection主要用于映射非实体(可embedded或基本数据类型),而@OneToMany用于映射实体.

与其他一对多和多对多关系类似,permissions 属性是一个惰性集合.

然后,在我们的服务层实现中,让我们使用 @Transactional 明确划分我们的事务边界:

@Service
public class SimpleUserService implements UserService {

    private final UserRepository userRepository;

    public SimpleUserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    @Transactional(readOnly = true)
    public Optional<User> findOne(String username) {
        return userRepository.findByUsername(username);
    }
}

3.1. The Expectation(期望)

当我们的代码调用 findOne 方法时,我们期望发生以下情况:

  1. 首先,Spring 代理拦截调用并获取当前事务,如果不存在则创建一个。
  2. 然后,它将方法调用委托给我们的实现。
  3. 最后,代理提交事务,并因此关闭底层的Session。毕竟,我们在服务层只需要那个Session

findOne方法实现中,我们没有初始化permissions集合. 因此,我们不应该在方法返回后使用 permissions。 如果我们对这个属性进行迭代,我们应该得到一个 LazyInitializationException.

3.2. Welcome to the Real World(欢迎来到真实的世界)

让我们编写一个简单的 REST 控制器来看看我们是否可以使用 permissions 属性:

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{username}")
    public ResponseEntity<?> findOne(@PathVariable String username) {
        return userService
                .findOne(username)
                .map(DetailedUserDto::fromEntity)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
}

在这里,我们在实体到 DTO 的转换期间迭代permissions。 由于我们希望转换失败并出现 LazyInitializationException,因此不应通过以下测试:

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class UserControllerIntegrationTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private MockMvc mockMvc;

    @BeforeEach
    void setUp() {
        User user = new User();
        user.setUsername("root");
        user.setPermissions(new HashSet<>(Arrays.asList("PERM_READ", "PERM_WRITE")));

        userRepository.save(user);
    }

    @Test
    void givenTheUserExists_WhenOsivIsEnabled_ThenLazyInitWorksEverywhere() throws Exception {
        mockMvc.perform(get("/users/root"))
          .andExpect(status().isOk())
          .andExpect(jsonPath("$.username").value("root"))
          .andExpect(jsonPath("$.permissions", containsInAnyOrder("PERM_READ", "PERM_WRITE")));
    }
}

但是,此测试不会引发任何异常,并且通过了。

因为OSIV在请求开始时创建了一个Session,事务代理使用当前可用的Session,而不是创建一个全新的。

因此,尽管我们可能会期望,我们实际上甚至可以在显式 @Transactional 之外使用 permissions 属性。 此外,可以在当前请求范围内的任何位置获取这些类型的惰性关联。

3.3. On Developer Productivity(关于开发人员生产力)

如果未启用 OSIV,我们必须在事务上下文中手动初始化所有必要的惰性关联。 最基本的(通常是错误的)方法是使用 Hibernate.initialize() 方法:

@Override
@Transactional(readOnly = true)
public Optional<User> findOne(String username) {
    Optional<User> user = userRepository.findByUsername(username);
    user.ifPresent(u -> Hibernate.initialize(u.getPermissions()));

    return user;
}

到目前为止,OSIV 对开发人员生产力的影响是显而易见的。 但是,这并不总是与开发人员的生产力有关。

4. Performance Villain(性能恶棍)

假设我们必须扩展我们的简单用户服务以在从数据库中获取用户后调用另一个远程服务:

@Override
public Optional<User> findOne(String username) {
    Optional<User> user = userRepository.findByUsername(username);
    if (user.isPresent()) {
        // remote call
    }

    return user;
}

在这里,我们删除了 @Transactional 注释,因为我们显然不想在等待远程服务时保持连接的 Session

4.1. Avoiding Mixed IOs(避免混合 IO)

让我们澄清如果我们不删除 @Transactional 注释会发生什么。 假设新的远程服务的响应速度比平时慢一点:

  1. 首先,Spring 代理获取当前的 Session 或创建一个新的。 无论哪种方式,此 Session 尚未连接。 也就是说,它不使用池中的任何连接。
  2. 一旦我们执行查询来查找用户,Session就连接起来,并从池中借用一个Connection
  3. 如果整个方法是事务性的,则该方法继续调用慢速远程服务,同时保留借用的 Connection

想象一下,在此期间,我们收到了对 findOne 方法的大量调用。 然后,一段时间后,所有 Connections 可能会等待来自该 API 调用的响应。 因此,我们可能很快就会耗尽数据库连接。

在事务环境中,将数据库IOs与其他类型的IOs混合是一种糟糕的感觉,我们应该不惜一切代价避免这种情况。

无论如何,因为我们从服务中删除了*@Transactional*注释,所以我们希望它是安全的。

4.2. Exhausting the Connection Pool(耗尽连接池)

当OSIV处于活动状态时,在当前的请求范围内总是有一个Session, 即使我们删除了*@Transactional*。虽然这个Session最初没有连接,但在我们的第一个数据库IO之后,它被连接并一直保持到请求结束。

因此,我们看似无辜且最近优化的服务实现是 OSIV 存在的灾难的秘诀:

@Override
public Optional<User> findOne(String username) {
    Optional<User> user = userRepository.findByUsername(username);
    if (user.isPresent()) {
        // remote call
    }

    return user;
}

以下是启用 OSIV 时发生的情况:

  1. 在请求开始时,相应的过滤器会创建一个新的 Session
  2. 当我们调用 findByUsername 方法时,该 Session 从池中借用了一个 Connection
  3. Session 保持连接直到请求结束。

尽管我们希望服务代码不会耗尽连接池,但仅仅是OSIV的存在就可能使整个应用程序失去响应。

更糟糕的是,问题的根本原因(远程服务慢)和症状(数据库连接池)是不相关的。由于这种相关性很小,因此在生产环境中很难诊断这种性能问题。

4.3. Unnecessary Queries(不必要的查询)

不幸的是,耗尽连接池并不是唯一与 OSIV 相关的性能问题。

由于 Session 对整个请求生命周期都是开放的,因此某些属性导航可能会在事务上下文之外触发更多不需要的查询。 甚至有可能最终出现 n+1查询问题,而最糟糕的消息是,我们可能直到生产时才会注意到这一点。

更糟糕的是,Session自动提交模式的方式执行所有这些额外的查询。在自动提交模式下,每个SQL语句都被视为一个事务,并在执行后立即自动提交。这反过来又给数据库带来了很大的压力。

5. Choose Wisely(做出明智的选择)

OSIV 是模式还是反模式无关紧要。 关键要看我们所处的环境。

  1. 如果我们正在开发一个简单的CRUD服务,使用OSIV可能是有意义的,因为我们可能永远不会遇到那些性能问题。

  2. 另一方面,如果我们发现自己调用了很多远程服务,或者在事务上下文之外发生了很多事情,我们强烈建议完全禁用OSIV

当有疑问时,不要使用OSIV,因为我们可以很容易地在以后启用它。另一方面,禁用一个已经启用的OSIV可能会很麻烦,因为我们可能需要处理大量的lazyinitializationexception

底线是,我们使用或忽略OSIV时应该注意的取舍。

6. Alternatives(选择)

如果我们禁用 OSIV,那么在处理惰性关联时,我们应该以某种方式防止潜在的 LazyInitializationExceptions。 在处理惰性关联的少数方法中,我们将在这里列举其中的两种。

6.1. Entity Graphs(实体图)

JPA 2.1推出来的@EntityGraph@NamedEntityGraph用来提高查询效率, 很好地解决了N+1条SQL的问题。 两者需要配合起来使用, 缺一不可。 @NamedEntityGraph配置在@Entity上面, 而@EntityGraph配置在Repository的查询方法上面。

在 Spring Data JPA 中定义查询方法时,我们可以使用 @EntityGraph 注释查询方法以急切地获取实体的某些部分

public interface UserRepository extends JpaRepository<User, Long> {

    @EntityGraph(attributePaths = "permissions")
    Optional<User> findByUsername(String username);
}

在这里,我们定义了一个临时实体图来急切地加载 permissions 属性,即使它默认是一个惰性集合。

如果我们需要从同一个查询返回多个投影,那么我们应该定义多个具有不同实体图配置的查询:

public interface UserRepository extends JpaRepository<User, Long> {
    @EntityGraph(attributePaths = "permissions")
    Optional<User> findDetailedByUsername(String username);

    Optional<User> findSummaryByUsername(String username);
}

6.2. 使用 Hibernate.initialize() 时的注意事项

有人可能会争辩说,我们可以使用臭名昭著的 Hibernate.initialize() 来代替实体图,在需要的地方获取惰性关联:

@Override
@Transactional(readOnly = true)
public Optional<User> findOne(String username) {
    Optional<User> user = userRepository.findByUsername(username);
    user.ifPresent(u -> Hibernate.initialize(u.getPermissions()));
        
    return user;
}

他们可能很聪明,还建议调用 getPermissions() 方法来触发获取过程:

Optional<User> user = userRepository.findByUsername(username);
user.ifPresent(u -> {
    Set<String> permissions = u.getPermissions();
    System.out.println("Permissions loaded: " + permissions.size());
});

不推荐这两种方法,因为 除了原始查询之外,它们(至少)会产生一个额外的查询来获取惰性关联。 也就是说,Hibernate 生成以下查询来获取用户及其权限:

> select u.id, u.username from users u where u.username=?
> select p.user_id, p.permissions from user_permissions p where p.user_id=?

尽管大多数数据库都非常擅长执行第二个查询,但我们应该避免额外的网络往返。

另一方面,如果我们使用实体图甚至 Fetch Joins,Hibernate 只需一个查询即可获取所有必要的数据:

> select u.id, u.username, p.user_id, p.permissions from users u 
  left outer join user_permissions p on u.id=p.user_id where u.username=?

7. 结论

在本文中,我们将注意力转向 Spring 和其他一些企业框架中一个颇具争议的特性:在视图中打开会话(Open Session in View)。 首先,我们在概念上和实现上都熟悉了这种模式。 然后我们从生产力和性能的角度对其进行了分析。

和往常一样,示例代码在GitHub上可用.

  • 3
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值