通过RocketMQ延时消息实现优惠券等业务MySQL当中定时自动过期

向我们常使用的优惠券都有使用时限,有的是几个小时有的是几天

在缓存当中我们可以设定TTL让redis自动销毁,但是数据库不行,需要我们自己执行数据库字段修改对应属性或者进行逻辑删除。

但是,这不应该是管理人员应该做的事情,这种事情很明显应该是程序自动实现而不是每次都去麻烦管理人员。

这里我们利用RocketMQ消息队列的特性:延时消息进行实现。(这也是我在自己的项目当中最常使用的一点)这里我们在优惠券的模板当中设置了一个字段:过期状态

为什么能够使用RocketMQ延时消息进行精确时间修改?

(传统方法可以通过XXL---Job等进行实现)

传统方法的弊端:

  1. 时间的精确度不高
    1. 原因:这个实现本质上是轮询。调度器的触发频率决定了延迟的下限
    2. 场景:假设每分钟扫描一次数据库,任务设定在10:00:01但是上次扫描正好在10:00:00结束,那么这个任务必须要等到10:01:00才能再次被取出然后执行
  2. 可能造成任务的堆积进而导致大量的资源占用、无法精确删除
    1. 原因:业务的处理速度如果低于生产任务的速度,或者某一次数据库的扫描取出的任务量过大,导致处理时间超过一个扫描间隔,导致,任务继续被积压,无法再相应时间结点被删除,甚至可能造成损失(例如优惠券业务)
    2. 雪崩效应:积压会导致下一次扫描时查询的数据量更大,更慢、处理更久,造成恶性循环
  3. 可能造成任务的堆积
    1. 原因:注意图当中的"内存任务队列"
      1. 断电、宕机风险:任务一旦数据库当中扫描出来并放入内存队列当中,数据库当中数据会被标记为处理当中。如果此时服务器宕机、重启,内存当中的任务就会消失。
  4. 延迟和性能之间的矛盾
    1. 想要延迟度低(精度高)→提高扫描频率→数据库压力爆大
    2. 想要保护数据库→降低扫描频率→延迟变高

使用rocketmq定时消息的优势:

  1. 定时精度高,开发门槛低:消息定时时间不存在阶梯间隔,可以轻松实现任意精度事件触发,无需业务去重

    1. 早期的rocketmq当中,延迟消息设计基于18个固定的延迟级别。开发者只能从预设的时间间隔当中选择------称为时间间隔

    2. rocketmq5之后引入了时间轮算法(像一个时钟上卖弄的有许多刻度一样),这样的设计使得rocket支持毫秒级的任意精度定时,根据业务需求设置时间。

    3. 传统定时任务为什么需要去重?

      1. 传统的定时任务解决方案如依赖数据库定时扫描,在分布式环境下存在普遍难题:为了避免单点故障,通常部署多个服务实例,每个实例都可能重复扫描到同一任务而导致任务被多次执行。要求开发者必须在业务层面开发一套其中机制(分布式锁/数据库当中记录执行状态)保证任务的幂等性

      2. 消息队列从根本上解决类这个问题,通过消息通知的方式将任务转为一条具有唯一性消息。设定时间到到达之后将消息传递给消费者集群当中某个实例进行处理(消费)

  2. 高性能、可扩展:传统的定时方案较为复杂,需要进行数据库扫描,容易遇到性能瓶颈问题,rocketmq可以基于定时消息特性完成时间驱动,实现百万级消息tps(transactions per second)能力

    1. RocketMQ的负载均衡机制

      1. 大量的定时任务在同一时间被触发时,消息队列可以作为缓冲,让消费者按照自己的处理能力慢慢进行消费而不是让数据库立即接受大量请求

      2. RocketMQ采用Push(投递)机制。消息到期之后,Broker根据负载均衡策略,将消息唯一地投递给消费正集群中的某一个实例

    2. 容错、重试机制

      1. 业务处理失败(抛出异常)/机器宕机,RocketMQ自带指数退避机制(EG:1\5\10是......重试),无需开发者自己手写重试逻辑

消息队列实战

这里没有使用更加高级的模板方法模式,为演示使用最简单的调用

引入依赖&配置文件

由于父模块已经进行了消息队列的版本管理,直接引入,无需写上版本

java 复制代码
<!--消息队列依赖-->
<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-spring-boot-starter</artifactId>
</dependency>

引入了依赖之后才能正常编写配置文件

生产者常常是单例的(单个模块当中)对应的组别、发送超时时间、是黑白重试次数等信息可以直接配置在配置文件当中

XML 复制代码
rocketmq:
  name-server: common-rocketmq-dev.magestack.cn:9876
  producer:
    group: oneCoupon_merchant-admin-marc060721-service_common-message-execute_pg
    send-message-timeout: 2000
    retry-times-when-send-failed: 1 #同步发送失败尝试次数
    retry-times-when-send-async-failed: 1 #异步发送失败尝试次数
编写消费者处理类

由于消费者可能有多个且每个的相关配置都不太一样,使用注解进行配置更加方便

java 复制代码
@Component
@RequiredArgsConstructor
//告诉接口监听哪个topic、consumerGroup
@RocketMQMessageListener(
        topic = "one-coupon_merchant-admin-service_coupon-template-delay_topic-marc060721",
        consumerGroup = "one-coupon_merchant-admin-service_coupon-template-delay-status_cg-marc060721"
)
@Slf4j(topic = "CouponTemplateDelayExecuteStatusConsumer")//用于输出日志时和其他日志进行区分
//定义消费者在拿到消息之后如何处理
public class CouponTemplateDelayExecuteStatusConsumer implements RocketMQListener<JSONObject> {

    private final CouponTemplateService couponTemplateService;
    //对消息进行处理
    @Override
    public void onMessage(JSONObject message) {
        //开头打印日志,平常可以用来debug,线上用来检查消息是否被消费、重新投递时获取参数等)
        log.info("[消费者]优惠券模板定时执行变更模板表状态 - 执行消费逻辑,消息体:{}",message.toString());

        //修改指定优惠券模板状态为已结束
        LambdaUpdateWrapper<CouponTemplateDO> updateWrapper = Wrappers.lambdaUpdate(CouponTemplateDO.class)
                .eq(CouponTemplateDO::getShopNumber, message.getLong("shopNumber"))
                .eq(CouponTemplateDO::getId, message.getLong("couponTemplateId"))
                .set(CouponTemplateDO::getStatus, CouponTemplateStatusEnum.ENDED.getStatus());
        couponTemplateService.update(updateWrapper);


    }
}
为什么没有为这个消费者类编写配置类照样能够处理消息队列消息
1-前提条件:添加了starter依赖

rocketmq-spring-boot-starter依赖添加到模块的配置当中。这个告诉Spring:在启动的时候为rocketmq编写一系列特殊自动配置类

2-@Component注解------让类被spring看见

告诉spring框架为这个类创建一个实例,放到spring的应用上下文当中(ioc容器进行统一管理)

3-@RocketMQListener------特殊指令牌

这才是真正的魔法发生的地方。rocketmq-spring-boot-starter 的自动配置程序在 Spring Boot 启动过程中,会专门做这样一件事:

"扫描 Spring 容器中所有 的 Bean,我正在寻找身上带有 @RocketMQMessageListener 这个注解的 Bean。"

当 Starter 发现了您的 CouponTemplateDelayExecuteStatusConsumer 这个 Bean 时,它看到了这个特殊的"指令牌"注解,立刻就明白了:"啊哈!这不只是一个普通的组件,这是一个我需要为它专门建立和配置的 RocketMQ 消息消费者!

4-框架完成工作

一旦 Starter 识别出你的类是一个消费者,它就会在后台为你完成所有脏活累活:

  1. 读取注解属性 :它会从 @RocketMQMessageListener 注解中提取出 topic ("one-coupon_...") 和 consumerGroup ("one-coupon_...") 的值。

  2. 创建底层消费者 :它在后台实例化并配置一个底层的 DefaultMQPushConsumer(这才是 RocketMQ 客户端库里真正的、原始的消费者对象)。

  3. 配置连接信息 :它从你的 application.yml 配置文件中获取 name-server 地址,告诉 DefaultMQPushConsumer 该去哪里连接 RocketMQ 集群。

  4. 订阅主题 :它使用从你注解中读到的 topic,告诉 DefaultMQPushConsumer 应该订阅哪个主题的消息。

  5. 设置消费组 :同样,它使用注解中的 consumerGroup 来设置这个消费者的组ID。

  6. 关联你的业务逻辑 :这是最关键的一步。框架建立了一个监听机制,一旦底层的 DefaultMQPushConsumer 从 Broker 拉取到消息,就会自动调用你的 CouponTemplateDelayExecuteStatusConsumer Bean 的 onMessage 方法,并把消息作为参数传给你。

  7. 管理生命周期 :框架会确保底层的 DefaultMQPushConsumer 在整个应用启动时正确启动,在应用关闭时优雅地关闭,你完全不用操心这些。

Sl4j当中topic属性作用

如果没有 topic 这个属性,那么你的日志打印是这样的:

java 复制代码
2024-08-22T19:26:15.172+08:00  INFO 90884 --- [io-10010-exec-1] c.n.o.m.a.s.i.CouponTemplateServiceImpl  : [生产者] 优惠券模板延时关闭 - 发送结果:SEND_OK,消息ID:2408820760D4CCC0901EE0E538FD681A6304251A69D7705148480000,消息Keys:d904fbe7-f8c6-4e77-997c-6b08f83868a3

添加了之后日志打印引用类就会更加规范:

java 复制代码
2024-08-22T19:23:17.456+08:00  INFO 78983 --- [cg-mading0924_2] CouponTemplateDelayExecuteStatusConsumer : [消费者] 优惠券模板定时执行@变更模板表状态 - 执行消费逻辑,消息体:{"couponTemplateId":1826580899668439042,"shopNumber":1810714735922956666}
修改服务层

自定义常量:(我不使用vm参数进行注入,直接定义一个常量类)

java 复制代码
public class MerchantAdminRocketMQConstant {
    public static final String COUPON_TEMPLATE_DELAY_CLOSE_TOPIC_KEY="one-coupon_merchant-admin-service_coupon-template-delay_topic-marc060721";
}

修改服务代码:

java 复制代码
@SneakyThrows//(这里作用不大)偷偷抛出受检异常(必须进行try-catch/throws),无需手动在方法当中添加throws ,无需手动try-catch
@LogRecord(
        success = """
                创建优惠券:{{#requestParam.name}}, \
                 优惠对象:{COMMON_ENUM_PARSE{'DiscountTargetEnum' + '_' + #requestParam.target}}, \
                 优惠类型:{COMMON_ENUM_PARSE{'DiscountTypeEnum' + '_' + #requestParam.type}}, \
                 库存数量:{{#requestParam.stock}}, \
                 优惠商品编码:{{#requestParam.goods}}, \
                 有效期开始时间:{{#requestParam.validStartTime}}, \
                 有效期结束时间:{{#requestParam.validEndTime}}, \
                 领取规则:{{#requestParam.receiveRule}}, \
                 消耗规则:{{#requestParam.consumeRule}};
                """,
        //日志类型,用于服务层进行判断
        type = "CouponTemplate",
        bizNo = "{{#bizNo}}",
        extra = "{{#requestParam.toString()}}"
)
//注解添加在对应的功能代码而不是控制层调用的方法上面更加符合代码逻辑和上锁的逻辑
@NoDuplicateSubmit
@Override
public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {
、、、、
    //使用rocketmq 5.x发送任意时间延时消息
    //定义topic,这里和原来项目不一样,我直接自己定义了常量,原来的项目是通过vm参数进行替换
    //rocketmq当中西奥菲这、生产者只关心topic
    String couponTemplateDelayTopic=COUPON_TEMPLATE_DELAY_CLOSE_TOPIC_KEY;

    //定义消息体
    JSONObject messageBody = new JSONObject();//JSON数据对象,并非单纯的json字符串,方便读写字段
    messageBody.put("couponTemplateId",couponTemplateDO.getId());
    messageBody.put("shopNumber",couponTemplateDO.getShopNumber());

    //设置消息送达时间,毫秒级Unix时间戳
    long deliverTimeStamp = couponTemplateDO.getValidEndTime().getTime();

    //构建消息体
    String messageKeys = UUID.randomUUID().toString();
    Message<JSONObject> message = MessageBuilder
            .withPayload(messageBody)//真正的消息
            .setHeader(MessageConst.PROPERTY_KEYS, messageKeys)
            .build();
    //执行rocketmq的消息发送&异常处理逻辑
    SendResult sendResult;

    try {
        //同步发送消息(串行)
        sendResult = rocketMQTemplate.syncSendDeliverTimeMills(couponTemplateDelayTopic, message, deliverTimeStamp);
        log.info("[生产者] 优惠券模板延时关闭 - 发送结果:{},消息ID:{},消息Keys:{}", sendResult.getSendStatus(), sendResult.getMsgId(), messageKeys);
    } catch (Exception ex) {
        log.error("[生产者] 优惠券模板延时关闭 - 消息发送失败,消息体:{}", couponTemplateDO.getId(), ex);
    }


}
结果

执行的操作:

后端输出:

可以看到对应的第三条数据设定为了已经结束状态:

注意:只有消息设定的时间到了才能够在控制面板当中查询到,在此之前都会将这个消息放入内部专用、统一的定时topic当中(一个"定时消息候车室",成功标示成功放入候车室)、

预定的时间到达之后定时服务将其作为普通消息发送给对应的业务topic当中

相关推荐
胡楚昊42 分钟前
CTF SHOW逆向
java·服务器·前端
Gundy44 分钟前
构建一个真正好用的简单搜索引擎
后端
疯狂的程序猴1 小时前
构建面向复杂场景的 iOS 应用测试体系 多工具协同下的高质量交付实践
后端
大巨头1 小时前
C# 中如何理解泛型
后端
烤麻辣烫1 小时前
黑马程序员苍穹外卖(新手)DAY12
java·开发语言·学习·spring·intellij-idea
BD_Marathon1 小时前
【IDEA】常用插件——3
android·java·intellij-idea
仙女修炼史1 小时前
目标分割学习之U_net
人工智能·深度学习·学习
用户992441031561 小时前
TRAE SOLO实战录:AI应用可观测性与风险管控的破局之道
后端
Dr丶net1 小时前
🔥NestJS 接口文档神器!nestjs-knife4j-plus 让 Swagger 颜值与功能双飞跃
后端