Spring Framework
Spring JDBC e transações declarativas
Intermediário8 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
O suporte a JDBC do Spring livra o SQL puro de toda a cerimônia de conexões, statements e result sets, e sua abstração de transações permite que uma única anotação @Transactional funcione com JDBC, JPA ou JTA. Este guia mostra o JdbcTemplate e o mais recente JdbcClient na prática, explica o que o proxy transacional realmente faz com uma conexão e percorre propagação, isolamento e regras de rollback, junto com as falhas que pegam as equipes de surpresa em produção.
O que você vai aprender
- Consultar e atualizar um banco de dados com JdbcTemplate e JdbcClient sem gerenciar recursos manualmente
- Explicar por que o Spring traduz SQLException para a hierarquia de exceções unchecked DataAccessException
- Configurar um transaction manager e aplicar @Transactional com as regras certas de propagação, isolamento e rollback
- Prever o que acontece com chamadas aninhadas REQUIRED e REQUIRES_NEW, incluindo a UnexpectedRollbackException
- Usar TransactionTemplate e @TransactionalEventListener quando as anotações não bastam
JDBC puro é verboso de um jeito que esconde bugs: obter uma conexão, preparar um statement, fazer o bind dos parâmetros, iterar o result set, fechar três recursos na ordem certa dentro de um finally e traduzir um código de SQLException específico do fornecedor em algo que o chamador consiga tratar. O módulo spring-jdbc do Spring cuida dessa cerimônia e deixa para você só o SQL, e sua abstração de transações transforma "begin, commit, rollback em caso de falha" em uma única anotação que funciona do mesmo jeito, seja o recurso subjacente uma conexão JDBC, um EntityManager do JPA ou uma transação JTA. Este guia tem como alvo o Spring Framework 6.x e parte do princípio de que já existe um bean DataSource — um pool HikariCP na maioria das aplicações.
JdbcTemplate
O JdbcTemplate é o cavalo de batalha original: um objeto thread-safe, criado uma única vez em torno de um DataSource e injetado onde quer que se execute SQL. Ele obtém e libera conexões, executa statements, itera result sets e traduz exceções, e faz tudo isso dentro da transação que estiver vinculada à thread naquele momento.
@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();
}
}
Um RowMapper<T> transforma uma linha em um objeto; o DataClassRowMapper faz isso automaticamente para records e classes com um construtor compatível, e o BeanPropertyRowMapper mapeia colunas para setters pelo nome. queryForObject lança EmptyResultDataAccessException quando nenhuma linha corresponde, então use query e pegue o primeiro elemento quando zero linhas for um resultado normal. O NamedParameterJdbcTemplate envolve o mesmo motor com placeholders :name, o que faz diferença assim que um comando passa de três parâmetros.
JdbcClient: a API fluente
O Spring Framework 6.1 trouxe o JdbcClient, uma fachada fluente sobre os dois templates, mais legível e que unifica parâmetros posicionais e nomeados:
@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) mapeia pelo nome da coluna com um SimplePropertyRowMapper, de modo que customer_id é vinculado a um componente ou propriedade chamado customerId. Você ainda pode passar um RowMapper explícito, e .query().singleValue() ou .listOfRows() retornam escalares e maps para consultas pontuais. O client é o ponto de entrada recomendado para código novo; o JdbcTemplate continua com suporte completo e é para ele que o JdbcClient delega.
Tradução de exceções
Todas as tecnologias de acesso a dados do Spring lançam subclasses da exceção unchecked DataAccessException, nunca uma SQLException crua. O tradutor analisa o SQL state e o código de erro do fornecedor e os converte em algo significativo e portável:
| Exceção | Causa típica |
|---|---|
DuplicateKeyException | Restrição de unicidade violada em um insert |
DataIntegrityViolationException | Falha em restrição de chave estrangeira, not-null ou check |
EmptyResultDataAccessException | queryForObject não encontrou nenhuma linha |
IncorrectResultSizeDataAccessException | Esperava uma linha, vieram várias |
CannotAcquireLockException | Timeout de lock ou deadlock |
QueryTimeoutException | O comando excedeu seu timeout |
BadSqlGrammarException | Erro de sintaxe SQL ou tabela inexistente |
DataAccessResourceFailureException | Não foi possível obter uma conexão |
A vantagem é que um service pode capturar DuplicateKeyException e retornar "e-mail já cadastrado" sem saber se o banco é PostgreSQL ou Oracle, e que uma falha irrecuperável se propaga como exceção runtime — que, como mostra a próxima seção, é justamente o que dispara um rollback.
A abstração de transações
O modelo do Spring tem três partes. Um PlatformTransactionManager sabe iniciar, confirmar e desfazer transações para um tipo de recurso: JdbcTransactionManager (o sucessor, no 6.x, do DataSourceTransactionManager, que adiciona tradução de exceções no commit) para JDBC, JpaTransactionManager para JPA e JtaTransactionManager para transações XA gerenciadas pelo contêiner. Uma TransactionDefinition descreve o que se deseja — propagação, isolamento, timeout, flag de somente leitura. Um TransactionStatus representa a transação em andamento e carrega o flag rollback-only.
@Configuration
@EnableTransactionManagement
public class DataConfig {
@Bean
DataSource dataSource() { /* HikariDataSource */ }
@Bean
PlatformTransactionManager transactionManager(DataSource dataSource) {
return new JdbcTransactionManager(dataSource);
}
}
@EnableTransactionManagement registra um bean post-processor que envolve em um proxy AOP todo bean que tenha @Transactional. Em torno de cada método anotado, o TransactionInterceptor do proxy:
- Pede ao transaction manager uma transação compatível com a definição do método — com
REQUIRED, entra na transação atual se a thread já tiver uma e, caso contrário, inicia uma nova. - No JDBC, isso significa obter uma conexão, chamar
setAutoCommit(false)e vincular a conexão à thread atual noTransactionSynchronizationManager.JdbcTemplate,JdbcCliente Hibernate olham primeiro ali, e é por isso que participam da transação sem que ninguém avise. - Invoca o método.
- Em um retorno normal, faz commit (ou, se apenas entrou em uma transação existente, não faz nada e deixa o método externo fazer o commit). Diante de uma exceção que dispara rollback, desfaz e relança a exceção. Por fim, desvincula e libera a conexão.
Esse vínculo com a thread explica uma regra que pega quem mistura threads: a transação não acompanha o trabalho enviado a um executor, a um CompletableFuture ou a um stream paralelo. A outra thread não tem conexão vinculada e roda em modo auto-commit.
Usando @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) { … }
}
Coloque a anotação na classe para aplicá-la a todos os métodos públicos e sobrescreva-a em métodos específicos. Estes são os atributos que importam.
A propagação decide como uma chamada se relaciona com uma transação existente.
| Propagação | Sem transação atual | Já existe uma transação |
|---|---|---|
REQUIRED (padrão) | Inicia uma | Entra nela |
REQUIRES_NEW | Inicia uma | Suspende-a e inicia uma nova, independente |
SUPPORTS | Roda sem transação | Entra nela |
NOT_SUPPORTED | Roda sem transação | Suspende-a e roda sem transação |
MANDATORY | Lança exceção | Entra nela |
NEVER | Roda sem transação | Lança exceção |
NESTED | Inicia uma | Cria um savepoint JDBC dentro dela |
REQUIRES_NEW é a que vale entender a fundo. Ela dá ao método interno sua própria conexão e seu próprio commit, que é exatamente o que você quer para "gravar a linha de auditoria mesmo que a operação de negócio falhe". Isso também significa que as alterações ainda não confirmadas da transação externa ficam invisíveis para a interna, e que duas conexões do pool ficam presas ao mesmo tempo — com um pool pequeno, chamadas REQUIRES_NEW aninhadas podem entrar em deadlock esperando conexões. Já NESTED usa um savepoint na mesma conexão, o que permite rollback parcial sem uma segunda conexão, mas só os transaction managers baseados em JDBC têm suporte a isso.
O isolamento corresponde diretamente aos níveis do JDBC — READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE —, com DEFAULT delegando ao banco de dados. Só vale o isolamento da transação mais externa; um método interno com REQUIRED que peça outro nível é ignorado, a menos que validateExistingTransaction esteja habilitado, caso em que ele lança exceção.
As regras de rollback, por padrão, só fazem rollback em RuntimeException e Error. rollbackFor e noRollbackFor ajustam isso por tipo de exceção. rollbackForClassName aceita nomes de classe como strings para exceções que você não consegue referenciar em tempo de compilação, mas classes explícitas são mais claras.
readOnly é uma dica repassada ao driver e ao Hibernate; pode melhorar o desempenho e permitir o roteamento para réplicas, mas não impõe nada.
timeout, em segundos, é aplicado aos comandos executados dentro da transação, via query timeout do JdbcTemplate, e não como uma interrupção forçada do método.
Visibilidade dos métodos e proxies
Como o @Transactional é aplicado por um proxy, tudo o que vale para o Spring AOP vale aqui. A autoinvocação passa por fora da transação; um objeto criado com new não tem nenhuma; um método final não pode receber advice com CGLIB. Desde o Spring Framework 6.0, métodos protected e package-private recebem advice em proxies baseados em classe — versões anteriores ignoravam silenciosamente a anotação em métodos não públicos, uma fonte de confusão por muito tempo. Proxies baseados em interface continuam exigindo métodos de interface públicos.
Transações programáticas
As anotações atendem à maior parte das necessidades, mas dois casos pedem código. Quando o limite transacional não coincide com um método — um loop que precisa fazer commit a cada mil linhas, por exemplo —, use 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
}
}
}
Quando algo precisa acontecer depois do commit — enviar um e-mail, publicar em um message broker —, não faça isso dentro do método transacional, onde rodaria mesmo que o commit falhasse em seguida. Publique um evento da aplicação e trate-o com @TransactionalEventListener, que por padrão dispara na fase AFTER_COMMIT e é totalmente ignorado em caso de rollback.
Erros comuns
Capturar a exceção dentro do método transacional e retornar normalmente. A transação faz commit mesmo assim, porque o interceptor nunca viu a exceção. Relance-a ou chame TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() quando precisar mesmo engoli-la.
Engolir a exceção de um método interno e esbarrar em UnexpectedRollbackException. A chamada interna com REQUIRED marcou a transação compartilhada como rollback-only; o commit externo então falha. Decida conscientemente: propague a falha ou isole o trabalho interno com REQUIRES_NEW.
Anotar o repository e não o service. Três chamadas ao repository viram três transações. Uma queda entre a segunda e a terceira deixa dados pela metade.
Achar que o @Transactional acompanha o pool de threads. O trabalho assíncrono roda fora da transação e fora do vínculo com a conexão. Passe dados para outras threads, não transações abertas.
Transações longas segurando conexões. Um método transacional que chama uma API HTTP remota prende uma conexão do pool e locks no banco durante toda a ida e volta. Tire as chamadas remotas da transação ou faça-as antes.
Confiar no readOnly para impedir escritas. É só uma dica. Se isso importa, garanta com permissões no banco de dados.
REQUIRES_NEW com um pool minúsculo. Cada nível segura uma conexão; com um pool de tamanho 2 e dois níveis aninhados sob carga concorrente, as threads ficam bloqueadas esperando conexões e acabam estourando o timeout.
Principais pontos
JdbcTemplateeJdbcClientcuidam de conexões, statements, result sets e da tradução de exceções; você fornece o SQL e o mapeamento das linhas.- O Spring converte
SQLExceptionna hierarquia portável e uncheckedDataAccessException, então os services podem reagir aDuplicateKeyExceptionsem conhecer o fornecedor. @Transactionalé um proxy AOP em torno do método que inicia a transação, vincula-a à thread e faz commit ou rollback — por isso autoinvocação, outras threads e objetos criados comnewficam de fora.REQUIREDentra na transação existente,REQUIRES_NEWa suspende e usa uma segunda conexão,NESTEDusa um savepoint; a regra de rollback padrão só cobre exceções runtime.- Coloque os limites transacionais em métodos de service que representem uma unidade de trabalho, mantenha-os curtos e nunca faça chamadas remotas dentro deles.
- Use
TransactionTemplatepara commits em lotes e@TransactionalEventListenerpara o trabalho que só deve rodar depois de um commit bem-sucedido.
Fontes e leituras complementares
Perguntas frequentes
- Por que minha transação não fez rollback quando uma exceção checked foi lançada?
- Por padrão, o Spring só faz rollback em RuntimeException e Error, seguindo a convenção do EJB de que exceções checked representam resultados de negócio esperados. Adicione rollbackFor = YourCheckedException.class (ou Exception.class) ao @Transactional, ou relance como exceção unchecked. No sentido oposto, noRollbackFor mantém a transação viva diante de uma exceção runtime específica.
- O que significa UnexpectedRollbackException?
- Um método interno com REQUIRED lançou uma exceção runtime, e o interceptor transacional marcou a transação compartilhada como rollback-only. O método externo capturou a exceção e retornou normalmente, então o interceptor tentou fazer commit — e o transaction manager recusou, porque a transação estava marcada para rollback. Deixe a exceção se propagar ou execute o trabalho interno em REQUIRES_NEW se a falha dele precisar ficar isolada.
- O @Transactional deve ficar no service ou no repository?
- Na camada de service. Um caso de uso normalmente mexe em vários repositories e precisa ter sucesso ou falhar como um todo; anotar cada repository separadamente cria várias transações pequenas e perde essa atomicidade. Os repositories podem ter @Transactional(readOnly = true) como dica, mas a unidade de trabalho é o método do service.
- readOnly = true é uma otimização de verdade?
- É uma dica, e o efeito depende da stack. Com JDBC, ele chama Connection.setReadOnly(true), que alguns drivers usam para rotear para réplicas ou rejeitar escritas; com Hibernate, desativa o dirty checking e o flush, o que pode reduzir bastante o uso de CPU e memória em leituras grandes. Ele nunca garante que as escritas vão falhar, então não conte com isso para a corretude.