Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

rabbitmq pattern

Tung Bui Thanh (TungDaDev)18 mins read

Core pattern.webp

Với microservices, tách các service khỏi nhau (decoupling) là nguyên tắc quan trọng. Hệ thống lớn dần thì gọi đồng bộ qua REST hay gRPC bắt đầu lộ điểm yếu: service này chậm kéo service kia chậm theo, một service chết là cả chuỗi gọi lỗi dây chuyền. Lúc đó Event-Driven Architecture (EDA) phát huy tác dụng, và RabbitMQ là một message broker lâu đời, đáng tin để làm việc này.

Bài này không dừng ở mức "Hello World". Mình sẽ đi qua các pattern hay dùng, cách cấu hình Spring AMQP cho production, và mấy bài toán khó hơn: giữ dữ liệu nhất quán, xử lý message trùng, và Saga cho distributed transaction.

# exchange types

Trung tâm của RabbitMQ là exchange chứ không phải queue. Producer không gửi thẳng vào queue mà gửi vào exchange, exchange dựa vào routing key và binding để quyết định message đi tới queue nào. Chọn đúng loại exchange quyết định việc routing linh hoạt tới đâu.

TypeRouting LogicUse Case
DirectExact routing key matchPoint-to-point, task queue
TopicWildcard routing key (* = 1 word, # = 0+ words)Selective subscription (vd: order.* để nhận mọi event về order).
FanoutBroadcast to all bound queuesNotifications, events
HeadersMatch on message headersComplex routing rules
// Direct exchange — route by exact key
@Bean
public DirectExchange orderExchange() {
   return new DirectExchange("order.exchange");
}
 
@Bean
public Queue orderCreatedQueue() {
   return QueueBuilder.durable("order.created.queue")
       .withArgument("x-dead-letter-exchange", "dlx.exchange")
       .withArgument("x-dead-letter-routing-key", "order.created.dlq")
       .build();
}
 
@Bean
public Binding orderBinding() {
   return BindingBuilder.bind(orderCreatedQueue())
       .to(orderExchange())
       .with("order.created"); // routing key
}
 
// Topic exchange — wildcard routing
@Bean
public TopicExchange eventExchange() {
   return new TopicExchange("event.exchange");
}
 
// Binds to "order.*" → matches order.created, order.cancelled, etc.
@Bean
public Binding auditBinding() {
   return BindingBuilder.bind(auditQueue())
       .to(eventExchange())
       .with("order.*");
}
 
// Fanout — all consumers receive
@Bean
public FanoutExchange notificationExchange() {
   return new FanoutExchange("notification.exchange");
}

Với topic exchange, các "word" trong routing key ngăn cách bằng dấu chấm. order.* khớp order.created nhưng không khớp order.item.added (hai word sau order); muốn bắt hết thì dùng order.#.

# producer patterns

Lỗi hay gặp khi làm việc với message broker là kiểu fire-and-forget: bắn đi rồi quên, không có cơ chế xác nhận. Ở môi trường tải cao, mạng chập chờn hoặc broker quá tải có thể làm message "bốc hơi" mà không ai hay.

# basic publish

@Service
@RequiredArgsConstructor
public class OrderEventProducer {
   private final RabbitTemplate rabbitTemplate;
 
   public void publishOrderCreated(Order order) {
       OrderCreatedEvent event = OrderCreatedEvent.builder()
           .orderId(order.getId())
           .timestamp(Instant.now())
           .build();
 
       rabbitTemplate.convertAndSend(
           "order.exchange",      // exchange
           "order.created",       // routing key
           event                  // message (auto-serialized via MessageConverter)
       );
   }
}

# with correlation and headers

public void publishWithHeaders(Object payload, String traceId) {
   rabbitTemplate.convertAndSend("exchange", "routing.key", payload, message -> {
       MessageProperties props = message.getMessageProperties();
       props.setCorrelationId(traceId);
       props.setHeader("X-Source-Service", "order-service");
       props.setContentType("application/json");
       props.setDeliveryMode(MessageDeliveryMode.PERSISTENT);
       return message;
   });
}

PERSISTENT là mặc định của Spring AMQP, ghi ra cho rõ ý. Message persistent chỉ sống sót qua lúc broker restart khi nằm trong queue durable; queue không durable thì restart là mất cả queue lẫn message.

# publisher confirms (reliability)

Muốn chắc message tới nơi thì bật hai thứ: Publisher Confirms (broker báo lại đã nhận và lưu message) và Publisher Returns (broker trả message về khi không route được tới queue nào).

// Kích hoạt trong application.yml
// spring.rabbitmq.publisher-confirm-type: correlated
// spring.rabbitmq.publisher-returns: true
 
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory factory) {
   RabbitTemplate template = new RabbitTemplate(factory);
   // Bắt buộc khi tự tạo RabbitTemplate: thiếu mandatory thì broker lặng lẽ
   // bỏ message không route được, ReturnsCallback không bao giờ được gọi
   template.setMandatory(true);
 
   // Xác nhận từ Broker
   template.setConfirmCallback((correlationData, ack, cause) -> {
       if (!ack) {
           log.error("Cảnh báo: Message chưa được confirm bởi Broker. Lý do: {}", cause);
           // Thực thi logic Retry hoặc lưu vào Database (Outbox/Failed Events table)
       }
   });
 
   // Bắt lỗi Unroutable messages
   template.setReturnsCallback(returned -> {
       log.error("Message bị trả về: routingKey={}, replyText={}",
           returned.getRoutingKey(), returned.getReplyText());
   });
   return template;
}

