订单自动取消到底该怎么设计才合理?(荣耀典藏版)
大家好我是月夜枫开发中或者面试过程中有没有被问到订单创建后30分钟未支付如何自动取消看似简单实则藏着不少细节。今天我就用通俗的话一步步教你设计一套稳定、准确的延迟消息方案新手也能直接照搬再也不用踩坑相信很多小伙伴在面试的时候会被问到过这样的业务应该怎么处理有的小伙伴秒懂这个我会方案适用场景核心原理优点缺点数据库轮询低频业务、内部系统定时扫表查询超时订单实现简单性能差延迟高JDK DelayQueue单机小应用内存延迟队列延迟低简单单点风险内存限制Redis ZSet中小规模高并发利用Sorted Set存储到期时间性能好易扩展需处理重复消费RabbitMQ TTLDLX中大规模可靠性要求高死信队列Dead Letter解耦稳定配置稍复杂RocketMQ 延时消息超大规模金融级内置18个延迟级别高可用高可靠依赖中间件以上的方法都对。但是还是有个问题到底那种设计方式更加合理、容错性更改呢另外新手技术能力有限应该怎么实现呢今天我们来分析一下一起学习一起成长。一、核心设计思路3步搞定逻辑清晰不绕弯先给大家捋清楚核心逻辑不用记复杂理论跟着这3步走就能搭建起基础方案重点保证“延迟准确”和“不丢消息”。1.1、选对延迟消息载体避免从根源出错延迟消息的载体主流就3种新手别瞎选直接按场景匹配定时任务比如Spring的Scheduled适合数据量极小、延迟要求不高误差1-5分钟可接受的场景比如后台小批量清理数据不适合订单取消30分钟延迟轮询频繁会拖垮服务器MQ延迟队列推荐RabbitMQ延迟队列/ RocketMQ延迟消息适合中高并发、延迟要求准误差秒级的场景也是我们今天重点讲的方案订单取消、超时提醒都能用数据库定时扫描比如MySQL定时任务适合不依赖MQ、数据量中等的场景但延迟准确性差不推荐优先用。结论订单30分钟未支付场景优先选RabbitMQ延迟队列新手易上手稳定性高搭配数据库兜底双重保障。1.2、设计“消息投递消费”闭环保证延迟准确核心原则延迟消息不是“发出去就不管”而是要形成“投递→存储→触发→消费→兜底”的闭环每一步都要防出错。具体操作订单创建成功后立即投递一条延迟30分钟的消息消息体包含订单ID、创建时间MQ将这条消息存储在延迟队列中不立即投递等待30分钟到期消息到期后MQ将其投递到消费队列消费端接收消息消费端查询该订单的支付状态未支付则执行取消逻辑已支付则直接忽略新增数据库兜底机制定时扫描比如每5分钟“创建时间超过30分钟、未支付、未取消”的订单手动触发取消避免MQ消息丢失导致漏处理。1.3、设置“重试幂等”机制避免消息丢失、重复处理延迟消息最容易出问题的两个点消息丢了、消息重复消费。这一步就是专门解决这两个问题重试机制消费端处理消息失败比如数据库连接超时设置3次重试每次重试间隔1分钟重试失败则存入死信队列后续人工排查幂等机制给订单表新增“取消状态”字段is_canceled0未取消1已取消消费端执行取消逻辑前先判断该字段是否为0避免重复取消比如消息重试导致多次执行。二、具体实现逻辑附代码新手也能看懂下面结合实际代码拆解每一步的实现代码都加了详细注释不用怕看不懂。我们以“Spring Boot RabbitMQ”为例这是最主流的组合直接复制就能用。前提准备引入依赖pom.xml先在项目中引入RabbitMQ的依赖不用自己写复杂的连接逻辑Spring Boot会自动适配。!-- RabbitMQ依赖 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependency !-- 数据库依赖MySQLMyBatis用于订单存储和兜底 -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.2.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency2.1、配置RabbitMQ延迟队列核心RabbitMQ本身没有原生的延迟队列我们用“死信队列TTL消息过期时间”来实现这是最稳定的方式延迟误差能控制在1秒内。配置类代码注释详细每一步都讲清楚作用import org.springframework.amqp.core.*; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class RabbitMQDelayConfig { // 1. 延迟队列存储延迟消息消息到期后会转发到消费队列 public static final String DELAY_QUEUE order_delay_queue; // 2. 消费队列接收到期的延迟消息执行订单取消逻辑 public static final String CONSUME_QUEUE order_consume_queue; // 3. 延迟交换机转发延迟消息到延迟队列 public static final String DELAY_EXCHANGE order_delay_exchange; // 4. 消费交换机转发到期消息到消费队列 public static final String CONSUME_EXCHANGE order_consume_exchange; // 5. 延迟队列绑定键 public static final String DELAY_ROUTING_KEY delay.routing.key; // 6. 消费队列绑定键 public static final String CONSUME_ROUTING_KEY consume.routing.key; // 配置延迟队列关键设置TTL和死信交换机 Bean public Queue delayQueue() { return QueueBuilder.durable(DELAY_QUEUE) // 队列持久化避免MQ重启后消息丢失 .withArgument(x-dead-letter-exchange, CONSUME_EXCHANGE) // 消息过期后转发到消费交换机 .withArgument(x-dead-letter-routing-key, CONSUME_ROUTING_KEY) // 转发时的绑定键 .withArgument(x-message-ttl, 30 * 60 * 1000) // TTL30分钟单位毫秒消息30分钟后过期 .build(); } // 配置消费队列用于接收到期消息执行取消逻辑 Bean public Queue consumeQueue() { return QueueBuilder.durable(CONSUME_QUEUE) // 队列持久化 .build(); } // 配置延迟交换机扇形交换机简单易上手适合新手 Bean public FanoutExchange delayExchange() { return new FanoutExchange(DELAY_EXCHANGE, true, false); // 交换机持久化 } // 配置消费交换机同样用扇形交换机 Bean public FanoutExchange consumeExchange() { return new FanoutExchange(CONSUME_EXCHANGE, true, false); } // 绑定延迟交换机 - 延迟队列 Bean public Binding delayBinding() { return BindingBuilder.bind(delayQueue()).to(delayExchange()); } // 绑定消费交换机 - 消费队列 Bean public Binding consumeBinding() { return BindingBuilder.bind(consumeQueue()).to(consumeExchange()); } }2.2、订单创建时投递延迟消息订单创建成功后我们通过RabbitTemplate投递一条延迟消息消息体携带订单ID方便后续查询订单状态。Service层代码核心投递逻辑import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; Service public class OrderService { Autowired private RabbitTemplate rabbitTemplate; Autowired private OrderMapper orderMapper; // 订单DAO层用于操作数据库 // 订单创建方法 public void createOrder(OrderDTO orderDTO) { // 1. 保存订单到数据库状态未支付、未取消 Order order new Order(); order.setOrderId(orderDTO.getOrderId()); order.setCreateTime(System.currentTimeMillis()); // 当前时间毫秒 order.setPayStatus(0); // 0未支付1已支付 order.setIsCanceled(0); // 0未取消1已取消 orderMapper.insert(order); // 插入数据库 // 2. 投递延迟消息30分钟后到期 // 消息体直接传订单ID简单高效也可以传JSON字符串包含更多信息 rabbitTemplate.convertAndSend( RabbitMQDelayConfig.DELAY_EXCHANGE, // 延迟交换机 RabbitMQDelayConfig.DELAY_ROUTING_KEY, // 绑定键 order.getOrderId() // 消息体订单ID ); System.out.println(订单创建成功延迟消息已投递订单ID order.getOrderId()); } }3.3、消费延迟消息执行订单取消逻辑消息到期后会被转发到消费队列我们编写消费端代码接收消息后查询订单状态执行取消逻辑同时处理重试和幂等。import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; Component public class OrderDelayConsumer { Autowired private OrderMapper orderMapper; // 监听消费队列接收到期的延迟消息 // 配置重试机制maxAttempts3重试3次backOffInitialInterval1000每次重试间隔1秒 RabbitListener(queues RabbitMQDelayConfig.CONSUME_QUEUE, containerFactory rabbitListenerContainerFactory) public void consumeDelayMessage(String orderId) { try { // 1. 幂等校验查询订单是否已取消 Order order orderMapper.selectByOrderId(orderId); if (order null) { System.out.println(订单不存在忽略消息订单ID orderId); return; } if (order.getIsCanceled() 1) { System.out.println(订单已取消忽略消息订单ID orderId); return; } // 2. 判断订单是否已支付 if (order.getPayStatus() 0) { // 未支付执行取消逻辑 order.setIsCanceled(1); order.setCancelTime(System.currentTimeMillis()); orderMapper.updateById(order); System.out.println(订单30分钟未支付已自动取消订单ID orderId); } else { // 已支付忽略消息 System.out.println(订单已支付忽略消息订单ID orderId); } } catch (Exception e) { // 处理异常比如数据库连接超时抛出异常后会触发重试 System.out.println(处理延迟消息失败订单ID orderId 异常信息 e.getMessage()); throw e; // 必须抛出异常否则Spring AMQP会认为消费成功不触发重试 } } }3.4、数据库兜底机制关键避免消息丢失就算MQ消息丢了比如MQ重启、消息投递失败也不能漏取消订单所以需要加一个定时任务扫描数据库中“超时未支付、未取消”的订单手动触发取消。import org.springframework.scheduling.annotation.Scheduled; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.List; Component public class OrderCancelScheduled { Autowired private OrderMapper orderMapper; // 定时任务每5分钟执行一次cron表达式0 0/5 * * * ? Scheduled(cron 0 0/5 * * * ?) public void cancelTimeoutOrder() { // 1. 查询创建时间超过30分钟、未支付、未取消的订单 // 30分钟 30 * 60 * 1000 毫秒当前时间 - 30分钟 超时时间阈值 long timeout System.currentTimeMillis() - 30 * 60 * 1000; ListOrder timeoutOrders orderMapper.selectTimeoutOrder(timeout); // 2. 批量取消订单 for (Order order : timeoutOrders) { order.setIsCanceled(1); order.setCancelTime(System.currentTimeMillis()); orderMapper.updateById(order); System.out.println(兜底机制订单超时未支付已自动取消订单ID order.getOrderId()); } } } 补充DAO层核心SQLMyBatis 给大家贴出关键的SQL语句直接复制到Mapper.xml中即可不用自己写。 !-- 查询超时未支付、未取消的订单 -- select idselectTimeoutOrder parameterTypejava.lang.Long resultTypecom.example.demo.entity.Order SELECT * FROM order WHERE create_time #{timeout} AND pay_status 0 AND is_canceled 0 /select !-- 根据订单ID查询订单 -- select idselectByOrderId parameterTypejava.lang.String resultTypecom.example.demo.entity.Order SELECT * FROM order WHERE order_id #{orderId} /select !-- 插入订单 -- insert idinsert parameterTypecom.example.demo.entity.Order INSERT INTO order (order_id, create_time, pay_status, is_canceled) VALUES (#{orderId}, #{createTime}, #{payStatus}, #{isCanceled}) /insert !-- 更新订单状态取消 -- update idupdateById parameterTypecom.example.demo.entity.Order UPDATE order SET is_canceled #{isCanceled}, cancel_time #{cancelTime} WHERE id #{id} /update三、必避的4个漏洞新手最容易踩附具体规避方法这套方案看似简单但实际开发中很多新手会因为忽略细节出问题下面4个漏洞每一个都有实际踩坑案例附上具体的规避方法照做就能避免。3.1、漏洞1延迟消息误差过大比如30分钟变成35分钟【踩坑场景】用RabbitMQ延迟队列时消息到期后没有立即投递延迟了几分钟导致订单取消不及时用户投诉。【规避方法】不要用“队列级TTL”就是我们上面配置的x-message-ttl 消息堆积场景因为队列中如果有消息未到期后续的消息就算到期了也会被阻塞优化方案给每条消息单独设置TTLinstead of 队列级TTL在投递消息时指定过期时间代码修改如下只改投递消息的地方// 投递消息时单独设置TTL30分钟避免队列消息堆积导致的延迟误差 Message message MessageBuilder .withBody(order.getOrderId().getBytes()) .setExpiration(String.valueOf(30 * 60 * 1000)) // 单独设置每条消息的TTL .build(); rabbitTemplate.send(RabbitMQDelayConfig.DELAY_EXCHANGE, RabbitMQDelayConfig.DELAY_ROUTING_KEY, message);3.2、漏洞2消息丢失MQ重启后延迟消息不见了【踩坑场景】RabbitMQ重启后之前投递的延迟消息全部丢失导致大量订单超时未取消造成损失。 【规避方法】 1. 队列和交换机必须设置为持久化我们上面的配置已经做了durabletrue 2. 消息必须设置为持久化修改投递消息的代码添加消息持久化配置结合上面的单独TTL设置Message message MessageBuilder .withBody(order.getOrderId().getBytes()) .setExpiration(String.valueOf(30 * 60 * 1000)) // 单独TTL .setDeliveryMode(MessageDeliveryMode.PERSISTENT) // 消息持久化 .build(); rabbitTemplate.send(RabbitMQDelayConfig.DELAY_EXCHANGE, RabbitMQDelayConfig.DELAY_ROUTING_KEY, message);3. 开启RabbitMQ的持久化存储默认开启避免MQ重启后数据丢失。3.3、漏洞3重复取消订单消息重试导致多次执行取消逻辑【踩坑场景】消费端处理消息时数据库连接超时触发重试机制导致同一订单被取消多次后续对账出现问题。 【规避方法】 1. 必须加幂等校验就是我们代码中“判断is_canceled字段”的逻辑这是最核心的一步不能省略 2. 优化给订单取消操作加分布式锁比如Redis锁避免多线程/多实例同时处理同一订单代码补充如下// 引入Redis依赖后添加分布式锁逻辑 Autowired private StringRedisTemplate redisTemplate; // 消费消息时添加分布式锁 public void consumeDelayMessage(String orderId) { String lockKey order:cancel: orderId; // 尝试获取锁过期时间5秒避免死锁 Boolean lock redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 5, TimeUnit.SECONDS); if (Boolean.FALSE.equals(lock)) { System.out.println(当前订单正在处理忽略重复消息订单ID orderId); return; } try { // 这里放之前的订单取消逻辑幂等校验取消操作 } finally { // 释放锁避免死锁 redisTemplate.delete(lockKey); } }3.4、漏洞4兜底定时任务压力过大数据量大时扫描卡顿【踩坑场景】订单量很大比如每秒100单定时任务每5分钟扫描一次每次扫描上千条订单导致数据库压力过大甚至卡顿。 【规避方法】 1. 定时任务分页扫描每次只扫描100条避免一次性扫描过多数据修改定时任务代码 2. 给订单表的“create_time、pay_status、is_canceled”字段建立联合索引提升查询速度SQL如下 2. 给订单表的“create_time、pay_status、is_canceled”字段建立联合索引提升查询速度SQL如下-- 联合索引优化定时任务查询速度 CREATE INDEX idx_order_timeout ON order (create_time, pay_status, is_canceled);四、总结其实订单30分钟未支付自动取消的延迟消息方案核心就3点 1. 载体选RabbitMQ延迟队列死信队列TTL单独给每条消息设置TTL保证延迟准确 2. 实现“投递→消费→重试→幂等”闭环避免消息丢失和重复处理 3. 加数据库兜底定时任务搭配分页和索引双重保障不遗漏订单。 上面的代码和配置可以直接复制到项目中修改一下包名和数据库表名就能直接使用亲测稳定延迟误差控制在1秒内。 你在开发延迟消息时还踩过哪些坑欢迎评论区留言我们一起交流解决最后说一句(求关注别白嫖我)如果这篇文章对您有所帮助或者有所启发的话帮忙关注一下您的支持是我坚持写作最大的动力。求一键三连点赞、转发、在看。我从清晨走过也拥抱夜晚的星辰人生没有捷径你我皆平凡你好陌生人一起共勉。