@Transactional ha aparegut a tots els exemples de les tres lliçons anteriors sense que l'hàgim explicada. L'hem posada als serveis, l'hem necessitada a @Modifying, hem dit que el context de persistència viu el que dura la transacció, que el dirty checking desa sense cridar save() i que readOnly = true optimitza alguna cosa. Tot això són afirmacions pendents de justificació. Aquesta lliçó les justifica.

I va més enllà, perquè les transaccions són on la correcció de CicloUrbana es juga de debò. Iniciar un lloguer exigeix marcar la bicicleta com a llogada i crear el registre de lloguer: si passa el primer sense el segon, una bicicleta queda bloquejada per sempre sense que ningú la tingui; si passa el segon sense el primer, dos ciutadans de Ribalta poden llogar la mateixa bicicleta. La transacció és el que impedeix que existeixi un estat intermedi. Veurem també els dos paranys que fan que @Transactional de vegades no faci res en absolut, sense cap avís: són responsables d'una quantitat desproporcionada d'errors en producció.

Contingut

  1. Què és una transacció: ACID a CicloUrbana
  2. Gestió declarativa amb @Transactional
  3. On es posa i per què
  4. Com funciona per dins: el proxy AOP
  5. Parany 1: l'autoinvocació
  6. Parany 2: els mètodes no públics
  7. propagation: els set valors
  8. isolation: els quatre nivells
  9. readOnly, timeout i els atributs restants
  10. La regla del rollback
  11. El context de persistència i el flush
  12. Bloqueig pessimista enfront d'optimista
  13. TransactionTemplate: gestió programàtica
  14. Esdeveniments transaccionals
  15. Transaccions i LazyInitializationException
  16. Errors Comuns i Consells
  17. Exercicis

  1. Què és una transacció: ACID a CicloUrbana

Una transacció és una unitat de treball que s'executa sencera o gens. La seva definició clàssica són les quatre propietats ACID, i convé veure-les sobre el cas real: iniciar un lloguer a l'estació «Plaça Major».

@Transactional
public LloguerResponse iniciar(IniciarLloguerRequest peticio) {
    Bicicleta bicicleta = bicicletaRepositori.findById(peticio.bicicletaId())
            .orElseThrow(() -> new RecursNoTrobatException("Bicicleta", peticio.bicicletaId()));

    if (!bicicleta.esPotLlogar(xarxaProperties.llindarBateria())) {
        throw new BicicletaNoDisponibleException(bicicleta.getMatricula());
    }

    bicicleta.setEstat(EstatBicicleta.EN_US);         // (1)
    bicicleta.setEstacio(null);                       // (2) surt de l'estació

    Lloguer lloguer = new Lloguer();
    lloguer.setUsuari(usuariRepositori.getReferenceById(peticio.usuariId()));
    lloguer.setBicicleta(bicicleta);
    lloguer.setEstacioOrigen(
            estacioRepositori.getReferenceById(peticio.estacioOrigenId()));
    lloguer.setInici(Instant.now());
    lloguerRepositori.save(lloguer);                   // (3)

    esdeveniments.publishEvent(new LloguerIniciat(lloguer.getId(), Instant.now()));
    return mapper.aResponse(lloguer);
}

Les tres operacions marcades han de passar juntes.

Propietat Què garanteix A CicloUrbana
Atomicitat Tot o res Si l'INSERT del lloguer falla, la bicicleta torna a DISPONIBLE
Consistència Es respecten les restriccions No es pot crear un lloguer amb una bicicleta_id inexistent
Aïllament Les transaccions concurrents no es trepitgen Dos usuaris no lloguen la mateixa bicicleta
Durabilitat Confirmat és permanent Després del 200 OK, un tall de llum no perd el lloguer

L'atomicitat es veu millor amb el contraexemple. Sense transacció, cada operació de repositori es confirma per separat: l'UPDATE bicicletes SET estat='EN_US' es confirma, i si l'INSERT INTO lloguers falla després, la bicicleta RB-0142 queda EN_US sense cap lloguer que la respatlli.

Aquest estat inconsistent és especialment nociu perquè no produeix cap error visible: la bicicleta simplement desapareix de l'inventari disponible i ningú no sap per què. Amb @Transactional, l'excepció provoca un rollback i tots dos canvis es desfan.

  1. Gestió declarativa amb @Transactional

Spring ofereix dues formes de gestionar transaccions: declarativa (una anotació) i programàtica (codi explícit). La declarativa és l'habitual perquè separa la gestió de la lògica de negoci.

@Transactional
public void metodeQueEscriu() { /* ... */ }

És tot. Darrere d'aquesta anotació passa el següent: s'obté una connexió del pool i es posa en autoCommit = false; s'obre un context de persistència associat a la transacció; s'executa el mètode; si acaba bé es fa flush i commit, i si llança una excepció no comprovada, rollback; i finalment es tanca el context i la connexió torna al pool.

Importa l'import. Hi ha dues anotacions amb el mateix nom:

Anotació Origen Recomanació
org.springframework.transaction.annotation.Transactional Spring Fes-la servir. Té tots els atributs
jakarta.transaction.Transactional Jakarta EE Funciona, però sense readOnly, isolation ni timeout

A CicloUrbana fem servir sempre la de Spring.

  1. On es posa i per què

