Todos os artigos

// Knowledge.log — 技術記事

Idempotência e outbox no Spring Boot 4.1 sem cobrança duplicada

Implemente Idempotency-Key e outbox na mesma transação JDBC com Spring Boot 4.1, Postgres e um teste real com Testcontainers.

Um POST /charges pode falhar para o cliente depois de o servidor confirmar a cobrança no banco. O cliente vê timeout, repete o POST e, sem um contrato de idempotência, cria outra cobrança. Mesmo que esse problema seja resolvido, ainda sobra outro: o commit pode funcionar e a publicação do evento falhar logo depois.

São duas falhas diferentes:

  • Idempotency-Key protege a operação HTTP contra repetições do mesmo pedido;
  • transactional outbox protege a passagem entre a transação do banco e a entrega assíncrona ao broker.

Uma chave idempotente não recupera um evento perdido. Uma outbox não impede duas chamadas legítimas ao serviço de criarem duas cobranças. Colocar os dois nomes no mesmo diagrama não os transforma no mesmo mecanismo — arquitetura também não funciona por proximidade tipográfica.

A implementação abaixo grava a chave, a cobrança e o evento de outbox em uma única transação JDBC. Um processo separado publica o evento. No final, um teste envia duas requisições iguais e consulta o Postgres para provar que existe uma cobrança e uma linha na outbox.

Versões e dependências

O exemplo usa Spring Boot 4.1.1, Java 25 LTS e Spring Framework 7.0.9 ou superior. Essa é uma combinação suportada: os requisitos do Spring Boot 4.1.1 aceitam Java de 17 até 26 e exigem Spring Framework 7.0.9+. O Boot 4.2 ainda está em preview, portanto não é a base de um exemplo de produção.

O pom.xml precisa destas dependências. As versões gerenciadas pelo BOM do Boot 4.1.1 incluem o driver PostgreSQL e o Testcontainers 2.0.5:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
</parent>

<properties>
    <java.version>25</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <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>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

No Testcontainers 2, observe o pacote org.testcontainers.postgresql.PostgreSQLContainer e o artefato testcontainers-postgresql. Copiar o import da linha 1.x é uma forma bastante econômica de começar o dia com um erro de compilação.

O contrato de Idempotency-Key

O cliente cria uma chave para uma tentativa lógica e envia a mesma chave em todos os retries dessa tentativa:

POST /charges HTTP/1.1
Content-Type: application/json
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324

{"amountCents":4900}

O documento do IETF sobre o header está como Internet-Draft expirado, não como RFC. O draft descreve o valor como uma String de Structured Fields, cuja forma conforme a RFC 8941 aparece entre aspas:

Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"

Na prática, muitos clientes enviam UUID sem aspas. A API pode aceitar as duas formas, normalizar para o UUID e publicar esse comportamento. Não convém anunciar suporte genérico a qualquer Structured Field se o parser só remove duas aspas.

Nosso contrato fica explícito:

  • o header é obrigatório; ausência ou UUID inválido retorna 400;
  • a unicidade vale para (key, endpoint), não para a chave no sistema inteiro;
  • a mesma chave com outro conteúdo retorna 422;
  • uma operação concorrente ainda em processamento retorna 409;
  • uma operação concluída devolve o status e o corpo armazenados;
  • a retenção é configurada e documentada pela API.

O draft não determina uma janela universal. A Stripe, por exemplo, documenta remoção de chaves depois de pelo menos 24 horas. Isso é uma política da Stripe, não um padrão da Internet. Neste serviço adotaremos 24 horas como decisão local; o cliente precisa conhecer essa janela, pois uma chave expirada volta a ser tratada como nova.

Em produção, conte os resultados created, replayed, payload_conflict e in_flight por endpoint, sem colocar a chave nos labels da métrica. Um aumento de replay mostra retries reais; conflitos apontam reuso incorreto do cliente. A chave pode aparecer em log estruturado com retenção e acesso compatíveis com a política do sistema, mas não deve virar label de alta cardinalidade.

Schema: chave, agregado e outbox

O request_hash funciona como fingerprint do pedido. Aqui ele será o SHA-256 de uma representação canônica dos campos que alteram a cobrança. Assim, diferenças irrelevantes de espaço no JSON não transformam a mesma operação em outra.

