Pub/Sub e Redis Streams: Diferenças e Como usar com Spring Boot
1. Visão geral
O Redis oferece dois mecanismos diferentes de transmissão de mensagens: Pub/Sub e Streams. Muitos iniciantes no Redis costumam confundir esses dois conceitos como sendo um só, levando à escolha errada da ferramenta e perda de dados quando o sistema reinicia.
Este artigo explica as diferenças fundamentais entre Pub/Sub e Streams, fornece código Java que funciona com ambos, e deixa claro quando usar cada um. Após ler, você terá confiança para escolher o modelo correto para seu problema real de mensageria.
2. O que é Redis Pub/Sub
Pub/Sub (Publish/Subscribe) funciona segundo o modelo “dispara e esquece” (fire-and-forget). O Publisher envia uma mensagem para um channel, e o Redis a retransmite imediatamente para todos os subscribers que estão escutando esse channel. Se nenhum subscriber estiver online, a mensagem desaparece para sempre.
2.1. Dependência Maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2.2. Publisher
@Service
public class OrderEventPublisher {
private final StringRedisTemplate redisTemplate;
public OrderEventPublisher(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void publish(String orderId, String status) {
String message = "orderId=%s;status=%s".formatted(orderId, status);
redisTemplate.convertAndSend("order-events", message);
}
}
convertAndSend envia a mensagem para o channel order-events. Se ainda não houver ninguém se subscrevendo, o comando ainda assim é bem-sucedido, mas a mensagem não fica armazenada em lugar nenhum.
2.3. Subscriber
@Component
public class OrderEventListener implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
String body = new String(message.getBody());
System.out.println("Evento recebido: " + body);
}
}
@Configuration
public class RedisPubSubConfig {
@Bean
public RedisMessageListenerContainer container(
RedisConnectionFactory connectionFactory,
OrderEventListener listener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(listener, new ChannelTopic("order-events"));
return container;
}
}
RedisMessageListenerContainer gerencia uma conexão dedicada (usando blocking connection) para escutar o channel order-events. Quando o publisher envia uma mensagem, onMessage é chamado imediatamente.
Saída quando você publica um evento:
Evento recebido: orderId=ORD-001;status=CONFIRMED
Atenção: Se
OrderEventListenernão tiver inicializado (o container ainda não fez bind), a mensagem publicada desaparece completamente, não há forma de reproduzi-la novamente.
3. O que é Redis Streams
Stream é uma estrutura de dados de log ordenada, append-only, onde cada mensagem possui um ID único no formato <timestamp>-<sequence>. Diferentemente de Pub/Sub, Streams armazena a mensagem no Redis até que você a remova explicitamente (XDEL) ou a reduza (XTRIM). Consumidores podem ler do início, ler em grupos (consumer group) com mecanismo de acknowledge semelhante ao Kafka.
3.1. Colocar mensagens em um Stream
@Service
public class OrderStreamPublisher {
private final StringRedisTemplate redisTemplate;
public OrderStreamPublisher(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public RecordId publish(String orderId, String status) {
Map<String, String> payload = Map.of(
"orderId", orderId,
"status", status
);
return redisTemplate.opsForStream()
.add("order-stream", payload);
}
}
opsForStream().add escreve um registro no stream order-stream e retorna um RecordId — semelhante a uma chave primária auto-gerada do registro. Esse registro permanece no Redis até ser reduzido ou deletado, independentemente se alguém já o leu.
3.2. Ler Stream usando Consumer Group
@Component
public class OrderStreamConsumer {
private final StringRedisTemplate redisTemplate;
public OrderStreamConsumer(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
createGroupIfAbsent();
}
private void createGroupIfAbsent() {
try {
redisTemplate.opsForStream()
.createGroup("order-stream", "order-processors");
} catch (Exception e) {
// Grupo já existe, ignorar
}
}
public void pollOnce() {
List<MapRecord<String, Object, Object>> records = redisTemplate.opsForStream()
.read(Consumer.from("order-processors", "worker-1"),
StreamReadOptions.empty().count(10),
StreamOffset.create("order-stream", ReadOffset.lastConsumed()));
for (MapRecord<String, Object, Object> record : records) {
System.out.println("Processando registro " + record.getId() + ": " + record.getValue());
redisTemplate.opsForStream().acknowledge("order-stream", "order-processors", record.getId());
}
}
}
createGroupcria o consumer grouporder-processors, semelhante ao consumer group no Kafka.Consumer.from("order-processors", "worker-1")declara um consumer chamadoworker-1pertencente a esse grupo. Múltiplos workers no mesmo grupo compartilham os registros, não leem duplicados.ReadOffset.lastConsumed()significa ler registros novos que ainda não foram atribuídos a nenhum consumer no grupo.acknowledgeconfirma que terminou o processamento, o Redis remove o registro da “pending list” do grupo.
Saída:
Processando registro 1696500000000-0: {orderId=ORD-001, status=CONFIRMED}
Atenção: Se você esquecer de chamar
acknowledge, o registro permanece na pending list. Outro consumer ou o próprio consumer (após reiniciar) pode usarXCLAIM/readPendingpara recuperar e processar novamente — esse é o mecanismo de “at-least-once delivery” que Pub/Sub não possui.
4. Comparação direta entre Pub/Sub e Streams
| Critério | Pub/Sub | Streams |
|---|---|---|
| Armazenamento de mensagens | Não | Sim, até ser reduzido/deletado |
| Reproduzir mensagens antigas | Não | Sim (XRANGE, offset customizável) |
| Consumer offline perde | Todas as mensagens durante offline | Nenhuma, pode ler novamente quando online |
| Balanceamento de carga com múltiplos consumers | Cada subscriber recebe sua própria cópia (broadcast) | Consumer group distribui trabalho, cada registro é processado por um consumer |
| Acknowledge / retry | Não possui | Sim (XACK, XCLAIM) |
| Complexidade | Muito simples | Mais complexo, semelhante ao Kafka |
| Caso de uso apropriado | Notificações em tempo real, sem garantia necessária | Event sourcing, job queue, precisa garantir o processamento |
4.1. Quando usar Pub/Sub
Use Pub/Sub quando você precisa de broadcast instantâneo e aceita perder mensagens se ninguém estiver escutando. Exemplos clássicos: atualizar status “digitando…” em um chat, sincronizar cache entre múltiplas instâncias da aplicação (invalidar cache), ou notificações em tempo real onde é aceitável perder alguns eventos.
@Service
public class CacheInvalidationPublisher {
private final StringRedisTemplate redisTemplate;
public CacheInvalidationPublisher(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void invalidate(String cacheKey) {
redisTemplate.convertAndSend("cache-invalidation", cacheKey);
}
}
Cada instância da aplicação se subscrive ao channel cache-invalidation, recebendo chaves que precisam ser removidas do cache local. Se uma instância estiver reiniciando e perder um evento, ela apenas usará o cache antigo por um tempo — não é um erro crítico.
4.2. Quando usar Streams
Use Streams quando as mensagens são dados críticos do negócio que precisam ser processadas completamente: pedidos, transações de pagamento, logs de auditoria. Você precisa garantir que um consumer que falhar no meio do caminho ainda conseguirá processar o registro novamente após reiniciar.
@Service
public class PaymentEventPublisher {
private final StringRedisTemplate redisTemplate;
public PaymentEventPublisher(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public RecordId publish(String paymentId, BigDecimal amount) {
Map<String, String> payload = Map.of(
"paymentId", paymentId,
"amount", amount.toString()
);
return redisTemplate.opsForStream().add("payment-stream", payload);
}
}
Cada evento de pagamento é registrado no payment-stream. Mesmo que PaymentWorker esteja desligado para deploy, o evento permanece armazenado no Redis, e o worker continuará processando quando reiniciar.
5. Escrevendo testes para o Stream Consumer
Como Streams possuem estado persistente, você deve testar cuidadosamente o fluxo de publicação – consumo – acknowledge. Use Testcontainers para executar um Redis real, evitando mockar incorretamente o comportamento.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@Testcontainers
class OrderStreamConsumerTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
StringRedisTemplate redisTemplate;
@BeforeEach
void setUp() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(
redis.getHost(), redis.getFirstMappedPort());
LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
factory.afterPropertiesSet();
redisTemplate = new StringRedisTemplate(factory);
}
@Test
void shouldConsumeAndAcknowledgeRecord() {
OrderStreamPublisher publisher = new OrderStreamPublisher(redisTemplate);
OrderStreamConsumer consumer = new OrderStreamConsumer(redisTemplate);
publisher.publish("ORD-002", "SHIPPED");
consumer.pollOnce();
PendingMessages pending = redisTemplate.opsForStream()
.pending("order-stream", Consumer.from("order-processors", "worker-1"),
Range.unbounded());
assertThat(pending.getTotalPendingMessages()).isZero();
}
}
Esse teste publica um registro, chama pollOnce() para consumir e fazer acknowledge, e depois verifica se a pending list está vazia — significando que o registro foi confirmado como processado e não está mais pendente no grupo.
6. Erros comuns
- Usar Pub/Sub para dados críticos: muitas pessoas iniciantes usam Pub/Sub para enviar eventos de pedidos e ficam surpresas quando o consumer reinicia e o evento se perde. Pub/Sub não é uma message queue.
- Não fazer trim no Stream: Streams não deletam dados antigos automaticamente. Se você publicar continuamente sem chamar
XTRIMou definirMAXLENao fazeradd, a memória do Redis crescerá com o tempo.
redisTemplate.opsForStream().add(
StreamRecords.newRecord()
.in("order-stream")
.ofMap(payload),
RedisStreamCommands.XAddOptions.maxlen(10_000)
);
- Esquecer de criar o consumer group antes de ler: o comando
XREADGROUPfalhará se o grupo não existir. Sempre chamecreateGroupcom try-catch para capturar o erro “BUSYGROUP” (grupo já existe). - Não configurar connection timeout para Pub/Sub: o subscriber usa uma conexão blocking dedicada; se a rede cair e não houver mecanismo de reconexão, você perde todas as mensagens daquele ponto até detectar e reconectar.
7. Conclusão
Pub/Sub e Streams resolvem dois problemas diferentes, ainda que ambos residam no Redis. Pub/Sub é simples, rápido, mas não oferece garantias — apropriado para notificações instantâneas que podem ser ignoradas. Streams é mais complexo mas robusto, com consumer groups e acknowledge — apropriado para dados críticos do negócio que precisam ser processados completamente, semelhante a uma message queue leve.
Se seu sistema precisa de ainda maior confiabilidade (exactly-once, particionamento grande, muitos consumer groups em paralelo em escala grande), considere Kafka ou RabbitMQ em vez de forçar o Redis Streams a fazer muito mais trabalho.