La resposta curta: a la capa de servei. La llarga explica per què no a les altres dues.

Capa @Transactional? Per què
Controlador No La transacció viuria durant la serialització JSON, retenint una connexió del pool; a més barreja responsabilitats
Servei Sí És on viu el cas d'ús, la unitat de treball amb sentit de negoci
Repositori No (ja la té) SimpleJpaRepository està anotat; cada mètode és la seva pròpia transacció si no n'hi ha cap en curs

Per què el servei és la frontera correcta. Un cas d'ús —«iniciar un lloguer»— és exactament el que ha de ser atòmic. Un mètode de repositori és massa petit: si iniciar fos la suma de tres transaccions independents, no hi hauria atomicitat. Un controlador és massa gran: inclou validació, mapatge i serialització, feina que no necessita una connexió oberta. El patró recomanat, que ja vam fer servir a 04-05:

@Service
@Transactional(readOnly = true)   // per defecte: només lectura
public class LloguerService {

    @Transactional                // els que escriuen ho declaren explícitament
    public LloguerResponse iniciar(IniciarLloguerRequest peticio) { /* ... */ }

    @Transactional
    public LloguerResponse finalitzar(Long id, FinalitzarLloguerRequest peticio) { /* ... */ }

    public PaginaResponse<LloguerResponse> llistar(Pageable pageable) { /* només llegeix */ }
}

Que el valor per defecte sigui readOnly = true té dos avantatges: optimitza totes les lectures i converteix escriure en una decisió conscient, de manera que un mètode que s'oblidi d'anotar-se no escriurà per accident.

  1. Com funciona per dins: el proxy AOP

Aquí hi ha la clau per entendre els dos paranys de l'apartat següent.

@Transactional no modifica el teu codi. Spring crea un proxy que embolcalla el teu bean —el mateix mecanisme de BeanPostProcessor de 02-03 i dels repositoris de 04-05—. El que s'injecta al controlador no és el teu LloguerService: és un proxy que el conté.

sequenceDiagram
    participant C as LloguerController
    participant P as Proxy de LloguerService
    participant TM as JpaTransactionManager
    participant S as LloguerService (real)

    C->>P: iniciar(peticio)
    P->>TM: hi ha transacció? No -> obrir
    TM->>TM: connexió del pool, autoCommit=false
    P->>S: iniciar(peticio)
    S-->>P: LloguerResponse
    P->>TM: commit (flush + COMMIT)
    P-->>C: LloguerResponse

Si el mètode llança una excepció no comprovada, el proxy demana rollback en lloc de commit i la rellança.

Spring crea el proxy amb JDK dinàmic si el bean implementa interfícies, o amb CGLIB generant una subclasse si no; Spring Boot fa servir CGLIB per defecte (spring.aop.proxy-target-class=true), cosa que explica el requisit que els mètodes transaccionals no siguin final ni private.

La conseqüència fonamental: només les crides que travessen el proxy activen la transacció. Una crida d'un mètode de la classe a un altre de la mateixa classe va per this i no passa pel proxy. D'aquí el parany 1.

  1. Parany 1: l'autoinvocació

És l'error més freqüent i el més difícil de detectar, perquè no produeix cap símptoma fins que alguna cosa falla a mitges.

@Service
public class LloguerService {

    public void processarLotDevolucions(List<Long> ids) {
        for (Long id : ids) {
            finalitzarAmbTransaccio(id);   // crida interna: this.finalitzarAmbTransaccio()
        }
    }

    @Transactional
    public void finalitzarAmbTransaccio(Long id) {
        // Aquesta anotació NO té cap efecte quan es crida des de dalt!
    }
}

La crida finalitzarAmbTransaccio(id) és en realitat this.finalitzarAmbTransaccio(id). this és l'objecte real, no el proxy, així que l'anotació s'ignora per complet. Cada operació s'autoconfirma com si no hi hagués transacció, i una fallada a mig lot deixa les devolucions anteriors confirmades i les següents sense fer.

Ni el compilador ni Spring avisen. El codi sembla correcte.

Les tres solucions, de millor a pitjor. A. Extreure a un altre bean (la recomanada):

@Service
public class ProcessadorDevolucions {

    private final LloguerService lloguerService;   // injectat: és el PROXY

    public void processarLot(List<Long> ids) {
        ids.forEach(lloguerService::finalitzarAmbTransaccio);   // passa pel proxy
    }
}

A més de funcionar, sol millorar el disseny: orquestrar el lot i executar l'operació individual són responsabilitats diferents.

B. Autoinjecció. Funciona i és lletja: injectar a la mateixa classe un camp @Lazy private final LloguerService self —el proxy d'ella mateixa— i cridar self::finalitzarAmbTransaccio. @Lazy és necessari per trencar el cicle de dependència (02-02).

C. TransactionTemplate (apartat 13): gestió programàtica dins del mateix mètode, sense proxy pel mig. Aquest parany s'aplica igual a @Cacheable (09-02), @Async (07-03) i @PreAuthorize (05-05): tota anotació basada en proxies falla en l'autoinvocació.

  1. Parany 2: els mètodes no públics

@Transactional
private void metodePrivat() { }         // NO funciona

@Transactional
protected void metodeProtegit() { }     // NO funciona de forma fiable

