Redis Streams com consumer group: processamento confiável
1. Visão geral
Pub/Sub do Redis tem uma grande desvantagem. Se o consumer está desligado quando a mensagem é publicada, ela é perdida. Redis Streams resolve esse problema. As mensagens são armazenadas permanentemente no stream, e cada consumer group se lembra de quais mensagens já foram processadas.
Este artigo o guia na construção de um pipeline de processamento de eventos de pedidos com Spring Boot 3 e Spring Data Redis. O pipeline compreende os seguintes passos: escrever o evento, ler via consumer group, ACK manual, recuperar mensagens travadas e transferir mensagens com erro para o stream dead-letter.
2. Como funcionam Redis Streams e consumer groups
Antes de escrever Java, você deve experimentar os comandos nativos em redis-cli:
XADD orders * orderId 1001 amount 250000
XGROUP CREATE orders shipping-group 0
XREADGROUP GROUP shipping-group worker-1 COUNT 1 STREAMS orders >
XPENDING orders shipping-group
XACK orders shipping-group 1728640000000-0
O significado de cada comando:
XADDadiciona uma entrada ao streamorders. O*diz ao Redis para gerar automaticamente um ID na forma<millis>-<seq>.XGROUP CREATE ... 0cria o gruposhipping-groupe começa a ler desde o início do stream. Se usar$, o grupo só lerá mensagens novas.XREADGROUP ... >obtém as mensagens que ainda não foram entregues a ninguém no grupo. A mensagem é entregue aworker-1e, ao mesmo tempo, é registrada na Pending Entries List (PEL) do grupo.XPENDINGmostra quantas mensagens foram entregues mas ainda não foram ACKed.XACKremove a mensagem da PEL. A partir desse momento, o grupo considera a mensagem como processada.
A saída de XPENDING antes do ACK se parece com:
1) (integer) 1
2) "1728640000000-0"
3) "1728640000000-0"
4) 1) 1) "worker-1"
2) "1"
A PEL é a chave da confiabilidade. Suponha que o consumer trave após receber a mensagem mas antes do ACK. A mensagem permanece na PEL e outro consumer pode reivindicá-la (claim) para continuar processando. Esta é a semântica at-least-once.
3. Instalação
Adicione a dependência do Spring Data Redis. Este starter usa Lettuce como cliente padrão:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Configure a conexão e o nome do stream:
spring:
data:
redis:
host: localhost
port: 6379
app:
stream:
key: orders
group: shipping-group
consumer: ${HOSTNAME:worker-local}
O nome do consumer é obtido de HOSTNAME, portanto cada instância (pod) tem um nome único. Isso é importante. Se duas instâncias usarem o mesmo nome, Redis as tratará como um único consumer e suas PELs se misturarão.
4. Escrever eventos no stream
@Service
public class OrderEventPublisher {
private final StringRedisTemplate redisTemplate;
private final String streamKey;
public OrderEventPublisher(StringRedisTemplate redisTemplate,
@Value("${app.stream.key}") String streamKey) {
this.redisTemplate = redisTemplate;
this.streamKey = streamKey;
}
public RecordId publishOrderCreated(String orderId, long amount) {
MapRecord<String, String, String> record = StreamRecords.newRecord()
.in(streamKey)
.ofMap(Map.of(
"type", "ORDER_CREATED",
"orderId", orderId,
"amount", String.valueOf(amount)));
return redisTemplate.opsForStream().add(record);
}
}
StreamRecords.newRecord().in(...).ofMap(...) cria um MapRecord com campos string. O comando opsForStream().add() é equivalente a XADD orders * ... e retorna um RecordId gerado pelo Redis, por exemplo 1728640000000-0.
Usar StringRedisTemplate torna os dados no Redis legíveis com redis-cli. Você não precisa lidar com serialização de bytes do JDK.
5. Criar um consumer group com segurança
O grupo deve existir antes de ler. A aplicação pode ser inicializada quando o stream não tiver mensagens, portanto você precisa do parâmetro MKSTREAM. O método createGroup() de StreamOperations não habilita esse parâmetro. Portanto, chamamos na camada de conexão:
@Component
public class StreamGroupInitializer {
private final StringRedisTemplate redisTemplate;
public StreamGroupInitializer(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void ensureGroup(String streamKey, String group) {
try {
redisTemplate.execute((RedisCallback<String>) connection ->
connection.streamCommands().xGroupCreate(
streamKey.getBytes(StandardCharsets.UTF_8),
group,
ReadOffset.from("0"),
true)); // MKSTREAM
} catch (DataAccessException e) {
String msg = NestedExceptionUtils.getMostSpecificCause(e).getMessage();
if (msg == null || !msg.contains("BUSYGROUP")) {
throw e;
}
// Grupo já existe: normal ao reiniciar a aplicação
}
}
}
O último parâmetro true habilita MKSTREAM. Redis criará um stream vazio se o stream ainda não existir. Quando a aplicação reinicia, Redis retorna o erro BUSYGROUP Consumer Group name already exists. Ignoramos esse erro específico e relançamos outros erros.
6. Ler mensagens com StreamMessageListenerContainer
6.1. Listener com ACK manual
@Component
public class OrderEventListener
implements StreamListener<String, MapRecord<String, String, String>> {
private final StringRedisTemplate redisTemplate;
private final ShippingService shippingService;
private final String streamKey;
private final String group;
public OrderEventListener(StringRedisTemplate redisTemplate,
ShippingService shippingService,
@Value("${app.stream.key}") String streamKey,
@Value("${app.stream.group}") String group) {
this.redisTemplate = redisTemplate;
this.shippingService = shippingService;
this.streamKey = streamKey;
this.group = group;
}
@Override
public void onMessage(MapRecord<String, String, String> record) {
Map<String, String> body = record.getValue();
shippingService.createShipment(body.get("orderId"));
redisTemplate.opsForStream().acknowledge(streamKey, group, record.getId());
}
}
O comando ACK é executado apenas quando createShipment é bem-sucedido. Se o método lançar uma exceção, a mensagem permanece na PEL para ser processada novamente mais tarde. Não faça ACK antes de processar. Fazer isso mudaria at-least-once para at-most-once.
O ShippingService neste artigo é um serviço simples. Ele registra os orderIds para os quais os envios foram criados:
@Service
public class ShippingService {
private final Set<String> shippedOrders = ConcurrentHashMap.newKeySet();
public void createShipment(String orderId) {
if (!shippedOrders.add(orderId)) {
return; // já processado, pula (idempotente)
}
// chama a API da transportadora...
}
public Set<String> shippedOrders() {
return Set.copyOf(shippedOrders);
}
}
6.2. Configurar o container
@Configuration
public class StreamConfig {
@Bean(destroyMethod = "stop")
public StreamMessageListenerContainer<String, MapRecord<String, String, String>>
orderStreamContainer(RedisConnectionFactory connectionFactory,
StreamGroupInitializer initializer,
OrderEventListener listener,
@Value("${app.stream.key}") String streamKey,
@Value("${app.stream.group}") String group,
@Value("${app.stream.consumer}") String consumerName) {
initializer.ensureGroup(streamKey, group);
var options = StreamMessageListenerContainer.StreamMessageListenerContainerOptions
.builder()
.pollTimeout(Duration.ofSeconds(2))
.batchSize(10)
.build();
var container = StreamMessageListenerContainer.create(connectionFactory, options);
var request = StreamMessageListenerContainer.StreamReadRequest
.builder(StreamOffset.create(streamKey, ReadOffset.lastConsumed()))
.consumer(Consumer.from(group, consumerName))
.autoAcknowledge(false)
.cancelOnError(t -> false)
.build();
container.register(request, listener);
container.start();
return container;
}
}
Os pontos principais na configuração:
ReadOffset.lastConsumed()corresponde a>emXREADGROUP, ou seja, obtém apenas as mensagens novas do grupo.pollTimeouté o tempo de bloqueio de cada leitura.batchSizeé o número máximo de mensagens em uma leitura.autoAcknowledge(false)desabilita o ACK automático, já que fazemos ACK manualmente no listener.cancelOnError(t -> false)é a linha mais importante. Por padrão, quando o listener lança uma exceção, o container cancela a subscription. O consumer silenciosamente parará de ler sem nenhum aviso claro.
Nota: Não use
receiveAutoAck(...)para operações críticas. Esse modo faz ACK imediatamente após a leitura, ou seja, antes de o listener terminar. Se a aplicação travar no meio, a mensagem será perdida permanentemente.
7. Processar mensagens travadas e dead-letter
O listener só lê mensagens novas (>). Uma mensagem com erro ou pertencente a um consumer morto permanecerá na PEL indefinidamente se ninguém a processar. Precisamos de um job periódico para isso:
@Component
public class PendingOrderReclaimer {
private static final Duration MIN_IDLE = Duration.ofMinutes(1);
private static final long MAX_DELIVERIES = 5;
private final StringRedisTemplate redisTemplate;
private final ShippingService shippingService;
private final String streamKey;
private final String group;
private final String consumerName;
public PendingOrderReclaimer(StringRedisTemplate redisTemplate,
ShippingService shippingService,
@Value("${app.stream.key}") String streamKey,
@Value("${app.stream.group}") String group,
@Value("${app.stream.consumer}") String consumerName) {
this.redisTemplate = redisTemplate;
this.shippingService = shippingService;
this.streamKey = streamKey;
this.group = group;
this.consumerName = consumerName;
}
@Scheduled(fixedDelay = 30_000)
public void reclaim() {
StreamOperations<String, String, String> ops = redisTemplate.opsForStream();
PendingMessages pending = ops.pending(streamKey, group, Range.unbounded(), 50);
for (PendingMessage pm : pending) {
if (pm.getElapsedTimeSinceLastDelivery().compareTo(MIN_IDLE) < 0) {
continue;
}
List<MapRecord<String, String, String>> claimed =
ops.claim(streamKey, group, consumerName, MIN_IDLE, pm.getId());
for (MapRecord<String, String, String> record : claimed) {
if (pm.getTotalDeliveryCount() >= MAX_DELIVERIES) {
moveToDeadLetter(record);
} else {
shippingService.createShipment(record.getValue().get("orderId"));
}
ops.acknowledge(streamKey, group, record.getId());
}
}
}
private void moveToDeadLetter(MapRecord<String, String, String> record) {
Map<String, String> body = new HashMap<>(record.getValue());
body.put("originalId", record.getId().getValue());
redisTemplate.opsForStream().add(
StreamRecords.newRecord().in(streamKey + ":dlq").ofMap(body));
}
}
O fluxo de processamento do job:
pending(...)é equivalente aXPENDING orders shipping-group - + 50. CadaPendingMessageinforma qual consumer está segurando a mensagem, o tempo ocioso e quantas vezes ela foi entregue.claim(...)é equivalente aXCLAIM. Este comando transfere a propriedade para o consumer atual. Redis só faz a transferência se a mensagem ainda está ociosa por pelo menosMIN_IDLE, portanto duas instâncias executando o job ao mesmo tempo não reivindicarão a mesma mensagem. Cada reivindicação também incrementa o delivery count.- As mensagens entregues mais de
MAX_DELIVERIESvezes são copiadas para o streamorders:dlqe depois feitas ACK. Mensagens de erro permanente (poison messages) portanto não são repetidas infinitamente.
Se createShipment lançar uma exceção, o comando ACK não é executado. A mensagem permanece na PEL e será repetida no próximo ciclo. Lembre-se de ativar @EnableScheduling em uma classe @Configuration.
Nota: A partir do Redis 6.2 há o comando
XAUTOCLAIM. Este comando combina os passosXPENDINGeXCLAIMem um e é mais eficiente quando a PEL é grande. Você pode chamá-lo viaredisTemplate.execute(...)se a versão do Spring Data Redis que está usando ainda não tem uma API conveniente.
8. Escrever testes com Testcontainers
Adicione as dependências para teste:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
O teste abaixo publica um evento. Então verifica se o envio foi criado e a PEL está vazia:
@SpringBootTest
@Testcontainers
class OrderStreamIntegrationTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired OrderEventPublisher publisher;
@Autowired ShippingService shippingService;
@Autowired StringRedisTemplate redisTemplate;
@Test
void whenOrderPublished_thenShipmentCreatedAndMessageAcked() {
publisher.publishOrderCreated("1001", 250_000);
await().atMost(Duration.ofSeconds(10)).untilAsserted(() -> {
assertThat(shippingService.shippedOrders()).contains("1001");
PendingMessagesSummary summary = redisTemplate.opsForStream()
.pending("orders", "shipping-group");
assertThat(summary.getTotalPendingMessages()).isZero();
});
}
}
@ServiceConnection(name = "redis") permite que o Spring Boot obtenha automaticamente o host e a porta do container, para que você não precise de @DynamicPropertySource. await() é necessário porque o listener é executado em uma thread separada. A asserção getTotalPendingMessages() igual a 0 prova que a mensagem foi ACKed, não apenas lida.
9. Erros comuns e quando não usar
Processamento não-idempotente. At-least-once significa que uma mensagem pode chegar duas vezes. Por exemplo: o consumer processa mas trava antes do ACK. Portanto, a lógica de negócio deve ser resiliente a repetições. Uma abordagem comum é verificar se o orderId já foi processado ou usar uma restrição unique no banco de dados.
Stream crescendo infinitamente. ACK não remove a entrada do stream. Você precisa limitar o tamanho ao escrever:
redisTemplate.opsForStream().add(record, XAddOptions.maxlen(100_000).approximateTrimming(true));
Esta opção é equivalente a XADD orders MAXLEN ~ 100000 * .... O ~ permite que o Redis corte por nó interno, muito mais rápido do que cortar com precisão.
Esquecer cancelOnError. A primeira exceção silenciosamente parará o consumer. O sintoma é que as mensagens se acumulam no stream sem erros claros nos logs.
Consumer “fantasma”. Cada deploy com um novo nome de consumer (por exemplo, nome do pod) deixa o nome antigo no grupo. Limpe periodicamente com XGROUP DELCONSUMER, mas apenas depois de ter reivindicado todas as mensagens pendentes desse consumer.
Quando NÃO usar Redis Streams:
- Você precisa armazenar eventos por muitos dias e fazer replay de grandes volumes. Redis mantém todos os dados na RAM, portanto o custo é muito alto.
- Você precisa garantir zero perda de dados. Com AOF
everysecou failover de replica assíncrono, ainda é possível perder algumas mensagens recém-escritas. - Throughput muito alto, precisando particionar e escalar para centenas de consumers. Kafka é mais adequado neste caso.
10. Conclusão
Redis Streams combinado com consumer groups oferece uma fila de eventos leve e confiável rodando no Redis que você já tem. Você precisa lembrar de quatro pontos principais:
- ACK manual após terminar o processamento.
- Configure
cancelOnError(t -> false)para que o consumer não pare silenciosamente. - Tenha um job que reclame as mensagens travadas, com um stream dead-letter para poison messages.
- Escreva lógica de processamento idempotente.
Adicione MAXLEN para controlar a memória e teste integrado com Testcontainers, e você terá um pipeline de processamento de eventos at-least-once suficientemente confiável para a maioria das aplicações Spring Boot.