Building a Billion‑Message, Millisecond‑Scale Private Messaging System with Spring Boot, RabbitMQ & Redis
This article presents a production‑grade design for a social private‑messaging system that handles billions of messages with millisecond latency, detailing how MySQL serves as the message fact store, RabbitMQ provides low‑latency distribution, Redis manages online state and indexes, and Outbox plus idempotent consumption ensure reliability and ordering.
1. Why a private‑messaging system deserves deep digging?
Private messaging looks simple to users but hides many engineering contradictions:
High‑concurrency writes coexist with hot conversation hotspots.
Real‑time delivery must coexist with durable persistence.
Multi‑device online state must converge across devices.
Strict ordering per conversation must coexist with horizontal scalability.
Cost and user experience must be balanced; not every message needs cross‑region strong consistency.
Therefore the system should be seen as a distributed state system centered on message facts, conversation state, and routing, not merely an MQ project.
2. System requirements and core goals
Core functions : one‑to‑one private chat, conversation list, history pull, read receipts, multi‑device sync.
Consistency model : MySQL stores the immutable message fact; delivery is at‑least‑once; business effect aims for effectively‑once.
Ordering : server_seq guarantees monotonic increasing order inside a conversation; no global order across conversations.
Real‑time : online users receive messages via WebSocket; failures fall back to pull‑based compensation.
State model : unread count, read status, last message preview are rebuilt from MySQL; Redis is only a hot‑state cache, not the source of truth.
High availability : stateless services, horizontal consumer scaling, temporary cache inconsistencies that can be repaired.
Operability : monitor backlog, duplicate consumption, delivery latency, hotspot sessions, ACK watermarks, etc.
3. Architecture overview
The production‑grade system is split into four layers:
Access layer : API gateway, WebSocket gateway, client SDK.
Message fact layer : MySQL stores message, conversation_cursor, and outbox_event tables.
Asynchronous distribution layer : RabbitMQ carries MessageCreated events from the outbox.
Online state layer : Redis holds online user routing, conversation indexes, unread hashes, and deduplication keys.
Each component has a clear responsibility, avoiding tight coupling between persistence, distribution, and online push.
4. Core principles and technology choices
4.1 Why RabbitMQ instead of Kafka?
Private‑messaging cares about low latency, flexible routing, and push‑friendly consumption, not raw throughput. RabbitMQ provides:
Queue‑oriented delivery suitable for task distribution.
Broker‑push model gives sub‑millisecond latency.
Exchange + routing‑key routing is very flexible.
Native dead‑letter, retry, and delayed delivery support.
Simpler operational footprint for a single‑region real‑time path.
Kafka excels at high‑throughput log replay and analytics, so a common production pattern is a dual‑track setup: RabbitMQ for real‑time push, Kafka for offline analytics, behavior tracking, and content moderation.
4.2 What should Redis store?
Redis is ideal for hot read/write, online state, and rebuildable indexes, but must not become the immutable source of truth. STRING online:user:{uid} – online node and last heartbeat. ZSET conv:index:{uid} – conversation sorting, score = last active time. HASH conv:unread:{uid} – per‑conversation unread count. HASH conv:lastmsg:{uid} – conversation preview and last‑message type. STRING consume:dedup:{eventId} – idempotent consumption marker. STRING push:dedup:{messageId}:{deviceId} – device‑level push deduplication.
Do NOT store the full message body, final read facts, strict sequence numbers, or irrevocable delivery status in Redis because cache loss would make the system unrecoverable.
4.3 Reliability: Outbox + idempotency + watermarks
Relying only on publisher confirms is insufficient; a failure between DB commit and MQ publish can lose the message. The production pattern uses an Outbox table inside the same DB transaction:
Write message and outbox_event together.
After transaction commit, a separate publisher scans pending outbox rows and sends them to RabbitMQ.
On success the row is marked published_at; on failure it is retried with exponential back‑off.
Consumers add two layers of safety:
Event‑level idempotent key ( event_key or message_id) prevents duplicate side effects.
State‑level monotonic fields ( server_seq, ack_watermark) ensure later updates never overwrite earlier ones.
The three pillars of reliability are:
Provable fact write.
Compensatable outbound event.
Idempotent state consumption.
5. Production‑grade code implementation
5.1 Core schema
CREATE TABLE im_message (
id BIGINT PRIMARY KEY,
conversation_id BIGINT NOT NULL,
sender_id BIGINT NOT NULL,
receiver_id BIGINT NOT NULL,
client_msg_id VARCHAR(64) NOT NULL,
server_seq BIGINT NOT NULL,
content_type VARCHAR(32) NOT NULL,
content_body JSON NOT NULL,
send_time DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL,
UNIQUE KEY uk_conv_client_msg (conversation_id, client_msg_id),
UNIQUE KEY uk_conv_server_seq (conversation_id, server_seq),
KEY idx_receiver_seq (receiver_id, server_seq)
);
CREATE TABLE im_conversation_cursor (
conversation_id BIGINT PRIMARY KEY,
last_server_seq BIGINT NOT NULL,
last_message_id BIGINT NOT NULL,
updated_at DATETIME(3) NOT NULL
);
CREATE TABLE im_outbox_event (
id BIGINT PRIMARY KEY,
aggregate_type VARCHAR(32) NOT NULL,
aggregate_id BIGINT NOT NULL,
event_type VARCHAR(64) NOT NULL,
event_key VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(16) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL,
published_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL,
UNIQUE KEY uk_event_key (event_key),
KEY idx_status_retry (status, next_retry_at)
);5.2 Spring configuration (application.yml excerpt)
spring:
rabbitmq:
host: ${RABBIT_HOST:localhost}
port: 5672
username: ${RABBIT_USER:admin}
password: ${RABBIT_PASSWORD:admin}
publisher-confirm-type: correlated
publisher-returns: true
listener:
simple:
acknowledge-mode: manual
prefetch: 100
concurrency: 4
max-concurrency: 16
retry:
enabled: false
redis:
lettuce:
pool:
max-active: 128
max-idle: 32
datasource:
hikari:
maximum-pool-size: 32
minimum-idle: 8
management:
endpoints:
web:
exposure:
include: health,info,prometheus5.3 RabbitMQ topology (Java config)
@Configuration
public class ImRabbitConfig {
public static final String EXCHANGE_MESSAGE = "im.message.exchange";
public static final String QUEUE_PUSH = "im.push.queue";
public static final String QUEUE_INDEX = "im.index.queue";
public static final String EXCHANGE_DLX = "im.dlx.exchange";
public static final String QUEUE_DLX = "im.dlx.queue";
@Bean
TopicExchange messageExchange() {
return new TopicExchange(EXCHANGE_MESSAGE, true, false);
}
@Bean
Queue pushQueue() {
return QueueBuilder.durable(QUEUE_PUSH)
.withArgument("x-dead-letter-exchange", EXCHANGE_DLX)
.withArgument("x-dead-letter-routing-key", "im.dead.push")
.build();
}
@Bean
Queue indexQueue() {
return QueueBuilder.durable(QUEUE_INDEX)
.withArgument("x-dead-letter-exchange", EXCHANGE_DLX)
.withArgument("x-dead-letter-routing-key", "im.dead.index")
.build();
}
@Bean
TopicExchange deadLetterExchange() {
return new TopicExchange(EXCHANGE_DLX, true, false);
}
@Bean
Queue deadLetterQueue() {
return QueueBuilder.durable(QUEUE_DLX).build();
}
}5.4 Sending flow (service code)
@Service
@RequiredArgsConstructor
public class MessageSendService {
private final ConversationCursorRepository cursorRepository;
private final MessageRepository messageRepository;
private final OutboxEventRepository outboxEventRepository;
private final ConversationReadModelCache conversationReadModelCache;
private final IdGenerator idGenerator;
@Transactional
public SendMessageResult send(SendMessageCommand command) {
ConversationCursor cursor = cursorRepository.lockByConversationId(command.conversationId())
.orElseGet(() -> cursorRepository.create(command.conversationId()));
long nextSeq = cursor.getLastServerSeq() + 1;
long messageId = idGenerator.nextId();
Instant now = Instant.now();
MessageEntity entity = MessageEntity.builder()
.id(messageId)
.conversationId(command.conversationId())
.senderId(command.senderId())
.receiverId(command.receiverId())
.clientMsgId(command.clientMsgId())
.serverSeq(nextSeq)
.contentType(command.contentType())
.contentBody(command.contentBody())
.sendTime(now)
.createdAt(now)
.build();
messageRepository.insert(entity);
cursorRepository.updateLastSeq(command.conversationId(), nextSeq, messageId, now);
OutboxEvent event = OutboxEvent.messageCreated(
idGenerator.nextId(),
entity.getConversationId(),
"msg-created:" + entity.getId(),
MessageCreatedPayload.from(entity),
now);
outboxEventRepository.insert(event);
conversationReadModelCache.updateSenderPreview(
command.senderId(),
command.conversationId(),
nextSeq,
entity.previewText(),
now);
return new SendMessageResult(entity.getId(), entity.getServerSeq(), now);
}
}5.5 Outbox publisher (scheduled job)
@Slf4j
@Component
@RequiredArgsConstructor
public class OutboxPublisherJob {
private final OutboxEventRepository outboxEventRepository;
private final RabbitTemplate rabbitTemplate;
@Scheduled(fixedDelay = 200)
public void publish() {
List<OutboxEvent> events = outboxEventRepository.lockBatchForPublish(100);
for (OutboxEvent event : events) {
try {
CorrelationData correlationData = new CorrelationData(event.getEventKey());
rabbitTemplate.convertAndSend(
ImRabbitConfig.EXCHANGE_MESSAGE,
"im.message.created",
event.getPayload(),
correlationData);
outboxEventRepository.markPublished(event.getId(), Instant.now());
} catch (Exception ex) {
log.error("publish outbox failed, eventId={}", event.getId(), ex);
outboxEventRepository.markRetry(
event.getId(),
event.getRetryCount() + 1,
Instant.now().plusSeconds(backoffSeconds(event.getRetryCount())));
}
}
}
private long backoffSeconds(int retryCount) {
return Math.min(60, 1L << Math.min(retryCount, 6));
}
}5.6 Push consumer (real‑time delivery)
@Slf4j
@Component
@RequiredArgsConstructor
public class MessagePushConsumer {
private final DedupService dedupService;
private final ConversationReadModelCache readModelCache;
private final OnlineRouteService onlineRouteService;
private final WebSocketForwarder webSocketForwarder;
@RabbitListener(queues = ImRabbitConfig.QUEUE_PUSH)
public void onMessage(MessageCreatedPayload payload, Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
String eventKey = "push:" + payload.messageId();
if (!dedupService.tryMark(eventKey, Duration.ofHours(24))) {
channel.basicAck(tag, false);
return;
}
try {
readModelCache.incrementUnread(
payload.receiverId(),
payload.conversationId(),
payload.serverSeq(),
payload.previewText(),
payload.sendTime());
Optional<OnlineRoute> route = onlineRouteService.find(payload.receiverId());
route.ifPresent(r -> webSocketForwarder.forward(r, payload));
channel.basicAck(tag, false);
} catch (Exception ex) {
dedupService.clear(eventKey);
log.error("push consume failed, messageId={}", payload.messageId(), ex);
channel.basicNack(tag, false, false);
}
}
}5.7 ACK service (watermark handling)
@Service
@RequiredArgsConstructor
public class MessageAckService {
private final ConversationAckRepository conversationAckRepository;
private final ConversationReadModelCache conversationReadModelCache;
@Transactional
public void ack(AckCommand command) {
long current = conversationAckRepository.queryAckWatermark(
command.userId(), command.conversationId());
if (command.serverSeq() <= current) {
return;
}
conversationAckRepository.updateAckWatermark(
command.userId(),
command.conversationId(),
command.serverSeq(),
Instant.now());
conversationReadModelCache.recalculateUnread(
command.userId(),
command.conversationId(),
command.serverSeq());
}
}5.8 WebSocket cluster push
public interface WebSocketForwarder {
void forward(OnlineRoute route, MessageCreatedPayload payload);
}
@Component
@RequiredArgsConstructor
public class RedisPubSubWebSocketForwarder implements WebSocketForwarder {
private final LocalSessionManager localSessionManager;
private final StringRedisTemplate stringRedisTemplate;
private final String localNodeId;
@Override
public void forward(OnlineRoute route, MessageCreatedPayload payload) {
if (localNodeId.equals(route.nodeId())) {
localSessionManager.send(route.userId(), route.deviceId(), payload);
return;
}
stringRedisTemplate.convertAndSend(
"im:ws:forward:" + route.nodeId(),
JsonUtils.toJson(new ForwardEnvelope(route.userId(), route.deviceId(), payload)));
}
}6. Production pitfalls and mitigation guides
6.1 Message backlog and consumption bottlenecks
Backlog is often caused by slow operations mixed into the consumer path:
Synchronous user‑setting checks before push.
Image transcoding or rich‑text expansion inside the consumer thread.
Per‑message remote audit logging.
Mixing conversation‑index updates with real‑time push.
Mitigation:
Keep the main consumer thread minimal; off‑load heavy work to async workers.
Separate push and index queues so they do not block each other.
Use prefetch and limited consumer concurrency instead of unbounded threads.
Set alerts on backlog size, consumption latency, dead‑letter rate.
When the push path is overloaded, prioritize message fact durability and pull‑based compensation.
6.2 Duplicate consumption and idempotency
MQ guarantees at‑least‑once delivery, so duplicates arise from outbox retries, broker redelivery, lost ACKs, or crashes after side effects.
Three‑layer idempotency strategy:
Write layer : unique constraint on conversation_id + client_msg_id prevents the sender from creating two different rows.
Event layer : dedup key event_key or message_id stored in Redis prevents the consumer from applying the same side effect twice.
State layer : monotonic fields ( server_seq, ack_watermark) ensure older updates cannot overwrite newer state.
6.3 Ordering and “ghost” messages
Typical ordering anomalies:
Message A sent earlier arrives after Message B.
Old conversation summary overwrites a newer one.
Read watermark is rolled back by a delayed event.
Multiple devices ACK concurrently and a smaller watermark overwrites a larger one.
Solution: always base ordering on the monotonic server_seq and apply max‑merge logic for ACK/read watermarks. Clients reconnect by pulling from the first server_seq they missed.
6.4 MySQL hotspot bottlenecks
Hotspots appear in:
Write‑heavy inboxes of popular users.
Serial sequence allocation per conversation.
Frequent updates of conversation list ordering.
Repeated unread/read writes for hot conversations.
Four‑layer mitigation:
Sharding : partition by receiver or conversation to keep hot rows localized.
Structure : split message detail, cursor, and unread aggregation into separate tables.
Path : async maintain conversation summary and unread count to shorten the critical write path.
Governance : tier traffic (system notifications, marketing, user chat) to prevent one type from starving others.
6.5 Redis memory avalanche
Redis stores massive session indexes and online state, leading to memory pressure and hot‑key risks.
Practical safeguards:
Allow cold‑user session indexes to expire and rebuild lazily from MySQL.
Separate unread hash and conversation summary to avoid a single huge hash.
Limit the number of cached conversations per hot user (e.g., keep only the latest 200‑500).
Add jitter to key TTLs to avoid synchronized expirations.
Monitor online:user:* and routing keys separately; trigger alerts on abnormal churn.
Design the system so that a Redis outage degrades to pull‑based compensation rather than total failure.
7. Evolution roadmap: from single node to cloud‑native
7.1 Stage 1 – single‑node validation
Goal: verify the message‑fact model quickly.
Monolithic Spring Boot service.
Single MySQL instance.
Single Redis instance.
RabbitMQ single cluster.
One WebSocket gateway.
Even at this stage implement client_msg_id idempotency, server_seq ordering, outbox, ACK watermarks, and rebuildable unread state.
7.2 Stage 2 – microservice split & containerization
message‑service: only the write path. push‑service: online delivery. conversation‑service: read model and pull‑based compensation. gateway‑service: connection handling and auth.
Each service remains stateless; scaling can be tuned per resource profile (DB‑bound vs network‑bound).
7.3 Stage 3 – Kubernetes elastic scaling
Stateless services use HPA (CPU, QPS, consumer backlog) for auto‑scaling.
RabbitMQ managed by an Operator with persistent volumes.
WebSocket gateways keep a node‑to‑session map in Redis; scaling must respect connection affinity and graceful draining.
Example HPA snippet (shown in the article) scales the push service between 3 and 20 replicas based on 65 % CPU utilization.
7.4 Stage 4 – multi‑active architecture & analytical off‑load
Message facts stay strongly consistent within a primary region.
Conversation read models are eventually consistent across regions.
Online routing may be temporarily inconsistent; pull‑based fallback guarantees delivery.
Long‑running analytics, moderation, and recommendation pipelines consume a Kafka stream replicated from the primary region.
Clients preferentially read from the primary region; secondary regions act as disaster‑recovery or low‑latency read replicas.
8. Conclusion
The essence of a reliable private‑messaging system is a single, replayable, compensatable state chain:
Persist messages in MySQL.
Use server_seq for per‑conversation ordering.
Outbox guarantees atomic write‑and‑publish.
RabbitMQ handles real‑time distribution.
Redis stores rebuildable hot read models and online routing.
ACK watermarks converge multi‑device state.
Pull‑based compensation covers any online‑push failure.
If any link is missing, typical production failures appear: lost ordering, incorrect unread counts, cache‑driven state divergence after restart, or ghost messages. By completing the chain with the components above, a Spring Boot + RabbitMQ + Redis stack can sustain billion‑scale social chat workloads without sacrificing correctness.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Cloud Architecture
Focuses on cloud‑native and distributed architecture engineering, sharing practical solutions and lessons learned. Covers microservice governance, Kubernetes, observability, and stability engineering to help your systems run stable, fast, and cost‑effectively.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
