How to Build a Reliable 15‑Minute Order Auto‑Cancel in Java: From Naïve @Scheduled to Production‑Ready Redisson

The article walks through the pitfalls of a seemingly simple 15‑minute unpaid‑order cancellation requirement, evaluates five implementation options—from a basic @Scheduled poll to Redis ZSet, DelayQueue, and distributed Redisson solutions—culminating in a production‑grade Redisson scheduler with optimistic‑lock safeguards and detailed best‑practice guidelines.

Linyb Geek Road
Linyb Geek Road
Linyb Geek Road
How to Build a Reliable 15‑Minute Order Auto‑Cancel in Java: From Naïve @Scheduled to Production‑Ready Redisson

Problem

A product manager requires orders that remain unpaid for 15 minutes to be cancelled automatically. The implementation must handle task drift, duplicate execution in a clustered deployment, payments arriving exactly at the timeout, retry handling for cancellation failures, and high‑concurrency order creation.

Boundary Cases

What if the scheduled task drifts?

Will multiple instances cancel the same order?

How to handle a payment that arrives at the 15‑minute mark?

What if cancellation fails – should it be retried?

How to cope with concurrent order creation?

Solution Options

Option 1 – @Scheduled polling the database

@Scheduled(fixedRate = 60_000)
public void checkTimeoutOrders() {
    List<Order> orders = orderMapper.selectTimeoutOrders();
    for (Order order : orders) {
        cancelOrder(order);
    }
}

Pros: Simple, no external dependencies.

Cons: Scans the DB every minute (potential overload) and executes duplicate cancellations across multiple instances.

Conclusion: Only suitable for small, non‑critical projects.

Option 2 – JUC DelayQueue

BlockingQueue<DelayOrder> delayQueue = new DelayQueue<>();
// on order creation
delayQueue.put(new DelayOrder(orderId, System.currentTimeMillis() + 900_000));
// listener thread
new Thread(() -> {
    while (true) {
        DelayOrder item = delayQueue.take(); // blocks
        cancelOrder(item.getOrderId());
    }
}).start();

Pros: No extra dependencies, native JUC support.

Cons: Tasks are lost on service restart, limited to a single node, and a process crash discards all pending delays.

Conclusion: Works for non‑core scenarios where durability is not required.

Option 3 – Redis ZSet sorting

// add order to ZSet with score = timeout timestamp
String key = "order:timeout";
Long score = System.currentTimeMillis() + 900_000;
redisTemplate.opsForZSet().add(key, orderId.toString(), score);
// periodic scan
while (true) {
    Set<String> timedOut = redisTemplate.opsForZSet()
        .rangeByScore(key, 0, System.currentTimeMillis());
    if (timedOut == null || timedOut.isEmpty()) break;
    timedOut.forEach(this::cancelOrder);
    redisTemplate.opsForZSet().remove(key, timedOut.toArray());
}

Pros: Lightweight, no middleware beyond Redis.

Cons: Still requires a scheduled scan; performance degrades with large data sets.

Conclusion: Acceptable for lightweight use cases but not elegant for high‑throughput systems.

Option 4 – Redis Stream ( XREAD )

RStream<String, Order> stream = redissonClient.getStream("order:timeout");
stream.readAndListen(GROUP_NAME, new StreamListener<String, Order>() {
    @Override
    public void onMessage(StreamMessage<String, Order> msg) {
        cancelOrder(msg.getValue().getOrderId());
    }
});

Pros: Real‑time processing, native Redis support.

Cons: Consumer‑group concept adds learning curve; Redis still needs operational management.

Conclusion: Viable but not the optimal choice for most projects.

Option 5 – Redisson Distributed Scheduler + Delayed Queue (final choice)

@Autowired
private RedissonClient redissonClient;

public void placeOrder(Order order) {
    RBucket<Order> bucket = redissonClient.getBucket("order:" + order.getId());
    bucket.set(order, 15, TimeUnit.MINUTES);
    RDelayedQueue<Order> delayedQueue = redissonClient.getDelayedQueue(
        redissonClient.getQueue("order:timeout-queue"));
    delayedQueue.offer(order, 15, TimeUnit.MINUTES);
}

new Thread(() -> {
    while (true) {
        Order order = delayedQueue.take(); // triggers after 15 min
        cancelOrder(order);
        redissonClient.getBucket("order:" + order.getId()).delete();
    }
}).start();

