Change Data Capture com Debezium e Kafka
1. Visão geral
Quando um serviço precisa saber que os dados em outro banco de dados mudaram, a forma comum é escrever no banco de dados e depois enviar uma mensagem para o Kafka. Essa abordagem é chamada de dual-write e facilmente causa divergência de dados. Change Data Capture (CDC) resolve esse problema lendo diretamente o transaction log do banco de dados.
Neste artigo, você vai configurar PostgreSQL, Kafka e Debezium usando Docker Compose e registrar um conector. Depois você escreve um consumer Spring Boot para processar os eventos INSERT, UPDATE e DELETE da tabela orders.
2. Por que CDC é necessário
Veja o seguinte código de dual-write comum:
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
kafkaTemplate.send("orders", order.getId().toString(), toJson(order));
}
Este código tem dois pontos fracos:
- Se o Kafka falhar após
saveser bem-sucedido, o banco de dados tem o pedido mas nenhum evento foi enviado. - Se
sendfor bem-sucedido mas a transação fizer rollback, o consumer receberá um evento de um pedido que não existe.
O CDC inverte esse fluxo. A aplicação apenas escreve no banco de dados. Um processo separado lê o transaction log (WAL com PostgreSQL, binlog com MySQL) e emite um evento para cada mudança que foi feita commit. Porque o log contém apenas mudanças que fizeram commit, o evento sempre corresponde aos dados reais.
Debezium é um conjunto de conectores CDC que rodam no Kafka Connect. O fluxo de dados fica assim:
Aplicação ──escreve──> PostgreSQL ──WAL──> Debezium (Kafka Connect) ──> Tópico Kafka ──> Consumer
3. Configurar o ambiente
3.1. Docker Compose
Você precisa de três containers: PostgreSQL com logical replication ativado, Kafka rodando no modo KRaft e Kafka Connect com o plugin Debezium pré-instalado.
services:
postgres:
image: postgres:16
command: ["postgres", "-c", "wal_level=logical"]
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: shop
ports:
- "5432:5432"
kafka:
image: apache/kafka:3.8.0
ports:
- "9092:9092"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:29092,CONTROLLER://:9093,EXTERNAL://:9092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,EXTERNAL://localhost:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
connect:
image: quay.io/debezium/connect:2.7
depends_on: [kafka, postgres]
ports:
- "8083:8083"
environment:
BOOTSTRAP_SERVERS: kafka:29092
GROUP_ID: 1
CONFIG_STORAGE_TOPIC: connect_configs
OFFSET_STORAGE_TOPIC: connect_offsets
STATUS_STORAGE_TOPIC: connect_statuses
Há três pontos a notar neste arquivo:
wal_level=logicalé obrigatório. Sem esse parâmetro, Debezium não consegue criar um replication slot.- O Kafka tem dois listeners. O container usa
kafka:29092, enquanto a aplicação Spring Boot rodando na sua máquina usalocalhost:9092. - O Kafka Connect salva a configuração, offset e status em três tópicos separados. Graças a isso, quando o conector é reiniciado, ele retoma a leitura do WAL do ponto anterior.
Inicie os containers e crie a tabela:
docker compose up -d
docker compose exec postgres psql -U postgres -d shop -c "
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_email VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL,
total NUMERIC(10,2) NOT NULL
);
ALTER TABLE orders REPLICA IDENTITY FULL;"
REPLICA IDENTITY FULL instrui o PostgreSQL a registrar a linha completa no WAL. Sem esse comando, o campo before em eventos UPDATE será null, e eventos DELETE conterão apenas a chave primária.
3.2. Registrar o conector
O Kafka Connect fornece uma API REST na porta 8083. Você registra o conector PostgreSQL assim:
curl -X POST -H "Content-Type: application/json" localhost:8083/connectors -d '{
"name": "shop-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"plugin.name": "pgoutput",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "postgres",
"database.password": "postgres",
"database.dbname": "shop",
"topic.prefix": "shop",
"table.include.list": "public.orders",
"slot.name": "shop_slot",
"publication.autocreate.mode": "filtered",
"decimal.handling.mode": "string",
"key.converter": "org.apache.kafka.connect.json.JsonConverter",
"key.converter.schemas.enable": "false",
"value.converter": "org.apache.kafka.connect.json.JsonConverter",
"value.converter.schemas.enable": "false"
}
}'
O significado das propriedades importantes:
plugin.name: pgoutputusa o plugin de decodificação nativo do PostgreSQL 10+, então você não precisa instalar uma extensão adicional.topic.prefixjunto com o esquema e nome da tabela criam o nome do tópico. Aqui o tópico éshop.public.orders.table.include.listrestringe quais tabelas serão capturadas.decimal.handling.mode: stringfaz com que colunasNUMERICsejam enviadas como strings"149.90". Por padrão, o Debezium codifica esse valor em bytes base64, que é muito difícil de ler.schemas.enable: falseremove a seçãoschemaque acompanha cada mensagem JSON, tornando o payload mais compacto.
Verifique o status do conector:
curl -s localhost:8083/connectors/shop-connector/status
{"name":"shop-connector","connector":{"state":"RUNNING","worker_id":"172.18.0.4:8083"},
"tasks":[{"id":0,"state":"RUNNING","worker_id":"172.18.0.4:8083"}],"type":"source"}
4. Estrutura do change event
Insira e depois atualize um pedido:
docker compose exec postgres psql -U postgres -d shop -c "
INSERT INTO orders (customer_email, status, total) VALUES ('an@example.com', 'NEW', 149.90);
UPDATE orders SET status = 'PAID' WHERE id = 1;"
Depois leia o tópico usando o consumer console que vem na imagem do Kafka:
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh \
--bootstrap-server localhost:9092 --topic shop.public.orders --from-beginning
Um evento UPDATE fica assim (com o campo source abreviado):
{
"before": {"id":1,"customer_email":"an@example.com","status":"NEW","total":"149.90"},
"after": {"id":1,"customer_email":"an@example.com","status":"PAID","total":"149.90"},
"source": {"connector":"postgresql","db":"shop","table":"orders","lsn":24023128, ...},
"op": "u",
"ts_ms": 1760170000123
}
O campo op indica o tipo de mudança:
op |
Significado | before |
after |
|---|---|---|---|
c |
INSERT | null |
linha nova |
u |
UPDATE | linha antiga | linha nova |
d |
DELETE | linha antiga | null |
r |
Leitura no snapshot inicial | null |
linha atual |
Quando uma linha é deletada, o Debezium envia uma tombstone adicional, que é uma mensagem com a mesma chave mas valor null. A tombstone permite que o log compaction do Kafka limpe essa chave completamente. O seu consumer deve conseguir lidar com valor null.
5. Consumir eventos com Spring Boot
5.1. Dependências e configuração
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
A versão das dependências acima é gerenciada pelo spring-boot-starter-parent 3.x.
spring:
kafka:
bootstrap-servers: localhost:9092
consumer:
group-id: order-sync
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
Aqui o consumer recebe o valor como String e faz o parsing manualmente. Essa abordagem oferece melhor controle sobre tombstone e erros de JSON em comparação com usar JsonDeserializer.
5.2. Modelo e parser
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrderRow(
Long id,
@JsonProperty("customer_email") String customerEmail,
String status,
BigDecimal total) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrderChangeEvent(
OrderRow before,
OrderRow after,
String op,
@JsonProperty("ts_ms") Long tsMs) {
}
@JsonIgnoreProperties(ignoreUnknown = true) ignora o campo source e qualquer novo campo que o Debezium possa adicionar no futuro. Jackson converte automaticamente a string "149.90" para BigDecimal, então você não perde precisão como faria com double.
@Component
public class OrderChangeParser {
private final ObjectMapper objectMapper;
public OrderChangeParser(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public Optional<OrderChangeEvent> parse(String value) {
if (value == null) {
return Optional.empty(); // tombstone
}
try {
return Optional.of(objectMapper.readValue(value, OrderChangeEvent.class));
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Invalid Debezium event", e);
}
}
}
O parser retorna Optional.empty() para tombstone, então o listener pode simplesmente ignorar esse caso.
5.3. Listener
@Component
public class OrderCdcListener {
private static final Logger log = LoggerFactory.getLogger(OrderCdcListener.class);
private final OrderChangeParser parser;
public OrderCdcListener(OrderChangeParser parser) {
this.parser = parser;
}
@KafkaListener(topics = "shop.public.orders")
public void onOrderChange(@Payload(required = false) String value) {
parser.parse(value).ifPresent(event -> {
switch (event.op()) {
case "c", "r" -> log.info("Order created: {}", event.after());
case "u" -> log.info("Order {} status {} -> {}",
event.after().id(), event.before().status(), event.after().status());
case "d" -> log.info("Order deleted: {}", event.before().id());
default -> log.warn("Unknown op: {}", event.op());
}
});
}
}
@Payload(required = false) permite que o Spring passe null para o método quando encontra uma tombstone. Sem essa anotação, o Spring reportará um erro de conversão. O caso "r" é agrupado com "c" porque para o consumer, uma linha existente no snapshot inicial é como uma linha recém criada.
Executando a aplicação com os dados da seção 4, você verá:
INFO OrderCdcListener : Order created: OrderRow[id=1, customerEmail=an@example.com, status=NEW, total=149.90]
INFO OrderCdcListener : Order 1 status NEW -> PAID
6. Testando o parser
A lógica de parsing é a parte mais frágil quando o Debezium ou schema mudam. Você pode testar isso sem precisar do Kafka:
class OrderChangeParserTest {
private final OrderChangeParser parser = new OrderChangeParser(new ObjectMapper());
@Test
void parsesUpdateEvent() {
String json = """
{"before":{"id":1,"customer_email":"an@example.com","status":"NEW","total":"149.90"},
"after":{"id":1,"customer_email":"an@example.com","status":"PAID","total":"149.90"},
"source":{"connector":"postgresql"},
"op":"u","ts_ms":1760170000123}
""";
OrderChangeEvent event = parser.parse(json).orElseThrow();
assertThat(event.op()).isEqualTo("u");
assertThat(event.before().status()).isEqualTo("NEW");
assertThat(event.after().status()).isEqualTo("PAID");
assertThat(event.after().total()).isEqualByComparingTo("149.90");
}
@Test
void returnsEmptyForTombstone() {
assertThat(parser.parse(null)).isEmpty();
}
@Test
void rejectsMalformedJson() {
assertThatThrownBy(() -> parser.parse("{not json"))
.isInstanceOf(IllegalArgumentException.class);
}
}
O primeiro teste verifica o mapeamento do nome de coluna customer_email e do tipo BigDecimal. O segundo teste garante que tombstone não causa erro no listener. O terceiro teste confirma que JSON malformado lança uma exceção clara em vez de ser ignorado silenciosamente.
7. Erros comuns e quando não usar CDC
Aviso: O replication slot mantém o WAL até que o Debezium confirme que foi lido. Se o conector ficar parado por muitos dias, o WAL crescerá e pode preencher o disco do banco de dados. Você deve monitorar
pg_replication_slotse deletar o slot quando remover o conector:SELECT pg_drop_replication_slot('shop_slot');.
Alguns outros erros que você encontrará:
- Consumer não é idempotente. Debezium e Kafka Connect garantem at-least-once. Após um restart, o mesmo evento pode chegar duas vezes. Use
idjunto comsource.lsnpara descartar duplicatas, ou implemente operações com semântica de upsert. - Valores
NUMERICcomo base64 incompreensível. Você esqueceu de configurardecimal.handling.mode. Da mesma forma, colunasTIMESTAMPpor padrão são enviadas como números de microsegundos desde a época, então você precisa converter manualmente. beforesemprenull. A tabela não temREPLICA IDENTITY FULLdefinido.- Snapshot inicial demora muito. Com tabelas grandes,
snapshot.mode=initiallê a tabela inteira antes de começar a fazer streaming. Considereno_datase você só precisa das mudanças a partir de agora, ou use incremental snapshot. - Vazamento de dados internos. O evento reflete a estrutura exata da tabela. Se você enviar diretamente para outro serviço, está expondo o schema do seu banco de dados. Para eventos de negócio, combine Debezium com o Outbox pattern: a aplicação escreve em uma tabela
outboxna mesma transação, e o Debezium só captura essa tabela. - Usar a conta
postgresem produção. Crie um usuário dedicado com permissõesREPLICATIONeSELECTapenas nas tabelas que precisa capturar.
Você não deve usar CDC quando:
- Tem apenas uma aplicação e ela mesma consegue gerar eventos no mesmo processo. Nesse caso, um transactional outbox com um simples poller é suficiente.
- Você precisa de eventos com significado de negócio como “OrderPaid” em vez de “linha X mudou coluna status”. CDC é apenas sobre mudanças de dados, não sobre a razão.
- Seu time de operações ainda não está pronto para gerenciar Kafka Connect, replication slots e evolução de schema.
8. Conclusão
Neste artigo, você configurou PostgreSQL com logical replication, Kafka e Debezium, depois registrou um conector para capturar a tabela orders. Você também aprendeu sobre a estrutura do change event com os campos before, after e op, e então escreveu um consumer Spring Boot que processa tombstone junto com testes para a parte de parsing.
CDC elimina o risco de dual-write porque o banco de dados se torna a única fonte da verdade. Em troca, você precisa monitorar o replication slot, escrever consumers idempotentes e considerar usar o Outbox pattern quando precisa de eventos de negócio bem definidos.