@Transactional
void metodeDePaquet() { }               // NO funciona de forma fiable

Amb proxies CGLIB, Spring genera una subclasse que sobreescriu els mètodes: un private no es pot sobreescriure, i protected o de paquet no s'intercepten de forma fiable. L'anotació s'ignora en silenci. Des de Spring Framework 6.0 l'arrencada registra un avís en detectar-ho, cosa que ajuda, però la regla continua sent simple: @Transactional només en mètodes public, i mai en mètodes ni classes final.

  1. propagation: els set valors

propagation respon a: què fer si ja hi ha una transacció en curs quan es crida aquest mètode?

Valor Si hi ha transacció Si no n'hi ha
REQUIRED (per defecte) S'hi uneix En crea una de nova
REQUIRES_NEW Suspèn l'actual i en crea una altra d'independent En crea una de nova
SUPPORTS S'hi uneix S'executa sense transacció
NOT_SUPPORTED Suspèn l'actual S'executa sense transacció
MANDATORY S'hi uneix Llança excepció
NEVER Llança excepció S'executa sense transacció
NESTED Crea un punt de desament (savepoint) En crea una de nova

Els tres que importen de debò, amb casos de CicloUrbana:

REQUIRED: el 95 % dels casos. LloguerService.iniciar crida BicicletaService.marcarLlogada; tots dos són @Transactional i el segon s'uneix a la transacció del primer: una sola transacció i un sol commit.

REQUIRES_NEW: registrar una cosa que ha de sobreviure a un rollback.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void registrarIntent(Long usuariId, String operacio, boolean reeixida) {
    registreAuditoriaRepositori.save(
            new RegistreAuditoria(usuariId, operacio, reeixida, Instant.now()));
}

Si l'intent de lloguer falla i la transacció principal fa rollback, el registre d'auditoria es conserva, perquè va viure a la seva pròpia transacció. És exactament el que es vol d'una auditoria: registrar també el que va sortir malament.

El seu cost cal conèixer-lo: REQUIRES_NEW fa servir una segona connexió del pool mentre la primera continua suspesa. Amb maximum-pool-size: 10, deu peticions concurrents amb REQUIRES_NEW esgoten el pool i es produeix un interbloqueig: cada fil espera una connexió que un altre fil suspès no deixarà anar. És una causa real i difícil de diagnosticar de caigudes en producció.

NESTED: desfer una part sense perdre la resta. Crea un punt de desament dins de la transacció actual; si falla, s'hi torna i la resta sobreviu. Requereix suport de JDBC —PostgreSQL en té— però no funciona amb JpaTransactionManager, només amb DataSourceTransactionManager, cosa que a la pràctica el descarta en aplicacions JPA.

  1. isolation: els quatre nivells

isolation controla quant pot veure una transacció de la feina en curs d'altres. Els fenòmens que es poden produir:

Fenomen Què és Exemple a CicloUrbana
Lectura bruta Llegir dades no confirmades que després es desfan Veure una bicicleta com a EN_US en una transacció que acaba en rollback
Lectura no repetible Llegir la mateixa fila dues vegades amb valors diferents La capacitat d'una estació canvia entre dues lectures
Lectura fantasma Una consulta retorna files noves en repetir-la Comptar bicicletes disponibles dues vegades i obtenir un nombre diferent

I els nivells que els prevenen:

Nivell Lectura bruta No repetible Fantasma Cost
READ_UNCOMMITTED Possible Possible Possible Mínim
READ_COMMITTED No Possible Possible Baix
REPEATABLE_READ No No Possible* Mitjà
SERIALIZABLE No No No Alt

PostgreSQL fa servir READ_COMMITTED per defecte, i és el nivell correcte per a pràcticament tot CicloUrbana. Dues particularitats seves convé saber-les: no implementa READ_UNCOMMITTED —demanar-lo dona READ_COMMITTED, perquè mai no permet lectures brutes— i el seu REPEATABLE_READ també evita els fantasmes, en estar implementat amb instantànies (MVCC), sent més fort del que exigeix l'estàndard. Apujar-lo té sentit en un informe mensual que hagi de veure una foto coherent: @Transactional(isolation = Isolation.REPEATABLE_READ).

La regla pràctica: no toquis isolation llevat que sàpigues exactament per què. Apujar-lo augmenta els bloqueigs i la probabilitat d'errors de serialització que obliguen a reintentar. Per al 99 % dels casos, READ_COMMITTED més el bloqueig optimista de @Version (04-03) és la combinació correcta.

  1. readOnly, timeout i els atributs restants

readOnly = true no és una simple declaració d'intencions: Hibernate canvia el FlushMode a MANUAL i deixa de fer flush automàtic; se salta el dirty checking, sense desar còpies de l'estat original de cada entitat, cosa que redueix notablement la memòria en consultes de moltes files; i marca la connexió JDBC com de només lectura, cosa que en algunes configuracions permet dirigir-la a una rèplica. En una consulta que retorna 10.000 entitats, no mantenir 10.000 còpies és un estalvi substancial: per això readOnly = true a nivell de classe és una bona pràctica i no un adorn.

Ull amb una expectativa equivocada: readOnly no impedeix escriure. Amb Hibernate, un INSERT explícit pot arribar a executar-se. És una optimització, no una barrera de seguretat.

