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 UNCOMMITTED no PostgreSQL funciona exatamente como READ COMMITTED. Você nunca terá um dirty read.
  • REPEATABLE READ també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: SERIALIZABLE nã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 de executeWithoutResult.
  • Spring traduz SQLSTATE 40001 (e deadlock 40P01) para subclasses de ConcurrencyFailureException. 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 para DataAccessException. Se você declarar manualmente o bean DataSourceTransactionManager, erros de commit se tornarão TransactionSystemException e 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 READ e SERIALIZABLE, o erro 40001 é 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 SERIALIZABLE em 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ção READ COMMITTED escrevendo na mesma tabela ainda pode violar a restrição.
  • Transação REPEATABLE READ rodando por muito tempo. Snapshots antigos impedem o VACUUM de 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, UNIQUE ou 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.

postgresqltransactionisolation levelspring bootconcurrency