CREATE TABLE charges (
    id UUID PRIMARY KEY,
    amount_cents INTEGER NOT NULL CHECK (amount_cents > 0),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE idempotency_keys (
    key TEXT NOT NULL,
    endpoint TEXT NOT NULL,
    request_hash TEXT NOT NULL,
    status TEXT NOT NULL CHECK (status IN ('STARTED', 'COMPLETED')),
    response_status INTEGER,
    response_body TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at TIMESTAMPTZ NOT NULL,
    UNIQUE (key, endpoint)
);

CREATE TABLE outbox (
    id UUID PRIMARY KEY,
    aggregate_id UUID NOT NULL REFERENCES charges(id),
    event_type TEXT NOT NULL,
    payload TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    published_at TIMESTAMPTZ
);

CREATE INDEX outbox_pending_idx
    ON outbox (created_at)
    WHERE published_at IS NULL;

A constraint composta resolve a corrida entre duas requisições que não encontraram a chave e tentaram inseri-la. Não use ON CONFLICT DO UPDATE para sobrescrever uma resposta concluída. Para retenção, um job pode remover registros COMPLETED cujo expires_at passou; registros STARTED antigos exigem uma política de recuperação separada, não uma limpeza otimista.

Controller e transação JDBC

O controller mantém o endpoint estável e entrega ao serviço um objeto já desserializado:

package academy.devdojo.charges;

import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/charges")
class ChargeController {

    private final ChargeService service;

    ChargeController(ChargeService service) {
        this.service = service;
    }

    @PostMapping
    ResponseEntity<String> create(
            @RequestHeader("Idempotency-Key") String key,
            @RequestBody ChargeRequest request) {
        ChargeResult result = service.charge(key, "/charges", request.amountCents());
        return ResponseEntity.status(result.status())
                .contentType(MediaType.APPLICATION_JSON)
                .body(result.body());
    }

    @ExceptionHandler(InvalidIdempotencyKey.class)
    ResponseEntity<Void> invalidKey() {
        return ResponseEntity.badRequest().build();
    }

    @ExceptionHandler(InFlightRequest.class)
    ResponseEntity<Void> inFlight() {
        return ResponseEntity.status(409).build();
    }

    @ExceptionHandler(PayloadConflict.class)
    ResponseEntity<Void> payloadConflict() {
        return ResponseEntity.unprocessableEntity().build();
    }
}

record ChargeRequest(int amountCents) {}
record ChargeResult(int status, String body) {}

O serviço faz quatro operações na mesma transação: reserva a chave, cria a cobrança, cria a outbox e salva a resposta reproduzível.

package academy.devdojo.charges;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Optional;
import java.util.UUID;

import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ChargeService {

    private final JdbcClient jdbc;

    public ChargeService(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    public ChargeResult charge(String rawKey, String endpoint, int amountCents) {
        String key = normalizeUuid(rawKey);
        String hash = sha256("amountCents=" + amountCents);

        Optional<IdempotencyRow> previous = jdbc.sql("""
                SELECT request_hash, status, response_status, response_body
                  FROM idempotency_keys
                 WHERE key = :key AND endpoint = :endpoint
                """)
                .param("key", key)
                .param("endpoint", endpoint)
                .query((rs, rowNum) -> new IdempotencyRow(
                        rs.getString("request_hash"),
                        rs.getString("status"),
                        rs.getObject("response_status", Integer.class),
                        rs.getString("response_body")))
                .optional();

        if (previous.isPresent()) {
            IdempotencyRow row = previous.get();
            if (!row.requestHash().equals(hash)) {
                throw new PayloadConflict();
            }
            if (!"COMPLETED".equals(row.status())) {
                throw new InFlightRequest();
            }
            return new ChargeResult(row.responseStatus(), row.responseBody());
        }

        try {
            jdbc.sql("""
                    INSERT INTO idempotency_keys
                        (key, endpoint, request_hash, status, expires_at)
                    VALUES
                        (:key, :endpoint, :hash, 'STARTED', now() + interval '24 hours')
                    """)
                    .param("key", key)
                    .param("endpoint", endpoint)
                    .param("hash", hash)
                    .update();
        } catch (DuplicateKeyException race) {
            throw new InFlightRequest();
        }

        UUID chargeId = UUID.randomUUID();
        jdbc.sql("""
                INSERT INTO charges (id, amount_cents)
                VALUES (:id, :amount)
                """)
                .param("id", chargeId)
                .param("amount", amountCents)
                .update();

        String eventPayload = "{\"chargeId\":\"" + chargeId
                + "\",\"amountCents\":" + amountCents + "}";
        jdbc.sql("""
                INSERT INTO outbox (id, aggregate_id, event_type, payload)
                VALUES (:id, :aggregateId, 'ChargeCreated', :payload)
                """)
                .param("id", UUID.randomUUID())
                .param("aggregateId", chargeId)
                .param("payload", eventPayload)
                .update();

        String responseBody = "{\"id\":\"" + chargeId + "\"}";
        jdbc.sql("""
                UPDATE idempotency_keys
                   SET status = 'COMPLETED',
                       response_status = 201,
                       response_body = :body
                 WHERE key = :key AND endpoint = :endpoint
                """)
                .param("body", responseBody)
                .param("key", key)
                .param("endpoint", endpoint)
                .update();

        return new ChargeResult(201, responseBody);
    }

    private static String normalizeUuid(String rawKey) {
        String candidate = rawKey;
        if (rawKey.length() >= 2 && rawKey.startsWith("\"") && rawKey.endsWith("\"")) {
            candidate = rawKey.substring(1, rawKey.length() - 1);
        }
        try {
            return UUID.fromString(candidate).toString();
        } catch (IllegalArgumentException exception) {
            throw new InvalidIdempotencyKey();
        }
    }

    private static String sha256(String value) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            return HexFormat.of().formatHex(
                    digest.digest(value.getBytes(StandardCharsets.UTF_8)));
        } catch (NoSuchAlgorithmException impossibleOnJava25) {
            throw new IllegalStateException("SHA-256 indisponível", impossibleOnJava25);
        }
    }

    private record IdempotencyRow(
            String requestHash,
            String status,
            Integer responseStatus,
            String responseBody) {}
}

