Una guida alla sessione aperta di primavera in vista

1. Panoramica

La sessione per richiesta è un modello transazionale per legare insieme la sessione di persistenza e i cicli di vita delle richieste. Non sorprende che Spring venga fornito con una propria implementazione di questo modello, denominata OpenSessionInViewInterceptor , per facilitare il lavoro con associazioni pigre e quindi migliorare la produttività degli sviluppatori.

In questo tutorial, in primo luogo, impareremo come funziona internamente l'interceptor e poi vedremo come questo modello controverso può essere un'arma a doppio taglio per le nostre applicazioni!

2. Presentazione di Open Session in View

Per comprendere meglio il ruolo di Open Session in View (OSIV), supponiamo di avere una richiesta in arrivo:

  1. Spring apre una nuova sessione di ibernazione all'inizio della richiesta. Queste sessioni non sono necessariamente connesse al database.
  2. Ogni volta che l'applicazione necessita di una Sessione, riutilizzerà quella già esistente.
  3. Alla fine della richiesta, lo stesso intercettore chiude quella Sessione.

A prima vista, potrebbe avere senso abilitare questa funzione. Dopotutto, il framework gestisce la creazione e la chiusura della sessione, quindi gli sviluppatori non si preoccupano di questi dettagli apparentemente di basso livello. Questo, a sua volta, aumenta la produttività degli sviluppatori.

Tuttavia, a volte, OSIV può causare lievi problemi di prestazioni in produzione . Di solito, questi tipi di problemi sono molto difficili da diagnosticare.

2.1. Spring Boot

Per impostazione predefinita, OSIV è attivo nelle applicazioni Spring Boot . Nonostante ciò, a partire da Spring Boot 2.0, ci avverte del fatto che è abilitato all'avvio dell'applicazione se non lo abbiamo configurato esplicitamente:

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

Ad ogni modo, possiamo disabilitare l'OSIV utilizzando la proprietà di configurazione spring.jpa.open-in-view :

spring.jpa.open-in-view=false

2.2. Pattern o Anti-Pattern?

Ci sono sempre state reazioni contrastanti nei confronti dell'OSIV. L'argomento principale del campo pro-OSIV è la produttività degli sviluppatori, specialmente quando si tratta di associazioni pigre.

D'altra parte, i problemi di prestazioni del database sono l'argomento principale della campagna anti-OSIV. Più avanti valuteremo entrambi gli argomenti in dettaglio.

3. Lazy Initialization Hero

Poiché OSIV lega il ciclo di vita della sessione a ciascuna richiesta, Hibernate può risolvere le associazioni pigre anche dopo il ritorno da un servizio @Transactional esplicito .

Per capire meglio questo, supponiamo di modellare i nostri utenti e le loro autorizzazioni di sicurezza:

@Entity @Table(name = "users") public class User { @Id @GeneratedValue private Long id; private String username; @ElementCollection private Set permissions; // getters and setters }

Simile ad altre relazioni uno-a-molti e molti-a-molti, la proprietà delle autorizzazioni è una raccolta lenta.

Quindi, nella nostra implementazione del livello di servizio, demarchiamo esplicitamente il nostro confine transazionale usando @Transactional :

@Service public class SimpleUserService implements UserService { private final UserRepository userRepository; public SimpleUserService(UserRepository userRepository) { this.userRepository = userRepository; } @Override @Transactional(readOnly = true) public Optional findOne(String username) { return userRepository.findByUsername(username); } }

3.1. L'aspettativa

Ecco cosa ci aspettiamo che accada quando il nostro codice chiama il metodo findOne :

  1. All'inizio, il proxy Spring intercetta la chiamata e ottiene la transazione corrente o ne crea una se non esiste.
  2. Quindi, delega la chiamata al metodo alla nostra implementazione.
  3. Infine, il proxy esegue il commit della transazione e di conseguenza chiude la Sessione sottostante . Dopotutto, abbiamo solo bisogno di quella Sessione nel nostro livello di servizio.

Nell'implementazione del metodo findOne , non abbiamo inizializzato la raccolta di autorizzazioni . Pertanto, non dovremmo essere in grado di utilizzare le autorizzazioni dopo la restituzione del metodo. Se iteriamo su questa proprietà , dovremmo ottenere una LazyInitializationException.

3.2. Benvenuto nel mondo reale

Scriviamo un semplice controller REST per vedere se possiamo usare la proprietà delle autorizzazioni :

@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()); } }

Qui, iteriamo sulle autorizzazioni durante la conversione da entità a DTO. Poiché ci aspettiamo che la conversione fallisca con un'eccezione LazyInitializationException, il test seguente non dovrebbe superare:

@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"))); } }

Tuttavia, questo test non genera eccezioni e viene superato.

Poiché OSIV crea una sessione all'inizio della richiesta, il proxy transazionaleutilizza la sessione disponibile corrente invece di crearne una nuova .

Quindi, nonostante quello che potremmo aspettarci, possiamo effettivamente utilizzare la proprietà delle autorizzazioni anche al di fuori di un @Transactional esplicito . Inoltre, questi tipi di associazioni pigre possono essere recuperati ovunque nell'ambito della richiesta corrente.

3.3. Sulla produttività degli sviluppatori

Se OSIV non fosse abilitato, dovremmo inizializzare manualmente tutte le associazioni pigre necessarie in un contesto transazionale . Il modo più rudimentale (e solitamente sbagliato) è usare il metodo Hibernate.initialize () :

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

Ormai, l'effetto di OSIV sulla produttività degli sviluppatori è ovvio. Tuttavia, non si tratta sempre di produttività degli sviluppatori.

4. Performance Villain

Suppose we have to extend our simple user service to call another remote service after fetching the user from the database:

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

Here, we're removing the @Transactional annotation since we clearly won't want to keep the connected Session while waiting for the remote service.

4.1. Avoiding Mixed IOs

Let's clarify what happens if we don't remove the @Transactional annotation. Suppose the new remote service is responding a little more slowly than usual:

  1. At first, the Spring proxy gets the current Session or creates a new one. Either way, this Session is not connected yet. That is, it's not using any connection from the pool.
  2. Once we execute the query to find a user, the Session becomes connected and borrows a Connection from the pool.
  3. If the whole method is transactional, then the method proceeds to call the slow remote service while keeping the borrowed Connection.

Imagine that during this period, we get a burst of calls to the findOne method. Then, after a while, all Connections may wait for a response from that API call. Therefore, we may soon run out of database connections.

Mixing database IOs with other types of IOs in a transactional context is a bad smell, and we should avoid it at all costs.

Anyway, since we removed the @Transactional annotation from our service, we're expecting to be safe.

4.2. Exhausting the Connection Pool

When OSIV is active, there is always a Session in the current request scope, even if we remove @Transactional. Although this Session is not connected initially, after our first database IO, it gets connected and remains so until the end of the request.

So, our innocent-looking and recently-optimized service implementation is a recipe for disaster in the presence of OSIV:

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

Here's what happens while the OSIV is enabled:

  1. At the beginning of the request, the corresponding filter creates a new Session.
  2. When we call the findByUsername method, that Session borrows a Connection from the pool.
  3. The Session remains connected until the end of the request.

Even though we're expecting that our service code won't exhaust the connection pool, the mere presence of OSIV can potentially make the whole application unresponsive.

To make matters even worse, the root cause of the problem (slow remote service) and the symptom (database connection pool) are unrelated. Because of this little correlation, such performance issues are difficult to diagnose in production environments.

4.3. Unnecessary Queries

Unfortunately, exhausting the connection pool is not the only OSIV-related performance issue.

Since the Session is open for the entire request lifecycle, some property navigations may trigger a few more unwanted queries outside of the transactional context. It's even possible to end up with n+1 select problem, and the worst news is that we may not notice this until production.

Adding insult to injury, the Session executes all those extra queries in auto-commit mode. In auto-commit mode, each SQL statement is treated as a transaction and is automatically committed right after it is executed. This, in turn, puts a lot of pressure on the database.

5. Choose Wisely

Whether the OSIV is a pattern or an anti-pattern is irrelevant. The most important thing here is the reality in which we're living.

If we're developing a simple CRUD service, it might make sense to use the OSIV, as we may never encounter those performance issues.

On the other hand, if we find ourselves calling a lot of remote services or there is so much going on outside of our transactional contexts, it's highly recommended to disable the OSIV altogether.

When in doubt, start without OSIV, since we can easily enable it later. On the other hand, disabling an already enabled OSIV may be cumbersome, as we may need to handle a lot of LazyInitializationExceptions.

The bottom line is that we should be aware of the trade-offs when using or ignoring the OSIV.

6. Alternatives

If we disable OSIV, then we should somehow prevent potential LazyInitializationExceptions when dealing with lazy associations. Among a handful of approaches to coping with lazy associations, we're going to enumerate two of them here.

6.1. Entity Graphs

When defining query methods in Spring Data JPA, we can annotate a query method with @EntityGraph to eagerly fetch some part of the entity:

public interface UserRepository extends JpaRepository { @EntityGraph(attributePaths = "permissions") Optional findByUsername(String username); }

Here, we're defining an ad-hoc entity graph to load the permissions attribute eagerly, even though it's a lazy collection by default.

If we need to return multiple projections from the same query, then we should define multiple queries with different entity graph configurations:

public interface UserRepository extends JpaRepository { @EntityGraph(attributePaths = "permissions") Optional findDetailedByUsername(String username); Optional findSummaryByUsername(String username); }

6.2. Caveats When Using Hibernate.initialize()

One might argue that instead of using entity graphs, we can use the notorious Hibernate.initialize() to fetch lazy associations wherever we need to do so:

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

They may be clever about it and also suggest to call the getPermissions() method to trigger the fetching process:

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

Both approaches aren't recommended since they incur (at least) one extra query, in addition to the original one, to fetch the lazy association. That is, Hibernate generates the following queries to fetch users and their permissions:

> 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=? 

Although most databases are pretty good at executing the second query, we should avoid that extra network round-trip.

On the other hand, if we use entity graphs or even Fetch Joins, Hibernate would fetch all the necessary data with just one query:

> 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. Conclusione

In questo articolo, abbiamo rivolto la nostra attenzione a una funzionalità piuttosto controversa in Spring e ad alcuni altri framework aziendali: Open Session in View. In primo luogo, ci siamo imbattuti in questo modello sia dal punto di vista concettuale che di implementazione. Quindi lo abbiamo analizzato dal punto di vista della produttività e delle prestazioni.

Come al solito, il codice di esempio è disponibile su GitHub.