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.

Java Tech Workshop
Java Tech Workshop
Java Tech Workshop
Order Timeout Auto-Cancellation: RabbitMQ Delayed Queue + Scheduled Task Dual Insurance Pattern

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 inventory

Core 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: 3

5.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".

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

distributed systemsspring-bootmessage queueRabbitMQIdempotencydelayed queuescheduled taskorder timeout
Java Tech Workshop
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.