Design a Distributed Message Queue
A deep-dive into message queue architectures, delivery semantics, event streaming, and production-grade C# implementations.
1. Introduction & Motivation
A distributed message queue is infrastructure that enables asynchronous communication between services. Producers send messages to the queue, and consumers read them at their own pace. The queue decouples producers from consumers, absorbs traffic spikes, and provides fault tolerance — if a consumer crashes, messages wait in the queue until it recovers.
Every large-scale system relies on message queues. Uber uses Kafka to process 7 million events per second for ride matching and ETA calculations. LinkedIn processes 2 trillion messages per day through Kafka for activity tracking, metrics, and log aggregation. Netflix uses SQS and Kafka for video encoding pipelines, recommendation updates, and billing events. Without message queues, these systems would be tightly coupled, fragile, and unable to scale independently.
The two fundamental paradigms are message queuing (point-to-point, competing consumers, each message processed once) and pub/sub (one-to-many, each subscriber gets every message). Modern systems like Kafka blur this line by combining durable log storage with pub/sub semantics. This article covers both paradigms, their trade-offs, and production-grade implementations in C#.
Why Message Queues Matter
- Decoupling: Services can evolve independently without breaking each other.
- Buffering: Absorb traffic spikes — consumers process at their own pace.
- Resilience: If a consumer crashes, messages survive in the queue.
- Scalability: Add more consumers to increase throughput without changing producers.
- Async processing: Non-blocking operations — fire and forget.
2. Interview Context
Why Interviewers Ask This
- Async architecture: Do you understand when to use sync vs async communication?
- Delivery semantics: Can you explain at-least-once vs exactly-once vs at-most-once?
- Trade-off analysis: Ordering vs throughput, durability vs latency, simplicity vs features.
- Scalability: How do you partition messages across brokers? How do consumers scale?
- Failure handling: What happens when a broker dies? When a consumer crashes mid-processing?
Common Framing
Interviewers may frame this as "Design a notification system," "Design an event-driven architecture," or "How would you decouple Service A from Service B?" The core answer is always a message queue — the specific design depends on the requirements (ordering, durability, throughput).
3. Functional Requirements
- Publish messages: Producers send messages to a named topic/queue.
- Subscribe to messages: Consumers receive messages from topics they subscribe to.
- Point-to-point: Each message is processed by exactly one consumer (competing consumers).
- Pub/sub: Each message is delivered to all subscribers.
- Message ordering: Messages within a partition are delivered in order.
- Message retention: Messages are retained for configurable duration (hours to days).
- Dead letter queue: Messages that fail processing are moved to a DLQ for investigation.
- Consumer groups: Multiple consumers share the load within a group.
4. Non-Functional Requirements
| Requirement | Target | Justification |
|---|---|---|
| Throughput | 100K+ messages/sec | Must handle high-volume event streams |
| Latency (publish) | < 10ms p99 | Low overhead for producers |
| Latency (deliver) | < 50ms p99 | Near-real-time consumer delivery |
| Durability | Messages survive broker crash | No message loss for critical events |
| Availability | 99.99% | Message queue is critical infrastructure |
| Ordering | Per-partition strict ordering | Event ordering matters for state machines |
| Retention | 7 days configurable | Replay capability for debugging/reprocessing |
| Max message size | 10 MB | Support event payloads with attachments |
5. Requirement Prioritization
| Priority | Requirement |
|---|---|
| Must | At-least-once delivery, durable storage, consumer groups, topic-based routing |
| Must | Partitioned ordering, dead letter queue, message retention |
| Should | Exactly-once semantics (idempotent producers), consumer lag monitoring, replay |
| Should | Cross-datacenter replication, schema registry, message compression |
| Could | Transactional outbox, event sourcing, stream processing integration |
6. Capacity Estimation
| Metric | Value | Calculation |
|---|---|---|
| Messages/sec (peak) | 100,000 | Given |
| Average message size | 1 KB | Measured from production |
| Daily throughput | 8.6 TB | 100K × 1KB × 86400s |
| Retention period | 7 days | Configurable per topic |
| Total storage needed | 60 TB | 8.6TB × 7 days |
| Replication factor | 3 | Standard for HA |
| Total storage with replication | 180 TB | 60TB × 3 |
| Number of brokers | 30 | 6TB per broker (SSD) |
Hardware Sizing
| Component | Spec per Node | Count |
|---|---|---|
| Broker (storage) | 16 vCPU, 64GB RAM, 6TB NVMe SSD | 30 |
| Broker (compute-heavy) | 32 vCPU, 128GB RAM, 2TB SSD | 10 |
| ZooKeeper / etcd (metadata) | 4 vCPU, 8GB RAM, 200GB SSD | 5 |
7. Message Models
Point-to-Point (Queue)
One producer, one consumer per message. Multiple consumers compete for messages in the queue. Each message is processed exactly once. Ideal for task distribution and work queues.
Point-to-Point Queue
Publish/Subscribe (Topic)
One producer, many subscribers. Each subscriber receives every message. Ideal for event broadcasting, notifications, and fan-out patterns.
Publish/Subscribe
Event Streaming (Log-based)
Messages are stored in an append-only log. Consumers read from their position in the log. Messages are retained for configurable duration. Ideal for event sourcing, replay, and stream processing.
Event Log with Consumer Groups
[m1, m2, m3, m4, m5]"] L --> P1["Partition 1
[m6, m7, m8, m9]"] L --> P2["Partition 2
[m10, m11, m12]"] P0 --> CG1["Consumer Group A"] P1 --> CG1 P2 --> CG1 P0 --> CG2["Consumer Group B"] P1 --> CG2 P2 --> CG2 CG1 --> CA1[Consumer A1] CG1 --> CA2[Consumer A2] CG2 --> CB1[Consumer B1] style L fill:#4caf50
Model Comparison
| Feature | Point-to-Point | Pub/Sub | Event Streaming |
|---|---|---|---|
| Delivery | Once per message | Once per subscriber | Once per consumer group |
| Message retention | Until consumed | Until consumed | Configurable (hours/days) |
| Ordering | FIFO per queue | No guarantee across subscribers | Per-partition ordering |
| Replay | Not supported | Not supported | Full replay from any offset |
| Best for | Task queues, job distribution | Notifications, broadcasts | Event sourcing, analytics |
| Examples | RabbitMQ, AWS SQS | RabbitMQ (fanout), Google Pub/Sub | Apache Kafka, AWS Kinesis |
8. Delivery Guarantees
Delivery semantics define the contract between the queue and its clients. The choice of guarantee directly impacts system complexity, throughput, and latency.
At-Most-Once
Messages may be lost but are never delivered twice. The producer sends and forgets. If the broker crashes before persisting, the message is lost. Simple but risky for critical data.
// At-most-once: Fire and forget
public class AtMostOnceProducer
{
public async Task SendAsync(string topic, byte[] payload)
{
// Send without waiting for ack
_ = _channel.WriteAsync(new Message { Topic = topic, Payload = payload });
// No retry, no confirmation
}
}
At-Least-Once (Recommended Default)
Messages are never lost but may be delivered more than once. The broker persists the message before acking. If the consumer crashes mid-processing, the message is redelivered. Consumers must be idempotent.
// At-least-once: Ack after processing
public class AtLeastOnceConsumer
{
public async Task ProcessMessagesAsync(string topic, string groupId)
{
while (true)
{
var message = await _consumer.PollAsync(topic, groupId);
try
{
await ProcessMessage(message);
// Ack only AFTER successful processing
await _consumer.AcknowledgeAsync(message.Offset);
}
catch (Exception ex)
{
// Don't ack — message will be redelivered
_logger.LogError(ex, "Failed to process message {Offset}", message.Offset);
}
}
}
}
Exactly-Once
Each message is delivered exactly once. The hardest to achieve — requires coordination between producer, broker, and consumer. Typically implemented via idempotent producers + transactional offsets.
// Exactly-once: Idempotent producer + transactional consume-produce
public class ExactlyOnceProcessor
{
public async Task ProcessExactlyOnce(string inputTopic, string outputTopic)
{
var transaction = await _kafkaClient.BeginTransactionAsync();
try
{
// Consume within transaction
var message = await _consumer.ConsumeAsync(inputTopic, transaction);
// Process (idempotent operation)
var result = await IdempotentProcess(message);
// Produce output within same transaction
await _producer.ProduceAsync(outputTopic, result, transaction);
// Commit consumer offset within transaction
await _consumer.CommitOffsetAsync(message.Offset, transaction);
// Commit everything atomically
await transaction.CommitAsync();
}
catch
{
await transaction.AbortAsync();
}
}
}
Delivery Guarantee Comparison
| Guarantee | Message Loss | Duplication | Complexity | Throughput | Use When |
|---|---|---|---|---|---|
| At-most-once | Possible | Never | Low | Highest | Metrics, logs, non-critical events |
| At-least-once | Never | Possible | Medium | High | Most use cases (recommended default) |
| Exactly-once | Never | Never | High | Lower | Financial transactions, billing |
Idempotency is the Key
With at-least-once delivery (the practical default), messages may be delivered twice during failures. The solution is idempotent consumers — operations that produce the same result whether executed once or twice. Common patterns: database upserts (INSERT ON CONFLICT), deduplication tables, and idempotency keys.
9. High-Level Architecture
Distributed Message Queue Architecture
Partition 0,1] LB --> B2[Broker 2
Partition 2,3] LB --> B3[Broker 3
Partition 4,5] B1 <-->|"Replication"| B2 B2 <-->|"Replication"| B3 end subgraph "Metadata Service" B1 --> ZK[(ZooKeeper/etcd)] B2 --> ZK B3 --> ZK end subgraph "Consumer Layer" B1 --> CG1[Consumer Group 1] B2 --> CG1 B3 --> CG1 B1 --> CG2[Consumer Group 2] B2 --> CG2 CG1 --> C1[Consumer 1.1] CG1 --> C2[Consumer 1.2] CG2 --> C3[Consumer 2.1] end style B1 fill:#ff6b35 style B2 fill:#ff6b35 style B3 fill:#ff6b35 style ZK fill:#0099ff
10. Production Architecture Diagram
Message Publish Flow
Producing a Message
Message Consume Flow
Consuming with Consumer Groups
11. Component Deep Dive
Append-Only Log
The core data structure is an append-only log. Messages are appended in order, assigned monotonically increasing offsets, and stored on disk with sequential I/O for maximum throughput.
public class AppendOnlyLog
{
private readonly string _logDirectory;
private readonly long _maxSegmentSize;
private LogSegment _activeSegment;
public AppendOnlyLog(string directory, long maxSegmentSizeMB = 1024)
{
_logDirectory = directory;
_maxSegmentSize = maxSegmentSizeMB * 1024 * 1024;
Directory.CreateDirectory(directory);
LoadSegments();
}
public async Task<long> AppendAsync(byte[] data, Dictionary<string, byte[]>? headers = null)
{
var entry = new LogEntry
{
Offset = _nextOffset++,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
Headers = headers ?? new Dictionary<string, byte[]>(),
Payload = data,
Checksum = Crc32.Compute(data)
};
var bytes = Serialize(entry);
await _activeSegment.AppendAsync(bytes);
if (_activeSegment.Size >= _maxSegmentSize)
await RollSegmentAsync();
return entry.Offset;
}
public async IAsyncEnumerable<LogEntry> ReadAsync(
long startOffset,
int maxMessages = 1000)
{
foreach (var segment in _segments.Where(s => s.EndOffset >= startOffset))
{
await foreach (var entry in segment.ReadAsync(startOffset))
{
yield return entry;
if (--maxMessages <= 0) yield break;
}
}
}
}
public class LogSegment
{
private readonly FileStream _file;
private readonly Dictionary<long, long> _offsetIndex; // offset -> file position
public long StartOffset { get; }
public long EndOffset { get; private set; }
public long Size => _file.Length;
public async Task AppendAsync(byte[] serializedEntry)
{
var position = _file.Position;
_offsetIndex[EndOffset] = position;
await _file.WriteAsync(serializedEntry);
EndOffset++;
}
public async IAsyncEnumerable<LogEntry> ReadAsync(long startOffset)
{
if (!_offsetIndex.TryGetValue(startOffset, out var position))
{
// Find nearest offset
position = _offsetIndex.Where(kv => kv.Key <= startOffset)
.OrderByDescending(kv => kv.Key)
.FirstOrDefault().Value;
}
_file.Seek(position, SeekOrigin.Begin);
using var reader = new BinaryReader(_file, Encoding.UTF8, leaveOpen: true);
while (_file.Position < _file.Length)
{
var entry = Deserialize(reader);
if (entry.Offset >= startOffset)
yield return entry;
}
}
}
Broker with Replication
public class Broker
{
private readonly string _brokerId;
private readonly Dictionary<string, Partition> _partitions;
private readonly IReplicationManager _replication;
private readonly ILogger<Broker> _logger;
public async Task<PublishResult> PublishAsync(string topic, string key, byte[] value)
{
var partition = _partitions[topic];
var partitionIndex = GetPartitionIndex(key, partition.ReplicaCount);
// Only leader accepts writes
if (!partition.IsLeader(partitionIndex))
throw new NotLeaderException(partition.GetLeader(partitionIndex));
// Append to local log
var offset = await partition.AppendAsync(partitionIndex, value);
// Replicate to followers
var replicas = partition.GetReplicas(partitionIndex);
var acks = 0;
var requiredAcks = (replicas.Count / 2) + 1; // Quorum
var replicationTasks = replicas.Select(async replica =>
{
try
{
await _replication.ReplicateAsync(replica, topic, partitionIndex, offset, value);
Interlocked.Increment(ref acks);
}
catch (Exception ex)
{
_logger.LogWarning("Replication failed to {Replica}: {Error}", replica, ex.Message);
}
});
await Task.WhenAll(replicationTasks);
if (acks >= requiredAcks)
{
return new PublishResult
{
Success = true,
Offset = offset,
Partition = partitionIndex
};
}
throw new InsufficientReplicasException(
$"Only {acks}/{requiredAcks} replicas acknowledged");
}
}
public class Partition
{
public string Topic { get; set; }
public int PartitionIndex { get; set; }
public string LeaderBroker { get; set; }
public List<string> Replicas { get; set; }
public AppendOnlyLog Log { get; set; }
public bool IsLeader(int partitionIndex) =>
LeaderBroker == Environment.MachineName;
public string GetLeader(int partitionIndex) => LeaderBroker;
}
Consumer Group Coordinator
public class ConsumerGroupCoordinator
{
private readonly Dictionary<string, ConsumerGroup> _groups = new();
private readonly ITopicMetadata _metadata;
public PartitionAssignment AssignPartitions(string groupId, string topic)
{
var group = _groups.GetOrAdd(groupId, _ => new ConsumerGroup
{
GroupId = groupId,
Members = new List<string>(),
State = GroupState.Stable
});
var partitions = _metadata.GetPartitions(topic);
var members = group.Members;
// Round-robin assignment
var assignments = new Dictionary<string, List<int>>();
for (int i = 0; i < partitions.Count; i++)
{
var memberId = members[i % members.Count];
if (!assignments.ContainsKey(memberId))
assignments[memberId] = new List<int>();
assignments[memberId].Add(i);
}
return new PartitionAssignment { Assignments = assignments };
}
public async Task HeartbeatAsync(string groupId, string memberId)
{
if (_groups.TryGetValue(groupId, out var group))
{
var member = group.Members.FirstOrDefault(m => m == memberId);
if (member != null)
group.LastHeartbeat[memberId] = DateTimeOffset.UtcNow;
}
}
public void CheckMemberHealth(TimeSpan timeout)
{
var now = DateTimeOffset.UtcNow;
foreach (var group in _groups.Values)
{
var deadMembers = group.LastHeartbeat
.Where(kv => now - kv.Value > timeout)
.Select(kv => kv.Key)
.ToList();
foreach (var memberId in deadMembers)
{
group.Members.Remove(memberId);
_logger.LogWarning("Member {MemberId} timed out in group {GroupId}",
memberId, group.GroupId);
// Trigger rebalance
RebalanceGroup(group.GroupId);
}
}
}
}
12. Data Modeling
Topic & Partition Metadata
CREATE TABLE topics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) UNIQUE NOT NULL,
partition_count INT NOT NULL DEFAULT 6,
replication_factor INT NOT NULL DEFAULT 3,
retention_ms BIGINT NOT NULL DEFAULT 604800000,
config JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE partitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
topic_id UUID REFERENCES topics(id),
partition_index INT NOT NULL,
leader_broker_id UUID NOT NULL,
replica_broker_ids UUID[] NOT NULL,
start_offset BIGINT DEFAULT 0,
end_offset BIGINT DEFAULT 0,
UNIQUE(topic_id, partition_index)
);
CREATE TABLE consumer_offsets (
group_id VARCHAR(255) NOT NULL,
topic VARCHAR(255) NOT NULL,
partition_index INT NOT NULL,
committed_offset BIGINT NOT NULL,
last_updated TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (group_id, topic, partition_index)
);
CREATE INDEX idx_partitions_topic ON partitions(topic_id);
CREATE INDEX idx_offsets_group ON consumer_offsets(group_id);
Internal Data Structures
| Structure | Purpose | Storage |
|---|---|---|
| Commit Log | Durable message storage | Append-only files on SSD |
| Offset Index | Map offset to file position | In-memory + sparse index on disk |
| Time Index | Map timestamp to offset | In-memory + sparse index on disk |
| Consumer Offset | Track consumer group progress | Internal __consumer_offsets topic |
| Group Membership | Track active consumers per group | Coordinator in-memory + ZooKeeper |
13. API Design
Producer API
public interface IMessageProducer
{
Task<PublishResult> PublishAsync<T>(string topic, string key, T value,
Dictionary<string, byte[]>? headers = null);
Task<List<PublishResult>> BatchPublishAsync<T>(string topic,
IEnumerable<MessageEnvelope<T>> messages);
event Action<PublishError>? OnPublishError;
}
public class PublishResult
{
public bool Success { get; set; }
public long Offset { get; set; }
public int Partition { get; set; }
public string? Error { get; set; }
}
Consumer API
public interface IMessageConsumer : IDisposable
{
Task<ConsumeResult<T>> ConsumeAsync<T>(string topic, string groupId,
CancellationToken cancellationToken = default);
Task<IAsyncEnumerable<ConsumeResult<T>>> ConsumeStreamAsync<T>(
string topic, string groupId, CancellationToken cancellationToken = default);
Task AcknowledgeAsync(long offset);
Task<ConsumerLag> GetLagAsync(string topic, string groupId);
}
public class ConsumeResult<T>
{
public string Topic { get; set; }
public int Partition { get; set; }
public long Offset { get; set; }
public string Key { get; set; }
public T Value { get; set; }
public Dictionary<string, byte[]> Headers { get; set; }
public DateTimeOffset Timestamp { get; set; }
}
Admin API
public interface IMessageQueueAdmin
{
Task<TopicMetadata> CreateTopicAsync(string name, int partitions,
int replicationFactor, TimeSpan retention);
Task DeleteTopicAsync(string name);
Task<List<TopicMetadata>> ListTopicsAsync();
Task ResetConsumerOffsetAsync(string groupId, string topic,
int partition, long offset);
}
ASP.NET Core Integration
builder.Services.AddSingleton<IMessageProducer, KafkaProducer>(sp =>
{
var config = builder.Configuration.GetSection("Kafka");
return new KafkaProducer(new ProducerConfig
{
BootstrapServers = config["BootstrapServers"],
Acks = Acks.All,
EnableIdempotence = true,
LingerMs = 5,
BatchSize = 16384,
CompressionType = CompressionType.Lz4
});
});
public class OrderEventConsumer : BackgroundService
{
private readonly IMessageConsumer _consumer;
private readonly IServiceProvider _services;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var result in _consumer.ConsumeStreamAsync<OrderEvent>(
"orders", "order-processor-group", stoppingToken))
{
try
{
using var scope = _services.CreateScope();
var handler = scope.ServiceProvider.GetRequiredService<IOrderEventHandler>();
await handler.HandleAsync(result.Value);
await _consumer.AcknowledgeAsync(result.Offset);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process order event at offset {Offset}", result.Offset);
}
}
}
}
14. Storage Engine
Log Segment Files
Messages are stored in segmented log files on disk. Each segment has a base offset and a maximum size. Old segments are deleted based on retention policy. The active segment receives new writes.
public class LogStorageEngine
{
private readonly string _topicDir;
private readonly ConcurrentDictionary<int, PartitionLog> _partitionLogs = new();
private readonly TimeSpan _retentionPeriod;
public async Task<long> AppendAsync(int partition, byte[] data)
{
var log = _partitionLogs.GetOrAdd(partition,
_ => new PartitionLog(Path.Combine(_topicDir, $"partition-{partition}")));
return await log.AppendAsync(data);
}
public async IAsyncEnumerable<MessageEntry> ReadAsync(
int partition, long startOffset, int maxCount = 1000)
{
if (!_partitionLogs.TryGetValue(partition, out var log))
yield break;
await foreach (var entry in log.ReadAsync(startOffset, maxCount))
yield return entry;
}
}
15. Read/Write Path
Write Path Optimizations
- Sequential I/O: Append-only writes use sequential disk I/O, which is 100-1000x faster than random I/O.
- Batch writes: Multiple messages batched into a single disk write (linger.ms, batch.size).
- Zero-copy: Messages sent directly from disk to network socket without user-space copy.
- Page cache: OS page cache buffers recent writes; fsync configurable for durability vs performance.
Read Path Optimizations
- Sequential reads: Consumers read sequentially from their current offset.
- Zero-copy transfer: sendfile() syscall transfers data directly from page cache to network.
- Prefetching: Broker prefetches next batch while consumer processes current batch.
- Compression: Messages compressed in batches (LZ4, Snappy, Zstd) — reduces I/O and network.
16. Consumer Patterns
Push vs Pull
| Model | Mechanism | Pros | Cons |
|---|---|---|---|
| Push (RabbitMQ) | Broker delivers to consumer | Low latency, real-time | Consumer overload risk, backpressure needed |
| Pull (Kafka) | Consumer polls broker | Consumer controls pace, simple backpressure | Higher latency if poll interval is long |
Consumer Group Rebalancing
Rebalance When Consumer Joins/Leaves
17. Ordering & Partitioning
Partitioning Strategy
public class Partitioner
{
public int GetPartition(string key, int partitionCount)
{
return Math.Abs(key.GetHashCode()) % partitionCount;
}
}
Ordering Guarantees
| Scope | Guarantee | Mechanism |
|---|---|---|
| Per partition | Strict ordering | Append-only log with sequential offsets |
| Per key | Ordering per key | Hash-based partitioning: same key to same partition |
| Global | No guarantee (by default) | Would require single partition = throughput bottleneck |
18. Scalability
Horizontal Scaling
Add more brokers and partitions to increase throughput. Partition count determines maximum parallelism within a consumer group.
| Partition Count | Max Throughput | Max Consumers/Group |
|---|---|---|
| 6 | ~300K msg/s | 6 |
| 24 | ~1.2M msg/s | 24 |
| 100+ | ~5M msg/s | 100+ |
19. Distributed Systems Design
In-Sync Replicas (ISR)
Only replicas that are sufficiently caught up with the leader are considered "in sync." The ISR ensures new leaders have the most recent data.
public class ISRManager
{
private readonly Dictionary<string, HashSet<string>> _isr = new();
public async Task CheckReplicaLagAsync(string topic, int partition)
{
var leaderOffset = await GetLeaderOffset(topic, partition);
var replicas = await GetReplicas(topic, partition);
foreach (var replica in replicas)
{
var replicaOffset = await GetReplicaOffset(replica, topic, partition);
var lag = leaderOffset - replicaOffset;
if (lag > 10000) // Max lag threshold
_isr[Key(topic, partition)].Remove(replica);
else
_isr[Key(topic, partition)].Add(replica);
}
}
}
20. Consistency Models
| Scenario | Guarantee | Mechanism |
|---|---|---|
| Message durability | ACK after disk flush | fsync to disk before acking producer |
| Consumer offset | Commit after processing | Store offset in internal topic |
| Replication | Quorum write | Write to ISR before acking |
| Ordering | Per-partition strict | Append-only log with sequential offsets |
21. Reliability
Dead Letter Queue
public class DeadLetterQueue
{
private readonly IMessageProducer _producer;
private readonly int _maxRetries = 3;
public async Task<bool> ProcessWithDLQAsync<T>(
string topic, string groupId, Func<T, Task<bool>> processor)
{
var result = await _consumer.ConsumeAsync<MessageWithRetry<T>>(topic, groupId);
if (result.Value.RetryCount >= _maxRetries)
{
await _producer.PublishAsync($"{topic}.dlq", result.Key,
result.Value.Payload, new Dictionary<string, byte[]>
{
["retry-count"] = BitConverter.GetBytes(result.Value.RetryCount),
["error"] = Encoding.UTF8.GetBytes(result.Value.LastError ?? "unknown")
});
await _consumer.AcknowledgeAsync(result.Offset);
return false;
}
try
{
var success = await processor(result.Value.Payload);
if (success) { await _consumer.AcknowledgeAsync(result.Offset); return true; }
}
catch (Exception ex) { result.Value.LastError = ex.Message; }
result.Value.RetryCount++;
await _producer.PublishAsync(topic, result.Key, result.Value);
await _consumer.AcknowledgeAsync(result.Offset);
return false;
}
}
22. Security
- Authentication: SASL/SCRAM for client auth, mTLS for inter-broker communication.
- Authorization: ACLs per topic — control produce/consume permissions.
- Encryption in transit: TLS for all client-broker and broker-broker communication.
- Encryption at rest: Disk-level encryption (LUKS, BitLocker) for message storage.
- Audit logging: Log all topic create/delete, ACL changes, and client connections.
23. Observability
Key Metrics
| Metric | Type | Description | Alert |
|---|---|---|---|
| mq_messages_published_total | Counter | Total messages published | - |
| mq_messages_consumed_total | Counter | Total messages consumed | - |
| mq_consumer_lag_messages | Gauge | Consumer lag in messages | Lag > 10K |
| mq_consumer_lag_ms | Gauge | Consumer lag in milliseconds | Lag > 60s |
| mq_publish_latency_ms | Histogram | Publish round-trip time | p99 > 50ms |
| mq_isr_shrink_rate | Counter | ISR shrink events | Any shrink |
| mq_disk_usage_bytes | Gauge | Disk usage per broker | > 80% |
24. High Availability
Multi-Broker Replication
Each partition has one leader and N-1 followers. If the leader dies, a follower in the ISR is promoted. Replication factor of 3 is standard.
Multi-Datacenter Replication
Cross-Datacenter Mirror
async replication"| B2 B2 -->|"MirrorMaker"| B1 B1 --> C1[Consumers DC1] B2 --> C2[Consumers DC2]
25. Performance
Latency Breakdown
| Component | P50 | P99 | Optimization |
|---|---|---|---|
| Producer to Broker (network) | 0.5ms | 3ms | Batching, compression |
| Broker write (disk) | 0.1ms | 1ms | Sequential I/O, page cache |
| Replication | 1ms | 5ms | Parallel replication |
| Broker to Consumer | 0.5ms | 3ms | Zero-copy, prefetch |
| Total end-to-end | 2ms | 12ms |
Throughput Optimizations
- Batching: Group messages into batches. Amortizes network and disk overhead.
- Compression: LZ4/Snappy/Zstd. 2-5x reduction in I/O at ~5% CPU cost.
- Zero-copy: sendfile() — data from page cache to network without user-space copy.
- Sequential I/O: Append-only writes are 100x faster than random writes.
26. Cost Analysis
| Component | Spec | Count | Monthly Cost |
|---|---|---|---|
| Message brokers | c5.2xlarge (8 vCPU, 6TB NVMe) | 30 | $10,500 |
| ZooKeeper | c5.large (2 vCPU) | 5 | $500 |
| Network + monitoring | Cross-AZ traffic | - | $500 |
| Total | $11,500/month |
27. Failure Scenarios
| Failure | Impact | Recovery | RTO |
|---|---|---|---|
| Single broker crash | Partition leader loss | ISR replica promotion | 5-10s |
| Disk failure | Partition data loss on node | Rebuild from replicas | 1-4 hours |
| Network partition | Split brain risk | Majority partition continues | 30s |
| Consumer crash | Unprocessed messages | Redeliver from committed offset | Immediate |
| ZooKeeper failure | Cannot elect leaders | ZK ensemble with 3-5 nodes | 30s |
28. Technology Choices (C#)
C# Message Queue Stack
| Component | Technology | Reasoning |
|---|---|---|
| Message Broker | Apache Kafka / RabbitMQ | Kafka for streaming, RabbitMQ for queues |
| C# Client (Kafka) | Confluent.Kafka | Official, high performance |
| C# Client (RabbitMQ) | RabbitMQ.Client | Official, AMQP 0-9-1 |
| Abstraction | MassTransit / NServiceBus | Broker-agnostic, sagas, routing |
| Serialization | System.Text.Json / Protobuf | Fast, schema evolution |
NuGet Packages
<PackageReference Include="Confluent.Kafka" Version="2.3.*" />
<PackageReference Include="RabbitMQ.Client" Version="6.8.*" />
<PackageReference Include="MassTransit" Version="8.2.*" />
<PackageReference Include="MassTransit.Kafka" Version="8.2.*" />
<PackageReference Include="MassTransit.RabbitMQ" Version="8.2.*" />
29. Alternatives & Trade-offs
| System | Model | Ordering | Best For |
|---|---|---|---|
| Apache Kafka | Log-based pub/sub | Per-partition | Event streaming, high throughput |
| RabbitMQ | AMQP queue | FIFO per queue | Task queues, routing |
| AWS SQS | Managed queue | Best-effort | AWS-native, simple queues |
| Google Pub/Sub | Managed streaming | Per-key | GCP-native, global |
| NATS JetStream | Log-based | Per-stream | Low-latency, edge |
| Redis Streams | Log-based | Per-stream | Already using Redis |
30. Real-World Case Studies
LinkedIn Kafka
- Scale: 2 trillion messages/day, 7M msg/sec peak.
- Use cases: Activity tracking, metrics, log aggregation, CDC.
- Lesson: A simple append-only log is a remarkably versatile abstraction.
Uber Kafka
- Scale: 7M events/sec, 100+ trillion messages/day.
- Use cases: Ride matching, ETA, payment processing, fraud detection.
- Lesson: Schema evolution is critical for long-lived event streams.
Netflix SQS + Kafka
- SQS: Simple task queues for encoding, email, billing.
- Kafka: Event streaming for activity, recommendations, A/B testing.
- Lesson: Use the right tool for the right job.
31. Interview Follow-ups
| Question | Key Points |
|---|---|
| How do you handle message ordering? | Per-partition ordering. Hash-based partitioning ensures same key to same partition. |
| What if a consumer crashes mid-processing? | At-least-once: redeliver from last committed offset. Consumer must be idempotent. |
| How do you achieve exactly-once? | Idempotent producer + transactional consume-produce + atomic offset commit. |
| Kafka vs RabbitMQ? | Kafka: log-based, replay, high throughput. RabbitMQ: AMQP queues, complex routing. |
| How do you handle backpressure? | Pull-based consumers (Kafka) or prefetch limits (RabbitMQ). |
| What happens when a broker dies? | ISR replica promoted. Unavailable briefly. Messages in unacknowledged writes may be lost. |
32. Senior/Staff/Principal Discussion
Architectural Decisions
- Event streaming vs traditional queue: Kafka for event sourcing, replay, and stream processing. RabbitMQ/SQS for simple task distribution.
- Partition count: More partitions = more parallelism but more metadata overhead. Start with 6-12, increase if needed.
- Exactly-once vs at-least-once: Exactly-once adds complexity. For most use cases, at-least-once with idempotent consumers is sufficient.
Operational Considerations
- Partition reassignment: When adding brokers, move partitions during low-traffic periods.
- Schema evolution: Use a schema registry with backward-compatible evolution.
- Consumer group management: Monitor group membership, heartbeats, and rebalance frequency.
33. Architecture Evolution
Message Queue Evolution
34. Key Takeaways
Core Principles
- At-least-once delivery is the practical default — consumers must be idempotent.
- Append-only log is the fundamental data structure — ordering, replay, durability.
- Partitioning enables horizontal scaling — more partitions = more parallelism.
- Consumer groups distribute work — each partition assigned to one consumer.
- Replication provides durability — ISR ensures data safety.
- Batching and zero-copy are the key performance optimizations.
- Dead letter queues handle poison messages — prevent infinite retry loops.
- Schema registry enables safe schema evolution.
- Consumer lag is the most important metric — measures how far behind consumers are.
- Choose the right tool: Kafka for streaming, RabbitMQ for queues, SQS for simple managed.
35. References
- Designing Data-Intensive Applications (Kleppmann, 2017)
- Apache Kafka: The Definitive Guide (Shapira et al., 2021)
- Kafka: a Distributed Messaging System for Log Processing (Kreps et al., 2011)
- RabbitMQ in Depth (Lindholm, 2016)
- Confluent.Kafka Documentation
- MassTransit Documentation
- The Log: What every software engineer should know (Kreps, 2011)
- Building Event-Driven Microservices (Adam Bellemare, 2020)
36. Message Queue Monitoring and Alerting Dashboard
Comprehensive monitoring of a distributed message queue requires tracking producer throughput, consumer lag, broker health, and end-to-end latency. The monitoring system must detect anomalies in real-time and trigger alerts before users notice degraded performance.
public class MessageQueueMonitor
{
private readonly IKafkaAdminClient _adminClient;
private readonly IMetricsCollector _metrics;
private readonly IAlertingService _alerting;
public async Task<QueueHealthReport> GetHealthReportAsync(string topic)
{
var consumerGroups = await _adminClient.ListConsumerGroupsAsync(topic);
var maxLag = 0L;
var lagByGroup = new Dictionary<string, long>();
foreach (var group in consumerGroups)
{
var offsets = await _adminClient.GetConsumerOffsetsAsync(group, topic);
var lag = offsets.Sum(o => o.Lag);
lagByGroup[group] = lag;
maxLag = Math.Max(maxLag, lag);
}
var brokerMetrics = await _adminClient.GetBrokerMetricsAsync(topic);
var report = new QueueHealthReport
{
Topic = topic,
PartitionCount = brokerMetrics.PartitionCount,
TotalMessages = brokerMetrics.TotalMessages,
MaxConsumerLag = maxLag,
LagByConsumerGroup = lagByGroup,
ProducerThroughput = brokerMetrics.ProducerThroughput,
BrokerHealth = brokerMetrics.Brokers.Select(b => new BrokerHealth
{
BrokerId = b.Id,
IsAlive = b.IsAlive,
DiskUsagePercent = b.DiskUsagePercent,
CpuUsagePercent = b.CpuUsagePercent
}).ToList()
};
if (maxLag > 1_000_000)
{
await _alerting.SendAlertAsync(new Alert
{
Severity = AlertSeverity.Critical,
Title = $"High consumer lag on {topic}",
Message = $"Max lag: {maxLag:N0} messages"
});
}
return report;
}
}
Key Monitoring Metrics
| Metric | Description | Alert Threshold |
|---|---|---|
| Consumer Lag | Messages behind latest offset | > 1M messages |
| Producer Throughput | Messages/sec published | < 50% of baseline |
| End-to-End Latency | Time from produce to consume | > 5 seconds |
| Broker Disk Usage | % of disk capacity used | > 85% |
| ISR Shrink Rate | In-sync replicas falling behind | > 0 partitions |
| Request Latency P99 | 99th percentile request time | > 500ms |
| Error Rate | Failed requests per second | > 0.1% |
Dead Letter Queue and Message Recovery
Messages that repeatedly fail processing must be routed to a Dead Letter Queue (DLQ) to prevent poison pill messages from blocking the queue. The DLQ provides a mechanism for operators to inspect, debug, and replay failed messages. A robust DLQ strategy includes automatic classification of failure types, configurable retry policies, and bulk replay capabilities.
public class DeadLetterQueueHandler
{
private readonly IQueueProducer _dlqProducer;
private readonly IQueueProducer _retryProducer;
private readonly IMetricsCollector _metrics;
public async Task<HandlerResult> HandleFailedMessageAsync(
QueueMessage message, Exception error, int attemptCount)
{
var classification = ClassifyError(error);
if (classification.IsTransient && attemptCount < MaxRetries)
{
var delay = CalculateExponentialBackoff(attemptCount);
await _retryProducer.ProduceAsync(message, delay);
_metrics.IncrementCounter("queue.retry",
new Dictionary<string, string>
{
["topic"] = message.Topic,
["attempt"] = attemptCount.ToString()
});
return HandlerResult.RetryScheduled;
}
// Route to DLQ
var dlqMessage = new DeadLetterMessage
{
OriginalMessage = message,
ErrorType = classification.Type,
ErrorMessage = error.Message,
StackTrace = error.StackTrace,
AttemptCount = attemptCount,
FirstFailedAt = message.CreatedAt,
LastFailedAt = DateTimeOffset.UtcNow,
Topic = message.Topic,
Partition = message.Partition,
Offset = message.Offset
};
await _dlqProducer.ProduceAsync("dlq", dlqMessage);
_metrics.IncrementCounter("queue.dlq",
new Dictionary<string, string>
{
["topic"] = message.Topic,
["error_type"] = classification.Type.ToString()
});
return HandlerResult.SentToDlq;
}
private ErrorClassification ClassifyError(Exception error)
{
return error switch
{
TimeoutException => new ErrorClassification
{
Type = ErrorType.Transient, IsTransient = true },
HttpRequestException => new ErrorClassification
{
Type = ErrorType.Transient, IsTransient = true },
JsonException => new ErrorClassification
{
Type = ErrorType.Deserialization, IsTransient = false },
InvalidOperationException => new ErrorClassification
{
Type = ErrorType.BusinessLogic, IsTransient = false },
_ => new ErrorClassification
{
Type = ErrorType.Unknown, IsTransient = false }
};
}
}
36. Conclusion
Distributed message queues are the backbone of modern async architectures. They decouple services, absorb traffic spikes, provide fault tolerance, and enable event-driven patterns that scale independently. The choice between a traditional message queue (RabbitMQ, SQS) and an event streaming platform (Kafka, Kinesis) depends on whether you need simple task distribution or durable, replayable event logs.
The key insight is that most systems need both paradigms: Kafka for high-throughput event streaming and analytics, and a traditional queue for simple task distribution and request-reply patterns. Understanding the trade-offs — ordering vs throughput, durability vs latency, complexity vs features — lets you choose the right tool for each use case.
Quick Recap
- Delivery: At-least-once with idempotent consumers
- Data structure: Append-only log with sequential offsets
- Scaling: Partitioning + consumer groups
- Durability: Replication + disk persistence
- Ordering: Per-partition, per-key
- Performance: Batching + zero-copy + compression