Transações e níveis de isolamento no PostgreSQL
1. Visão geral
Quando múltiplas requisições leem e escrevem na mesma tabela simultaneamente, o resultado pode estar errado mesmo que cada SQL esteja correto. O nível de isolamento define quanto de mudanças de outras transações uma transação consegue “enxergar”.
Neste artigo, veremos como o PostgreSQL implementa os níveis de isolamento. Você conseguirá reproduzir os erros de lost update e write skew. No final, saberá como escolher o nível apropriado, configurá-lo no Spring Boot e escrever a lógica de retry corretamente.
2. Transações no PostgreSQL
Uma transação agrupa múltiplas instruções em uma unidade. Ou tudo sucede, ou nenhuma tem efeito:
CREATE TABLE account (
id BIGINT PRIMARY KEY,
owner TEXT NOT NULL,
balance NUMERIC(12, 2) NOT NULL CHECK (balance >= 0)
);
INSERT INTO account VALUES (1, 'An', 1000), (2, 'Binh', 500);
BEGIN;
UPDATE account SET balance = balance - 300 WHERE id = 1;
UPDATE account SET balance = balance + 300 WHERE id = 2;
COMMIT;
Se o segundo UPDATE falhar, você chama ROLLBACK e o saldo de An permanece intacto. Quando não há BEGIN, o PostgreSQL funciona em modo autocommit: cada instrução é uma transação separada.
O PostgreSQL usa MVCC (Multi-Version Concurrency Control). Cada comando UPDATE cria uma nova versão da linha em vez de sobrescrevê-la. Cada transação lê dados através de um snapshot. Portanto, operações de leitura não bloqueiam operações de escrita, e vice-versa. O nível de isolamento define quando o snapshot é tirado: no início de cada instrução ou uma única vez para toda a transação.
3. Fenômenos anômalos e os quatro níveis de isolamento
O padrão SQL define os seguintes fenômenos:
- Dirty read: ler dados que outra transação ainda não fez commit.
- Non-repeatable read: ler a mesma linha duas vezes mas obter valores diferentes.
- Phantom read: executar novamente uma consulta com condição, mas linhas aparecem ou desaparecem do resultado.
- Lost update: duas transações leem e escrevem a mesma linha, e uma escrita é sobrescrita.
- Write skew: duas transações leem dados comuns, depois cada uma escreve em uma linha diferente, violando uma restrição de negócio.
A tabela a seguir descreve como o PostgreSQL trata cada fenômeno:
Level Dirty read Non-repeatable Phantom Lost update Write skew
READ UNCOMMITTED Não Sim Sim Sim Sim
READ COMMITTED Não Sim Sim Sim Sim
REPEATABLE READ Não Não Não Não (*) Sim
SERIALIZABLE Não Não Não Não (*) Não (*)
(*) A transação é cancelada com SQLSTATE 40001, o aplicativo deve fazer retry.
Existem dois pontos que diferem do padrão SQL que você precisa lembrar:
READ UNCOMMITTEDno PostgreSQL funciona exatamente comoREAD COMMITTED. Você nunca terá um dirty read.REPEATABLE READtambém bloqueia phantom reads, pois é essencialmente snapshot isolation.
O nível padrão é READ COMMITTED. Você pode verificar com o seguinte comando:
SHOW default_transaction_isolation;
default_transaction_isolation
-------------------------------
read committed
4. READ COMMITTED
Neste nível, cada instrução obtém um novo snapshot. Abra duas sessões psql e execute-as intercaladamente nesta ordem:
-- Sessão A
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 700.00
-- Sessão B
UPDATE account SET balance = 900 WHERE id = 1; -- autocommit
-- Sessão A
SELECT balance FROM account WHERE id = 1; -- 900.00
COMMIT;
Dentro da mesma transação, a sessão A vê dois valores diferentes. Este é um non-repeatable read.
4.1. Lost update
O erro mais comum em aplicações é o padrão “ler em Java, calcular, depois escrever de volta”:
BigDecimal balance = jdbc.queryForObject(
"SELECT balance FROM account WHERE id = ?", BigDecimal.class, 1L);
jdbc.update("UPDATE account SET balance = ? WHERE id = ?",
balance.subtract(new BigDecimal("100")), 1L);
Suponha que duas threads executem este código e ambas leiam 1000. Ambas escreverão 900. Como resultado, uma dedução de dinheiro se perde.
Existem duas maneiras de corrigir isso mantendo READ COMMITTED.
Forma 1: deixe o banco de dados fazer o cálculo, escreva como uma instrução atômica única.
UPDATE account SET balance = balance - 100 WHERE id = 1;
Quando encontra uma linha sendo atualizada por outra transação, o PostgreSQL espera aquela transação fazer commit. Depois, lê novamente a versão mais recente da linha e então aplica a subtração.
Forma 2: bloqueie a linha ao ler usando SELECT ... FOR UPDATE.
BEGIN;
SELECT balance FROM account WHERE id = 1 FOR UPDATE;
-- aplicativo verifica negócio, calcula...
UPDATE account SET balance = 900 WHERE id = 1;
COMMIT;
A segunda transação será bloqueada no comando SELECT ... FOR UPDATE até que a primeira transação faça commit. Portanto, ela sempre lerá o valor novo.
5. REPEATABLE READ
Neste nível, o snapshot é obtido uma única vez na primeira instrução da transação. Todas as leituras subsequentes veem o mesmo estado dos dados.
Se você tentar atualizar uma linha que outra transação modificou e fez commit após a captura do snapshot, o PostgreSQL vai recusar:
-- Sessão A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM account WHERE id = 1; -- 1000.00
-- Sessão B
UPDATE account SET balance = balance - 100 WHERE id = 1; -- commit
-- Sessão A
UPDATE account SET balance = balance - 200 WHERE id = 1;
ERROR: could not serialize access due to concurrent update
Lost update não acontece silenciosamente mais. Em vez disso, a sessão A deve fazer ROLLBACK e executar novamente desde o começo. O código de erro 40001 (serialization_failure) é um sinal para o aplicativo fazer retry.
6. SERIALIZABLE e write skew
REPEATABLE READ ainda permite write skew. Considere o seguinte exemplo clássico: um hospital exige sempre ter pelo menos um médico de plantão.
CREATE TABLE doctor (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
on_call BOOLEAN NOT NULL
);
INSERT INTO doctor VALUES (1, 'Lan', true), (2, 'Minh', true);
Ambos os médicos pedem para sair de plantão, cada um em uma transação:
-- A e B executam:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctor WHERE on_call; -- ambos veem 2
-- A
UPDATE doctor SET on_call = false WHERE id = 1;
-- B
UPDATE doctor SET on_call = false WHERE id = 2;
COMMIT; -- A: OK
COMMIT; -- B: OK -> ninguém está mais de plantão!
As duas transações escrevem em duas linhas diferentes, então nenhum conflito de escrita é detectado. Se trocar para SERIALIZABLE, o PostgreSQL usa o mecanismo Serializable Snapshot Isolation (SSI). SSI monitora as dependências leitura-escrita entre transações e cancela uma delas:
ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt.
HINT: The transaction might succeed if retried.
O erro pode aparecer no comando UPDATE ou no COMMIT. O código de erro ainda é 40001.
Observação:
SERIALIZABLEnão bloqueia linhas extras. Ele apenas detecta conflitos e cancela a transação. Com SSI, o PostgreSQL pode cancelar por engano transações que não entram realmente em conflito. Portanto, com alta carga de escrita concorrente, a taxa de retry pode aumentar significativamente.
7. Usando níveis de isolamento no Spring Boot
Adicione as seguintes dependências:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
A forma mais simples é declarar o nível de isolamento em @Transactional:
@Service
public class ReportService {
private final JdbcTemplate jdbc;
public ReportService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public BigDecimal totalBalance() {
BigDecimal total = jdbc.queryForObject(
"SELECT sum(balance) FROM account", BigDecimal.class);
Long count = jdbc.queryForObject(
"SELECT count(*) FROM account", Long.class);
// ambas as consultas usam o mesmo snapshot -> dados consistentes
return total;
}
}
Spring chama Connection.setTransactionIsolation(...) antes de iniciar uma transação. O driver PostgreSQL o converte em SET TRANSACTION ISOLATION LEVEL ....
7.1. Retry ao encontrar serialization failure
Retry deve estar fora da transação. Retry dentro é sem sentido, pois a transação já foi marcada como ruim. Usar TransactionTemplate ajuda a evitar a armadilha de self-invocation de @Transactional:
@Service
public class OnCallService {
private static final int MAX_ATTEMPTS = 3;
private final JdbcTemplate jdbc;
private final TransactionTemplate tx;
public OnCallService(JdbcTemplate jdbc, PlatformTransactionManager tm) {
this.jdbc = jdbc;
this.tx = new TransactionTemplate(tm);
this.tx.setIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE);
}
public void goOffCall(long doctorId) {
for (int attempt = 1; ; attempt++) {
try {
tx.executeWithoutResult(status -> doGoOffCall(doctorId));
return;
} catch (ConcurrencyFailureException e) {
if (attempt >= MAX_ATTEMPTS) {
throw e;
}
}
}
}
private void doGoOffCall(long doctorId) {
Integer onCall = jdbc.queryForObject(
"SELECT count(*) FROM doctor WHERE on_call", Integer.class);
if (onCall == null || onCall < 2) {
throw new IllegalStateException("Deve haver pelo menos um médico de plantão");
}
jdbc.update("UPDATE doctor SET on_call = false WHERE id = ?", doctorId);
}
}
O código acima funciona assim:
setIsolationLevel(ISOLATION_SERIALIZABLE)se aplica a cada chamada deexecuteWithoutResult.- Spring traduz SQLSTATE
40001(e deadlock40P01) para subclasses deConcurrencyFailureException. Graças a isso, você não precisa ler o código de erro do driver. - Na próxima tentativa, uma nova transação obtém um novo snapshot. Ela vê que o outro médico já saiu, então lança
IllegalStateException. A restrição de negócio é respeitada.
Observação: Spring Boot 3 configura automaticamente
JdbcTransactionManager. Esta classe traduz erros que ocorrem no commit paraDataAccessException. Se você declarar manualmente o beanDataSourceTransactionManager, erros de commit se tornarãoTransactionSystemExceptione o loop de retry acima não conseguirá capturá-lo.
8. Testando write skew com Testcontainers
Para reproduzir com precisão a ordem intercalada, use duas Connection JDBC diretamente no mesmo thread. Dessa forma, não depende do tempo de execução da thread. Adicione a dependência de teste:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Classe de teste:
@Testcontainers
class WriteSkewTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@BeforeEach
void setUp() throws SQLException {
try (Connection c = connect(); Statement st = c.createStatement()) {
st.execute("DROP TABLE IF EXISTS doctor");
st.execute("CREATE TABLE doctor (id BIGINT PRIMARY KEY, name TEXT, on_call BOOLEAN)");
st.execute("INSERT INTO doctor VALUES (1, 'Lan', true), (2, 'Minh', true)");
}
}
@Test
void repeatableReadAllowsWriteSkew() throws SQLException {
runConcurrently(Connection.TRANSACTION_REPEATABLE_READ);
assertThat(countOnCall()).isZero();
}
@Test
void serializableRejectsWriteSkew() throws SQLException {
assertThatThrownBy(() -> runConcurrently(Connection.TRANSACTION_SERIALIZABLE))
.isInstanceOf(SQLException.class)
.extracting(e -> ((SQLException) e).getSQLState())
.isEqualTo("40001");
assertThat(countOnCall()).isEqualTo(1);
}
private void runConcurrently(int level) throws SQLException {
try (Connection a = begin(level); Connection b = begin(level)) {
assertThat(countOnCall(a)).isEqualTo(2);
assertThat(countOnCall(b)).isEqualTo(2);
offCall(a, 1);
a.commit();
offCall(b, 2);
b.commit();
}
}
private Connection begin(int level) throws SQLException {
Connection c = connect();
c.setAutoCommit(false);
c.setTransactionIsolation(level);
return c;
}
private Connection connect() throws SQLException {
return DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
}
private void offCall(Connection c, long id) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(
"UPDATE doctor SET on_call = false WHERE id = ?")) {
ps.setLong(1, id);
ps.executeUpdate();
}
}
private int countOnCall(Connection c) throws SQLException {
try (Statement st = c.createStatement();
ResultSet rs = st.executeQuery("SELECT count(*) FROM doctor WHERE on_call")) {
rs.next();
return rs.getInt(1);
}
}
private int countOnCall() throws SQLException {
try (Connection c = connect()) {
return countOnCall(c);
}
}
}
Resultado esperado dos dois testes:
repeatableReadAllowsWriteSkew -> PASSED (ambos fazem commit, 0 médicos de plantão)
serializableRejectsWriteSkew -> PASSED (B é cancelado com 40001, 1 médico permanece de plantão)
O segundo teste não afirma se o erro ocorreu no UPDATE ou no COMMIT de B. Ele apenas verifica o código 40001, pois o momento em que o SSI detecta o conflito pode variar entre versões do PostgreSQL.
9. Erros comuns e quando não usar
- Elevar o nível mas não fazer retry. Em
REPEATABLE READeSERIALIZABLE, o erro40001é um comportamento normal, não um bug. Sem retry, o usuário receberá erros 500 aleatoriamente. - Retry dentro de
@Transactional. A transação ruim deve ser revertida e reiniciada do zero. Coloque o retry na camada de chamada fora. - Chamar API externa dentro de transação com retry. Emails ou comandos de pagamento podem ser enviados múltiplas vezes. Execute side effects após o commit bem-sucedido.
- Usar
SERIALIZABLEem todo o sistema “para ser seguro”. Isso causa overhead de monitoramento de SSI e mais retries. Use apenas para trechos com restrições que abrangem múltiplas linhas. - Misturar níveis na mesma operação. SSI só protege quando todas as transações envolvidas rodam
SERIALIZABLE. Uma transaçãoREAD COMMITTEDescrevendo na mesma tabela ainda pode violar a restrição. - Transação
REPEATABLE READrodando por muito tempo. Snapshots antigos impedem oVACUUMde limpar versões de linhas mortas, fazendo a tabela crescer. Mantenha as transações curtas.
Observação: Para restrições expressáveis no esquema, como
CHECK,UNIQUEou exclusion constraint, prefira usar constraint. É mais barato e mais confiável do que elevar o nível de isolamento.
10. Conclusão
O PostgreSQL executa READ COMMITTED por padrão. Este nível é suficiente para a maioria das aplicações, desde que você escreva atualizações atômicas ou use SELECT ... FOR UPDATE para evitar lost update.
REPEATABLE READ fornece um snapshot consistente durante toda a transação, adequado para relatórios. SERIALIZABLE também bloqueia write skew. Ambos os níveis exigem que o aplicativo faça retry ao encontrar SQLSTATE 40001.
No Spring Boot, você declara o nível via @Transactional(isolation = ...) ou TransactionTemplate, captura ConcurrencyFailureException fora da transação e valida o comportamento com Testcontainers usando uma ordem de operações determinística.