timeout limita la durada en segons (@Transactional(timeout = 10)). En superar-se es llança TransactionTimedOutException i es fa rollback. És una xarxa de seguretat valuosa, perquè una transacció que s'eternitzi reté una connexió del pool i bloqueja files; a CicloUrbana té sentit en operacions que puguin degenerar, com informes amb filtres oberts.

Atribut Per a què Valor per defecte
propagation Comportament davant d'una transacció existent REQUIRED
isolation Nivell d'aïllament El del motor (DEFAULT)
readOnly Optimització de lectura false
timeout Durada màxima en segons Sense límit
rollbackFor Excepcions comprovades que provoquen rollback Cap
noRollbackFor Excepcions que no l'han de provocar Cap

  1. La regla del rollback

Per defecte, Spring fa rollback només davant de RuntimeException i Error. Les excepcions comprovades confirmen la transacció.

És una herència d'EJB que sorprèn tothom:

@Transactional
public void operacio() throws IOException {
    repositori.save(entitat);
    throw new IOException("fallada de xarxa");   // es fa COMMIT! El save es confirma
}
// Per canviar-ho: @Transactional(rollbackFor = Exception.class)

A CicloUrbana el problema no es planteja perquè CicloUrbanaException, arrel de la jerarquia de 03-06, estén RuntimeException. RecursNoTrobatException, EstacioPlenaException, BicicletaNoDisponibleException i les altres provoquen rollback automàticament. Va ser una bona decisió de disseny i aquesta n'és una de les raons.

L'error clàssic: capturar l'excepció i perdre el rollback.

@Transactional
public void iniciarAmbCobramentMalament(IniciarLloguerRequest peticio) {
    Lloguer lloguer = crearLloguer(peticio);
    try {
        passarelaPagament.cobrar(lloguer.getImportTotal());
    } catch (PagamentRebutjatException e) {
        log.error("Pagament rebutjat", e);   // capturada i "gestionada"
    }
    // La transacció fa COMMIT: el lloguer queda creat SENSE haver-se cobrat
}

Capturar l'excepció impedeix que arribi al proxy, i sense excepció el proxy fa commit. El lloguer es registra encara que el pagament fallés.

I una variant encara més desconcertant: si l'excepció es llança en un mètode intern també @Transactional (propagació REQUIRED) i es captura a l'extern, la transacció ja va quedar marcada per a rollback per l'intern, i en confirmar salta UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only. Les dues solucions correctes:

// A. Rellançar com a excepció del domini (l'habitual)
catch (PagamentRebutjatException e) {
    log.error("Pagament rebutjat per al lloguer {}", lloguer.getId(), e);
    throw new ReglaNegociException("PAGAMENT_REBUTJAT", "El pagament ha estat rebutjat");
}

// B. Marcar explícitament la transacció per a rollback
catch (PagamentRebutjatException e) {
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}

La A és preferible: el @RestControllerAdvice de 03-06 la converteix en un ProblemDetail amb el seu codi, i el client sap què ha passat.

  1. El context de persistència i el flush

El context de persistència viu exactament el que dura la transacció. Aquest és el vincle que hem anat anunciant des de 04-01.

Quan fa Hibernate el flush, és a dir, quan envia a la base de dades el SQL acumulat:

Moment Hi ha flush?
Abans del commit Sempre
Abans d'una consulta JPQL que pugui veure's afectada Sí (FlushMode.AUTO)
En cridar entityManager.flush() o saveAndFlush() Sí, explícit
Amb readOnly = true No automàticament
En cridar save() sobre una entitat nova No necessàriament: només assigna l'id

El flush automàtic abans d'una consulta és més important del que sembla: desar una estació nova i consultar tot seguit existsByNom retorna true perquè Hibernate aboca l'INSERT pendent abans d'executar la consulta, encara que en el moment del save només hagués assignat l'id.

El dirty checking, en detall. En carregar una entitat, Hibernate desa una còpia del seu estat (la snapshot); al flush compara camp a camp i genera un UPDATE només si alguna cosa ha canviat, amb només les columnes modificades si està activat el SQL dinàmic.

@Transactional
public void ajustarCapacitat(Long id, int novaCapacitat) {
    Estacio estacio = estacioRepositori.findById(id)
            .orElseThrow(() -> new RecursNoTrobatException("Estació", id));
    estacio.setCapacitat(novaCapacitat);
    // Sense save(). En fer commit:
    //   UPDATE estacions SET capacitat=?, versio=? WHERE id=? AND versio=?
}

És correcte i és l'idiomàtic. Cridar save() aquí és redundant.

El cost ocult del dirty checking: mantenir la còpia de cada entitat carregada consumeix memòria, i comparar-les totes a cada flush consumeix CPU. Per això readOnly = true a les consultes és una optimització real.

  1. Bloqueig pessimista enfront d'optimista

A 04-03 vam afegir @Version i vam veure el bloqueig optimista. Aquí arriba el seu complement.

Optimista (@Version) Pessimista (@Lock)
Estratègia Detecta el conflicte en escriure Preveu el conflicte bloquejant la fila
Bloqueja files No Sí, fins al final de la transacció
Cost Una columna Contenció; risc d'interbloqueig
Falla En fer commit (409) Espera, o dona timeout
Adequat per a Conflictes rars Conflictes freqüents en un recurs escàs

