Order Timeout Auto-Cancellation: RabbitMQ Delayed Queue + Scheduled Task Dual Insurance Pattern
This article details a production-ready dual-insurance pattern for e-commerce order timeout cancellation, combining RabbitMQ delayed message queues for real-time processing with scheduled database scans as a fallback, including Spring Boot implementation code, idempotent cancellation logic, and distributed deployment considerations.
1. Order Timeout Cancellation Business Scenario
In e-commerce systems, a common requirement: after a user places an order, if payment is not completed within 30 minutes, the order should be automatically closed and inventory released.
User places order → Order created (unpaid) → Payment within 30 min → Order completed
↓
30 min unpaid → Auto close order → Release inventoryCore requirement: 30 minutes after order creation, if unpaid, automatically close order and release inventory .
2. Common Implementation Approaches Comparison
Database scheduled scan : Scheduled task scans timeout orders. Pros: Simple, reliable. Cons: Poor performance, high latency, heavy DB load.
Redis expiration listening : Key expiration triggers event. Pros: Good performance. Cons: Unreliable, messages lost on crash, no delivery guarantee.
RabbitMQ delayed queue : Message delayed delivery. Pros: Real-time, decoupled. Cons: MQ crash loses messages, message backlog.
Time wheel algorithm : In-memory time wheel. Pros: High performance. Cons: Restart loses tasks, custom implementation needed.
Distributed time wheel : ZooKeeper/Redis implementation. Pros: High performance. Cons: Complex, high maintenance cost.
3. Why Dual Insurance Is Needed
Single solutions all have defects:
Only scheduled scan : High latency (wait for scan cycle), heavy DB pressure
Only MQ delayed queue : MQ crash loses messages, message loss, consumer downtime means no consumption
Only Redis expiration listening : Redis unreliable, expiration events not guaranteed
Production practice: RabbitMQ delayed queue + scheduled task, dual insurance .
RabbitMQ delayed queue : High real-time, handles 99% normal cases (send delayed message on order creation, receive after 30 min to close)
Scheduled task fallback : Scans DB every 5 minutes, handles 1% abnormal cases (MQ crash, message loss, consumer exceptions)
Dual insurance core idea:
Delayed queue handles "fast": real-time processing of normal orders, good user experience
Scheduled task handles "stable": fallback for abnormal orders, no lost orders, no financial loss
4. RabbitMQ Delayed Queue Principles
4.1 What Is a Delayed Queue?
Delayed queue: messages sent to queue are not consumed immediately, but wait for specified time before consumption .
Order timeout scenario:
User places order successfully, send a delayed message to MQ with 30-minute delay
After 30 minutes, message delivered to consumer queue
Consumer receives message, checks if order paid
Unpaid → close order, release inventory; Paid → ignore
4.2 Two Ways to Implement Delayed Queue in RabbitMQ
RabbitMQ has no built-in "delayed queue"; two implementation approaches:
Dead Letter Exchange (DLX) : Messages enter dead letter exchange after expiration. Applicable: Fixed/few delay times.
Delayed Message Plugin (rabbitmq-delayed-message-exchange) : Plugin supports delay parameter. Applicable: Flexible delay times.
Production recommendation: Delayed Message Plugin . Simple configuration, flexible delay times, community-validated.
4.3 Delayed Plugin Principle
Install rabbitmq-delayed-message-exchange plugin, then RabbitMQ supports a special exchange type: x-delayed-message.
When sending messages, set x-delay header with delay time in milliseconds:
Message message = MessageBuilder
.withBody(orderId.getBytes())
.setHeader("x-delay", 30 * 60 * 1000) // 30-minute delay
.build();
rabbitTemplate.convertAndSend("order.delay.exchange", "order.delay.routingKey", message);The exchange does not immediately deliver to queue , but stores internally in MQ and delivers after delay expires.
5. Spring Boot Integration with RabbitMQ Delayed Queue
5.1 Add Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>5.2 Configure application.yml
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
# Confirm mechanism: message reaches exchange
publisher-confirm-type: correlated
# Confirm mechanism: message reaches queue
publisher-returns: true
# Consumer manual ACK
listener:
simple:
acknowledge-mode: manual
retry:
enabled: true
max-attempts: 35.3 Declare Exchange, Queue, Binding
@Configuration
public class RabbitMQConfig {
// Delayed exchange
public static final String ORDER_DELAY_EXCHANGE = "order.delay.exchange";
// Delayed queue
public static final String ORDER_DELAY_QUEUE = "order.delay.queue";
// Routing key
public static final String ORDER_DELAY_ROUTING_KEY = "order.delay.routingKey";
// Declare delayed exchange (x-delayed-message type)
@Bean
public CustomExchange orderDelayExchange() {
Map<String, Object> args = new HashMap<>();
args.put("x-delayed-type", "direct");
return new CustomExchange(ORDER_DELAY_EXCHANGE, "x-delayed-message", true, false, args);
}
// Declare delayed queue
@Bean
public Queue orderDelayQueue() {
return new Queue(ORDER_DELAY_QUEUE, true);
}
// Bind queue to exchange
@Bean
public Binding orderDelayBinding() {
return BindingBuilder.bind(orderDelayQueue())
.to(orderDelayExchange())
.with(ORDER_DELAY_ROUTING_KEY)
.noargs();
}
}5.4 Send Delayed Message After Successful Order Creation
@Service
public class OrderService {
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private OrderMapper orderMapper;
private static final long DELAY_TIME = 30 * 60 * 1000; // 30 minutes
@Transactional
public void createOrder(Order order) {
// 1. Save order to database
order.setStatus(OrderStatus.UNPAID); // Unpaid
orderMapper.insert(order);
// 2. Send delayed message to MQ
sendDelayMessage(order.getId());
}
private void sendDelayMessage(Long orderId) {
Message message = MessageBuilder
.withBody(orderId.toString().getBytes())
.setHeader("x-delay", DELAY_TIME)
.build();
rabbitTemplate.convertAndSend(
RabbitMQConfig.ORDER_DELAY_EXCHANGE,
RabbitMQConfig.ORDER_DELAY_ROUTING_KEY,
message
);
}
}5.5 Consume Delayed Message, Close Order
@Component
@RabbitListener(queues = RabbitMQConfig.ORDER_DELAY_QUEUE)
public class OrderDelayConsumer {
@Autowired
private OrderService orderService;
@RabbitListener(queues = RabbitMQConfig.ORDER_DELAY_QUEUE)
public void onMessage(Message message, Channel channel) throws IOException {
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
String orderId = new String(message.getBody());
// Check order status, close if unpaid
orderService.closeOrderIfUnpaid(Long.valueOf(orderId));
// Manual ACK
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
// Reject and requeue (retry)
channel.basicNack(deliveryTag, false, true);
}
}
}5.6 Order Closing Logic (Idempotent!)
@Service
public class OrderService {
@Transactional
public void closeOrderIfUnpaid(Long orderId) {
Order order = orderMapper.selectById(orderId);
// Order not exists or already paid, return directly (idempotent)
if (order == null || !OrderStatus.UNPAID.equals(order.getStatus())) {
return;
}
// Atomic operation: only unpaid can be closed (prevents concurrency issues)
int updated = orderMapper.closeOrderIfUnpaid(orderId);
if (updated > 0) {
// Release inventory
stockService.releaseStock(order.getProductId(), order.getCount());
// Log
log.info("Order timeout auto-close: {}", orderId);
}
}
}Key: Close operation must be idempotent! Because scheduled task also scans this order, duplicate close execution may occur. Use update order set status = 'CLOSED' where id = ? and status = 'UNPAID' , only updates when status is unpaid, ensuring idempotency.
6. Scheduled Task Fallback: Scan and Close Orders
6.1 Why Scheduled Task Is Needed
RabbitMQ delayed queue may fail:
MQ crash, message loss
Consumer exception, message not consumed
Network partition, message delivery failure
Delayed plugin failure, message not delivered on time
Scheduled task fallback:
Scan database every 5 minutes
Find timeout orders (creation time > 30 minutes) still in "unpaid" status
Execute close order logic
6.2 Spring Boot Scheduled Task
@Component
@Slf4j
public class OrderTimeoutJob {
@Autowired
private OrderService orderService;
/**
* Execute every 5 minutes
* Scan timeout unpaid orders, close them
*/
@Scheduled(fixedDelay = 5 * 60 * 1000)
public void scanTimeoutOrders() {
log.info("Start scanning timeout unpaid orders...");
// Find timeout orders: creation time > 30 minutes ago, status unpaid
List<Long> timeoutOrderIds = orderMapper.selectTimeoutOrderIds(
LocalDateTime.now().minusMinutes(30),
OrderStatus.UNPAID
);
for (Long orderId : timeoutOrderIds) {
try {
orderService.closeOrderIfUnpaid(orderId);
} catch (Exception e) {
log.error("Close order failed, orderId={}", orderId, e);
}
}
log.info("Scan completed, processed {} timeout orders", timeoutOrderIds.size());
}
}6.3 Mapper Query SQL
@Mapper
public interface OrderMapper {
@Select("SELECT id FROM order_info " +
"WHERE status = 'UNPAID' " +
"AND create_time < #{timeoutTime} " +
"LIMIT 1000")
List<Long> selectTimeoutOrderIds(LocalDateTime timeoutTime, String status);
@Update("UPDATE order_info SET status = 'CLOSED', update_time = NOW() " +
"WHERE id = #{orderId} AND status = 'UNPAID'")
int closeOrderIfUnpaid(Long orderId);
}Key points: LIMIT 1000 : Process max 1000 per scan, prevent overload WHERE status = 'UNPAID' : Only process unpaid orders update ... where status = 'UNPAID' : Atomic operation, idempotent
6.4 Distributed Deployment Issue
If application deployed on multiple nodes, scheduled task runs on each machine, causing duplicate scans.
Solutions:
Distributed scheduled task : XXL-Job, Elastic-Job, PowerJob — only one machine executes
Distributed lock : Acquire lock before task execution, only lock holder executes
@Scheduled(fixedDelay = 5 * 60 * 1000)
public void scanTimeoutOrders() {
// Try acquire distributed lock, only one executes
boolean locked = redisLock.tryLock("order:timeout:scan", 5, TimeUnit.MINUTES);
if (!locked) {
return;
}
try {
// Scan logic
} finally {
redisLock.unlock("order:timeout:scan");
}
}7. Dual Insurance Architecture
┌─────────┐ Order placed ┌──────────────┐
│ User │ ──────────────────► │ Order DB │
└─────────┘ └──────┬───────┘
│
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Send │ │ Scheduled│ │ (Other │
│ delayed │ │ task │ │ paths) │
│ message │ │ scan │ └──────────┘
│ to MQ │ │ every 5m │
└─────┬────┘ └────┬─────┘
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ 30 min │ │ Timeout │
│ later MQ │ │ orders │
│ delivers │ │ unpaid │
└─────┬────┘ └────┬─────┘
│ │
▼ ▼
┌────────────────────────────┐
│ Consumer: Close logic │
│ (Idempotent: only unpaid) │
└────────────────────────────┘
│
▼
┌──────────┐
│ Release │
│ inventory│
│ Update │
│ status │
└──────────┘Dual Insurance Coordination
Normal case: MQ delayed message triggers after 30 minutes, real-time close
Abnormal case: MQ misses, scheduled task falls back within 5 minutes
Both call same close method, idempotent design prevents issues from duplicate execution
Order timeout cancellation, message queues, distributed tasks, idempotent design are core distributed system concepts and frequent interview topics for mid-to-senior developers. Understanding this dual-insurance pattern enables transferable knowledge for distributed transactions, eventual consistency, reliable message delivery, and rapid troubleshooting of issues like "order not closed" or "inventory not released".
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.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
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.