Reasons for selection:

Distributed coordination – safe in multi‑instance deployments.

Reliability – tasks survive service restarts via Redis persistence.

Second‑level precision.

No extra middleware beyond Redis.

Handling Payment Success

If a user pays before the delayed task fires, the order must be removed from the queue; otherwise the cancellation would still occur.

public void onPaid(String orderNo) {
    Order order = orderMapper.selectByOrderNo(orderNo);
    RQueue<Order> queue = redissonClient.getQueue(TIMEOUT_QUEUE);
    queue.remove(order); // crucial step
    redissonClient.getBucket(ORDER_PREFIX + order.getId()).delete();
    order.setStatus(OrderStatus.PAID.getCode());
    orderMapper.updateById(order);
}

If the delayed task has already been triggered but the cancellation logic has not yet run, an optimistic‑lock update prevents double processing:

int updated = orderMapper.updateStatus(order.getId(),
    OrderStatus.PENDING_PAYMENT.getCode(),
    OrderStatus.CANCELLED.getCode());
if (updated == 0) {
    log.info("Order {} status changed, ignore timeout", order.getOrderNo());
    return;
}
// continue with cancellation

Advanced: Message‑Queue Solution (Production Recommendation)

When a message queue is already in use, delayed messages can be employed.

// RocketMQ delayed message (level 4 ≈ 15 s, configurable)
Message<Order> message = new Message<>(order);
message.setDelayLevel(4);
rocketMQTemplate.syncSend("order-timeout-topic", message, 3000);

@RocketMQMessageListener(topic = "order-timeout-topic", consumerGroup = "order-timeout-consumer")
public class OrderTimeoutConsumer implements RocketMQListener<Order> {
    @Override
    public void onMessage(Order order) {
        cancelOrder(order);
    }
}

Note: RocketMQ 3.x/4.x only support predefined delay levels; version 5.x allows arbitrary delays, enabling a 15‑minute level.

Key Pitfalls & Lessons Learned

Idempotency

// ✅ Correct: optimistic lock
UPDATE t_order SET status = 1, update_time = NOW()
WHERE id = ? AND status = 0;
// ❌ Wrong: blind update
UPDATE t_order SET status = 1 WHERE id = ?;

Multi‑Instance Deployment

Redisson’s distributed delayed queue internally uses a Redis list + ZSet, providing a single source of truth across instances without extra locks.

Configurable Timeout

order:
  timeout:
    minutes: 15 # can be changed via Nacos/Apollo without restart

Monitoring & Alerting

@Scheduled(fixedRate = 300_000)
public void monitorTimeoutQueue() {
    RQueue<Order> queue = redissonClient.getQueue(TIMEOUT_QUEUE);
    long size = queue.size();
    if (size > 1000) {
        alertService.send("Order timeout queue backlog: " + size);
    }
    metricCollector.gauge("order.timeout.queue.size", size);
}

Memory Leak Prevention

// After payment, clean Redis data
redissonClient.getBucket(ORDER_PREFIX + order.getId()).delete();

Solution Comparison (concise)

@Scheduled

polling – low complexity, limited reliability, suitable for non‑core low‑traffic services.

JUC DelayQueue – in‑memory only, high real‑time capability, not durable across restarts.

Redis ZSet – lightweight, moderate reliability, requires periodic scans.

Redis Stream – real‑time, higher operational cost.

Redisson distributed delayed queue – high reliability, good real‑time precision, recommended for most production systems.

RocketMQ delayed message – highest reliability when MQ is already present.

RabbitMQ delayed plugin – comparable reliability to RocketMQ when RabbitMQ is available.

Quartz distributed scheduler – fine‑grained scheduling but highest complexity.

Final Recommendations

Small project or quick launch: Redisson distributed delayed queue.

System already uses RocketMQ: RocketMQ delayed message.

System already uses RabbitMQ: RabbitMQ delayed plugin.

No additional middleware desired: Redis ZSet with periodic scan.

Core Principle

When updating the order status in the database, always include a state check (optimistic lock) to avoid overwriting a payment or an already‑processed cancellation.

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 systemsJavaRedisoptimistic lockRedissonscheduled tasksOrder Timeout
Linyb Geek Road
Written by

Linyb Geek Road

Tech notes

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.