El cas on l'optimista no n'hi ha prou a CicloUrbana és la cursa per l'última bicicleta d'una estació: dos ciutadans premen «llogar» alhora a «Plaça Major», totes dues transaccions llegeixen la mateixa bicicleta com a DISPONIBLE i totes dues la marquen com a EN_US. Amb @Version, el segon commit falla amb un 409, cosa que és correcta però mala experiència: l'usuari rep un error quan li hauríem pogut assignar una altra bicicleta.

El bloqueig pessimista ho evita serialitzant l'accés:

public interface BicicletaRepositori extends JpaRepository<Bicicleta, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
    @Query("""
           select b from Bicicleta b
            where b.estacio.id = :estacioId
              and b.estat = com.ciclourbana.bicicletes.EstatBicicleta.DISPONIBLE
              and b.nivellBateria >= :llindar
            order by b.nivellBateria desc
            limit 1
           """)
    Optional<Bicicleta> bloquejarMillorDisponible(@Param("estacioId") Long estacioId,
                                                  @Param("llindar") int llindar);
}

Genera un SELECT ... FOR UPDATE: la fila queda bloquejada fins al final de la transacció i la segona espera. Quan el primer confirma, el segon rellegeix, veu que ja no està disponible i en busca una altra. Ningú no rep un 409.

Els modes disponibles: PESSIMISTIC_READ (bloqueig compartit: altres llegeixen, no escriuen), PESSIMISTIC_WRITE (exclusiu, SELECT ... FOR UPDATE), PESSIMISTIC_FORCE_INCREMENT (exclusiu i a més incrementa @Version), OPTIMISTIC (comprova la versió al final) i OPTIMISTIC_FORCE_INCREMENT (la incrementa encara que res no canviï).

Tres regles per al bloqueig pessimista: fixa sempre un timeout, perquè sense ell una transacció llarga pot bloquejar les altres indefinidament; mantén la transacció tan curta com sigui possible, ja que el bloqueig dura fins al commit; i bloqueja les files sempre en el mateix ordre a tots els mètodes, o dues transaccions que bloquegin A→B i B→A produiran un interbloqueig que PostgreSQL resoldrà matant-ne una.

El criteri d'elecció: optimista per defecte —és el que porten Estacio, Bicicleta i Lloguer—, i pessimista només als punts concrets d'alta contenció sobre un recurs escàs.

  1. TransactionTemplate: gestió programàtica

Quan l'anotació no encaixa —control fi de l'abast, transaccions dins d'un bucle, o per esquivar l'autoinvocació—, TransactionTemplate dona control explícit:

@Service
public class ImportadorEstacions {

    private final TransactionTemplate plantilla;   // new TransactionTemplate(gestor)
    private final EstacioRepositori estacioRepositori;

    public ResultatImportacio importar(List<CrearEstacioRequest> lot) {
        int correctes = 0, fallides = 0;
        for (CrearEstacioRequest fila : lot) {
            try {
                // Una transacció PER FILA: una fallada no arrossega la resta
                plantilla.executeWithoutResult(estat -> {
                    estacioRepositori.save(mapper.aEntitat(fila));
                });
                correctes++;
            } catch (DataAccessException e) {
                log.warn("Fila descartada: {}", fila.nom(), e);
                fallides++;
            }
        }
        return new ResultatImportacio(correctes, fallides);
    }
}

Aquest cas —importar el catàleg d'estacions de Ribalta des d'un CSV amb files potencialment errònies— és l'exemple canònic: amb @Transactional al mètode, un sol error anul·laria la importació sencera; amb una transacció per fila s'importa el que és vàlid i es registra el que es descarta.

@Transactional TransactionTemplate
Llegibilitat Millor Pitjor (codi addicional)
Control de l'abast Tot el mètode Exacte
Afectat per l'autoinvocació Sí No
Transaccions en bucle No Sí

La regla: @Transactional per defecte; TransactionTemplate quan l'anotació no arriba.

  1. Esdeveniments transaccionals

A 02-02 vam publicar l'esdeveniment LloguerIniciat. Un @EventListener normal s'executa de forma síncrona i dins de la transacció, cosa que produeix dos problemes greus.

Problema 1: es notifica una cosa que pot no passar. Si l'escoltador envia un correu i després la transacció fa rollback, el correu anuncia un lloguer que no existeix. Problema 2: es reté una connexió durant la crida externa. Cobrar a la passarel·la pot trigar dos segons, i durant tot aquest temps la connexió continua prestada: la causa d'esgotament del pool que vam analitzar a 04-02.

@TransactionalEventListener resol tots dos:

