超时未支付订单之分库分表+定时任务+RMQ延时消息

高并发下的数据扫描策略任务触发架构

之前在某海外社交 App 搞过支付相关业务------你懂的,foreigners + 社交 + 虚拟金币 = 框框充钱停不下来 💰。正是在这种"土豪流水哗哗响"的场景下,我才真正体会到:订单超时取消,不是功能,是底线! 当时调研方案写了一沓草稿(没它我今天真想不起来细节)------再次验证那句老话:好记性不如烂笔头,好架构不如烂代码跑通!

当然,不是所有方案都适合你。

------你要是每天就 10 单,用 Thread.sleep(30*60*1000) 都行;

------但一旦上亿单,高并发下的超时取消,就是一场"数据与时间的赛跑"

这套路还被迁移到后面的物流公司做业务,这方案针对其他行业医疗、IoT......凡是"状态+时效"的敏感场景,皆可套用!做人要灵活!


🧪 先亮结论(别急着杠):

"分库分表 + 定时任务 + RMQ 延时消息" 不是三个东西,而是两层组合拳:

  • 底层扫描策略:分库分表 + 分片定时任务 → 解决"怎么扫亿级数据",别管我们有没有这么大的量,我们老板说我们有,我们必须得有(实际差不多)
  • 上层触发架构:RMQ 延时(快) + 定时任务(稳) → 解决"怎么可靠触发"

它们不是互斥选项,而是"快腿+铁脚"的黄金搭档!

下面,我们一条条"审问":

🚀 第一招:短延迟(≤2h)------ RMQ 延时消息 + 本地消息表(快如闪电)

  • RocketMQ 的 18 级延迟(最大 2 小时)精度够、吞吐高,适合"30分钟未支付自动取消"。

定时任务确实是最可控、可审计、强一致 的方式,你扫的是最终写入 DB 的状态( 订单是否该取消,只取决于 DB 中 statuscreate_time) ,天然和订单主流程一致;;但是纯定时任务的话会有延迟:可能下一次才能轮到你, 像下面的@Scheduled(fixedDelay = 1000)

复制代码
// 后台异步投递(独立线程),这个可以借助xxl-job,失败重试
@Scheduled(fixedDelay = 1000)
public void pollAndSendToMQ() {
    //发送失败,后台持续重试 查询findReadyToSend 会再次查出然后重试
    List<OutboxMessage> messages = outboxMapper.findReadyToSend();
    for (OutboxMessage msg : messages) {
        if (rmqProducer.send(msg)) {
            outboxMapper.markAsSent(msg.getId()); // 标记已发送
        }
        // 发送失败?下次继续重试!
    }
}

💡 总结

RMQ 延时像"智能闹钟"------设好就忘,到点就响。但万一它死机了?所以你得有个"老妈子"(本地消息表)天天催:"该发消息了!

当然这块定时任务也能优化,比如任务调度器分配分片 (如xxl-job分片广播),实例只扫 order_id % N == 自己的分片ID;无锁、无冲突、线性扩展,DB 压力均匀

定时任务分片:XXL-JOB、ElasticJob、Quartz Cluster

复制代码
// XXL-JOB 示例:分片广播模式
@XxlJob("cancelOrderJob")
public void execute() {
    
    //任务实例,部署N个任务实例,协作扫描这些表
    int shardIndex = XxlJobHelper.getShardIndex(); // 当前实例编号(0 ~ N-1)
    int shardTotal = XxlJobHelper.getShardTotal(); // 总实例数N
    
     // 计算该实例负责的分片范围
    //tableSuffix表序号=当前实力编号
    //共拆1024张表 循环,1024总分片数是必须知道的,可以变但是要是一个常量数字,推荐2^10,对齐内存/缓存
    //tableSuffix += shardTotal,每次跳过 shardTotal个表,大家轮流"认领"
    for (int tableSuffix = shardIndex; tableSuffix < 1024; tableSuffix += shardTotal) {
         String tableName = "orders_" + suffix;
        //扫描这张表中的超时订单
        List<Order> timeoutOrders = orderMapper.selectTimeoutFromTable(tableName);
        for (Order order : timeoutOrders) {
            cancelOrderSafely(order); // 幂等取消
        }
    }
}
  • for循环分片效果:A 0 4 0, 4, 8, 12, ..., 1020
  • B 1 4 1, 5, 9, 13, ..., 1021 自己扫自己分片,这片我罩着 那片你负责
  • C 2 4 2, 6, 10, 14, ..., 1022天然隔离,无遗漏无锁
  • D 3 4 3, 7, 11, 15, ..., 1023 每个实例扫约 1024 / 4 = 256 张表

