Перейти к основному содержимому

Руководство по открытой сессии Spring In View

· 10 мин. чтения

1. Обзор

Сеанс на запрос — это транзакционный шаблон, связывающий жизненные циклы сеанса постоянства и запроса. Неудивительно, что Spring поставляется со своей собственной реализацией этого шаблона, называемой OpenSessionInViewInterceptor , для облегчения работы с ленивыми ассоциациями и, следовательно, повышения производительности труда разработчиков.

В этом уроке мы сначала узнаем, как работает перехватчик внутри, а затем мы увидим, как этот спорный шаблон может быть обоюдоострым мечом для наших приложений!

2. Знакомство с открытой сессией в представлении

Чтобы лучше понять роль Open Session in View (OSIV), давайте предположим, что у нас есть входящий запрос:

  1. Spring открывает новую сессию Hibernate в начале запроса. Эти сеансы не обязательно подключены к базе данных.
  2. Каждый раз, когда приложению требуется сеанс, оно будет повторно использовать уже существующий.
  3. В конце запроса тот же перехватчик закрывает эту сессию.

На первый взгляд может иметь смысл включить эту функцию. В конце концов, фреймворк обрабатывает создание и завершение сеанса, поэтому разработчики не беспокоятся об этих, казалось бы, низкоуровневых деталях. Это, в свою очередь, повышает продуктивность разработчиков.

Однако иногда OSIV может вызывать небольшие проблемы с производительностью в производственной среде . Обычно такие проблемы очень трудно диагностировать.

2.1. Весенний ботинок

По умолчанию 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

В любом случае, мы можем отключить OSIV, используя свойство конфигурации spring.jpa.open-in-view :

spring.jpa.open-in-view=false

2.2. Паттерн или антипаттерн?

Всегда были неоднозначные реакции на OSIV. Главный аргумент сторонников OSIV — продуктивность разработчиков, особенно при работе с ленивыми ассоциациями .

С другой стороны, проблемы производительности баз данных являются основным аргументом кампании против OSIV. Позже мы подробно рассмотрим оба аргумента.

3. Герой ленивой инициализации

Поскольку OSIV привязывает жизненный цикл Session к каждому запросу, Hibernate может разрешать ленивые ассоциации даже после возврата из явной службы @Transactional . ****

Чтобы лучше понять это, давайте предположим, что мы моделируем наших пользователей и их разрешения безопасности:

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

@Id
@GeneratedValue
private Long id;

private String username;

@ElementCollection
private Set<String> permissions;

// getters and setters
}

Подобно другим отношениям «один ко многим» и «многие ко многим», свойство « разрешения » представляет собой ленивую коллекцию.

Затем, в нашей реализации сервисного уровня, давайте явно разграничим нашу транзакционную границу, используя @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. Ожидание

Вот что мы ожидаем, когда наш код вызовет метод findOne :

  1. Сначала прокси-сервер Spring перехватывает вызов и получает текущую транзакцию или создает ее, если ее не существует.
  2. Затем он делегирует вызов метода нашей реализации.
  3. Наконец, прокси фиксирует транзакцию и, следовательно, закрывает базовую сессию . В конце концов, нам нужна только эта сессия на нашем сервисном уровне.

В реализации метода findOne мы не инициализировали коллекцию разрешений . Следовательно, мы не сможем использовать разрешения после возврата метода. Если мы повторим это свойство , мы должны получить исключение LazyInitializationException.

3.2. Добро пожаловать в реальный мир

Давайте напишем простой контроллер REST, чтобы увидеть, можем ли мы использовать свойство разрешений :

@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. Поскольку мы ожидаем, что преобразование завершится ошибкой с 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 создает сеанс в начале запроса, транзакционный прокси `использует текущий доступный сеанс вместо создания нового .`

Таким образом, несмотря на то, что мы могли бы ожидать, мы действительно можем использовать свойство разрешений даже вне явного @Transactional . Более того, такого рода ленивые ассоциации могут быть извлечены из любого места в текущей области запроса.

3.3. О продуктивности разработчиков

Если бы 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. Злодей-исполнитель

Предположим, нам нужно расширить наш простой пользовательский сервис для вызова другого удаленного сервиса после получения пользователя из базы данных :

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

return user;
}

Здесь мы удаляем аннотацию @Transactional , поскольку явно не хотим сохранять подключенный сеанс во время ожидания удаленной службы.

4.1. Избегайте смешанных операций ввода-вывода