@Component
public class NotificadorLloguer {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void enConfirmar(LloguerIniciat esdeveniment) {
        // Només s'executa si la transacció s'ha confirmat de debò
        notificacioService.enviarConfirmacio(esdeveniment.lloguerId());
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void enDesfer(LloguerIniciat esdeveniment) {
        log.warn("El lloguer {} no ha arribat a confirmar-se", esdeveniment.lloguerId());
    }
}
Fase Quan s'executa Ús típic
BEFORE_COMMIT Abans del commit, encara dins Validacions finals
AFTER_COMMIT (per defecte) Després de confirmar Notificacions, integracions
AFTER_ROLLBACK Després de desfer Registrar la fallada
AFTER_COMPLETION Després de qualsevol dels dos Neteja

Dos advertiments importants. A AFTER_COMMIT la transacció ja ha acabat: si l'escoltador intenta escriure a la base de dades necessita @Transactional(propagation = REQUIRES_NEW), i sense això els canvis es perden en silenci. I continua sent síncron per defecte, executant-se al mateix fil després del commit però abans de respondre al client; per no endarrerir la resposta, combina'l amb @Async (07-03).

Aplicat a CicloUrbana, el cobrament del lloguer passa a ocórrer després del commit, amb la connexió ja retornada al pool: aquest canvi per si sol pot multiplicar la capacitat de l'aplicació.

  1. Transaccions i LazyInitializationException

Tanquem el cercle obert a 04-04. Amb open-in-view: false (04-02), el context de persistència mor amb la transacció del servei. Tot el que en surti està separat.

// MALAMENT: l'entitat surt del servei amb relacions mandroses sense carregar
@Transactional(readOnly = true)
public Estacio obtenir(Long id) {
    return estacioRepositori.findById(id).orElseThrow();
}
// El controlador serialitza i accedeix a getBicicletes() -> LazyInitializationException
// BÉ: el mapatge a DTO passa DINS de la transacció
@Transactional(readOnly = true)
public EstacioDetallResponse obtenirDetall(Long id) {
    Estacio estacio = estacioRepositori.cercarAmbBicicletes(id)
            .orElseThrow(() -> new RecursNoTrobatException("Estació", id));
    return mapper.aDetall(estacio);   // aquí la transacció continua oberta
}

Això converteix la regla de 03-05 —«el servei retorna DTOs, no entitats»— de bona pràctica en requisit tècnic. Les tres decisions del mòdul encaixen aquí: LAZY a totes les associacions (04-04), open-in-view: false (04-02) i mapatge dins de la transacció. Juntes garanteixen que cap consulta no es dispari per accident durant la serialització.

Errors Comuns i Consells

Cridar un mètode @Transactional des de la mateixa classe. L'anotació s'ignora sense cap avís. Extreu a un altre bean.

Posar @Transactional en mètodes privats o final. No s'intercepten. Només mètodes public en classes no final.

Capturar una excepció i no rellançar-la. El commit es produeix igualment, o apareix UnexpectedRollbackException. Rellança com a excepció del domini.

Esperar rollback amb excepcions comprovades. Per defecte no n'hi ha. A CicloUrbana no és problema perquè tot hereta de RuntimeException.

Fer crides externes dins de la transacció. Reté una connexió durant tota la crida de xarxa. Fes servir @TransactionalEventListener(AFTER_COMMIT).

Abusar de REQUIRES_NEW. Consumeix una segona connexió mentre la primera està suspesa; amb concurrència esgota el pool i produeix interbloqueigs.

Apujar l'isolation «per seguretat». Augmenta bloqueigs i errors de serialització. READ_COMMITTED més @Version cobreix gairebé tot.

Consell: @Transactional(readOnly = true) a la classe de servei. Optimitza totes les lectures i fa que escriure sigui una decisió conscient.

Consell: transaccions curtes. Cada mil·lisegon de transacció és un mil·lisegon de connexió prestada i de files bloquejades. Valida abans d'obrir-la i notifica després de tancar-la.

Consell: comprova que la transacció està activa. Amb logging.level.org.springframework.transaction: DEBUG veuràs els missatges Creating new transaction i Initiating transaction commit; si no apareixen on esperaves, gairebé segur que és el parany 1.

Exercicis

Exercici 1: finalitzar un lloguer

Implementa LloguerService.finalitzar(Long lloguerId, FinalitzarLloguerRequest peticio). Ha de: verificar que el lloguer existeix i és en curs; comprovar que l'estació de destí té lloc (si no, EstacioPlenaException); calcular l'import amb CalculadoraTarifa; marcar la bicicleta com a DISPONIBLE a l'estació de destí; tancar el lloguer; i notificar l'usuari només si tot s'ha confirmat. Justifica cada decisió transaccional.

Exercici 2: trobar quatre errades transaccionals

@Service
public class MantenimentService {

    @Transactional
    public void revisioNocturna() {
        List<Bicicleta> bicicletes = bicicletaRepositori.findByNivellBateriaLessThan(20);
        for (Bicicleta bici : bicicletes) {
            processarBicicleta(bici);
        }
        serveiExtern.notificarTaller(bicicletes.size());
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    private void processarBicicleta(Bicicleta bici) {
        try {
            bici.setEstat(EstatBicicleta.MANTENIMENT);
            bicicletaRepositori.save(bici);
        } catch (Exception e) {
            log.error("Error", e);
        }
    }
}

Exercici 3: triar el tipus de bloqueig

Per a cada operació de CicloUrbana, decideix entre bloqueig optimista, pessimista o cap, i justifica-ho.

  1. Editar el nom d'una estació des del tauler de l'ajuntament.
  2. Llogar l'última bicicleta disponible d'una estació en hora punta.
  3. Consultar el llistat públic d'estacions.
  4. Descomptar saldo del moneder d'un usuari en finalitzar un lloguer.

Solucions

Solució 1.

@Service
@Transactional(readOnly = true)
public class LloguerService {

