Spring Cloud Stream: Binder Abstraction, Partitioning, and Config Pitfalls
This article analyzes Spring Cloud Stream's binder abstraction, partitioning mechanics, and configuration pitfalls through real incidents, source code insights, and production guidelines, revealing why 'swap MQ without code changes' only half-works and how to avoid silent failures in message-driven systems.
1. Three Real Incidents
Incident 1: Local RabbitMQ, production Kafka, code unchanged, consumption chaos after launch. Two developers locally each received a copy of every message (broadcast), convenient for debugging. In production, same-group instances competed for partitions; when one instance restarted, its partitions had no consumer, backlog instantly spiked to 100k+. Root cause: Rabbit's exchange broadcasts to all bound queues, while Kafka gives exclusive partition ownership within a group — the abstraction layer only unified "send" and "receive" actions, but consumption semantics differences were not erased.
Incident 2: Partition key configured for ordering, still out-of-order. partitionKeyExpression was correct, same order went to same partition, yet downstream saw "ship before pay". Reason: concurrency set to 6. Partitions only guarantee broker-layer write ordering; when a single partition is consumed by 6 threads in parallel, application-layer ordering is lost. Partitioning ensures "delivery order", not "processing order".
Incident 3: Consumption failure infinite retry, 40GB logs in one day. maxAttempts clearly set to 3, yet logs showed same message processed tens of thousands of times. Failure occurred during deserialization — message format changed, JSON parsing threw exception before entering listener, retry logic never reached it, framework could only re-deliver, creating a death loop.
Three incidents map to three core issues: abstraction boundary (incident 1), real ordering conditions (incident 2), error layer location (incident 3). Without understanding these, copying configs will still cause accidents.
2. Technical Analysis: Three-Layer Model and One Abstraction Boundary
2.1 Three-Layer Structure
Application Layer
Supplier / Function / Consumer
Abstraction Layer
Binding / Binder
Physical Layer
Kafka Topic / Rabbit Queue Destinationis a logical name ; Binder maps it to physical resources. So destination=order-created is a topic in Kafka, but an exchange plus queues in Rabbit. Name is logical, behavior is physical — all pits stem from this sentence.
2.2 What Is Abstracted, What Is Not
<code>Abstracted:
Connection management / Serialization
Health checks / Retry skeleton
Consumer groups / Concurrency containers
Not abstracted:
Ordering semantics / Transactions
Delayed queues / Priority
Message size limits
Dead-letter exact formatJudge if a feature can be "cross-MQ universal": check if it is a vendor-unique concept . Delayed queues, priority, transactional messages are vendor-specific; abstraction layer either doesn't support them or only works for a specific binder. Once code uses them, the service is bound to that MQ — exactly what Stream tries to avoid.
2.3 Functional Model: Three Functions = Entire Topology
Supplier<Order> scheduled emit (polling)
Function<A, B> receive one, emit one
Consumer<A> receive one, endSince 4.x functional style recommended; annotation style ( @StreamListener / @EnableBinding) deprecated. Benefit: topology becomes declarative : spring.cloud.function.definition=orderCreated;consumeOrder decides which bindings start. Cost: debugging "which bean connects to which topic" becomes non-intuitive — binding names are derived: <functionName>-in-0, <functionName>-out-0.
A common confusion: Function with both input and output creates two bindings. If you only want consumption without emitting return value, return void and use Consumer, or discard return value — otherwise a superfluous outbound binding quietly creates a topic nobody consumes.
2.4 Four Concepts, One Most Easily Forgotten
Destination logical target name
Group consumer group
decides competing vs broadcast
Partition partition
decides ordering and parallelism
Binder physical implementationAmong these, group is most easily forgotten and most fatal . Without group, each instance gets its own anonymous group, so all instances receive full messages — you think you're doing cluster consumption, actually doing broadcast, downstream written N times, and because "every message processed successfully", monitoring shows zero anomaly.
2.5 When Not to Use It
Not suitable:
Only one or two topics
Need delayed queue / priority
Need fine-grained partition assignment control
Need transactional messages
Suitable:
Multiple services, multiple topics
Need unified error handling and monitoring
May switch MQ in futureAbstraction has cost: extra binding layer, extra config set, extra "which bean connects where" confusion. If whole system has only one topic, one consumer, directly using KafkaTemplate + @KafkaListener is simpler — half the config, half the stack trace when issues arise. Similarly, if core need is delayed queue (e.g., "cancel if unpaid in 30 min"), Rabbit's TTL+DLX is native; Stream's abstraction has almost no expression for it — forcing it just adds detours, eventually falling back to native API.
2.6 Dependencies: Starter and Binder Are Two Things
spring-cloud-starter-stream-kafka
spring-cloud-starter-stream-rabbitStarter already includes corresponding binder implementation and auto-configuration; usually no need to add binder separately. But with multiple binders coexisting, a pit: without explicit spring.cloud.stream.default-binder, framework may pick one by classpath order — you tune Kafka config but Rabbit binder actually runs.
3. Core Flow: From Startup to Ack
3.1 Four Startup Steps
① BinderFactory selects binder per config
② Parse definition to generate bindings
③ bindProducer / bindConsumer
④ Consumer joins group, triggers rebalanceStep ④ rebalance is a production jitter hotspot: instance comes online, all consumers pause, reassign partitions, entire group stops, messages pile up. During rolling deploy, if many instances and few partitions, rebalance triggers repeatedly , showing as "deploy half hour, consumption nearly stalled" — after deploy completes it instantly recovers, easily misjudged as network issue.
3.2 Full Produce-to-Consume Path
Supplier -> Message<Order>
-> Serialization (usually JSON)
-> binder.send(dest, key, payload)
-> topic / queue
-> poll -> Deserialization
-> Consumer / Function
-> ackNote key position: it's in message headers , not payload. Partition key travels via headers, so changing partition key doesn't require changing message body — but requires all producers to update; half-updated means same order splits across partitions, ordering guarantee void, with zero errors.
3.3 Three Ack Modes
AUTO commit after method returns
throw exception -> no commit -> redeliver
MANUAL get Acknowledgment yourself
catch exception yet ack = tell framework "success"
NONE commit on receipt
easiest way to lose messagesTwo binders use different keys: Rabbit uses acknowledgeMode (AUTO/MANUAL/NONE), Kafka uses ackMode (RECORD/BATCH/TIME/MANUAL/MANUAL_IMMEDIATE). Names differ, semantics similar, but NONE on both means "may lose" — using it on order/payment flows is planting a landmine.
3.4 Three Ordering Conditions, None Missing
① Same key into same partition
partition-key-expression
② Same partition held by single consumer
same group + partition assignment
③ That consumer single-threaded
concurrency = 1Most overlooked is third condition. Devs configure partition key, see "partition 6, concurrency 6" and think perfect, but concurrency 6 means 6 threads grabbing messages from same partition , whoever finishes first takes next, ordering completely gone. To keep order you must sacrifice parallelism on that chain; no third way.
3.5 Real Path from Exception to Dead Letter
listener throws exception
-> local retry maxAttempts times
interval per backOff
-> still fails -> deliver to DLQ
-> manual / scheduled job handlesThis chain has a premise: exception must occur inside listener . Deserialization failure, oversized message, illegal header — these throw before entering listener, retry config completely ineffective, framework only re-delivers — that's the whole truth of incident 3.
3.6 Message Conversion: Classic contentType Mismatch Error
Producer
contentType: application/json
-> convert to byte[] write
Consumer
select converter by contentType
mismatch -> MessageConversionExceptionThis error almost everyone sees once: Cannot convert from [application/json] to .... Root cause usually both ends' contentType inconsistent — producer set json, consumer method signature is String, framework doesn't know how to convert. Explicitly declare content-type: application/json and keep both ends' method signatures consistent saves far more time than "guess why conversion fails".
Custom headers also have a pit: default only passes whitelisted headers; to carry business headers (traceId, tenantId) must explicitly configure headerMode: headers or spring.cloud.stream.default.header-mode. Unconfigured, headers silently dropped while message delivers normally — another "no error but no effect".
4. Core Source Code: Abstraction Layer Actually Only Two Methods
4.1 Binder Interface
public interface Binder<T,
C extends ConsumerProperties,
P extends ProducerProperties> {
Binding<C> bindConsumer(String name,
String group, T inboundBindTarget,
C consumerProperties);
Binding<P> bindProducer(String name,
T outboundBindTarget,
P producerProperties);
}The entire abstraction layer is just these two methods. It can only abstract "the binding action" — how messages flow after binding, how failures handled, how ordering guaranteed, all delegated to concrete binder. So "seamless cross-MQ migration" was never promised by this interface; it only promises "consistent invocation style".
4.2 Auto-Create Topic: Convenient, But Must Disable in Production
Kafka binder checks topic existence during bindProducer, creates if missing, controlled by autoCreateTopics and broker's auto.create.topics.enable dual control. Production must disable both .
Reason: a typo in destination silently creates new topic, producer sends normally, consumer waits on another topic, both logs clean, only business data mismatches. Such issues can burn a whole day; disabling auto-create makes it fail fast with "topic not exist".
4.3 Where concurrency Actually Lands
consumer.concurrency = 3
-> 3 concurrent containers
-> each a listener
-> partitions assigned by group
-> one partition to one consumerDirect inference: concurrency exceeding partition count is meaningless , extra threads get no partitions, idle; conversely partitions far exceed concurrency, partitions queue, scaling adds rebalances. These two numbers must be planned together, not tuned separately — hence the accounting in 5.3.
4.4 Source of Those Dozens of Kafka Parameters
spring.cloud.stream.kafka.binder
.configuration.max.request.size
.configuration.fetch.max.bytesKeys under configuration.* are passed verbatim to Kafka client , a fallback channel: any parameter binder hasn't wrapped can be shoved here. Cost: typos don't error — fetch.max.byte missing 's', config silently ineffective, you think it works, load test "validates" it.
Correct usage: first use binder's explicit properties; only when truly absent use configuration, and document used keys in config center — because IDE can't autocomplete, no one spell-checks in code review.
4.5 Which Segment Error Handler Covers
Default DefaultErrorHandler handles "retry N times after listener throws, then give up", with DeadLetterPublishingRecoverer publishing to dead-letter topic.
But it does not cover deserialization exceptions — that's ErrorHandlingDeserializer 's job. To route deserialization failures to DLQ, wrap deserializer with ErrorHandlingDeserializer and configure failedDeserializationInfo; otherwise exception surfaces earlier, bypassing all retry and DLQ logic.
4.6 Send Failure: Timeout != Not Written
send() is async
future timeout
-> message may have been written
-> or may not
-> indistinguishableClassic distributed dilemma: sender gets no definite answer, only choose "resend" (may duplicate) or "don't resend" (may lose). Correct approach: make it idempotent — send with business unique key, consumer idempotent, resend won't cause duplicate business. Not obsessing "did it actually send", that question never has answer.
Also distinguish two failures: pre-send failures (serialization error, oversized, topic missing) are definite failures, retry useless; mid-send failures (network timeout, broker failover) need retry. Many implementations mix both, retrying a message that will never succeed until end of time.
5. Production Config: Partition Is the Trouble Zone
5.1 Main Config
spring:
cloud:
function:
definition: orderCreated;consumeOrder
stream:
bindings:
orderCreated-out-0:
destination: order-created
consumeOrder-in-0:
destination: order-created
group: order-service
consumer:
concurrency: 2
max-attempts: 3
kafka:
binder:
brokers: kafka-0:9092
auto-create-topics: falseThree details: group must be explicit , missing = anonymous group = each instance independent consumer; concurrency 2 with partition 6 means 2 threads share 6 partitions — only when instances × concurrency ≤ partitions is load balanced; auto-create-topics off , turns typos into startup failures not data black holes.
5.2 Partition Requires Five Items, Head and Tail Both Needed
Producer
partition-key-expression
payload.orderId
partition-count
6
Consumer
consumer.partitioned
true
instance-index
${INDEX:0}
instance-count
${COUNT:1}Easiest to miss: consumer's partitioned: true. Producer configured partition, consumer didn't declare, binder won't assign partitions by instance, all instances grab same batch — ordering guarantee silently fails on this config , no error. In K8s instance-index usually takes StatefulSet ordinal; Deployment has no stable ordinal, must rely on partition assignment strategy not manual.
5.3 Partition Count vs Concurrency Accounting
Partition count = target throughput / single partition throughput
Concurrency <= partition count
Instances × concurrency <= partition countExample: target 6000 TPS, single partition 1000 TPS, at least 6 partitions; 3 instances, each concurrency 2, exactly 6, full load. Add one more instance, one completely idle — because partitions can't be split further.
This is root cause of "scale instances no throughput gain": you scale consumers, not partitions. To increase throughput only two paths — add partitions (scrambles key mapping, see 6.5) or optimize per-message latency. Identify which first, then talk scaling.
5.4 Message Size: Three Limits Must Align
max.request.size producer single message limit
fetch.max.bytes consumer single fetch limit
message.max.bytes broker limitAll three must be tuned together; any one below actual message size causes reject or stall. But more importantly: messages over 1MB shouldn't go through MQ . They consume bandwidth, block entire partition (one large message stalls dozens of small ones behind), retry cost extreme. Common pattern: message carries only ID + key fields, large content in object storage, consumer pulls on demand.
5.5 Two Poll Parameters Decide If You Get Kicked Out of Group
max.poll.records 500
single fetch count
larger -> higher throughput
but longer single batch processing
max.poll.interval.ms 300000
max interval between two polls
timeout -> kick out group
-> rebalanceRelationship: max.poll.records × single processing time must be less than max.poll.interval.ms . Default 500 records, 5 minutes looks loose, but if single message takes 1 second, batch = 500 seconds — directly timeout kicked.
Kick signature distinct: logs show repeated rebalance, messages re-consumed (pre-commit not committed), lag fluctuates, but consumption rate not slow. Many first think "too many messages", actual is "batch too large to finish" , fix is reduce max.poll.records, not add instances.
5.6 Config Has Four Levels, Wrong Level = No Effect
stream.default.*
global defaults, cross-binder abstract items
stream.bindings.x.consumer.*
single binding consumer params
stream.kafka.bindings.x.*
binder-specific params (ackMode etc.)
stream.kafka.binder.*
binder global (brokers etc.)Override order: "more specific wins", but trap: default layer only recognizes abstract-layer keys . Putting Kafka's ackMode in default won't work because it's not an abstract concept; must go under kafka.bindings.x.consumer.
So troubleshooting "config changed not effective", first step isn't check value, but check which level the key sits at . Misplaced config doesn't error, doesn't alert, just quietly does nothing — this config system's most maddening trait.
6. Six Advanced Topics
6.1 Ordering: Partition != Ordering
Section 3.4 gave three conditions; here's a more hidden destroyer — retry . One message fails, retries three times, subsequent messages in same partition blocked; if earlier message already acked when retry re-delivers, retried message goes to end, ordering still broken.
Thus ordering chain only two self-consistent strategies: fail-stop (block partition, wait human, cost availability) or fail-skip (send to DLQ, sacrifice this message, preserve ordering). "Both retry and strict ordering" impossible in distributed systems, only "eventual consistency".
6.2 Four Reasons Retry Configured But Ineffective
① Exception in deserialization phase
not yet in listener
② Throwing Error not Exception
default only retries Exception
③ MANUAL ack catch exception
then manual ack = success
④ Group missing or typo
binding not built right, retry strategy voidThird most ironic: to "not block on failure", catch and ack, framework thinks success, message dropped, retry and DLQ never trigger — seemingly most robust code is actually most thorough message loss .
6.3 Dead Letter: Two Radically Different Approaches
Rabbit DLX + bound queue
native support
auto carries x-death header
can see retry count
Kafka independent topic
convention name xxx.DLT
binder uses enableDlq
essentially "send another"Kafka has no native dead-letter concept; binder simulates with "send another to DLT". Benefit: uniform behavior, programmable; drawback: dead-letter topic also needs consumer and alerting , otherwise just a more hidden black hole — messages exist but nobody knows.
6.4 What Actually Changes When Switching MQ
Must change:
Connection config / Serialization format
Partition and concurrency params
Dead-letter config
May need change:
Transaction boundaries (Kafka strong)
Delayed / Priority (vendor unique)
Message size limits
No change:
Function signatures / Business logicSo "swap MQ no code change" accurately: business code unchanged, config and ops model redo . Real migration typically 2-6 weeks, half time spent load-testing partition and concurrency — because these two numbers directly decide throughput and ordering, and behave completely differently on two MQs.
6.5 Backlog and Scaling: Adding Partitions Has Cost
Kafka only adds partitions, cannot reduce, and adding partitions scrambles existing key-to-partition mapping : originally hash(key) % 6, after becomes % 8, same order may land different partition. Ordering guarantee breaks at scaling moment, subtly — only messages crossing scaling boundary for same key go out of order.
Safer: provision enough partitions upfront , calculate for next year's peak, better idle partitions early. Kafka per-partition overhead tiny, extra few far cheaper than later scaling. If must scale, pick low-traffic, accept "no cross-partition ordering guarantee across scaling".
6.6 At-Least-Once Is Default, Idempotency DIY
All three binders default to at-least-once: message at least once, may duplicate . Ack loss, rebalance, retry all cause redelivery. So idempotency not optional, mandatory.
Unique index DB fallback
most reliable, but know conflicts
State machine only allow legal transitions
paid cannot pay again
Dedup table record message ID
need expiry cleanupDon't expect "configure exactly-once". Kafka transactional producer only works in specific consume-process-produce loop, and performance halves; cross-DB, cross-HTTP it helps zero. Idempotency ultimately lands on business unique key .
6.7 One Topic, Multiple Consumer Groups
topic order-created
group order-service
post-order processing -> full
group search-index
update search index -> full
group data-warehouse
warehouse ingest -> fullDifferent groups each get full copy — standard "one event multiple consumers"; same group competes. Mixing these two has concrete consequences : new downstream reuses original service's group, two services steal each other's messages, each processes half — data neither complete nor correct, both sides no error.
Practice: group name includes service name , not business word like "order". Service name naturally unique, instantly shows who consumes; business word looks elegant but when second consumer appears, you hesitate "reuse or new", that hesitation often starts accident.
6.8 Dual-Write During Migration
Phase 1 Dual-write
old MQ + new MQ both send
consumers still read old
Phase 2 Dual-read verify
new consumer shadow runs
compare both processing results
Phase 3 Cut read
new consumer promoted
old channel kept one weekDual-write most underestimated: ordering . Two channels delivery timing inconsistent; if business order-sensitive, shadow phase sees many "seemingly out-of-order" alerts, actually just different latencies. Fix: compare final state only, not intermediate process.
Another pit: idempotency broken by dual-write : same business message from two channels each once; if idempotency key is "message ID", two channels have different IDs, idempotency fails. Idempotency key must be business key, not message ID — especially fatal during migration.
7. Eight High-Frequency Pits
① No group configured
full broadcast, duplicate processing
② Producer configured partition
consumer didn't enable partitioned
③ concurrency > partition count
threads idle, waste resources
④ maxAttempts set
deserialization failure ineffective
⑤ auto-create enabled
typo silently creates new topic
⑥ configuration typo
no error, silently ineffective
⑦ Adding partition scrambles key mapping
ordering breaks across scaling point
⑧ NONE ack / catch then ack
most thorough message loss codeAmong these, ①②③ are "config combo wrong" — each alone correct, together cancel out; ⑤⑥ are "silent failures", hardest to debug because all metrics normal; ⑦⑧ are "invisible normally, data disaster when incident".
8. Three Troubleshooting Axes
① Check bindings
/actuator/bindings
destination / group / state
any running?
② Check lag
kafka-consumer-groups --describe
lag growing = consumption can't keep up
lag not growing but no consumption
= deserialization or dead loop
③ Check dead letters
DLQ piling -> business exception
DLQ empty but messages lost
-> ack too early or NONESecond axis fork especially useful: lag growing means consumption rate insufficient (add partitions or optimize latency); lag not growing but no business data means messages never processed (deserialization fail, binding not built, group typo). These two phenomena require opposite fixes, confusing them makes debugging worse.
9. Learning Path and Interview Questions
9.1 Four Stages
Beginner (1 day): Write a Consumer and Supplier, understand mapping among definition, binding name, destination, see running in actuator.
Intermediate (1 week): Configure partitioning once: producer specifies key, consumer enables partitioned, use two instances verify "same key only one instance"; then set concurrency > partitions, observe idle.
Advanced (2 weeks): Read four source sections — Binder interface, Kafka binder topic creation, concurrent container partition assignment, DefaultErrorHandler retry and recovery; clarify "why deserialization exception doesn't enter retry".
Practice (ongoing): Run one chaos drill: induce deserialization failure, induce consumer hang, verify DLQ delivery, alert trigger, lag curve as expected; re-calculate partition vs concurrency accounting.
9.2 Twelve Interview Questions (with Summary Answers)
Q1 · What problem does Spring Cloud Stream solve?
Answer: Abstracts "using MQ" into binding: app only declares Supplier/Function/Consumer and logical destination, concrete Kafka or Rabbit decided by binder. Purpose: decouple business logic from MQ implementation, swap MQ without changing business logic. Note it decouples invocation style, not semantics.
Q2 · Where is Binder abstraction boundary?
Answer: Entire Binder interface only two methods: bindConsumer and bindProducer, abstracts only "binding" action. Post-binding message flow, failure handling, ordering guarantees all decided by concrete binder. So ordering semantics, transactions, delayed queues, priority — vendor-specific features — abstraction either unsupported or only works for specific binder.
Q3 · What happens if group not configured?
Answer: Each instance gets own anonymous consumer group, all instances receive full messages, becomes broadcast not cluster consumption. Symptom: downstream processed N times, but each "success" so monitoring sees no anomaly. Competing consumption scenarios must explicitly configure group.
Q4 · Does configuring partition key guarantee ordering?
Answer: No. Partition only guarantees "same key same partition, partition write ordered". To become "processing ordered" needs two more: same partition held by single consumer (same group + correct assignment), and that consumer single-threaded (concurrency=1). Concurrency >1 means multiple threads grab from same partition, ordering instantly gone.
Q5 · Relationship between concurrency and partition count?
Answer: Concurrency is consumer threads per instance, cannot exceed partition count — excess threads get no partitions, idle. Planning must satisfy "instances × concurrency ≤ partitions", otherwise some consumers always idle, manifests as "scale instances no throughput gain".
Q6 · Why maxAttempts set but infinite retry?
Answer: Exception occurs in deserialization phase, before listener, retry and DLQ logic unreachable, framework only re-delivers. Fix: wrap with ErrorHandlingDeserializer so deserialization failure also enters error channel, otherwise infinite loop, logs explode.
Q7 · How is dead-letter queue done on Kafka?
Answer: Kafka has no native DLQ; Spring Cloud Stream Kafka binder simulates with "send another to agreed DLT topic", enable enableDlq and set dlqName . Rabbit uses native DLX + bound queue, message carries x-death header, shows retry count.
Q8 · Why does rolling deploy cause consumption backlog?
Answer: New instance joining group triggers rebalance, all consumers pause, reassign partitions, whole group briefly stops. Many instances, few partitions -> rebalance repeatedly triggered, stall prolonged. Mitigation: static membership, control deploy pace, or partitions far exceed instances.
Q9 · What needs changing when switching MQ?
Answer: Business code (function signatures, logic) unchanged; must change connection config, serialization format, partition/concurrency params, DLQ config; may need change transaction boundaries, delayed/priority vendor-unique features, message size limits. Real migration time spent mostly load-testing partition and concurrency, not code changes.
Q10 · How to achieve no-duplicates with at-least-once?
Answer: Framework layer can't; only business-layer idempotency: DB unique index (most reliable), state machine restrict illegal transitions, dedup table record message ID with expiry cleanup. Don't expect exactly-once; Kafka transactions only work in consume-process-produce loop, cross-DB/HTTP useless.
Q11 · What to do with oversized messages?
Answer: First admit they shouldn't go through MQ: large messages hog bandwidth, block partition, retry cost high. Pattern: message carries only ID + key fields, large content in object storage, consumer pulls on demand. If must transmit, raise max.request.size , fetch.max.bytes , broker message.max.bytes together.
Q12 · How to troubleshoot and scale consumption backlog?
Answer: First check lag: lag growing = consumption rate insufficient, check per-message latency and concurrency saturation; lag not growing but no business data = messages not processed (deserialization fail, binding not built). Scaling must consider partitions — only add partitions, can't reduce, and adding scrambles key mapping, so better provision enough upfront.
9.3 One-Page Cheat Sheet
Three layers
App functions -> Binding -> Binder -> MQ
Name logical, behavior physical
Abstraction boundary
Abstracted: connection/serialization/retry skeleton
Not abstracted: ordering/transactions/delayed/priority
Using vendor-unique = locked in
Four concepts
Destination / Group
Partition / Binder
No group = full broadcast
Ordering three conditions
Same key same partition
Same partition one consumer
That consumer single-threaded
Miss one = chaos, no third way
Partition accounting
Concurrency <= partitions
Instances × concurrency <= partitions
Scaling scrambles key mapping
Retry
Only covers listener-internal exceptions
Deserialization not in retry -> death loop
Catch then ack = message loss
Semantics
Default at-least-once
Idempotency only business can doSummary: Spring Cloud Stream is "interface-layer abstraction", not "semantic-layer abstraction". It lets you swap MQ without rewriting business, but doesn't give same ordering guarantees, same failure behaviors, same ops model. Correct posture: treat differences as config to manage, not as transparency to assume — partition, concurrency, ack, DLQ these four param groups are explicit expression of two MQs' differences.
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.
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.