class InvalidIdempotencyKey extends RuntimeException {}
class InFlightRequest extends RuntimeException {}
class PayloadConflict extends RuntimeException {}

Não existe chamada a Kafka, RabbitMQ ou HTTP nesse método. Se qualquer INSERT ou UPDATE falhar, o @Transactional reverte chave, cobrança e outbox juntos. O método precisa ser chamado pelo proxy do Spring; uma chamada this.charge(...) dentro do próprio bean não abre a interceptação transacional esperada.

A corrida no INSERT resulta em rollback e 409. Depois que a primeira requisição terminar, o cliente pode repetir e receber a resposta armazenada. Para observar a atomicidade em produção, alerte se houver cobrança sem outbox correspondente e acompanhe violações da constraint de idempotência. Essa consulta de reconciliação deve rodar fora do caminho HTTP.

Relay com SKIP LOCKED

O relay tem outra transação. No Postgres, FOR UPDATE SKIP LOCKED permite que vários workers retirem lotes distintos da tabela sem esperar por linhas já bloqueadas:

package academy.devdojo.charges;

import java.util.List;
import java.util.UUID;

import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
class OutboxRelay {

    private final JdbcClient jdbc;
    private final EventPublisher publisher;

    OutboxRelay(JdbcClient jdbc, EventPublisher publisher) {
        this.jdbc = jdbc;
        this.publisher = publisher;
    }

    @Transactional
    public int publishBatch() {
        List<OutboxRow> rows = jdbc.sql("""
                SELECT id, aggregate_id, event_type, payload
                  FROM outbox
                 WHERE published_at IS NULL
                 ORDER BY created_at
                 LIMIT 50
                   FOR UPDATE SKIP LOCKED
                """)
                .query((rs, rowNum) -> new OutboxRow(
                        rs.getObject("id", UUID.class),
                        rs.getObject("aggregate_id", UUID.class),
                        rs.getString("event_type"),
                        rs.getString("payload")))
                .list();

        for (OutboxRow row : rows) {
            publisher.publish(row.eventType(), row.aggregateId(), row.payload());
            jdbc.sql("""
                    UPDATE outbox SET published_at = now() WHERE id = :id
                    """)
                    .param("id", row.id())
                    .update();
        }
        return rows.size();
    }

    private record OutboxRow(
            UUID id, UUID aggregateId, String eventType, String payload) {}
}

@Component
class OutboxScheduler {

    private final OutboxRelay relay;

    OutboxScheduler(OutboxRelay relay) {
        this.relay = relay;
    }

    @Scheduled(fixedDelayString = "${outbox.poll-delay:1s}")
    void poll() {
        relay.publishBatch();
    }
}

