5.借助RocketMQ实现批量消费存储设备上线次数

1 业务背景

在前面章节的业务场景中,我们提到过为了便于汇总成每天活跃设备用户数,需要存储设备每天的上线次数。

这就需要订阅MQTT设备上线事件,以此来保存设备每天的上线次数。

订阅存储设备每天的上线次数

当每日有大量的设备上线,会导致同时有大量的设备上线次数等数据需要写入DB,对DB压力很大,为了应对同时大量数据写入DB有如下解决办法:

  • 方案1:分库分表,但是不支持和其他表关联查询(我们业务有和其他表关联查询的需求),此方法行不通。

  • 方案2:同时大量数据写入,调整为同时小批量数据批量写入+分多次拉取。例如:同时4000条数据写入,调整为同时1000条数据批量写入,分4次写。即小批量拉取批量写入+多次拉取的策略

因此,我们考虑使用消息队列RocketMQ,来改进为一次小批量拉取批量写入+多次拉取:配置RocketMQ的消费者的消费速率,即控制每次拉取的消息数量和间隔,进而调节写入DB速度,实现一次小批量拉取写入+多次拉取,避免数据库压力过大。

2 解决方案

借助RocketMQ实现间隔批量消费入库:

2.1 MQTT监听器接收设备上线事件,发送到RocketMQ

2.2 消费者订阅并设置策略(一次小批量拉取写入+多次拉取)

借助RocketMQ实现间隔批量消费入库

3 实现细节

3.1 MQTT监听器接收设备上线事件,发送到RocketMQ

在此之前需要引入依赖:

xml 复制代码
<!-- Spring Boot Starter for RocketMQ -->
<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-spring-boot-starter</artifactId>
    <version>2.2.1</version>
</dependency>

配置生产者

yaml 复制代码
# application.yml
rocketmq:
  name-server: 192.168.56.200:9876;192.168.56.201:9876
  producer:
    group: device_online_producer
    send-message-timeout: 3000
    retry-times-when-send-failed: 2

设备上线事件,转发到RocketMQ

typescript 复制代码
/**
 * @description: 上线事件处理
 * @author:xg
 * @date: 2025/3/1
 * @Copyright:
 */
@TslTypeHandler("online")
@Slf4j
@Component
public class OnlineEventHandler implements TslEventHandler {

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    @Override
    public boolean canHandle(String tslType) {
        return "online".equals(tslType);
    }

    /**
     * 处理设备上线操作: 监听到设备上线事件,转发到RocketMQ
     * @param json
     */
    @Override
    public void handle(JSONObject json) {
        log.info("处理设备上线的操作, msgPayload: {}", json);

        // 监听到设备上线事件,转发到RocketMQ
        String devId = json.getString("devId");
        DeviceOnlineCountDTO deviceOnlineCountDTO =
                new DeviceOnlineCountDTO(Long.valueOf(devId), new Date(), 1L);
        Message<DeviceOnlineCountDTO> message = MessageBuilder
                .withPayload(deviceOnlineCountDTO)
                // 设置keys为devId,实现消息的追踪查询
                .setHeader(RocketMQHeaders.KEYS, devId).build();
        rocketMQTemplate.send(DataStatsTopic.DEVICE_ONLINE_TOPIC, message);

        log.info("转发deviceOnlineCount到RockerMQ:{}", JSON.toJSONString(deviceOnlineCountDTO));

    }
}

3.2 消费者订阅匀速存储数据到DB

注意:为了保证匀速存储数据到DB,需要设置策略:一次小批量拉取写入+多次拉取(即配置一次拉取数据条数、间隔、消费者线程数量)

一次拉取多条设备上线数据,批量写入DB+分多次拉取写入,减轻DB压力。

typescript 复制代码
/**
 * @description: 批量消费拉取设备上线事件,并写入DB
 * @author:xg
 * @date: 2025/3/15
 * @Copyright:
 */
@Slf4j
@Component
@RocketMQMessageListener(
        topic = DataStatsTopic.DEVICE_ONLINE_TOPIC,
        consumerGroup = "device_online_consumer",
        consumeMode = ConsumeMode.CONCURRENTLY,  // 更改为并发消费,与实际使用的监听器一致
        messageModel = MessageModel.CLUSTERING,
        consumeThreadMax = 16
)
public class DeviceOnlineConsumer implements RocketMQListener,RocketMQPushConsumerLifecycleListener {

    @Resource
    private DeviceOnlineCountMapper deviceOnlineCountMapper;


    /**
     * 最大批量拉取数据条数
     */
    private static final int BATCH_MAX_SIZE = 500;
    /**
     * 一次拉取数据条数
     */
    private static final int PULL_BATCH_SIZE = 500;
    /**
     * 拉取间隔 500ms
     */
    private static final int PULL_INTERVAL = 500;


