Spring Framework
Spring JDBC et les transactions déclaratives
Intermédiaire8 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Le support JDBC de Spring débarrasse le SQL brut de tout le cérémonial des connexions, des statements et des result sets, et son abstraction transactionnelle permet à une seule annotation @Transactional de fonctionner avec JDBC, JPA ou JTA. Ce guide montre JdbcTemplate et le plus récent JdbcClient en pratique, explique ce que le proxy transactionnel fait réellement d'une connexion, puis passe en revue la propagation, l'isolation et les règles de rollback, avec les pièges qui surprennent les équipes en production.
Ce que vous allez apprendre
- Interroger et mettre à jour une base de données avec JdbcTemplate et JdbcClient sans gérer les ressources à la main
- Expliquer pourquoi Spring traduit SQLException en exceptions non vérifiées de la hiérarchie DataAccessException
- Configurer un transaction manager et appliquer @Transactional avec les bonnes règles de propagation, d'isolation et de rollback
- Prévoir le comportement d'appels imbriqués REQUIRED et REQUIRES_NEW, y compris l'UnexpectedRollbackException
- Recourir à TransactionTemplate et à @TransactionalEventListener quand les annotations ne suffisent pas
Le JDBC brut est verbeux, et cette verbosité cache des bugs : obtenir une connexion, préparer un statement, lier les paramètres, parcourir le result set, fermer trois ressources dans le bon ordre dans un finally, puis traduire un code SQLException propre au fournisseur en quelque chose d'exploitable par l'appelant. Le module spring-jdbc de Spring se charge de ce cérémonial et ne vous laisse que le SQL ; son abstraction transactionnelle, elle, réduit « begin, commit, rollback en cas d'échec » à une seule annotation, qui se comporte de la même façon que la ressource sous-jacente soit une connexion JDBC, un EntityManager JPA ou une transaction JTA. Ce guide cible Spring Framework 6.x et suppose qu'un bean DataSource existe déjà — un pool HikariCP dans la plupart des applications.
JdbcTemplate
JdbcTemplate est le cheval de bataille historique : un objet thread-safe, créé une seule fois autour d'une DataSource et injecté partout où l'on exécute du SQL. Il acquiert et libère les connexions, exécute les statements, parcourt les result sets et traduit les exceptions — le tout au sein de la transaction éventuellement liée au thread courant.
@Repository
public class JdbcCustomerRepository implements CustomerRepository {
private final JdbcTemplate jdbc;
public JdbcCustomerRepository(DataSource dataSource) {
this.jdbc = new JdbcTemplate(dataSource);
}
private static final RowMapper<Customer> MAPPER = (rs, rowNum) ->
new Customer(rs.getLong("id"), rs.getString("email"), rs.getString("name"));
@Override
public Optional<Customer> findById(long id) {
List<Customer> rows = jdbc.query("select id, email, name from customer where id = ?", MAPPER, id);
return rows.stream().findFirst(); // query() returns an empty list rather than throwing
}
@Override
public int countByDomain(String domain) {
return jdbc.queryForObject("select count(*) from customer where email like ?", Integer.class, "%@" + domain);
}
@Override
public long insert(Customer c) {
var keys = new GeneratedKeyHolder();
jdbc.update(con -> {
var ps = con.prepareStatement("insert into customer (email, name) values (?, ?)", new String[] {"id"});
ps.setString(1, c.email());
ps.setString(2, c.name());
return ps;
}, keys);
return keys.getKey().longValue();
}
}
Un RowMapper<T> transforme une ligne en un objet ; DataClassRowMapper le fait automatiquement pour les records et les classes dotées d'un constructeur correspondant, et BeanPropertyRowMapper associe les colonnes aux setters par leur nom. queryForObject lève EmptyResultDataAccessException quand aucune ligne ne correspond : lorsque zéro ligne est un résultat normal, utilisez query et prenez le premier élément. NamedParameterJdbcTemplate enveloppe le même moteur avec des placeholders :name, ce qui devient précieux dès qu'une requête dépasse trois paramètres.
JdbcClient : l'API fluide
Spring Framework 6.1 a introduit JdbcClient, une façade fluide au-dessus des deux templates, plus lisible et qui unifie paramètres positionnels et nommés :
@Repository
public class OrderRepository {
private final JdbcClient db;
public OrderRepository(DataSource dataSource) {
this.db = JdbcClient.create(dataSource);
}
public List<Order> openOrdersFor(long customerId) {
return db.sql("""
select id, customer_id, total_cents, status
from orders
where customer_id = :customer and status = :status
""")
.param("customer", customerId)
.param("status", "OPEN")
.query(Order.class) // record or bean mapped by column name
.list();
}
public Optional<Order> find(long id) {
return db.sql("select id, customer_id, total_cents, status from orders where id = ?")
.param(id) // positional
.query(Order.class)
.optional();
}
public int markShipped(long id) {
return db.sql("update orders set status = 'SHIPPED' where id = :id and status = 'OPEN'")
.param("id", id)
.update(); // affected rows
}
}
query(Class) effectue le mapping par nom de colonne via un SimplePropertyRowMapper : customer_id se lie ainsi à un composant ou à une propriété nommé customerId. Vous pouvez toujours passer un RowMapper explicite, et .query().singleValue() ou .listOfRows() renvoient des scalaires et des maps pour les besoins ponctuels. Le client est le point d'entrée recommandé pour le nouveau code ; JdbcTemplate reste pleinement supporté, et c'est d'ailleurs à lui que JdbcClient délègue.
La traduction des exceptions
Toutes les technologies d'accès aux données de Spring lèvent des sous-classes de l'exception non vérifiée DataAccessException, jamais une SQLException brute. Le traducteur examine le SQL state et le code d'erreur du fournisseur, puis les convertit en une exception parlante et portable :
| Exception | Cause typique |
|---|---|
DuplicateKeyException | Contrainte d'unicité violée lors d'un insert |
DataIntegrityViolationException | Échec d'une contrainte de clé étrangère, not-null ou check |
EmptyResultDataAccessException | queryForObject n'a trouvé aucune ligne |
IncorrectResultSizeDataAccessException | Une ligne attendue, plusieurs obtenues |
CannotAcquireLockException | Timeout de verrou ou deadlock |
QueryTimeoutException | La requête a dépassé son timeout |
BadSqlGrammarException | Erreur de syntaxe SQL ou table inexistante |
DataAccessResourceFailureException | Impossible d'obtenir une connexion |
Le bénéfice : un service peut intercepter DuplicateKeyException et répondre « e-mail déjà enregistré » sans savoir si la base est PostgreSQL ou Oracle, et une défaillance irrécupérable se propage sous forme d'exception runtime — ce qui, comme le montre la section suivante, est précisément ce qui déclenche un rollback.
L'abstraction transactionnelle
Le modèle de Spring repose sur trois éléments. Un PlatformTransactionManager sait démarrer, valider et annuler une transaction pour un type de ressource donné : JdbcTransactionManager (le successeur en 6.x de DataSourceTransactionManager, qui ajoute la traduction des exceptions au commit) pour JDBC, JpaTransactionManager pour JPA, JtaTransactionManager pour les transactions XA gérées par le conteneur. Une TransactionDefinition décrit ce qui est demandé — propagation, isolation, timeout, lecture seule. Un TransactionStatus représente la transaction en cours et porte le flag rollback-only.
@Configuration
@EnableTransactionManagement
public class DataConfig {
@Bean
DataSource dataSource() { /* HikariDataSource */ }
@Bean
PlatformTransactionManager transactionManager(DataSource dataSource) {
return new JdbcTransactionManager(dataSource);
}
}
@EnableTransactionManagement enregistre un bean post-processor qui enveloppe dans un proxy AOP chaque bean portant @Transactional. Autour de chaque méthode annotée, le TransactionInterceptor du proxy :
- Demande au transaction manager une transaction conforme à la définition de la méthode — en
REQUIRED, il rejoint la transaction en cours si le thread en possède déjà une, et en démarre une nouvelle sinon. - Pour JDBC, cela revient à obtenir une connexion, appeler
setAutoCommit(false)et lier la connexion au thread courant dansTransactionSynchronizationManager.JdbcTemplate,JdbcClientet Hibernate regardent tous d'abord à cet endroit, ce qui explique qu'ils participent à la transaction sans qu'on le leur dise. - Invoque la méthode.
- En cas de retour normal, valide la transaction (ou, s'il l'a seulement rejointe, ne fait rien et laisse la méthode externe s'en charger). Sur une exception qui déclenche un rollback, annule puis relance l'exception. Enfin, délie et libère la connexion.
Ce lien avec le thread explique une règle qui piège ceux qui introduisent du multithreading : une transaction ne suit pas le travail soumis à un executor, à une CompletableFuture ou à un stream parallèle. L'autre thread n'a aucune connexion liée et s'exécute en auto-commit.
Utiliser @Transactional
@Service
public class TransferService {
private final AccountRepository accounts;
private final LedgerRepository ledger;
public TransferService(AccountRepository accounts, LedgerRepository ledger) {
this.accounts = accounts;
this.ledger = ledger;
}
@Transactional(timeout = 5, rollbackFor = InsufficientFundsException.class)
public void transfer(long from, long to, long cents) throws InsufficientFundsException {
Account source = accounts.lockForUpdate(from);
if (source.balanceCents() < cents) {
throw new InsufficientFundsException(from); // checked: rolls back only because of rollbackFor
}
accounts.adjust(from, -cents);
accounts.adjust(to, cents);
ledger.record(from, to, cents);
}
@Transactional(readOnly = true)
public Statement statement(long account) { … }
}
Placez l'annotation sur la classe pour l'appliquer à toutes les méthodes publiques, puis redéfinissez-la méthode par méthode. Voici les attributs qui comptent.
La propagation détermine comment un appel se positionne par rapport à une transaction existante.
| Propagation | Aucune transaction en cours | Une transaction existe |
|---|---|---|
REQUIRED (défaut) | En démarre une | La rejoint |
REQUIRES_NEW | En démarre une | La suspend et en démarre une nouvelle, indépendante |
SUPPORTS | S'exécute sans | La rejoint |
NOT_SUPPORTED | S'exécute sans | La suspend et s'exécute sans |
MANDATORY | Lève une exception | La rejoint |
NEVER | S'exécute sans | Lève une exception |
NESTED | En démarre une | Crée un savepoint JDBC à l'intérieur |
REQUIRES_NEW est celle qu'il faut comprendre en profondeur. Elle donne à la méthode interne sa propre connexion et son propre commit, ce qui correspond exactement au besoin « enregistrer la ligne d'audit même si l'opération métier échoue ». Cela signifie aussi que les modifications non validées de la transaction externe sont invisibles pour la transaction interne, et que deux connexions du pool sont détenues simultanément — avec un petit pool, des appels REQUIRES_NEW imbriqués peuvent provoquer un deadlock en attendant des connexions. NESTED utilise à la place un savepoint sur la même connexion : un rollback partiel devient possible sans seconde connexion, mais seuls les transaction managers basés sur JDBC le supportent.
L'isolation correspond directement aux niveaux JDBC — READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE —, DEFAULT s'en remettant à la base de données. Seule l'isolation de la transaction la plus externe s'applique ; une méthode interne en REQUIRED qui demande un autre niveau est ignorée, sauf si validateExistingTransaction est activé, auquel cas elle lève une exception.
Les règles de rollback prévoient par défaut un rollback sur RuntimeException et Error uniquement. rollbackFor et noRollbackFor ajustent ce comportement par type d'exception. rollbackForClassName accepte des noms de classes sous forme de chaînes, pour les exceptions que vous ne pouvez pas référencer à la compilation, mais des classes explicites sont plus claires.
readOnly est une indication transmise au driver et à Hibernate ; elle peut améliorer les performances et permettre le routage vers des réplicas, mais n'impose rien.
timeout, exprimé en secondes, s'applique aux requêtes exécutées dans la transaction, via le query timeout de JdbcTemplate ; ce n'est pas une interruption brutale de la méthode.
Visibilité des méthodes et proxies
Comme @Transactional est appliqué par un proxy, tout ce qui vaut pour Spring AOP vaut ici. L'auto-invocation contourne la transaction ; un objet créé avec new n'en a aucune ; une méthode final ne peut pas recevoir d'advice avec CGLIB. Depuis Spring Framework 6.0, les méthodes protected et package-private reçoivent bien l'advice des proxies basés sur les classes — les versions antérieures ignoraient silencieusement l'annotation sur les méthodes non publiques, ce qui a longtemps semé la confusion. Les proxies basés sur les interfaces exigent toujours des méthodes d'interface publiques.
Les transactions programmatiques
Les annotations couvrent l'essentiel des besoins, mais deux cas réclament du code. Quand la frontière transactionnelle ne coïncide pas avec une méthode — une boucle qui doit valider toutes les mille lignes, par exemple — utilisez TransactionTemplate :
@Service
public class ImportService {
private final TransactionTemplate tx;
private final JdbcClient db;
public ImportService(PlatformTransactionManager manager, JdbcClient db) {
this.tx = new TransactionTemplate(manager);
this.tx.setTimeout(30);
this.db = db;
}
public void importAll(List<Row> rows) {
for (int from = 0; from < rows.size(); from += 1_000) {
List<Row> chunk = rows.subList(from, Math.min(from + 1_000, rows.size()));
tx.executeWithoutResult(status -> {
chunk.forEach(r -> db.sql("insert into staging (k, v) values (:k, :v)")
.param("k", r.key()).param("v", r.value()).update());
}); // commits per chunk
}
}
}
Quand une action doit avoir lieu après le commit — envoyer un e-mail, publier sur un message broker —, ne la placez pas dans la méthode transactionnelle, où elle s'exécuterait même si le commit échouait ensuite. Publiez un événement applicatif et traitez-le avec @TransactionalEventListener, qui se déclenche par défaut dans la phase AFTER_COMMIT et n'est pas du tout exécuté en cas de rollback.
Erreurs courantes
Intercepter l'exception dans la méthode transactionnelle et en sortir normalement. La transaction est tout de même validée, puisque l'intercepteur n'a jamais vu l'exception. Relancez-la, ou appelez TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() si vous devez vraiment l'absorber.
Absorber l'exception d'une méthode interne et tomber sur UnexpectedRollbackException. L'appel interne en REQUIRED a marqué la transaction partagée comme rollback-only ; le commit externe échoue alors. Tranchez en connaissance de cause : propagez l'échec, ou isolez le traitement interne avec REQUIRES_NEW.
Annoter le repository plutôt que le service. Trois appels de repository deviennent trois transactions. Un crash entre le deuxième et le troisième laisse des données partielles.
Croire que @Transactional suit le pool de threads. Le travail asynchrone s'exécute hors de la transaction et hors du lien avec la connexion. Transmettez des données aux autres threads, pas des transactions ouvertes.
Des transactions longues qui monopolisent des connexions. Une méthode transactionnelle qui appelle une API HTTP distante garde une connexion du pool et des verrous en base pendant tout l'aller-retour. Sortez les appels distants de la transaction ou faites-les avant.
Compter sur readOnly pour empêcher les écritures. Ce n'est qu'une indication. Si c'est important, imposez-le par les droits en base de données.
REQUIRES_NEW avec un pool minuscule. Chaque niveau détient une connexion ; avec un pool de taille 2 et deux niveaux imbriqués sous charge concurrente, les threads se bloquent en attendant des connexions et finissent par dépasser leur délai d'attente.
Points clés à retenir
JdbcTemplateetJdbcClientgèrent les connexions, les statements, les result sets et la traduction des exceptions ; vous fournissez le SQL et le mapping des lignes.- Spring convertit
SQLExceptionen exceptions portables et non vérifiées de la hiérarchieDataAccessException: les services peuvent réagir à uneDuplicateKeyExceptionsans rien savoir du fournisseur. @Transactionalest un proxy AOP autour de la méthode qui démarre la transaction, la lie au thread, puis la valide ou l'annule — l'auto-invocation, les autres threads et les objets créés avecnewy échappent donc.REQUIREDrejoint,REQUIRES_NEWsuspend et utilise une seconde connexion,NESTEDs'appuie sur un savepoint ; la règle de rollback par défaut ne couvre que les exceptions runtime.- Placez les frontières transactionnelles sur les méthodes de service qui représentent une unité de travail, gardez-les courtes et n'y faites jamais d'appels distants.
- Utilisez
TransactionTemplatepour les commits par lots et@TransactionalEventListenerpour le travail qui ne doit s'exécuter qu'après un commit réussi.
Sources et lectures complémentaires
Questions fréquentes
- Pourquoi ma transaction n'a-t-elle pas été annulée quand une exception vérifiée a été levée ?
- Par défaut, Spring n'effectue un rollback que sur RuntimeException et Error, reprenant la convention d'EJB selon laquelle les exceptions vérifiées représentent des issues métier attendues. Ajoutez rollbackFor = YourCheckedException.class (ou Exception.class) à @Transactional, ou relancez-la sous forme d'exception non vérifiée. À l'inverse, noRollbackFor maintient la transaction en vie malgré une exception runtime donnée.
- Que signifie UnexpectedRollbackException ?
- Une méthode interne en REQUIRED a levé une exception runtime : l'intercepteur transactionnel a donc marqué la transaction partagée comme rollback-only. La méthode externe a intercepté l'exception et s'est terminée normalement, si bien que l'intercepteur a tenté un commit — que le transaction manager a refusé, puisque la transaction était marquée pour rollback. Laissez l'exception se propager, ou exécutez le traitement interne en REQUIRES_NEW si son échec doit rester isolé.
- Faut-il placer @Transactional sur le service ou sur le repository ?
- Sur la couche service. Un cas d'usage touche généralement plusieurs repositories et doit réussir ou échouer en bloc ; annoter chaque repository crée une multitude de petites transactions et fait perdre cette atomicité. Les repositories peuvent porter @Transactional(readOnly = true) à titre d'indication, mais l'unité de travail reste la méthode de service.
- readOnly = true est-il une véritable optimisation ?
- C'est une indication, dont l'effet dépend de la stack. Avec JDBC, il appelle Connection.setReadOnly(true), que certains drivers exploitent pour router vers des réplicas ou refuser les écritures ; avec Hibernate, il désactive le dirty checking et le flush, ce qui peut réduire sensiblement la consommation CPU et mémoire sur de grosses lectures. Il ne garantit jamais l'échec des écritures : ne comptez pas dessus pour la correction du code.