interface EventPublisher {
    void publish(String eventType, UUID aggregateId, String payload);
}

A aplicação também precisa de @EnableScheduling em uma classe de configuração. O SKIP LOCKED produz uma visão deliberadamente incompleta: uma linha bloqueada some daquele lote. Isso é apropriado para uma fila, não para uma consulta de relatório.

A entrega é pelo menos uma vez. Se o broker aceitar a mensagem e o processo morrer antes de marcar published_at, o próximo ciclo publicará novamente. Portanto, o consumidor deve deduplicar pelo id da outbox; não prometa exactly-once porque o UPDATE parece confiante.

Mantenha lotes pequenos, timeout de publicação definido e registre falhas sem marcar a linha como publicada. As observações úteis são a idade da linha pendente mais antiga, o total pendente, a taxa de publicação e os erros por destino. Idade crescente detecta relay travado mesmo quando o scheduler continua acordando pontualmente.

No MySQL 8, também existe SKIP LOCKED. Outra opção é serializar o poller inteiro com GET_LOCK('outbox-relay', 0) e liberar com RELEASE_LOCK; ela reduz concorrência, mas pode ser suficiente para um único relay por banco.

Teste com Postgres real

Como schema.sql não é aplicado automaticamente a bancos não embarcados, o teste define spring.sql.init.mode=always. O Testcontainers fornece os detalhes da conexão por @ServiceConnection:

package academy.devdojo.charges;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

@SpringBootTest(
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        properties = "spring.sql.init.mode=always")
@Testcontainers
class ChargeIdempotencyIT {

    @Container
    @ServiceConnection
    static PostgreSQLContainer postgres =
            new PostgreSQLContainer("postgres:16-alpine");

    @LocalServerPort
    int port;

    @Autowired
    JdbcClient jdbc;

    @MockitoBean
    EventPublisher publisher;

    private final HttpClient http = HttpClient.newHttpClient();

    @BeforeEach
    void cleanDatabase() {
        jdbc.sql("TRUNCATE outbox, idempotency_keys, charges").update();
    }

    @Test
    void sameKeyCreatesOneChargeAndOneOutboxRow() throws Exception {
        String key = "8e03978e-40d5-43e8-bc93-6894a57f9324";
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("http://localhost:" + port + "/charges"))
                .header("Content-Type", "application/json")
                .header("Idempotency-Key", key)
                .POST(HttpRequest.BodyPublishers.ofString("{\"amountCents\":4900}"))
                .build();

        HttpResponse<String> first =
                http.send(request, HttpResponse.BodyHandlers.ofString());
        HttpResponse<String> replay =
                http.send(request, HttpResponse.BodyHandlers.ofString());

        assertEquals(201, first.statusCode());
        assertEquals(201, replay.statusCode());
        assertEquals(first.body(), replay.body());
        assertEquals(1L, count("charges"));
        assertEquals(1L, count("outbox"));
        assertEquals(1L, count("idempotency_keys"));
    }

    private long count(String table) {
        return jdbc.sql("SELECT count(*) FROM " + table)
                .query(Long.class)
                .single();
    }
}

O nome da tabela no helper não vem de entrada externa; os três valores são constantes do teste. Em código de aplicação, concatenar um nome fornecido pelo usuário continuaria sendo SQL injection, mesmo com um teste muito simpático ao lado.

Esse caso cobre o replay concluído. A suíte deve acrescentar três verificações: a mesma chave com valor diferente retorna 422; duas chamadas sobrepostas produzem um vencedor e um 409; uma falha no relay mantém published_at nulo para nova tentativa. Para validar o contrato operacional, acompanhe no ambiente de teste as mesmas métricas de replay, conflito e idade da outbox usadas em produção.

Quando adotar

Adote Idempotency-Key mais outbox quando um comando HTTP pode ser repetido e seu commit precisa originar integração assíncrona confiável. Se a operação é somente leitura ou não produz efeito externo após o commit, talvez um dos mecanismos seja desnecessário; mantenha a distinção antes de manter duas tabelas por costume.

O próximo passo é executar o teste com Postgres, depois interromper o publisher entre o envio e o UPDATE da outbox. Quando a mensagem reaparecer sem criar outra cobrança, o sistema estará demonstrando as duas garantias separadamente — que é exatamente o ponto.

javaspring-bootmicroservices

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos