Node 定时任务、排他锁、RabbitMQ 实现过期订单自动更新

1、为什么要使用定时任务

  • 当用户下单了,如果长期没付款。将来再去付款的时候,支付宝和微信那边就会提示订单过期了,无法支付。这就是说,订单支付超时后,最好将订单的状态设置为已过期,让用户无法再发起支付。
  • 当用户支付了订单,就会自动变成大会员身份。但是大会员身份是有有效期的,在过期后,就需要将这些用户变成普通用户。

2、node-schedule的使用

2.1、 安装 node-schedule

npm install node-schedule

2.2、 定时代码解析

在这里插入图片描述

*    *    *    *    *    *
┬    ┬    ┬    ┬    ┬    ┬
│    │    │    │    │    │
│    │    │    │    │    └ 星期几 (0 - 7) (07 是星期天)
│    │    │    │    └───── 月份 (1 - 12)
│    │    │    └────────── 日期 (1 - 31)
│    │    └─────────────── 小时 (0 - 23)
│    └──────────────────── 分钟 (0 - 59)
└─────────────────────────  (0 - 59, 可选)

例一:在文档例子里

const job = schedule.scheduleJob('42 * * * *', function(){
  console.log('The answer to life, the universe, and everything!');
});

大家先数一数,这里只有 5 位数,而完整的格式是 6 位数。这就说明,秒被省略了。那么这个42就是分钟了。这就是说,每个小时的第 42 分钟,会执行一次

例二,下面还有个例子:

const job = schedule.scheduleJob('0 17 ? * 0,4-6', function(){
  console.log('Today is recognized by Rebecca Black!');
});
  • 0:表示在第 0 分钟时触发。
  • 17:是说在 17 点(也就是下午 5 点)触发。
  • ?:表示不指定具体的日期,因为在最后面一位,指定了星期几。
  • *:表示每个月都运行。
  • 0,4-6:最后面的这个,0 表示:星期天(0)。4-6 表示:星期四(4)、星期五(5)、和星期六(6)都可以。

3.4. 在 Node 项目中使用 node-schedule

// 定时任务
const schedule = require('node-schedule');
const job = schedule.scheduleJob('* * * * * *', function(){
  console.log('Hello xw!');
});

3、 项目中封装订单的处理

因为一个项目可能有多个定时任务,所以我们可以专门建一个文件夹,来存放定时任务相关的文件

3.1、在根目录中,新建tasks目录,里面再新建一个check-order.js文件。

const schedule = require('node-schedule');
const { sequelize, Order } = require('../models');
const { Op } = require('sequelize');
const logger = require('../utils/logger');
const moment = require('moment');

/**
 * 定时检查并处理超时未支付订单
 * 每天凌晨 4:30 执行一次 测试时可改为0 * * * * *  每分钟的第 0 秒
 */
function scheduleOrderCheck() {
  schedule.scheduleJob('0 30 4 * * *', async () => {
    const t = await sequelize.transaction();

    try {
      // 查找超时未支付的订单
      const expiredOrders = await Order.findAll({
        attributes: ['id'],
        where: {
          status: 0,
          createdAt: {
            [Op.lt]: moment().subtract(1, 'day').toDate()
          }
        },
        transaction: t,
        lock: true  // 使用排它锁,防止并发更新
      });

      // 已超时订单的 ID 列表
      const orderIds = expiredOrders.map(order => order.id);

      // 批量更新超时订单状态
      await Order.update(
        {
          status: 2,      // 订单状态:已取消(超时)
        },
        {
          where: {
            id: orderIds
          },
          transaction: t
        }
      );

      await t.commit();
    } catch (error) {
      await t.rollback();
      logger.error('定时任务处理超时订单失败:', error);
    }
  });
}

module.exports = scheduleOrderCheck;

  • 先在上面做了一些引用
  • 定义一个检查超时订单的任务。
  • 定时的第一个参数,先设置成0,也就是每分钟的第 0 秒执行一次
  • 下面开启了事务
  • 查找状态为0,也就是未支付的,并且在一天之前创建的订单
  • 找到的订单,都加上排它锁
  • 然后使用map,遍历一下,将所有订单的ID组装成数组
  • 接着就调用update方法,批量更新状态为2,也就是已过期。
  • 完成后就提交事务。
  • 最底下,如果执行失败,除了回滚外,还加上了日志。

3.2、index.js 注册所有定时任务