    @Transactional   // sobreescriu readOnly: aquest mètode escriu
    public LloguerResponse finalitzar(Long lloguerId, FinalitzarLloguerRequest peticio) {

        Lloguer lloguer = lloguerRepositori.findById(lloguerId)
                .orElseThrow(() -> new RecursNoTrobatException("Lloguer", lloguerId));

        if (lloguer.getFi() != null) {
            throw new ConflicteRecursException("El lloguer " + lloguerId + " ja ha finalitzat");
        }

        Estacio desti = estacioRepositori.findById(peticio.estacioDestiId())
                .orElseThrow(() -> new RecursNoTrobatException(
                        "Estació", peticio.estacioDestiId()));

        if (bicicletaRepositori.countByEstacioId(desti.getId()) >= desti.getCapacitat()) {
            throw new EstacioPlenaException(desti.getId());
        }

        Instant fi = Instant.now();
        BigDecimal importTotal = selectorTarifa.perUsuari(lloguer.getUsuari())
                .calcular(Duration.between(lloguer.getInici(), fi));

        Bicicleta bicicleta = lloguer.getBicicleta();
        bicicleta.setEstat(EstatBicicleta.DISPONIBLE);
        desti.afegirBicicleta(bicicleta);          // sincronitza tots dos costats (04-04)

        lloguer.setFi(fi);
        lloguer.setEstacioDesti(desti);
        lloguer.setImportTotal(importTotal);
        lloguer.setEstat(EstatLloguer.FINALITZAT);
        // sense save(): les tres entitats estan gestionades (dirty checking)

        esdeveniments.publishEvent(new LloguerFinalitzat(lloguer.getId(), importTotal, fi));
        return mapper.aResponse(lloguer);
    }
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void notificarFinalitzacio(LloguerFinalitzat esdeveniment) {
    notificacioService.enviarResum(esdeveniment.lloguerId(), esdeveniment.importTotal());
}

Les decisions transaccionals, una a una:

  • @Transactional sense readOnly, perquè escriu en tres entitats: la classe declara readOnly = true i aquest mètode ho sobreescriu, de manera que escriure és una decisió explícita.
  • Tot en una sola transacció. Tancar el lloguer, alliberar la bicicleta i assignar-la a l'estació han de passar junts; si l'UPDATE del lloguer fallés després de canviar la bicicleta, aquesta quedaria DISPONIBLE sense estar aparcada enlloc.
  • Sense save(): les tres entitats estan gestionades i el dirty checking genera els UPDATE en el commit.
  • Les excepcions són de CicloUrbanaException, no comprovades, així que provoquen rollback i el @RestControllerAdvice de 03-06 les converteix en 409 o 404 amb ProblemDetail.
  • La notificació va en AFTER_COMMIT: enviar-la a dins retindria la connexió durant la crida al servei de correu i podria anunciar un lloguer que després es desfà.
  • El mapatge a DTO passa dins de la transacció, requisit amb open-in-view: false. I sense bloqueig pessimista, perquè @Version a Lloguer n'hi ha prou: dos usuaris no finalitzen el mateix lloguer alhora.

Solució 2. Les quatre errades:

1. Autoinvocació (la més greu). processarBicicleta(bici) es crida amb this, sense passar pel proxy, així que REQUIRES_NEW s'ignora per complet i tot s'executa a la transacció externa.

2. @Transactional en un mètode private. No és interceptable: encara que es corregís l'autoinvocació, continuaria sense funcionar. Ha de ser public i viure en un altre bean.

3. Excepció capturada i no rellançada. Si el save falla, es registra al log i el bucle continua: la transacció externa confirma i es donen per processades bicicletes que no ho estan. I si la fallada hagués marcat la transacció per a rollback, el commit final donaria UnexpectedRollbackException.

4. Crida externa dins de la transacció. serveiExtern.notificarTaller(...) reté la connexió durant tota la crida de xarxa; en una revisió nocturna sobre centenars de bicicletes, la transacció pot durar minuts amb una connexió bloquejada.

Un cinquè problema de disseny: carregar totes les bicicletes per modificar-les una a una és innecessari, ja que una consulta @Modifying (04-06) ho faria en una sola sentència. Versió corregida:

@Service
public class MantenimentService {

    private final ProcessadorBicicletes processador;   // altre bean: es passa pel proxy
    private final ApplicationEventPublisher esdeveniments;

    public void revisioNocturna() {   // SENSE @Transactional: només orquestra
        List<Long> ids = bicicletaRepositori.cercarIdsAmbBateriaBaixaDisponibles(20);
        int processades = 0;
        for (Long id : ids) {
            try {
                processador.marcarEnManteniment(id);   // transacció independent
                processades++;
            } catch (DataAccessException e) {
                log.error("No s'ha pogut processar la bicicleta {}", id, e);
            }
        }
        esdeveniments.publishEvent(new RevisioNocturnaCompletada(processades));
    }
}

@Service
public class ProcessadorBicicletes {