Mấy điều nên biết về confirm. ack = true chỉ có nghĩa broker đã nhận message vào exchange, không có nghĩa nó tới được queue nào; message không route được vẫn được ack, nên phải có returns mới biết. Confirm chạy bất đồng bộ: convertAndSend trả về ngay, callback tới sau. Muốn biết callback ứng với message nào thì gửi kèm CorrelationData (ví dụ chứa ID của event) qua overload convertAndSend(exchange, routingKey, payload, correlationData); không truyền thì correlationData trong callback là null. Còn khi Spring Boot tự tạo RabbitTemplate, nó tự bật mandatory theo publisher-returns; tự khai báo bean như trên thì phải nhớ gọi setMandatory(true).

# consumer patterns

Cách cấu hình consumer quyết định hệ thống xử lý nhanh tới đâu, và có mất message khi app crash đột ngột hay không.

# @RabbitListener

@Component
@Slf4j
public class OrderConsumer {
 
   // Simple consumer
   @RabbitListener(queues = "order.created.queue")
   public void handleOrderCreated(OrderCreatedEvent event) {
       log.info("Processing order: {}", event.getOrderId());
       processOrder(event);
   }
 
   // With message metadata
   @RabbitListener(queues = "order.created.queue")
   public void handleWithMetadata(OrderCreatedEvent event,
                                  @Header(AmqpHeaders.DELIVERY_TAG) long tag,
                                  @Header(AmqpHeaders.CORRELATION_ID) String correlationId,
                                  Channel channel) {
       log.info("[traceId={}] Processing order: {}", correlationId, event.getOrderId());
       processOrder(event);
   }
 
   // Batch consumer
   @RabbitListener(queues = "batch.queue", containerFactory = "batchListenerFactory")
   public void handleBatch(List<OrderCreatedEvent> events) {
       log.info("Processing batch of {} events", events.size());
       events.forEach(this::processOrder);
   }
}

Hai listener đầu được viết để so sánh cách nhận tham số. Để cả hai cùng nghe order.created.queue trong app thật thì chúng sẽ chia nhau message (mỗi message chỉ tới một consumer), chứ không phải mỗi listener nhận một bản. Header correlationId khai báo như trên là bắt buộc, message không có header này sẽ lỗi; thêm required = false nếu không chắc producer luôn gửi. Batch listener cần container factory bật setBatchListener(true).

# manual acknowledgment

Mặc định (AcknowledgeMode.AUTO), Spring ack sau khi method listener chạy xong không lỗi, còn nếu method ném exception thì message bị reject và mặc định được requeue. Cách này an toàn với crash, nhưng một message lỗi vĩnh viễn (poison message) sẽ bị requeue rồi xử lý lại liên tục. Manual ack cho bạn tự quyết từng trường hợp: ack, requeue, hay đẩy xuống DLQ.

@Bean
public SimpleRabbitListenerContainerFactory manualAckFactory(ConnectionFactory factory) {
   SimpleRabbitListenerContainerFactory f = new SimpleRabbitListenerContainerFactory();
   f.setConnectionFactory(factory);
   f.setAcknowledgeMode(AcknowledgeMode.MANUAL); // Kiểm soát hoàn toàn vòng đời message
   f.setPrefetchCount(10); // Không lấy quá 10 messages cùng lúc để tránh ngộp memory
   return f;
}
 
@RabbitListener(queues = "critical.queue", containerFactory = "manualAckFactory", concurrency = "5-20")
public void handleCriticalTask(Message message, Channel channel) throws IOException {
   long deliveryTag = message.getMessageProperties().getDeliveryTag();
   try {
       // Thực thi Business Logic
       processData(message);
 
       // Xác nhận thành công
       channel.basicAck(deliveryTag, false);
   } catch (RetryableBusinessException e) {
       // Lỗi có thể thử lại -> Requeue
       channel.basicNack(deliveryTag, false, true);
   } catch (Exception e) {
       // Lỗi nghiêm trọng (NPE, Invalid Data) -> Reject, đẩy thẳng xuống DLQ
       channel.basicNack(deliveryTag, false, false);
   }
}

Thuộc tính concurrency = "5-20" cho container bắt đầu với 5 consumer và tự tăng dần lên tối đa 20 khi các consumer liên tục bận, rảnh thì giảm lại.

Cẩn thận với nhánh requeue: basicNack(..., true) đưa message về queue ngay lập tức, không có độ trễ nào. Lỗi mà chưa tự hết (DB đang down chẳng hạn) thì message quay vòng nhận, lỗi, requeue hàng nghìn lần mỗi giây, ăn CPU và log. Nên giới hạn số lần (đếm qua header, hoặc dùng quorum queue với x-delivery-limit) hoặc dùng retry có backoff ở phần dưới. Nhánh requeue = false chỉ đưa message vào DLQ khi queue đã cấu hình dead letter exchange, không có thì message bị xoá luôn.

# concurrency

// Multiple threads consuming from same queue
@RabbitListener(queues = "high-volume.queue", concurrency = "5-20")
public void handleConcurrent(Event event) {
   // 5 consumers min, scale up to 20 under load
}

# dead letter queue (dlq)

Hệ thống nào cũng có lúc lỗi. Khi một message thất bại quá số lần cho phép, nên tách nó sang Dead Letter Queue để nó không chặn các message khác, và để người vận hành xem lại sau.

# configuration

@Bean
public Queue mainQueue() {
   return QueueBuilder.durable("order.queue")
       .withArgument("x-dead-letter-exchange", "dlx.exchange")
       .withArgument("x-dead-letter-routing-key", "order.dlq")
       .withArgument("x-message-ttl", 60000) // optional: message TTL
       .build();
}
 
@Bean
public DirectExchange dlxExchange() {
   return new DirectExchange("dlx.exchange");
}
 
@Bean
public Queue dlq() {
   return QueueBuilder.durable("order.dlq").build();
}
 
@Bean
public Binding dlqBinding() {
   return BindingBuilder.bind(dlq()).to(dlxExchange()).with("order.dlq");
}

Message bị dead-letter trong ba trường hợp: consumer reject/nack với requeue = false, message hết TTL, hoặc queue vượt giới hạn độ dài. Vì vậy dòng x-message-ttl ở trên không hề "optional" cho vui: message nằm trong order.queue quá 60 giây chưa ai xử lý (consumer chậm, đang deploy) sẽ bị chuyển sang DLQ dù chẳng có lỗi gì. Chỉ đặt TTL khi bạn thật sự muốn vậy. Thêm nữa, các argument x-... này cố định lúc tạo queue; đổi giá trị trong code mà queue đã tồn tại thì RabbitMQ báo lỗi PRECONDITION_FAILED, nên đổi kiểu đó tốt hơn là dùng policy.

# retry with backoff

Cấu hình Spring Retry kết hợp recoverer:

@Bean
public SimpleRabbitListenerContainerFactory retryFactory(ConnectionFactory factory) {
   SimpleRabbitListenerContainerFactory f = new SimpleRabbitListenerContainerFactory();
   f.setConnectionFactory(factory);
   f.setAdviceChain(RetryInterceptorBuilder.stateless()
       .maxAttempts(3) // Tổng cộng 3 lần chạy: lần đầu + 2 lần retry
       .backOffOptions(1000, 2.0, 10000) // Chờ 1s rồi 2s giữa các lần (tối đa 10s)
       .recoverer(new RejectAndDontRequeueRecoverer()) // Hết 3 lần -> Bắn xuống DLQ
       .build());
   return f;
}

maxAttempts tính cả lần chạy đầu tiên, nên 3 nghĩa là chỉ retry 2 lần. Retry kiểu này diễn ra ngay trong thread consumer: trong lúc chờ backoff, thread đó (và các message đã prefetch về nó) đứng yên. Backoff dài thì nên chuyển sang cách "retry queue": nack vào một queue chờ có TTL, hết TTL thì dead-letter quay lại queue chính.

# dlq reprocessing

Có thể viết một job định kỳ đẩy message trong DLQ về lại queue chính sau khi hệ thống ổn định:

@Scheduled(fixedDelay = 300000) // Chạy mỗi 5 phút
public void reprocessDlq() {
   Message msg;
   int count = 0;
   // Lấy tối đa 100 messages mỗi lần chạy để tránh thắt nút cổ chai
   // Kiểm tra count TRƯỚC khi receive: receive() lấy message ra khỏi queue luôn (auto-ack),
   // đảo thứ tự điều kiện là message thứ 101 bị lấy ra rồi bỏ mất
   while (count < 100 && (msg = rabbitTemplate.receive("order.dlq")) != null) {
       try {
           rabbitTemplate.send("order.exchange", "order.created", msg);
           count++;
       } catch (Exception e) {
           log.error("Reprocess thất bại", e);
           // Message đã rời DLQ, gửi trả lại để không mất
           rabbitTemplate.send("dlx.exchange", "order.dlq", msg);
           break;
       }
   }
   if (count > 0) log.info("Đã khôi phục thành công {} messages từ DLQ", count);
}

Điểm dễ quên: RabbitTemplate.receive() dùng basicGet với auto-ack, message rời queue ngay khi nhận. Nên thứ tự điều kiện trong vòng lặp và nhánh lỗi đều phải tính tới chuyện không làm rơi message. Ngoài ra, message hỏng thật (dữ liệu sai) sẽ cứ đi vòng DLQ, queue chính, DLQ mỗi 5 phút. Nên đọc header x-death (RabbitMQ ghi số lần message bị dead-letter) và dừng đẩy lại sau vài vòng, để người xem tay.

# idempotent consumer

RabbitMQ (và hầu hết message broker) chỉ đảm bảo at-least-once delivery, tức giao ít nhất một lần. Consumer có thể nhận một message hai lần: ví dụ xử lý xong nhưng app crash hoặc mất kết nối trước khi ack tới broker, broker sẽ giao lại message đó.

Cách xử lý là làm cho consumer idempotent: xử lý một lần hay N lần đều ra cùng một kết quả.

# sử dụng redis (hiệu năng cao, độ trễ thấp)

@Component
@Slf4j
@RequiredArgsConstructor
public class IdempotentConsumer {
   private final StringRedisTemplate redis;
   private static final Duration DEDUP_TTL = Duration.ofHours(24);
 
   @RabbitListener(queues = "payment.queue")
   public void handle(PaymentEvent event) {
       String dedupKey = "processed:payment:" + event.getEventId();
 
       // Atomic operation: Chỉ set thành công nếu key chưa tồn tại
       Boolean isNew = redis.opsForValue().setIfAbsent(dedupKey, "1", DEDUP_TTL);
       if (Boolean.FALSE.equals(isNew)) {
           log.warn("Bỏ qua duplicate message: {}", event.getEventId());
           return;
       }
 
       try {
           processPayment(event);
       } catch (Exception e) {
           redis.delete(dedupKey); // Xóa key để có thể retry nếu thực sự xảy ra lỗi hệ thống
           throw e;
       }
   }
}

setIfAbsent (lệnh SET NX EX của Redis) là atomic nên hai consumer nhận cùng message lúc chỉ một bên đi tiếp. Nhưng cách này đánh dấu "đã xử lý" trước khi xử lý, nên có kẽ hở: app chết giữa chừng (không kịp vào catch) thì key vẫn còn, message được giao lại sẽ bị bỏ qua và việc không bao giờ được làm cho tới khi key hết hạn. Tương tự, bản trùng tới đúng lúc bản đầu đang chạy sẽ bị bỏ qua, rồi bản đầu lỗi và xoá key thì cũng chẳng còn ai làm. Muốn chặt hơn thì lưu hai trạng thái (PROCESSING có TTL ngắn, DONE có TTL dài), hoặc với nghiệp vụ tiền bạc thì dùng cách database bên dưới.

# sử dụng database unique constraint

Hợp khi cần nhất quán chặt, thường đi kèm việc cập nhật trạng thái entity.

@Transactional
public void handleIdempotent(PaymentEvent event) {
   if (processedEventRepo.existsByEventId(event.getEventId())) {
       return; // already processed
   }
 
   processPayment(event);
 
   processedEventRepo.save(new ProcessedEvent(event.getEventId(), Instant.now()));
}

Câu existsByEventId chỉ là đường tắt cho trường hợp thường gặp. Thứ thật sự chặn trùng là unique constraint trên cột event_id của bảng processed_events: hai bản trùng chạy song song đều qua được bước kiểm tra, nhưng bản insert sau sẽ lỗi vi phạm constraint, cả transaction (gồm thay đổi của processPayment) rollback. Điều kiện là processPayment chỉ ghi vào cùng database đó; nếu nó gọi API bên ngoài (cổng thanh toán) thì rollback DB không thu hồi được lời gọi đó, phải dùng idempotency key phía API.

# message ordering

Nếu có nhiều hơn một consumer nghe trên một queue (concurrency > 1), RabbitMQ không đảm bảo thứ tự xử lý. Queue vẫn giao message theo thứ tự, nhưng consumer nhanh chậm khác nhau nên message sau có thể xử lý xong trước. Requeue cũng làm xáo thứ tự.

Khi các message của cùng một entity phải xử lý tuần tự (ví dụ OrderCreated phải chạy trước OrderUpdated), có mấy cách:

  1. Single consumer: concurrency = "1", đơn giản nhưng không có parallelism
  2. Consistent hashing exchange: Route same entity to same queue
  3. Sequence number: Consumer reorders or rejects out-of-order messages

Consistent hash exchange băm routing key (ví dụ orderId) để mọi event của cùng một order luôn vào cùng một queue. Mỗi queue chỉ có một consumer thì thứ tự theo từng order được giữ, mà các order khác nhau vẫn chạy song song trên nhiều queue.

// Consistent hash — same orderId always goes to same queue/consumer
@Bean
public CustomExchange consistentHashExchange() {
   return new CustomExchange("order.hash.exchange", "x-consistent-hash", true, false);
}
 
// Binding with weight
@Bean
public Binding hashBinding1() {
   return BindingBuilder.bind(queue1())
       .to(consistentHashExchange())
       .with("1") // weight
       .noargs();
}

Exchange x-consistent-hash đến từ plugin rabbitmq_consistent_hash_exchange, có sẵn trong bản phân phối nhưng phải bật (rabbitmq-plugins enable rabbitmq_consistent_hash_exchange). Với binding của loại exchange này, "routing key" là trọng số (weight) chứ không phải pattern, còn routing key của message mới là thứ được băm, nên producer phải gửi orderId làm routing key. Mỗi queue phía sau phải có đúng một consumer (concurrency = "1"); có thể dùng thêm tính năng single active consumer (x-single-active-consumer, từ RabbitMQ 3.8) để chạy nhiều instance dự phòng mà mỗi lúc chỉ một instance nhận.

# transactional messaging

Vấn đề lớn nhất của EDA là dual-write: bạn lưu dữ liệu vào database thành công, nhưng tiến trình chết ngay trước khi kịp bắn event vào RabbitMQ. Kết quả là các hệ thống xung quanh không bao giờ biết dữ liệu đã được tạo. Đảo thứ tự (gửi event trước, lưu DB sau) cũng không xong, vì DB lỗi thì event "ma" đã đi mất.

# outbox pattern

Ý tưởng: lưu dữ liệu và lưu event trong cùng một database transaction, rồi một worker riêng đọc bảng outbox và đẩy lên broker.

// 1. Core Logic (Write Side)
@Transactional
public Order handleCreateOrderCommand(CreateOrderCommand cmd) throws JsonProcessingException {
   // Lưu Aggregate Root
   Order order = orderRepo.save(new Order(cmd));
 
   // Lưu Event vào Outbox table trong CÙNG MỘT transaction
   outboxRepo.save(OutboxEvent.builder()
       .aggregateId(order.getId().toString())
       .eventType("ORDER_CREATED")
       .payload(objectMapper.writeValueAsString(order)) // JsonProcessingException là checked
       .status(OutboxStatus.PENDING)
       .build());
 
   return order;
}
 
// 2. Message Relay Worker
@Scheduled(fixedDelay = 1000)
@Transactional
public void publishPendingEvents() {
   List<OutboxEvent> events = outboxRepo.findTop100ByStatus(OutboxStatus.PENDING);
   for (OutboxEvent event : events) {
       try {
           // Payload đã là JSON: gửi thẳng bytes, đừng để Jackson converter serialize lần nữa
           // (sẽ thành một chuỗi JSON bị bọc trong dấu nháy)
           Message message = MessageBuilder
               .withBody(event.getPayload().getBytes(StandardCharsets.UTF_8))
               .setContentType(MessageProperties.CONTENT_TYPE_JSON)
               .build();
           rabbitTemplate.send("domain.events.exchange", event.getEventType(), message);
           event.setStatus(OutboxStatus.PUBLISHED);
       } catch (Exception e) {
           log.error("Không thể publish event: {}", event.getId());
       }
   }
   outboxRepo.saveAll(events);
}

Có hai chỗ trong code trên dễ làm bạn tưởng an toàn hơn thực tế. Thứ nhất, send không ném lỗi không có nghĩa broker đã nhận: confirm tới bất đồng bộ, nên đánh dấu PUBLISHED ngay sau send vẫn có thể mất event nếu broker không ack. Muốn chắc thì chờ confirm trước khi đánh dấu, ví dụ gửi kèm CorrelationData rồi correlationData.getFuture().get(5, TimeUnit.SECONDS) và kiểm tra isAck(). Thứ hai, chạy nhiều instance thì các worker sẽ giành nhau cùng một dòng; dùng SELECT ... FOR UPDATE SKIP LOCKED hoặc ShedLock để mỗi dòng chỉ một worker xử lý. Dù làm kỹ tới đâu, outbox vẫn là at-least-once (gửi xong mà commit DB lỗi thì lần sau gửi lại), nên consumer phía sau vẫn phải idempotent như phần trên.

Bảng outbox cũng hay được dùng làm nguồn để cập nhật read model trong CQRS. Muốn bỏ hẳn worker polling thì có thể dùng CDC (Debezium đọc transaction log của DB).

# saga pattern (distributed transactions)

Khi một quy trình trải qua nhiều service, ta không dùng một ACID transaction cho cả quy trình được. Saga chia nó thành chuỗi local transaction ở từng service, kèm hành động bù (compensation) để hoàn tác các bước đã làm nếu một bước sau thất bại.

# choreography-based

// Service A: Thanh toán thành công -> Phát tín hiệu xác nhận đơn
@RabbitListener(queues = "payment.confirmed.queue")
public void onPaymentConfirmed(PaymentConfirmedEvent event) {
   orderService.confirmOrder(event.getOrderId());
   rabbitTemplate.convertAndSend("saga.exchange", "order.confirmed",
       new OrderConfirmedEvent(event.getOrderId()));
}
 
// Service A: Thanh toán thất bại -> Kích hoạt Compensating Transaction (Hoàn tác)
@RabbitListener(queues = "payment.failed.queue")
public void onPaymentFailed(PaymentFailedEvent event) {
   orderService.cancelOrder(event.getOrderId());
   rabbitTemplate.convertAndSend("saga.exchange", "order.cancelled",
       new OrderCancelledEvent(event.getOrderId()));
}

Choreography dễ làm ở giai đoạn đầu: mỗi service tự nghe event và tự phản ứng, không ai điều phối. Nhưng khi luồng nghiệp vụ dài ra, qua chục service, thì rất khó trả lời "đơn này đang kẹt ở bước nào". Ở quy mô lớn, người ta thường chuyển sang orchestration-based saga, có một orchestrator trung tâm giữ state machine của từng quy trình. Lưu ý code trên cũng là một dual-write (cập nhật order rồi mới gửi event), nên trong thực tế phần gửi event cũng nên đi qua outbox.

# messageConverter configuration

@Bean
public Jackson2JsonMessageConverter messageConverter() {
   ObjectMapper mapper = new ObjectMapper();
   mapper.registerModule(new JavaTimeModule());
   mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
   return new Jackson2JsonMessageConverter(mapper);
}
 
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory factory, MessageConverter converter) {
   RabbitTemplate template = new RabbitTemplate(factory);
   template.setMessageConverter(converter);
   return template;
}

Bean RabbitTemplate thứ hai này trùng tên với bean ở phần publisher confirms; trong cùng một app chỉ giữ một bean, gộp cả converter, mandatory và hai callback vào đó. Thật ra chỉ cần khai báo bean MessageConverter là đủ: Spring Boot tự gắn nó vào RabbitTemplate và listener container factory do Boot tạo. Tự khai báo RabbitTemplate thì mất các cấu hình Boot làm sẵn từ spring.rabbitmq.template.* (mandatory, retry khi publish...).

# configuration (application.yml)

spring:
  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest
    virtual-host: /
    publisher-confirm-type: correlated
    publisher-returns: true
    listener:
      simple:
        acknowledge-mode: auto
        prefetch: 10
        retry:
          enabled: true
          max-attempts: 3
          initial-interval: 1000ms
          multiplier: 2.0
          max-interval: 10000ms

Bật listener.simple.retry thì khi hết số lần thử, Spring Boot mặc định dùng RejectAndDontRequeueRecoverer: message bị reject không requeue, tức đi vào DLQ nếu queue có cấu hình DLX. Tài khoản guest mặc định chỉ đăng nhập được từ localhost, lên server thật phải tạo user riêng.

# decision matrix

ScenarioExchangeAck ModeRetryIdempotent
Task queue (workers)DirectAuto + retryYes (3x)Yes
Event broadcastFanoutAutoNoOptional
Selective subscriptionTopicAutoYesYes
Critical paymentDirectManualYes (DLQ)Mandatory
Audit loggingFanoutAutoYesNo (append-only)
Saga orchestrationDirectManualYes (DLQ)Mandatory

Dùng tốt RabbitMQ là biết thiết kế cho lúc mọi thứ hỏng, chứ không chỉ biết gọi API. Outbox lo chuyện không mất event, DLQ giữ lại message lỗi để xem sau, idempotency lo chuyện message trùng. Có đủ ba thứ đó thì hạ tầng trục trặc, dữ liệu vẫn không lệch.

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.