const scheduleOrderCheck = require('./check-order');
const logger = require('../utils/logger');

/**
 * 初始化所有定时任务
 */
function initScheduleTasks() {
  try {
    // 启动订单超时检查任务
    scheduleOrderCheck();
  } catch (error) {
    logger.error('定时任务启动失败:', error);
  }
}

module.exports = initScheduleTasks;

3.3、修改 app.js

// 启动定时任务
const initScheduleTasks = require('./tasks');
initScheduleTasks();

4、定时器、排他锁结合RabbitMQ实现 订单自动过期功能

参考:RabbitMQ 消息队列 优化发送邮件写法

参考:数据库事务、乐观锁及悲观锁

4.1、安装RabbitMQ

npm i amqplib

4.2、封装RabbitMQ 生产者及消费者

utils/rabbit-mq.js

const amqp = require('amqplib');
// 日志 可去除
const logger = require('./logger');
const {sequelize, Order} = require("../models");

// 创建全局的 RabbitMQ 连接和通道
let connection;
let channel;

// 封装一个重试连接的函数,增加连接的稳定性
const connectWithRetry = async (url, retries = 5, delay = 5000) => {
  let attempt = 0;
  while (attempt < retries) {
    try {
      return await amqp.connect(url);
    } catch (error) {
      attempt++;
      logger.error(`RabbitMQ 连接尝试 ${attempt} 失败:`, error);
      if (attempt < retries) {
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
  throw new Error('无法连接到 RabbitMQ,已达到最大重试次数');
};

/**
 * 连接到 RabbitMQ
 * @returns {Promise<*>}
 */
const connectToRabbitMQ = async (queueName) => {
  if (connection && channel) return;

  try {
    connection = await connectWithRetry(process.env.RABBITMQ_URL);
    channel = await connection.createChannel();

    // 监听连接关闭事件,方便处理异常
    connection.on('close', () => {
      logger.warn('RabbitMQ 连接已关闭,尝试重新连接...');
      connection = null;
      channel = null;
    });

    // 监听连接错误事件,增强错误处理能力
    connection.on('error', (err) => {
      logger.error('RabbitMQ 连接发生错误:', err);
    });

    await channel.assertQueue(queueName, {durable: true});
  } catch (error) {
    logger.error('RabbitMQ 连接失败:', error);
    throw error;
  }
};

/**
 * 生产者(发送消息)
 */
const producer = async (queueName, msg) => {
  try {
    await connectToRabbitMQ(queueName); // 确保已连接

    // 消息持久化设置,提高消息可靠性
    const options = {persistent: true};
    const sent = channel.sendToQueue(queueName, Buffer.from(JSON.stringify(msg)), options);
    if (!sent) {
      logger.warn('消息未能立即入队,等待下次机会');
    }
  } catch (error) {
    logger.error('邮件队列生产者错误:', error);
    throw error;
  }
};

/**
 * 过期订单队列消费者(接收消息)
 * @returns {Promise<void>}
 */
async function consumeExpiredOrders() {
  try {
    // 链接RabbitMQ
    await connectToRabbitMQ('expired_orders_queue');
    channel.consume('expired_orders_queue', async (msg) => {
        if (msg) {
          const orderIds = JSON.parse(msg.content.toString());
          const t = await sequelize.transaction();
          try {
            // 批量更新超时订单状态
            await Order.update(
              {
                status: 2,      // 订单状态:已取消(超时)
              },
              {
                where: {
                  id: orderIds
                },
                transaction: t
              }
            );
            await t.commit();
            channel.ack(msg);
            console.log(`成功处理 ${orderIds.length} 个过期订单`);

            logger.info('过期订单消费者已开始监听');

          } catch (error) {
            await t.rollback();
            logger.error('处理过期订单时出错:', error);
            channel.nack(msg);
          }
        }
      }
    )
    ;
  } catch (error) {
    console.error('消费过期订单消息时出错:', error);
  }
}

module.exports = {
  producer,
  consumeExpiredOrders

};

utils/order-consumer.js

require('dotenv').config();
const {consumeExpiredOrders} = require('./rabbit-mq');

const logger = require('./logger');
// 封装启动消费者的函数,方便后续扩展和错误处理
const startxpiredOrdersConsumer = async () => {
  try {
    await consumeExpiredOrders();
    logger.info('订单已启动');
  } catch (error) {
    logger.error('启动订单时出错:', error);
    process.exit(1);
  }
};

// 启动消费者
startxpiredOrdersConsumer();

// 监听进程信号,优雅关闭消费者
process.on('SIGINT', async () => {
  logger.info('收到 SIGINT 信号,正在优雅关闭订单消费者...');
  try {
    // 这里可以添加关闭连接和通道的逻辑
    process.exit(0);
  } catch (error) {
    logger.error('关闭订单时出错:', error);
    process.exit(1);
  }
});

ecosystem.config.js增加order-consumer脚本

module.exports = {
  apps: [
    {
      name: "express-app",
      script: "./bin/www",
      watch: process.env.NODE_ENV === 'development', // 根据环境变量决定是否开启监听
      interpreter: "node",
      env: {
        NODE_ENV: "development"
      },
      env_production: {
        NODE_ENV: "production"
      }
    },
	{
      name: "order-consumer",
      script: "./utils/order-consumer.js",
      interpreter: "node",
      env: {
        NODE_ENV: "development"
      },
      env_production: {
        NODE_ENV: "production"
      }
    }
  ]
};

4.3、order定时器封装修改参照 上述第3点修改check-order.js

tasks/check-order.js

const schedule = require('node-schedule');
const {sequelize, Order} = require('../models');
const {Op} = require('sequelize');
const logger = require('../utils/logger');
const moment = require('moment');
const {producer} = require('../utils/rabbit-mq')

/**
 * 定时检查并处理超时未支付订单
 * 每天凌晨 0:00 执行一次
 */
async function processExpiredOrders() {
  // 定义每次查询的订单数量,即分页查询时每页的记录数
  const pageSize = 1000;
  // 偏移量,用于分页查询,初始为 0 表示从第一条记录开始查询
  let offset = 0;
  // 标记是否还有更多数据可查询,初始设为 true
  let hasMoreData = true;

  // 当还有数据可查询时,进入循环进行分页查询和处理
  while (hasMoreData) {
    // 开启一个数据库事务,确保数据操作的原子性
    const t = await sequelize.transaction();
    try {
      // 查找超时未支付的订单,使用分页查询的方式
      const expiredOrders = await Order.findAll({
        // 只查询订单的 id 字段,减少数据传输量
        attributes: ['id'],
        where: {
          // 筛选出状态为 0(未支付)的订单
          status: 0,
          // 筛选出创建时间早于当前时间一天前的订单,即超时未支付订单
          createdAt: {
            [Op.lt]: moment().subtract(1, 'day').toDate()
          }
        },
        // 每页查询的记录数
        limit: pageSize,
        // 偏移量,用于分页
        offset: offset,
        // 将查询操作纳入当前事务
        transaction: t,
        // 使用排它锁,防止并发更新,保证数据一致性
        lock: true
      });

      // 如果查询到的过期订单数量为 0,说明没有更多符合条件的数据了
      if (expiredOrders.length === 0) {
        hasMoreData = false;
        // 提交事务
        await t.commit();
        // 跳出循环
        break;
      }

      // 提取已超时订单的 ID 列表,方便后续批量操作
      const orderIds = expiredOrders.map(order => order.id);

      // 将订单 ID 发送到消息队列,以便后续异步处理
      await producer('expired_orders_queue', orderIds)
      // 批量更新超时订单的状态为 2(已取消(超时))
      await Order.update(
        {
          status: 2,      // 订单状态:已取消(超时)
        },
        {
          where: {
            id: orderIds
          },
          // 将更新操作纳入当前事务
          transaction: t
        }
      );

      // 提交事务,确保数据更新操作生效
      await t.commit();
      // 偏移量增加,用于下一次分页查询
      offset += pageSize;
    } catch (error) {
      // 若出现异常,回滚事务,保证数据的一致性
      await t.rollback();
      // 记录错误日志,方便后续排查问题
      logger.error('定时任务处理超时订单失败:', error);
      // 跳出循环,终止当前查询流程
      break;
    }
  }
}

function scheduleOrderCheck() {
  // 使用 node - schedule 模块设置定时任务,每天凌晨 0:00 执行 processExpiredOrders 函数
  schedule.scheduleJob('0 0 0  * * *', processExpiredOrders);
}

// 导出 scheduleOrderCheck 函数,供其他模块调用以启动定时任务
module.exports = scheduleOrderCheck;

4.4 重启项目

pm2 start ecosystem.config.js --no-daemon
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值