    @Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 5)
    public void marcarEnManteniment(Long bicicletaId) {
        Bicicleta bici = bicicletaRepositori.findById(bicicletaId)
                .orElseThrow(() -> new RecursNoTrobatException("Bicicleta", bicicletaId));
        bici.setEstat(EstatBicicleta.MANTENIMENT);
        // sense save(): dirty checking
    }
}

revisioNocturna ja no és transaccional: només orquestra. Cada bicicleta es processa a la seva pròpia transacció, una fallada individual no arrossega la resta i la notificació al taller viatja en un esdeveniment que s'atén fora de qualsevol transacció.

Solució 3.

  1. Optimista (@Version). Els conflictes són rars —dos administratius editant la mateixa estació alhora— i el 409 amb «recarrega-ho i torna-ho a provar» és una resposta perfectament acceptable. Bloquejar la fila penalitzaria totes les lectures concurrents sense guany real.
  2. Pessimista (PESSIMISTIC_WRITE amb timeout). És el cas de contenció real: en hora punta, diversos ciutadans competeixen pel mateix recurs escàs. Amb optimista, tots menys un reben un 409 innecessari quan el sistema podria assignar-los una altra bicicleta. El SELECT ... FOR UPDATE serialitza l'accés i cada usuari obté una bicicleta o un missatge honest que no en queden.
  3. Cap. És una lectura pura: @Transactional(readOnly = true) i cap bloqueig. Qualsevol bloqueig aquí seria pur cost.
  4. Pessimista. El saldo d'un moneder és l'exemple canònic d'actualització perduda: llegir 12,50 €, restar 1,75 € i escriure 10,75 € des de dues transaccions concurrents fa desaparèixer un dels descomptes. L'optimista ho detectaria, però fallar un cobrament ja realitzat és pitjor que esperar uns mil·lisegons. Encara millor és evitar la lectura prèvia amb un UPDATE atòmic —update Moneder m set m.saldo = m.saldo - :importTotal where m.id = :id and m.saldo >= :importTotal—, que resol la cursa sense bloquejar res.

Conclusió

Ja saps què fa realment @Transactional, i era molt més del que l'anotació aparenta. Entens les quatre propietats ACID sobre el cas concret d'iniciar un lloguer a Ribalta, on marcar la bicicleta i crear el registre han de passar junts o cap, i has vist que l'estat inconsistent més perillós és el que no produeix cap error. Saps que l'anotació va a la capa de servei —no al controlador, on retindria la connexió durant la serialització, ni al repositori, els mètodes del qual són massa petits per ser un cas d'ús— i fas servir el patró de readOnly = true a la classe amb @Transactional explícit als mètodes que escriuen, perquè escriure sigui sempre una decisió conscient.

Coneixes el mecanisme del proxy AOP i, amb ell, els dos paranys que fan que l'anotació no faci absolutament res: l'autoinvocació, perquè una crida per this no travessa el proxy, i els mètodes no públics, que CGLIB no pot interceptar. Tots dos fallen en silenci, i la seva solució —extreure a un altre bean— gairebé sempre millora també el disseny. Manegues els set valors de propagation sabent que REQUIRED cobreix el 95 % dels casos, que REQUIRES_NEW és la resposta correcta per a una auditoria que ha de sobreviure al rollback però consumeix una segona connexió que pot esgotar el pool, i que NESTED no és viable amb JpaTransactionManager. Situes els quatre nivells d'isolation davant dels tres fenòmens de concurrència, saps que PostgreSQL fa servir READ_COMMITTED per defecte i no implementa lectures brutes, i tens clar que apujar el nivell «per seguretat» compra bloqueigs, no correcció.

Domines la regla del rollback: per defecte només amb excepcions no comprovades, cosa que a CicloUrbana no dona problemes perquè tota la jerarquia de 03-06 hereta de RuntimeException; i reconeixes l'error clàssic de capturar l'excepció i quedar-te sense rollback, juntament amb la seva variant desconcertant, la UnexpectedRollbackException. Saps quan fa flush Hibernate i per què el dirty checking fa innecessari cridar save() sobre una entitat gestionada, així com el cost en memòria que readOnly = true evita. Distingeixes el bloqueig optimista del pessimista i saps triar: @Version per defecte, SELECT ... FOR UPDATE amb timeout en la cursa per l'última bicicleta de «Plaça Major». Fas servir TransactionTemplate quan l'anotació no arriba —una transacció per fila en importar el catàleg d'estacions— i has tret el cobrament i les notificacions fora de la transacció amb @TransactionalEventListener(AFTER_COMMIT), retornant la connexió al pool abans de fer res lent. En sistemes distribuïts aquesta atomicitat deixa d'estar disponible i cal recórrer a patrons com la saga, que veurem a 07-05.

Queda un cap solt que arrosseguem des de 04-02: l'esquema. ddl-auto: update continua creant taules pel seu compte, ningú no sap exactament quin SQL s'ha executat sobre la base de dades i això no pot arribar a producció. La lliçó 04-08, Migracions d'Esquema amb Flyway, tanca el mòdul: veurem per què l'esquema s'ha de versionar com a codi, la convenció de noms dels scripts i la taula flyway_schema_history amb els seus checksums, escriurem l'esquema inicial complet de CicloUrbana en SQL de PostgreSQL —coherent amb les entitats i relacions de 04-03 i 04-04— i la migració de dades que jubila el CarregadorEstacionsDemo del mòdul 1, i aprendrem el patró expand/contract per reanomenar una columna sense aturar el servei.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats