Spring Framework
Spring JDBC y transacciones declarativas
Intermedio8 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
El soporte JDBC de Spring libera al SQL puro de todo el ritual de conexiones, statements y result sets, y su abstracción de transacciones permite que una sola anotación @Transactional funcione igual con JDBC, JPA o JTA. Esta guía muestra JdbcTemplate y el más reciente JdbcClient en la práctica, explica qué hace realmente el proxy transaccional con una conexión y repasa la propagación, el aislamiento y las reglas de rollback, junto con los fallos que sorprenden a los equipos en producción.
Lo que aprenderás
- Consultar y actualizar una base de datos con JdbcTemplate y JdbcClient sin gestionar recursos a mano
- Explicar por qué Spring traduce SQLException a la jerarquía de excepciones no comprobadas DataAccessException
- Configurar un transaction manager y aplicar @Transactional con las reglas adecuadas de propagación, aislamiento y rollback
- Predecir qué ocurre con llamadas anidadas REQUIRED y REQUIRES_NEW, incluida la UnexpectedRollbackException
- Usar TransactionTemplate y @TransactionalEventListener cuando las anotaciones no bastan
El JDBC puro es verboso de una forma que esconde bugs: obtener una conexión, preparar un statement, enlazar parámetros, recorrer el result set, cerrar tres recursos en el orden correcto dentro de un finally y traducir un código de SQLException específico del proveedor a algo sobre lo que el llamador pueda actuar. El módulo spring-jdbc de Spring se encarga de ese ritual y te deja solo el SQL, y su abstracción de transacciones convierte «begin, commit, rollback si algo falla» en una única anotación que funciona igual tanto si el recurso subyacente es una conexión JDBC como un EntityManager de JPA o una transacción JTA. Esta guía se centra en Spring Framework 6.x y da por hecho que ya existe un bean DataSource: un pool de HikariCP en la mayoría de las aplicaciones.
JdbcTemplate
JdbcTemplate es el caballo de batalla original: un objeto thread-safe que se crea una vez alrededor de un DataSource y se inyecta allí donde se ejecuta SQL. Adquiere y libera conexiones, ejecuta statements, recorre result sets y traduce excepciones, y todo ello dentro de la transacción que esté ligada en ese momento al thread.
@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> convierte una fila en un objeto; DataClassRowMapper lo hace automáticamente para records y clases con un constructor que encaje, y BeanPropertyRowMapper asigna columnas a setters por nombre. queryForObject lanza EmptyResultDataAccessException cuando ninguna fila coincide, así que usa query y toma el primer elemento cuando cero filas sea un resultado normal. NamedParameterJdbcTemplate envuelve el mismo motor con placeholders :name, algo que se agradece en cuanto una sentencia tiene más de tres parámetros.
JdbcClient: la API fluida
Spring Framework 6.1 añadió JdbcClient, una fachada fluida sobre ambos templates que se lee mejor y unifica los parámetros posicionales y con nombre:
@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) mapea por nombre de columna con un SimplePropertyRowMapper, de modo que customer_id se enlaza con un componente o propiedad llamado customerId. Sigues pudiendo pasar un RowMapper explícito, y .query().singleValue() o .listOfRows() devuelven escalares y mapas para consultas puntuales. El cliente es el punto de entrada recomendado para código nuevo; JdbcTemplate sigue plenamente soportado y es en él donde delega JdbcClient.
Traducción de excepciones
Todas las tecnologías de acceso a datos de Spring lanzan subclases de la excepción no comprobada DataAccessException, nunca una SQLException en bruto. El traductor examina el SQL state y el código de error del proveedor y los convierte en algo significativo y portable:
| Excepción | Causa típica |
|---|---|
DuplicateKeyException | Restricción de unicidad violada en un insert |
DataIntegrityViolationException | Falló una restricción de clave foránea, not-null o check |
EmptyResultDataAccessException | queryForObject no encontró ninguna fila |
IncorrectResultSizeDataAccessException | Se esperaba una fila y llegaron varias |
CannotAcquireLockException | Timeout de bloqueo o deadlock |
QueryTimeoutException | La sentencia superó su timeout |
BadSqlGrammarException | Error de sintaxis SQL o tabla inexistente |
DataAccessResourceFailureException | No se pudo obtener una conexión |
La ventaja es que un servicio puede capturar DuplicateKeyException y devolver «email ya registrado» sin saber si la base de datos es PostgreSQL u Oracle, y que un fallo irrecuperable se propaga como excepción runtime, que es justo lo que, como verás en la siguiente sección, dispara un rollback.
La abstracción de transacciones
El modelo de Spring tiene tres piezas. Un PlatformTransactionManager sabe iniciar, confirmar y deshacer transacciones para un tipo de recurso: JdbcTransactionManager (el sucesor en 6.x de DataSourceTransactionManager, que añade traducción de excepciones en el commit) para JDBC, JpaTransactionManager para JPA y JtaTransactionManager para transacciones XA gestionadas por el contenedor. Una TransactionDefinition describe lo que se pide: propagación, aislamiento, timeout y el flag de solo lectura. Un TransactionStatus representa la transacción en curso y lleva el flag rollback-only.
@Configuration
@EnableTransactionManagement
public class DataConfig {
@Bean
DataSource dataSource() { /* HikariDataSource */ }
@Bean
PlatformTransactionManager transactionManager(DataSource dataSource) {
return new JdbcTransactionManager(dataSource);
}
}
@EnableTransactionManagement registra un bean post-processor que envuelve en un proxy AOP cada bean que lleva @Transactional. Alrededor de cada método anotado, el TransactionInterceptor del proxy:
- Pide al transaction manager una transacción que cumpla la definición del método: con
REQUIRED, se une a la actual si el thread ya tiene una y, si no, inicia una nueva. - En JDBC, eso significa obtener una conexión, llamar a
setAutoCommit(false)y ligar la conexión al thread actual enTransactionSynchronizationManager.JdbcTemplate,JdbcCliente Hibernate miran ahí primero, y por eso participan en la transacción sin que nadie se lo diga. - Invoca el método.
- Si el método retorna con normalidad, hace commit (o, si se unió a una transacción existente, no hace nada y deja que el método externo confirme). Ante una excepción que provoca rollback, deshace y vuelve a lanzarla. Por último, desliga y libera la conexión.
Esa ligadura al thread explica una regla que sorprende a quien mezcla threads: la transacción no acompaña al trabajo enviado a un executor, a un CompletableFuture o a un stream paralelo. El otro thread no tiene ninguna conexión ligada y se ejecuta en modo auto-commit.
Uso de @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) { … }
}
Coloca la anotación en la clase para aplicarla a todos los métodos públicos y sobrescríbela después en métodos concretos. Estos son los atributos que importan.
La propagación decide cómo se relaciona una llamada con una transacción existente.
| Propagación | Sin transacción en curso | Hay una transacción en curso |
|---|---|---|
REQUIRED (por defecto) | Inicia una | Se une a ella |
REQUIRES_NEW | Inicia una | La suspende e inicia otra independiente |
SUPPORTS | Se ejecuta sin transacción | Se une a ella |
NOT_SUPPORTED | Se ejecuta sin transacción | La suspende y se ejecuta sin transacción |
MANDATORY | Lanza una excepción | Se une a ella |
NEVER | Se ejecuta sin transacción | Lanza una excepción |
NESTED | Inicia una | Crea un savepoint JDBC dentro de ella |
REQUIRES_NEW es la que hay que entender a fondo. Da al método interno su propia conexión y su propio commit, que es lo que quieres para «registrar la fila de auditoría aunque la operación de negocio falle». También implica que los cambios sin confirmar de la transacción externa son invisibles para la interna, y que se retienen dos conexiones del pool a la vez: con un pool pequeño, las llamadas REQUIRES_NEW anidadas pueden acabar en deadlock esperando conexiones. NESTED, en cambio, usa un savepoint sobre la misma conexión, así que permite un rollback parcial sin una segunda conexión, aunque solo lo soportan los transaction managers basados en JDBC.
El aislamiento se corresponde directamente con los niveles de JDBC (READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE), y DEFAULT delega en la base de datos. Solo se aplica el aislamiento de la transacción más externa; si un método interno con REQUIRED pide otro nivel, se ignora, salvo que esté activado validateExistingTransaction, en cuyo caso lanza una excepción.
Las reglas de rollback hacen por defecto rollback solo ante RuntimeException y Error. rollbackFor y noRollbackFor ajustan esto por tipo de excepción. rollbackForClassName acepta nombres de clase como cadenas para excepciones que no puedes referenciar en tiempo de compilación, pero las clases explícitas son más claras.
readOnly es una pista que se pasa al driver y a Hibernate; puede mejorar el rendimiento y permitir el enrutamiento a réplicas, pero no impone nada.
timeout, en segundos, se aplica a las sentencias ejecutadas dentro de la transacción a través del query timeout de JdbcTemplate; no mata el método por la fuerza.
Visibilidad de métodos y proxies
Como @Transactional lo aplica un proxy, todo lo que se dice de Spring AOP vale aquí. La autoinvocación se salta la transacción; un objeto creado con new no tiene ninguna; un método final no puede recibir advice con CGLIB. Desde Spring Framework 6.0, los métodos protected y package-private sí reciben advice en los proxies basados en clases; las versiones anteriores ignoraban en silencio la anotación en métodos no públicos, lo que fue durante mucho tiempo una fuente de confusión. Los proxies basados en interfaces siguen exigiendo métodos de interfaz públicos.
Transacciones programáticas
Las anotaciones cubren la mayoría de las necesidades, pero hay dos casos que piden código. Cuando el límite transaccional no coincide con un método (por ejemplo, un bucle que debe hacer commit cada mil filas), usa 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
}
}
}
Cuando algo debe ocurrir después del commit (enviar un email, publicar en un message broker), no lo hagas dentro del método transaccional, donde se ejecutaría aunque el commit fallara después. Publica un evento de aplicación y gestiónalo con @TransactionalEventListener, que por defecto se dispara en la fase AFTER_COMMIT y se omite por completo si hay rollback.
Errores comunes
Capturar la excepción dentro del método transaccional y retornar con normalidad. La transacción hace commit igualmente, porque el interceptor nunca vio la excepción. O la vuelves a lanzar, o llamas a TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() cuando no tengas más remedio que tragártela.
Tragarse la excepción de un método interno y toparse con UnexpectedRollbackException. La llamada interna con REQUIRED marcó la transacción compartida como rollback-only, y el commit externo falla. Decide de forma deliberada: propaga el fallo o aísla el trabajo interno con REQUIRES_NEW.
Anotar el repositorio y no el servicio. Tres llamadas al repositorio se convierten en tres transacciones. Una caída entre la segunda y la tercera deja datos a medias.
Dar por hecho que @Transactional acompaña al pool de threads. El trabajo asíncrono se ejecuta fuera de la transacción y fuera de la ligadura de la conexión. Pasa datos a otros threads, no transacciones abiertas.
Transacciones largas que retienen conexiones. Un método transaccional que llama a una API HTTP remota retiene una conexión del pool y bloqueos en la base de datos durante todo el viaje de ida y vuelta. Saca las llamadas remotas de la transacción o hazlas antes.
Confiar en readOnly para impedir escrituras. Es una pista. Si importa, imponlo con permisos en la base de datos.
REQUIRES_NEW con un pool diminuto. Cada nivel retiene una conexión; con un pool de tamaño 2 y dos niveles anidados bajo carga concurrente, los threads se bloquean esperando conexiones y acaban agotando el tiempo de espera.
Ideas clave
JdbcTemplateyJdbcClientgestionan conexiones, statements, result sets y la traducción de excepciones; tú aportas el SQL y el mapeo de filas.- Spring convierte
SQLExceptionen la jerarquía portable y no comprobadaDataAccessException, de modo que los servicios pueden reaccionar aDuplicateKeyExceptionsin saber nada del proveedor. @Transactionales un proxy AOP alrededor del método que inicia la transacción, la liga al thread y hace commit o rollback; por eso la autoinvocación, otros threads y los objetos creados connewquedan fuera.REQUIREDse une,REQUIRES_NEWsuspende y usa una segunda conexión,NESTEDusa un savepoint; la regla de rollback por defecto solo cubre las excepciones runtime.- Pon los límites transaccionales en métodos de servicio que representen una unidad de trabajo, mantenlos cortos y nunca hagas llamadas remotas dentro de ellos.
- Usa
TransactionTemplatepara commits por lotes y@TransactionalEventListenerpara el trabajo que solo debe ejecutarse tras un commit correcto.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Por qué mi transacción no hizo rollback al lanzarse una excepción comprobada?
- Por defecto, Spring solo hace rollback ante RuntimeException y Error, siguiendo la convención de EJB según la cual las excepciones comprobadas son resultados de negocio esperados. Añade rollbackFor = YourCheckedException.class (o Exception.class) a @Transactional, o vuelve a lanzarla como excepción no comprobada. A la inversa, noRollbackFor mantiene viva la transacción ante una excepción runtime concreta.
- ¿Qué significa UnexpectedRollbackException?
- Un método interno con REQUIRED lanzó una excepción runtime, así que el interceptor transaccional marcó la transacción compartida como rollback-only. El método externo capturó la excepción y terminó con normalidad, de modo que el interceptor intentó hacer commit, y el transaction manager se negó porque la transacción estaba marcada para rollback. O dejas que la excepción se propague, o ejecutas el trabajo interno con REQUIRES_NEW si su fallo debe quedar aislado.
- ¿@Transactional va en el servicio o en el repositorio?
- En la capa de servicio. Un caso de uso suele tocar varios repositorios y debe tener éxito o fallar como un todo; anotar cada repositorio por separado crea muchas transacciones pequeñas y pierde esa atomicidad. Los repositorios pueden llevar @Transactional(readOnly = true) como pista, pero la unidad de trabajo es el método de servicio.
- ¿readOnly = true es una optimización real?
- Es una pista, y su efecto depende del stack. Con JDBC llama a Connection.setReadOnly(true), que algunos drivers usan para enrutar a réplicas o rechazar escrituras; con Hibernate desactiva el dirty checking y el flush, lo que puede reducir notablemente el uso de CPU y memoria en lecturas grandes. Nunca garantiza que las escrituras fallen, así que no dependas de ello para la corrección.