Part 6: "The Real-Time Pulse" — Redis Streams and Consumer Groups
GlobalMart needs an event-driven order processing system. We explore Redis Streams, comparing them to Pub/Sub and Kafka. We implement durable, at-least-once message delivery using Consumer Groups and XACK semantics.
Story Opening
The distributed Redis Cluster was humming along, effortlessly serving GlobalMart’s massive read traffic. Maya, the Staff Engineer, had solved the caching, the memory limits, and the high availability. But the architecture was still fundamentally request-driven. When a user clicked “Checkout,” the monolithic application synchronously called the Inventory API, then the Billing API, and finally the Shipping API. If any of those services were slow or down, the entire checkout process failed.
The engineering team decided to move to an event-driven architecture. When an order was placed, the checkout service would simply publish an “OrderCreated” event and return a success message to the user immediately. The Inventory, Billing, and Shipping services would then consume that event asynchronously at their own pace.
The team’s first instinct was to deploy Apache Kafka. Kafka is the undisputed king of distributed event streaming. However, deploying and managing a production Kafka cluster (with its ZooKeeper or KRaft dependencies) is a massive operational burden. GlobalMart already had a highly available, blazingly fast Redis Cluster running.
“Can’t we just use Redis?” a junior engineer asked.
Maya knew Redis had a Pub/Sub feature, but she also knew its fatal flaw: it’s a “fire and forget” system. If the Billing service is restarting when the “OrderCreated” event is published via Pub/Sub, that event is lost forever. The message is not stored anywhere.
She needed a durable, append-only log. She needed consumer groups to allow multiple services to process the same stream of events independently. She needed acknowledgment semantics to ensure no message was lost if a consumer crashed mid-processing.
She needed Redis Streams, the data structure introduced in Redis 5.0 that brought Kafka-like semantics to the in-memory world.
Conceptual Deep-Dive
To understand Redis Streams, it’s helpful to compare them to the older Redis Pub/Sub and the industry-standard Apache Kafka.
1. Redis Pub/Sub: Fire and Forget
Redis Pub/Sub is incredibly fast and lightweight. A publisher sends a message to a channel, and Redis immediately broadcasts it to all clients currently subscribed to that channel.
The problem? It has zero memory.
If a subscriber disconnects for 5 seconds due to a network blip, any messages published during those 5 seconds are gone. Pub/Sub is perfect for ephemeral notifications (like updating a live dashboard or a chat room), but it is entirely unsuitable for critical business events like processing orders.
2. Redis Streams: The Append-Only Log
Redis Streams solves the durability problem. A Stream is an append-only data structure. When you add a message to a stream (using the XADD command), it is permanently stored in memory (and persisted to disk via RDB/AOF, as we learned in Part 3).
A message in a stream is not just a simple string; it’s a collection of key-value pairs (like a Redis Hash). Furthermore, every message is automatically assigned a unique, monotonically increasing ID based on the Redis server’s timestamp (e.g., 1698765432100-0).
order_id: 101
user: maya] M2[ID: 152-1
order_id: 102
user: alex] M3[ID: 155-0
order_id: 103
user: sam] end Producer[Checkout Service] -->|XADD| M3 M1 --> M2 M2 --> M3 classDef msg fill:#e1f5fe,stroke:#333,stroke-width:2px; class M1,M2,M3 msg;
Because the stream is persistent, consumers can read messages from the beginning, read from a specific ID, or block and wait for new messages to arrive (using XREAD). If a consumer crashes and restarts, it can simply resume reading from the last ID it processed.
3. Consumer Groups: Scaling the Work
If you have 10,000 orders per second, a single instance of the Billing service cannot process them fast enough. You need to run 5 instances of the Billing service and distribute the load among them.
If all 5 instances simply XREAD the stream, they will all receive every message, meaning the customer gets billed 5 times.
This is where Consumer Groups (a concept heavily inspired by Kafka) come in.
A Consumer Group is a logical entity that sits between the Stream and the consumers. It tracks which messages have been delivered to which consumers, and more importantly, which messages have been successfully processed.
When “Billing Instance A” asks the group for a new message (using XREADGROUP), the group gives it message ID: 1 and marks it as “Pending” for Instance A. When “Billing Instance B” asks, it gets ID: 2. The load is automatically distributed.
Crucially, the message remains in the “Pending Entries List” (PEL) until the consumer explicitly acknowledges it using the XACK command.
If Instance A crashes while processing ID: 1, it will never send the XACK. Another consumer can inspect the PEL, notice that ID: 1 has been pending for too long, and claim ownership of it to reprocess it. This provides robust at-least-once delivery semantics.
Redis Streams vs. Kafka
Maya had to justify using Redis Streams over Kafka to the architecture review board.
| Feature | Redis Streams | Apache Kafka |
|---|---|---|
| Storage | In-Memory (Fast, but limited by RAM) | Disk-Based (Slower, but massive capacity) |
| Retention | Usually trimmed by length (e.g., keep last 1M) | Usually trimmed by time (e.g., keep 7 days) |
| Throughput | High (Microseconds) | Extremely High (High latency, massive batching) |
| Complexity | Low (Built into existing Redis) | High (Requires dedicated cluster, ZK/KRaft) |
For GlobalMart’s order processing, the events were small, and they didn’t need to replay orders from 6 months ago. They only needed a reliable buffer to decouple the microservices. Redis Streams, already running in their highly available cluster, was the perfect fit.
Technical Explanation
Maya architected the order flow:
- Publish: The Checkout Service executes
XADD orders * order_id 12345 amount 99.99. The*tells Redis to auto-generate the ID. - Group Creation: Maya pre-created two consumer groups on the
ordersstream:billing_groupandinventory_group. Both groups read from the same stream, meaning every order goes to both Billing and Inventory independently. - Consume: The Billing Service instances execute
XREADGROUP GROUP billing_group consumer_1 BLOCK 5000 STREAMS orders >. The>symbol is special; it means “give me new messages that have never been delivered to anyone in this group.” - Acknowledge: After successfully charging the credit card, the Billing Service executes
XACK orders billing_group <message_id>. - Trim: To prevent the stream from consuming all of Redis’s RAM (Part 4), the Checkout Service periodically executes
XTRIM orders MAXLEN ~ 1000000to keep only the most recent 1 million orders.
Step-by-Step Hands-On
Let’s look at how Maya implemented the Billing Service consumer using Spring Data Redis.
Step 1: The Stream Listener
Spring Data Redis provides a powerful StreamMessageListenerContainer that acts similarly to a Kafka @KafkaListener or a JMS @JmsListener. It continuously polls the stream in a background thread and invokes your code when a message arrives.
Maya created a listener class implementing StreamListener.
import org.springframework.data.redis.connection.stream.MapRecord;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.data.redis.stream.StreamListener;import org.springframework.stereotype.Service;
@Servicepublic class OrderBillingListener implements StreamListener<String, MapRecord<String, String, String>> {
private final RedisTemplate<String, String> redisTemplate; private static final String STREAM_KEY = "orders"; private static final String GROUP_NAME = "billing_group";
public OrderBillingListener(RedisTemplate<String, String> redisTemplate) { this.redisTemplate = redisTemplate; }
@Override public void onMessage(MapRecord<String, String, String> message) { try { // 1. Extract the data String messageId = message.getId().getValue(); String orderId = message.getValue().get("order_id"); String amount = message.getValue().get("amount");
System.out.println("Processing Order: " + orderId + " for $" + amount);
// 2. Simulate business logic (charging credit card) processBilling(orderId, amount);
// 3. Explicitly Acknowledge the message (XACK) // If this line is not reached (e.g., exception thrown or app crashes), // the message remains in the Pending Entries List (PEL) for reprocessing. redisTemplate.opsForStream().acknowledge(STREAM_KEY, GROUP_NAME, messageId);
System.out.println("Successfully acknowledged message: " + messageId);
} catch (Exception e) { // Log the error. Do NOT acknowledge. // A background task will claim and retry it later. System.err.println("Failed to process billing for message: " + message.getId()); } }
private void processBilling(String orderId, String amount) { // Complex third-party API call here... }}Step 2: Configuring the Listener Container
Maya then configured the Spring container to bind the listener to the specific stream and consumer group.
import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.data.redis.connection.RedisConnectionFactory;import org.springframework.data.redis.connection.stream.Consumer;import org.springframework.data.redis.connection.stream.MapRecord;import org.springframework.data.redis.connection.stream.ReadOffset;import org.springframework.data.redis.connection.stream.StreamOffset;import org.springframework.data.redis.stream.StreamMessageListenerContainer;
import java.time.Duration;import java.util.UUID;
@Configurationpublic class StreamConfig {
@Bean public StreamMessageListenerContainer<String, MapRecord<String, String, String>> streamMessageListenerContainer( RedisConnectionFactory connectionFactory, OrderBillingListener orderBillingListener) {
// 1. Configure the polling behavior StreamMessageListenerContainer.StreamMessageListenerContainerOptions<String, MapRecord<String, String, String>> options = StreamMessageListenerContainer.StreamMessageListenerContainerOptions.builder() .pollTimeout(Duration.ofMillis(100)) .build();
StreamMessageListenerContainer<String, MapRecord<String, String, String>> container = StreamMessageListenerContainer.create(connectionFactory, options);
// 2. Define the Consumer Group and Consumer Name // In production, the consumer name should be unique per instance (e.g., hostname or UUID) String consumerName = "billing_instance_" + UUID.randomWindow();
// 3. Bind the listener to the stream // ReadOffset.lastConsumed() is equivalent to the '>' symbol in XREADGROUP container.receive( Consumer.from("billing_group", consumerName), StreamOffset.create("orders", ReadOffset.lastConsumed()), orderBillingListener);
// 4. Start the background polling thread container.start();
return container; }}Debugging/Troubleshooting Tips
- The Poison Pill: If a message contains invalid data that consistently crashes the
processBillingmethod, it will never beXACKed. It will remain in the PEL forever, and every time a consumer claims it, it will crash again. Solution: Implement a Dead Letter Queue (DLQ). If a message is claimed and fails more than times (check thedelivery countin the PEL), move it to a separateorders_dlqstream andXACKit from the main stream so an engineer can inspect it manually. - Memory Exhaustion: Streams are persistent. If you publish 10,000 messages a second and never trim the stream, Redis will eventually hit
maxmemoryand either OOM or start evicting your cached products (Part 4). Solution: Always useXADD ... MAXLEN ~ 1000000to automatically cap the stream size. The~means “approximately,” allowing Redis to trim entire macro-nodes efficiently rather than exactly counting 1 million items. - Group Creation: Spring Data Redis does not automatically create the stream or the consumer group. If you try to listen to a group that doesn’t exist, it throws an error. Solution: Write a startup script (using
CommandLineRunner) that executesXGROUP CREATE orders billing_group 0 MKSTREAMbefore the listener container starts.
Key Takeaways
- Pub/Sub vs. Streams: Pub/Sub is ephemeral (fire and forget). Streams are durable, append-only logs.
- Consumer Groups: Allow multiple instances of a service to scale out message processing by automatically distributing the load and tracking state.
- At-Least-Once Delivery: The
XACKcommand ensures messages are not lost if a consumer crashes. Unacknowledged messages remain in the PEL for reprocessing. - Trimming: Always use
MAXLENwhen adding to a stream to prevent unbounded memory growth.
Story Closing + Teaser
The transition to an event-driven architecture using Redis Streams was a resounding success. The monolithic checkout process was decoupled into resilient microservices. If the Billing API went down for 10 minutes, the orders simply queued up in the Redis Stream. When Billing came back online, it processed the backlog instantly. No data was lost, and the customer experience was flawless.
GlobalMart’s backend was now a modern, distributed, highly available, and event-driven powerhouse.
But the e-commerce world was changing. The product team had a vision for the future: they didn’t just want users to search for “blue running shoes.” They wanted users to upload a photo of a shoe and find similar products. They wanted users to ask a chatbot, “I’m running a marathon in the rain next week, what should I wear?” and get a highly personalized, context-aware recommendation.
Traditional keyword search (SQL LIKE '%shoe%') was completely inadequate for this. Maya needed to bridge the gap between structured data and human semantics. She needed to understand vector embeddings, distance metrics, and how to turn her caching layer into a high-dimensional AI memory bank. The era of Redis Stack and Vector Search awaited in the next chapter.
References
[1] Redis Streams Tutorial. https://redis.io/docs/latest/develop/data-types/streams/ [2] Spring Data Redis: Redis Streams. https://docs.spring.io/spring-data/redis/reference/redis/redis-streams.html