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实现间隔批量消费入库:
- 业务后端订阅MQTT上线事件
- 转发事件到RocketMQ
- 业务后端实行间隔订阅批量拉取事件,批量写入DB,避免对DB写入压力过大