    /**
     * 每隔500ms批量拉取批量写入DB,减轻DB压力
     * @param consumer
     */
    @Override
    public void prepareStart(DefaultMQPushConsumer consumer) {
        // 设置批量消费配置
        consumer.setConsumeMessageBatchMaxSize(BATCH_MAX_SIZE);
        consumer.setPullBatchSize(PULL_BATCH_SIZE);
        consumer.setPullInterval(PULL_INTERVAL);

        // 注册批量消息监听器
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> messages, ConsumeConcurrentlyContext context) {
                try {
                    log.info("收到批量消息,数量: {}", messages.size());

                    // 处理消息并返回聚合结果
                    List<DeviceOnlineCount> aggregatedRecords = processMessages(messages);

                    // 批量写入数据库
                    if (!aggregatedRecords.isEmpty()) {
                        deviceOnlineCountMapper.batchInsertOrUpdate(aggregatedRecords);
                        log.info("成功处理 {} 条原始消息,聚合为 {} 条记录",
                                messages.size(), aggregatedRecords.size());
                    }

                    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                } catch (Exception e) {
                    log.error("处理消息批次失败,消息数量: {}, 错误: {}", messages.size(), e.getMessage(), e);
                    return ConsumeConcurrentlyStatus.RECONSUME_LATER;
                }
            }
        });
    }

    /**
     * 处理消息列表并返回聚合后的设备在线记录
     */
    private List<DeviceOnlineCount> processMessages(List<MessageExt> messages) {
        // 聚合相同设备的计数
        Map<String, DeviceOnlineCount> aggMap = new HashMap<>();

        for (MessageExt msg : messages) {
            try {
                DeviceOnlineCountDTO record = JSON.parseObject(msg.getBody(), DeviceOnlineCountDTO.class);
                DeviceOnlineCount deviceOnlineCount = new DeviceOnlineCount();
                BeanUtils.copyProperties(record, deviceOnlineCount);

                // 使用设备ID和日期作为聚合键
                String key = deviceOnlineCount.getDevId() + ":" + deviceOnlineCount.getOnlineDate();

                aggMap.compute(key, (k, v) -> {
                    if (v == null) {
                        return deviceOnlineCount;
                    }
                    v.setOnlineCount(v.getOnlineCount() + deviceOnlineCount.getOnlineCount());
                    return v;
                });
            } catch (Exception e) {
                // 单条消息解析错误不应影响整个批次
                log.warn("处理单条消息失败: {}", e.getMessage());
            }
        }

        return new ArrayList<>(aggMap.values());
    }

    @Override
    public void onMessage(Object message) {

    }
}

xml中的批量写入或者更新sql语句

xml 复制代码
 <!--批量写入或者更新数据-->
    <insert id="batchInsertOrUpdate" parameterType="java.util.List">
        insert into device_online_count
        <trim prefix="(" suffix=")" suffixOverrides=",">
            dev_id,
            online_date,
            online_count
        </trim>
        values
        <foreach collection="list" item="item" separator=",">
            (
            #{item.devId,jdbcType=BIGINT},
            #{item.onlineDate,jdbcType=TIMESTAMP},
            #{item.onlineCount,jdbcType=BIGINT}
            )
        </foreach>
        ON DUPLICATE KEY UPDATE online_count = online_count + VALUES(online_count);
    </insert>

【注意】:需要对dev_id、online_date组合设置唯一索引,这样才能实现当不存在这个组合时写入,存在则更新上线次数的目的。

4 相关参数

4.1 consumeThreadMax

含义:

这个参数指定了RocketMQ消费者可以使用的最大线程数量。