Давайте проясним, что произойдет, если мы не удалим аннотацию @Transactional . Предположим, что новый удаленный сервис отвечает немного медленнее, чем обычно:

  1. Сначала прокси-сервер Spring получает текущую сессию или создает новую. В любом случае, этот сеанс еще не подключен. То есть он не использует какое-либо соединение из пула.
  2. Как только мы выполним запрос для поиска пользователя, сессия подключается и заимствует соединение из пула.
  3. Если весь метод является транзакционным, то метод продолжает вызывать медленную удаленную службу, сохраняя при этом заимствованное соединение .

Представьте, что в этот период мы получаем всплеск обращений к методу findOne . Затем, через некоторое время, все соединения могут ожидать ответа от этого вызова API. Поэтому скоро у нас могут закончиться соединения с базой данных.

Смешивание операций ввода-вывода базы данных с другими типами операций ввода-вывода в контексте транзакций — неприятный запах, и мы должны избегать этого любой ценой.

В любом случае, поскольку мы удалили аннотацию @Transactional из нашего сервиса, мы рассчитываем на безопасность .

4.2. Исчерпание пула соединений

Когда OSIV активен , в текущей области запроса всегда есть сеанс , даже если мы удалим @Transactional . Хотя эта сессия изначально не подключена, после нашего первого ввода-вывода базы данных она подключается и остается такой до конца запроса.

Итак, наша невинно выглядящая и недавно оптимизированная реализация сервиса — это рецепт катастрофы в присутствии 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 , этот сеанс заимствует соединение из пула.
  3. Сессия остается подключенной до конца запроса .

Несмотря на то, что мы ожидаем, что наш сервисный код не исчерпает пул соединений, простое присутствие OSIV потенциально может привести к тому, что все приложение не будет отвечать на запросы.

Что еще хуже, коренная причина проблемы (медленная удаленная служба) и симптом (пул соединений с базой данных) не связаны между собой . Из-за этой небольшой корреляции такие проблемы с производительностью трудно диагностировать в производственных средах.

4.3. Ненужные запросы

К сожалению, исчерпание пула соединений — не единственная проблема с производительностью, связанная с OSIV.

Поскольку сеанс открыт для всего жизненного цикла запроса, некоторые переходы по свойствам могут вызвать еще несколько нежелательных запросов вне контекста транзакции . Возможна даже проблема выбора n+1 , и худшая новость заключается в том, что мы можем не заметить этого до производства.

Вдобавок ко всему, Session выполняет все эти дополнительные запросы в режиме автоматической фиксации . В режиме автоматической фиксации каждый оператор SQL рассматривается как транзакция и автоматически фиксируется сразу после его выполнения. Это, в свою очередь, создает большую нагрузку на базу данных.

5. Выбирайте с умом

Является ли OSIV шаблоном или анти-шаблоном, не имеет значения. Самое главное здесь — реальность, в которой мы живем.

Если мы разрабатываем простую службу CRUD, возможно, имеет смысл использовать OSIV , поскольку мы можем никогда не столкнуться с такими проблемами производительности.

С другой стороны, если мы обнаружим, что обращаемся к большому количеству удаленных служб или так много происходит за пределами наших транзакционных контекстов, настоятельно рекомендуется вообще отключить OSIV.

Если вы сомневаетесь, начните без OSIV, так как мы можем легко включить его позже. С другой стороны, отключение уже включенного OSIV может быть громоздким, так как нам может потребоваться обработать множество исключений LazyInitializationException.

Суть в том, что мы должны знать о компромиссах при использовании или игнорировании OSIV.

6. Альтернативы

Если мы отключим OSIV, то должны каким-то образом предотвратить потенциальные исключения LazyInitializationException при работе с ленивыми ассоциациями. Среди нескольких подходов к борьбе с ленивыми ассоциациями мы собираемся перечислить здесь два из них.

6.1. Графики сущностей

При определении методов запроса в Spring Data JPA мы можем аннотировать метод запроса с помощью @EntityGraph , чтобы с нетерпением получать некоторую часть объекта :

public interface UserRepository extends JpaRepository<User, Long> {

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

Здесь мы определяем специальный граф сущностей для быстрой загрузки атрибута разрешений , даже если по умолчанию это ленивая коллекция.

Если нам нужно вернуть несколько проекций из одного и того же запроса, мы должны определить несколько запросов с разными конфигурациями графа сущностей:

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 .