MQ Design (Redis Streams)
This document is the design specification for the mq/ module (Redis Streams-based message queue), covering partitioning, consumer groups & rebalance, retry & DLQ, admin & observability. Class names, method names, key names, and defaults are aligned with mq/src/main/java; Redis command-level interactions are in MQ-Broker-Interaction.md.
Goals
- High throughput, low latency; horizontally scalable (partitions + consumer groups).
- Semantics: At-Least-Once, with configurable retry and dead-letter.
- Robust: automatic orphan pending reclaim (
XPENDING + XCLAIM), lease rebalancing, graceful shutdown. - Operable: unified topic registry & metadata, aggregated stats, pluggable metrics.
Concepts
- Topic: logical topic (single external name, e.g.,
orders). - Partition: physical Redis Stream for parallelism, key
{streamKeyPrefix}:{topic}:p:{i}(defaultstream:topic:orders:p:{0..P-1}). - Consumer Group: load-sharing within a group; groups are independent.
- Consumer Instance: a process can lease multiple partitions (cap
maxLeasedPartitionsPerConsumer, 0 =workerThreads). - Lease: Redis string key + TTL implementing partition exclusivity (at most one instance per group consumes a partition at a time).
- DLQ: one dead-letter stream per Topic:
{streamKeyPrefix}:{topic}:dlq.
Architecture
Partitioning
- Parallelism: P partitions processed by multiple instances, total throughput scales with partition count.
- Hotspot isolation: hot keys only block their partition.
- Redis Cluster friendly: partition keys spread across slots.
- Trade-offs: growing partition count changes key→partition mapping (
updatePartitionCountonly allows increase, does not rewrite already-delivered messages); more workers; rebalance is eventually consistent.
Partition Metadata & Registry
{keyPrefix}:topics:registry(Set): all Topics (TopicRegistry, avoidsKEYS/SCAN).{keyPrefix}:topic:{t}:meta(Hash): fieldpartitionCount(TopicPartitionRegistry).{keyPrefix}:topic:{t}:partitions(Set): partition stream keys.- Missing/unparseable meta falls back to partition count = 1.
Producer
- Routing: default
HashPartitioner(String.hashCode() % P, random when key is null);Partitioneris a single-method interface, replaceable (repo currently only has Hash implementation, no RR/consistent-hash). - Forced partition: header
x-force-partition-id(MqHeaders.FORCE_PARTITION_ID) honored byHashBrokerRouterand produce path; DLQ replay relies on it to return to original partition. - Write (
RedisBrokerPersistence.append): first write registers Topic + meta; entry fields serialized to strings, LuaXADD ... MAXLEN = Natomically appends and trims byretentionMaxLenPerPartition(=exact semantics, Redis 7+; older versions fall back to approximateXTRIM+ pagedXDELhard cap). - Broker abstraction:
Broker = BrokerRouter (pick partition) + BrokerPersistence (persist), assembled byBrokerFactory; defaultRedisBrokerFactory, alsoJdbcBrokerFactory(MySQL tablers_messages, implements produceappendonly, reads still use Redis stream semantics).
Consumer Groups & Rebalance
- Semantics: within a group "one partition consumed exclusively by one instance"; one instance can consume multiple partitions.
- Group creation:
subscribeuses Lua for idempotency —XINFO GROUPScheck thenXGROUP CREATE <stream> <group> 0-0 MKSTREAM,BUSYGROUPtreated as exists. Because group is created at0-0, backlog before group creation is also delivered (>means "not delivered to this group"). - Lease (
lease.LeaseManager): acquire withSET NX EX(atomic, crash leaves no keyless lease); renew/release via Lua "value equals my id thenPEXPIRE/DEL", avoids deleting successor's new lease. Key{keyPrefix}:lease:{t}:{g}:{i}. - Reclaim: scheduler thread per
pendingScanIntervalSecrunsXPENDING(RedissonlistPending) on owned partitions, claims entries idle >claimIdleMsviaXCLAIM(Redissonclaim) and replays handler. Implementation does not useXAUTOCLAIM. - Workers: one serial worker per partition (
consumerPool); separate scheduler thread pool (schedulerPool) for rebalance (rebalanceIntervalSec), renew (renewIntervalSec), pending scan, delayed retry mover (retryMoverIntervalSec). - Concurrency cap:
maxInFlight > 0uses semaphore to limit per-instance concurrent handling (MQ-08: unpaired permits on stop not released, avoids permanent quota shrink);maxLeasedPartitionsPerConsumerlimits active partitions per instance. - Subscription-level filtering:
SubscriptionOptions.partitionModulo/partitionRemainderlets an instance only lease partitions wherei % mod == rem % mod.
Retry & DLQ
- At-least-once: failures retry; exhausted retries go to DLQ.
- Strategy interface
retry.DeliveryRetryPolicy(formerlyRetryPolicy, renamed 2026-10-12 to disambiguate from thereliability.RetryPolicyconfig POJO) has two methods:getMaxAttempts()andnextBackoffMs(attempt); built-inExponentialBackoffRetryPolicy(baseMs * 2^(n-1)capped atmaxBackoffMs, shift saturates at 62 to avoid overflow). NoDeadLetterPolicyclass exists in repo; "fail to DLQ" rule is hardcoded in consume path. - Actual retry cap =
min(message.maxRetries, retryPolicy.maxAttempts);MessagedefaultmaxRetries=3(constructor),retryMaxAttemptsdefault 5. - Failure path (
RETRY):- Record
x-original-message-id(retry produces new stream id, original id kept for correlation/dedup). - Backoff ≤ 50ms: direct re-delivery to partition stream with new id.
- Otherwise write retry Hash
{keyPrefix}:retry:item:{t}:{uuid}(fields topic/partitionId/payload/key/headers/retryCount/maxRetries/originalMessageId) + retry ZSET{keyPrefix}:retry:{t}(score=due timestamp). - Mover thread holds
streaming:mq:retry:lock:{t}lock, Lua atomically executes "ZRANGEBYSCORE take due → XADD back to partition → ZREM → DEL Hash", up toretryMoverBatchper round. - Original message ACKed only after requeue succeeds (reverse order loses messages).
- Record
- Terminal failure (
FAIL/DEAD_LETTER/retries exhausted): assembleDeadLetterRecord, write{streamKeyPrefix}:{t}:dlq(fields originalTopic/payload/timestamp/failedAt/retryCount/partitionId/headers/originalMessageId/maxRetries), DLQ write success then ACK; write failure keeps pending for redelivery. - Poison messages (MQ-06): parse failures (corrupt timestamp, missing payload ref, etc.) go straight to DLQ and ACK to avoid infinite re-claim; headers carry
x-payload-missing/x-payload-missing-ref. - Pros: deterministic, observable, avoids hot loops; Cons: retry generates new IDs; delayed retry needs mover task.
ACK & Delete Policy
MqOptions.ackDeletePolicy (default none) executed by DefaultBroker.ack:
none: onlyXACK, entry stays in stream, cleaned by retention.immediate:XACKthenXDEL(single-group consumption only).all-groups-ack:SADDgroup to{keyPrefix}:acks:{t}:p:{i}:{id}(TTLacksetTtlSec); when ack set covers "registered group count" (XINFO GROUPScount, includes stopped groups — MQ-04: registered groups are delivery contract, cannot use live lease count) thenXDELand delete set.
Commit Frontier
- After ACK, Lua atomically maintains
{keyPrefix}:commit:{t}:p:{i}(Hash, field=group, value=max acknowledged stream id, MQ-11): onlyHSETwhen new id (ms-seq compare) > stored value, eliminates concurrent ack worker read-after-write regression; stored value unquoted then compared by ms-seq, non-stream-id legacy values overwritten (self-healing). - Frontier is currently observability/ops info (Admin group stats use
XINFOlast-delivered-id), not used for consumption start decisions.
Large Payload Lifecycle (impl.PayloadLifecycleManager)
- Threshold:
PayloadHeaders.MAX_INLINE_PAYLOAD_SIZE = 64KB; oversize payload JSON stored at{keyPrefix}:payload:{t}:p:{i}:{uuid}, stream carries headerx-payload-hash-ref/x-payload-storage-type=hash. - Primary deletion path is ACK (
deletePayloadHashFromStreamData); index fallbacks: per-Topic Set{keyPrefix}:payload:idx:{t}(cleanupTopicPayloadHashes, called bydeleteTopic) and global time ZSET{keyPrefix}:payload:ts(cleanupOrphanedPayloadHashes). - If
retentionMs > 0configured, payload keys TTL capped at that value; default 0 (no TTL, ACK deletion takes precedence). - DLQ replay: if ref still exists, renew/re-store; if ref lost, mark
x-payload-missingand replay anyway.
Admin & Observability
- Stats:
RedisMessageQueueAdmincross-partition aggregation per topic (length sum, first/lastId aggregate, group count max); lag approximated as per-partitionlastIdvs grouplast-delivered-idtimestamp delta. - Pending details: cross-partition sampling (≤200 per partition) merged & sorted, supports sort by idle/delivery-count/ID with
minIdleMsfilter. - Maintenance:
trimQueue(length, amortized across partitions) /trimQueueByAge(time-based, pagedXDEL, page size system propmq.admin.test.trimAgePageSizedefault 500);deleteTopic(partition streams/DLQ/meta/partition set/load hash + remove from registry);resetConsumerGroupOffset(delete and recreate group at0-0/$/specific id);updatePartitionCount(grow only). - Metrics (
metrics.MqMetricsCollector, default Noop; starter provides Micrometer impl): countersincProduced/incConsumed/incAcked/incRetried/incDeadLetter/incPayloadMissing/incDlqDelete/incDlqClear, gaugessetInFlight/setEligiblePartitions/setLeasedPartitions/setMaxLeasedPartitions, timersrecordHandleLatency/recordBackpressureWait/recordDlqReplay; retention trim viaRetentionMetricsCollector.recordTrim/recordDlqTrim. Labels include topic, partitionId, consumerName, group.
Retention Policy
- Length:
retentionMaxLenPerPartition(default 100000) executed atomically on write path (XADD ... MAXLEN = N). - Time:
retentionMs,dlqRetentionMsand periodtrimIntervalSecconsumed by spring-boot-starter'sStreamRetentionHousekeeperbackground task (XTRIM MAXLEN ~/XTRIM MINID ~); mq module itself does not use these for trimming (retentionMsonly serves as payload TTL ceiling). - DLQ length override
dlqRetentionMaxLen(default 0=disabled) also only consumed by starter housekeeper.
Relation to Kafka
- Semantic correspondence: partitions / exclusive consumption within a group / rebalance / lag aggregation; the coordination mechanism is replaced by Redis keys +
XPENDING+XCLAIM. - Advantages: no external coordinator required; easy to deploy.
- Limitations: leases are eventually consistent; Redis memory and network overhead.
Sequences
Produce
Consume / Retry / Dead Letter
Lease & Reclaim
Redis Commands vs Kafka
The core Redis commands the implementation actually uses, mapped to Kafka features (Redisson already wraps the raw commands; the underlying commands are listed here for comparison and troubleshooting):
Produce (Kafka Producer → send to a partition)
XADD stream:topic:{t}:p:{i} * field value …: appends a message and returns an id of the formtimestamp-seq; with retention enabled it becomesXADD ... MAXLEN = N.- Kafka mapping: producer sends to a Topic Partition (Kafka assigns the offset; Redis generates the id from the stream).
Consume and consumer groups (Kafka Consumer/Group → fetch/commit)
XGROUP CREATE <stream> <group> 0-0 MKSTREAM(bothsubscribeand the Broker read path make this idempotent via Lua, swallowingBUSYGROUP).XREADGROUP GROUP <g> <c> COUNT n BLOCK ms STREAMS <stream> >: reads undelivered entries; corresponds to the Kafka fetch.XACK <stream> <group> <id ...>: acknowledges and removes from the PEL; corresponds to commit.XPENDING <stream> <group>(RedissonlistPending): lists unacked entries (idle, delivery count); Kafka has no direct equivalent and infers it from offsets/monitoring.
Failure recovery and rebalance (Kafka Rebalance/Coordinator → partition exclusivity and orphan takeover)
SET key value NX EX ttl: acquires the partition lease; renew/release runs Lua that compares the owner id beforePEXPIRE/DEL.XCLAIM <stream> <group> <consumer> <min-idle-time> <id>: claims pending entries past the idle timeout (the implementation combineslistPending + claim;XAUTOCLAIMis not used).- Kafka mapping: the group coordinator assigns partitions and heartbeats keep the session alive; after rebalance, unacked messages are taken over.
Delayed retry (Kafka retry topics/backoff → Redis delay bucket + Lua mover)
ZADD streaming:mq:retry:{t} <dueAtMs> <itemKey>: enqueues into the bucket by due time.HSET streaming:mq:retry:item:{t}:{uuid} topic ... partitionId ... payload ... key ... headers ... retryCount ... maxRetries ... originalMessageId ...: multi-field string envelope (payload/headers JSON-encoded; a null payload omits the field).EVAL <lua>: under lock, atomically executesZRANGEBYSCOREfor due items →XADDback to the partition →ZREM→DELHash.- Kafka mapping: retry topic buckets by delay, then re-sends to the main topic.
Dead letters (Kafka DLQ → Redis DLQ stream)
XADD stream:topic:{t}:dlq * ...with fieldsoriginalTopic / partitionId / originalMessageId / retryCount / maxRetries / timestamp / failedAt / headers / payload.- Replay: read with
XRANGE <dlq> <id> <id>, thenXADDback to the original partition bypartitionId(orx-force-partition-id); replay does not auto-XDELDLQ entries.
Topic governance (Kafka topic management/retention/delete → Redis management)
XINFO STREAM / XINFO GROUPS(RedissongetInfo/listGroups): stream and group stats.XTRIM MAXLEN = N(write path, exact) /XTRIM MAXLEN ~ N,XTRIM MINID <id>(periodic, starter housekeeper) / pagedXDEL(trimQueueByAge, hard-cap fallback).DEL <key ...>:deleteTopicremoves partition streams/DLQ/meta etc. (destructive — data loss).
| Redis Command | Kafka Mapping |
|---|---|
XADD | Producer → partition |
XREADGROUP / XACK | fetch / commit |
XPENDING | in-flight (Kafka infers from lag/monitoring) |
Lease + XPENDING+XCLAIM | Group coordinator / rebalance |
ZADD+Hash + Lua mover | Retry topic / delayed replay |
stream:topic:{t}:dlq | DLQ topic |
XADD ... MAXLEN = / XTRIM | retention |
Version: 0.2.0 Last Updated: 2026-10-05
References:
- Redis Streams: XADD / XREADGROUP / XACK / XPENDING / XCLAIM / XTRIM / XDEL
- Kafka: Partition & Consumer Group design
- Command-level interactions & sequence diagrams: MQ-Broker-Interaction.md
- Full configuration table: MQ.md