ini 复制代码
@RocketMQMessageListener(
       ...
       consumeThreadMax = 16

作用:

  • 它控制了消费者并发处理消息的能力

  • 决定了同时可以有多少个线程来消费消息队列中的消息

  • 在高负载情况下,更多的线程可以提高消息处理的吞吐量

设置合理的大小:

  • 根据服务器的CPU核心数(通常设置为CPU核心数的1-2倍较为合理)
  • 根据消息量(高峰期消息量大时可能需要更多线程)

我们服务器8核心,所以初始设置为8 *2 = 16个线程。后期可以根据消息量的大小、系统负载等因数逐步调整。

4.2 consumeMode消费模式

RocketMQ的两种消费模式:

  • ConsumeMode.CONCURRENTLY(并发消费)

  • 适用场景:对消息顺序没有严格要求的普通消息

  • 优点:多个消息可以被多个线程同时处理,并发度高,消费性能好,消费速度较快

  • 缺点:不保证消息顺序

  • ConsumeMode.ORDERLY(顺序消费)

  • 适用场景:需要严格保证消息顺序的场景(如订单状态流转)

  • 优点:保证消息顺序性

  • 缺点:性能相对较低,因为要保证顺序性会带来一定的锁开销

consumeMode应该怎么设置?我们项目中没有严格的顺序要求,所以使用并发消费。

ini 复制代码
@RocketMQMessageListener(
        ...
        consumeMode = ConsumeMode.CONCURRENTLY,  // 更改为并发消费,与实际使用的监听器一致

5 数据库连接池HikariCP

SpringBoot2.x默认使用HikariCP连接池。默认参数不满足高并发场景,需要进行参数调整。

5.1 参数调整

  • 连接池大小

根据公式:((CPU核心数 * 2) + 磁盘数)配置,如下:

ini 复制代码
int cores = Runtime.getRuntime().availableProcessors();
// 假设磁盘数为1
int diskCount = 1;
return ((cores * 2) + diskCount);
  • 超时时间(快速失败)

设置2000ms

  • 超时未关闭报警

最终配置如下:

yaml 复制代码
spring:
  datasource:
    hikari:
      # 根据公式:((CPU核心数 * 2) + 磁盘数)配置
      maximum-pool-size: 16
      # 与maximum-pool-size保持一致可以提高性能
      minimum-idle: 16
      # 建议设置为不超过2000毫秒
      connection-timeout: 2000
      # 60秒未关闭连接则报警
      leak-detection-threshold: 60000

5.2 打印参数验证是否生效

首先,需要连接池开启debug日志,便于查看参数生效与否

yaml 复制代码
logging:
  level:
    com.zaxxer.hikari: DEBUG
    com.zaxxer.hikari.HikariConfig: DEBUG

然后,查看日志打印连接池参数,如下:

ini 复制代码
03-22 13:03:51.977 DEBUG [ConsumeMessageThread_2] com.zaxxer.hikari.HikariConfig:1098 - leakDetectionThreshold..........60000
03-22 13:03:51.977 DEBUG [ConsumeMessageThread_2] com.zaxxer.hikari.HikariConfig:1098 - maxLifetime.....................1800000
03-22 13:03:51.977 DEBUG [ConsumeMessageThread_2] com.zaxxer.hikari.HikariConfig:1098 - maximumPoolSize.................16
03-22 13:03:51.978 DEBUG [ConsumeMessageThread_2] com.zaxxer.hikari.HikariConfig:1098 - minimumIdle.....................16
03-22 13:03:51.976 DEBUG [ConsumeMessageThread_2] com.zaxxer.hikari.HikariConfig:1098 - connectionTimeout...............2000

说明,配置的参数生效了。

6 总结

本章主要说明了当面临有大量设备上线数据需要写入DB,为避免写入操作对DB压力过大,我们的解决方案。当然,解决方案不止这一种,肯定还有其他的办法。我们这里只是提供了其中一个解决办法,即借助RocketMQ实现间隔批量消费入库:

  1. 业务后端订阅MQTT上线事件
  2. 转发事件到RocketMQ
  3. 业务后端实行间隔订阅批量拉取事件,批量写入DB,避免对DB写入压力过大
相关推荐
老马啸西风3 小时前
Occlum 是一个内存安全的、支持多进程的 library OS,特别适用于 Intel SGX。
网络·后端·算法·阿里云·云原生·中间件·golang
冯浩(grow up)8 小时前
Spring Boot 连接 MySQL 配置参数详解
spring boot·后端·mysql
Asthenia04128 小时前
面试复盘:left join 底层算法(嵌套/哈希/分块) & 主从复制(异步/半同步/同步)
后端
秋野酱8 小时前
基于javaweb的SpringBoot雪具商城系统设计与实现(源码+文档+部署讲解)
java·spring boot·后端
计算机-秋大田9 小时前
基于Spring Boot的ONLY在线商城系统设计与实现的设计与实现(LW+源码+讲解)
java·vue.js·spring boot·后端·课程设计
赵钰老师9 小时前
【DeepSeek大语言模型】AI智能体开发与大语言模型的本地化部署、优化技术
人工智能·语言模型·自然语言处理·数据分析
爱的叹息9 小时前
spring boot + thymeleaf整合完整例子
java·spring boot·后端
Asthenia04129 小时前
MySQL:意向锁与兼容性/MySQL中的锁加在什么上?/innodb中锁的底层是怎么实现的?
后端
程序猿DD_10 小时前
如何用Spring AI构建MCP Client-Server架构
java·人工智能·后端·spring·架构
小兵张健11 小时前
Cursor 嵌入产研 —— 从产品背景到后端代码实现
后端·ai编程·cursor