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.
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 cancellationAdvanced: 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 restartMonitoring & 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)
@Scheduledpolling – 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.
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.