如果压力大,加实例就行!比如从 4 个扩到 8 个:

  • 实例 0:0, 8, 16, ...
  • 实例 1:1, 9, 17, ...
  • ... 挂了只影响自己那嘎达,是不是很有分寸感、边界感
  • 自动重新分配,无需改代码

RMQ 延时消息

加速器 。如 RocketMQ 的 18 级延迟(最大 2 小时),适合快速取消未支付订单,但无法覆盖"7 天自动收货"这种长周期;部分重复 再次复习吧算是

复制代码
// 订单创建事务,同一个事务中
@Transactional
public void createOrder(Order order) {
    // 1. 写订单表
    orderMapper.insert(order);
    
    // 2. 写本地消息表(同一事务!)
    messageOutboxMapper.insert(new OutboxMessage(
        "ORDER_CANCEL",
        toJson(orderId),
        System.currentTimeMillis() + 30*60*1000
    ));
}

// 后台异步投递(独立线程),这个可以借助xxl-job
@Scheduled(fixedDelay = 1000)
public void pollAndSend() {
    //发送失败,后台持续重试 查询findReadyToSend 会再次查出然后重试
    List<OutboxMessage> messages = outboxMapper.findReadyToSend();
    for (OutboxMessage msg : messages) {
        if (rmqProducer.send(msg)) {
            outboxMapper.markAsSent(msg.getId()); // 标记已发送
        }
        // 发送失败?下次继续重试!
    }
}

//补充msg
DefaultMQProducer producer = new DefaultMQProducer("DelayProducerGroup");
producer.start();
Message msg = new Message(
    "ORDER_CANCEL_TOPIC",               // Topic
    "UNPAID_ORDER",                     // Tag(可选)
    ("{orderId: \"12345\"}").getBytes() // 消息体
);
// ⭐ 关键:设置延时等级(不是直接设秒数!)
msg.setDelayTimeLevel(3); // 表示第3级延迟,1:1s 2:5s 3:10s 4:30s 5:1m 6:2m 7:3m 8:4m 9:5m 10:6m 11:7m 12:8m 13:9m 14:10m 15:20m  16:30m  17:1h. 18:2h
producer.send(msg);

时间轮 + 多级 CommitLog, 消息存到SCHEDULE_TOPIC_XXXX内部topic中,后台线程DeliverDelayedMessageTimerTask,到期消息投递到指定的topic中,注意哦这个顺序是乱的,不能保证

而且事务是不支持延时消息的,需先提交事务,再发延时消息

消费吧蛋炒饭君:

复制代码
public class OrderCancelConsumer implements MessageListenerConcurrently {
    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ...) {
        for (MessageExt msg : msgs) {
            String orderId = parseOrderId(msg.getBody());
            
            // ⭐ 先查状态!避免重复取消
            if (orderService.getStatus(orderId) == OrderStatus.UNPAID) {
                orderService.cancelOrder(orderId); // 幂等接口
            }
        }
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }
}
限制 原因 影响
最大延迟 2 小时 RocketMQ 延时级别固定(1s~2h 共 18 级) 无法用于"7 天自动收货"
消息可能丢失 Broker 宕机 + 未刷盘(异步刷盘模式) 需配合 本地消息表 或 定时任务兜底
无精确时间控制 延迟是"至少",非"精确"(受消费线程调度 网络影响) 30 分钟可能 30 分 5 秒才触发

那么对于超过2h的情况咱们怎么办?

"长延迟用定时任务"是高并发、强一致性系统中的经典解法 ,尤其适用于 "7天自动收货""30天未评价自动好评""90天订单归档" 等超过 MQ 延迟上限(如 上面RocketMQ 的 2 小时)的场景

🎯 核心思想:不依赖外部中间件,直接读取数据库最终状态

咱们上面也提到多,定时任务是没有外部依赖、强一致(只相信表里面status和create_time),并且可以做记录日志,时间也是可以任选,这就是无拘无束、自由自在的感觉吧

  • order_id % 1024 拆成 1024 张表:orders_0, orders_1, ..., orders_1023

    @XxlJob("autoConfirmReceiptJob")
    public void execute() {
    int shardIndex = XxlJobHelper.getShardIndex(); // 当前实例编号(0 ~ N-1)
    int shardTotal = XxlJobHelper.getShardTotal(); // 总实例数(如 32)

    复制代码
      //分片扫描:每个实例负责部分分表
      for (int suffix = shardIndex; suffix < 1024; suffix += shardTotal) {
          String tableName = "orders_" + suffix;
          scanTableForAutoConfirm(tableName);
      }

    }

    private void scanTableForAutoConfirm(String tableName) {
    long lastOrderId = 0;
    while (true) {
    //满足条件的100个单子
    List<Order> batch = orderMapper.selectBatch(tableName, cutoffTime, lastOrderId, 100);
    if (batch.isEmpty()) break;
    //开启业务处理 更新状态 记录日志
    for (Order order : batch) {
    // 幂等确认(同上)
    confirmOrderSafely(order);
    lastOrderId = order.getOrderId();
    }
    }
    }
    单机的也比较简单:自己看吧
    // 每小时执行一次
    @Scheduled(cron = "0 0 * * * ?") // 每小时整点
    public void scanAndAutoConfirm() {
    // 查找:已发货 + 超过7天 + 未确认收货
    List<Order> orders = orderMapper.selectForAutoConfirm(
    OrderStatus.DELIVERED,
    System.currentTimeMillis() - 7L * 24 * 3600 * 1000
    );

    复制代码
      for (Order order : orders) {
          // 幂等更新:只有状态仍是 DELIVERED 才确认
          int updated = orderMapper.confirmReceiptIfStatusMatch(
              order.getOrderId(),
              OrderStatus.DELIVERED, // 期望旧状态
              OrderStatus.CONFIRMED   // 新状态
          );
         
          if (updated > 0) {
              log.info("✅ Auto-confirm order: {}", order.getOrderId());
              // 可触发积分、通知等后续逻辑
          }
      }

    }

兜底

即使这样也是会有风险的,所以低频率定时任务1h扫描漏单,一个都不能少,必须都消费掉

复制代码
// 1h扫描所有"该处理但未处理"的订单
List<Order> leakOrders = orderMapper.selectLeakedOrders(cutoffTime);
for (Order order : leakOrders) {
    handleSafely(order); // 幂等处理
}
MQ 延时实现 最大延迟 自定义时间 可靠性
RocketMQ 内部队列 + 时间轮 2 小时 ❌ 固定18级 ⭐⭐⭐⭐(持久化)
RabbitMQ TTL + DLX 无上限 ✅ 任意秒 ⭐⭐(内存压力大)
Kafka ❌ 原生不支持 --- --- ---
Pulsar ✅ 支持任意延迟 无上限 ⭐⭐⭐⭐

当然这还是有个隐患,咱们开始的标题也提到了,再怎么说订单数量很大的情况下,单表查询岂不是吃了熊心豹子胆、在关公面前耍小卡拉米,所以是不是得分库分表,要不然做什么事情都背着一个超级大的麻袋一样,这多费劲,所以咱们分而治之

分库分表

  • order_id 取模,比如 order_id % 1024
  • 如果按用户查询比较多也可以按用户,一切跟业务强度挂钩
  • 拆成 1024 个物理表 (如 orders_0, orders_1, ..., orders_1023
  • 可能分布在多个 MySQL 实例(分库)或同一实例(分表)

💡 目的:写入和查询都分散到不同表,避免热点

😎 最后一句金句收尾:

**"短延迟靠 MQ,像外卖小哥------快但可能迟到;

长延迟靠定时任务,像邮政老伯------慢但永不丢件。

真正的大厂架构:

------既雇得起小哥,也信得过老伯,

------还配了个'鹰眼'(兜底任务)盯死角!

这才叫:快、稳、全!"** 💥

相关推荐
Sylvia33.2 小时前
如何获取足球数据统计数据API
java·前端·python·websocket·数据挖掘
毕设源码-郭学长2 小时前
【开题答辩全过程】以 基于SSM的河传图书馆座位预约系统的设计和实现为例,包含答辩的问题和答案
java
それども2 小时前
线程池阻塞队列选择ArrayBlockingQueue与LinkedBlockingQueue区别
java·开发语言·网络协议
沛沛老爹2 小时前
从Web到AI:Agent Skills安全架构实战——权限控制与数据保护的Java+Vue全栈方案
java·开发语言·前端·人工智能·llm·安全架构·rag
Remember_9932 小时前
文件系统与IO操作:深入解析与Java实践
java·开发语言·数据结构·ide·python·算法
ss2732 小时前
若依微服务环境下配置 MySQL + 达梦 DM 多数据源
mysql·微服务·架构
进阶小白猿2 小时前
Java技术八股学习Day24
java·开发语言·学习
淘源码d2 小时前
基于Spring Cloud Alibaba的智慧工地微服务源码实战:快速构建与二次开发指南
java·源码·二次开发·saas·智慧工地
韩立学长2 小时前
【开题答辩实录分享】以《志愿者公益网站的设计与实现》为例进行选题答辩实录分享
android·java·开发语言