书接上文:
1-改造方式一:线程池异步入库
当系统比较单一,而且QPS也不高的情况下,可以直接用线程池来进行优化,异步的收集日志
java
@Configuration
@EnableAsync// 启用异步方法执行功能,配合 @Async 注解使用
public class LogAsyncPoolConfig {
@Bean("logThreadPool")// 将方法返回的对象注册为名为 "logThreadPool" 的 Spring Bean
public Executor logThreadPool() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(4);
// 最大线程数:线程池允许的最大线程数量
executor.setMaxPoolSize(8);
// 队列容量:任务队列最大容量,超出后创建新线程,后期可以改为:丢弃策略DiscardOldestPolicy
executor.setQueueCapacity(500);
// 创建的线程名称前缀,便于调试识别
executor.setThreadNamePrefix("log-async-");
// 丢弃逻辑
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardOldestPolicy());
// 等待任务完成,日志能尽量处理完再关机
executor.setWaitForTasksToCompleteOnShutdown(true);
// 最多等待 60 秒
executor.setAwaitTerminationSeconds(60);
// 线程执行前,插入包装逻辑
// TaskDecorator 是"任务执行上下文增强器",常用于 ThreadLocal 传递
executor.setTaskDecorator(new MdcTaskDecorator());
executor.initialize();
return executor;
}
}
增强,将主线程MDC线程局部变量放入子线程中:
java
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// 捕获主线程上下文
Map<String, String> context = MDC.getCopyOfContextMap();
return () -> {
try {
// 把主线程的 MDC 内容塞进异步线程
if (context != null) {
MDC.setContextMap(context);
}
runnable.run();
} finally {
// 用完必须清理,避免线程复用导致 traceId 串号
MDC.clear();
}
};
}
}
异步改造:
java
@Component
@Slf4j
@RequiredArgsConstructor
public class ApiLogCollector {
private final ApiLogService apiLogService;
@Async("logThreadPool")
public void collect(ApiLogEvent event) {
// 日志入库
try {
// 后续可改为批量入库
apiLogService.save(new ApiLog(event));
} catch (Exception e) {
log.error("ApiLog 异步入库失败, event={}", event, e); // 或者用监控上报
// 不要再往外抛异常
}
}
}
2-改造方式二:使用MQ解耦
为了解耦、削峰、可扩展等特点,可以使用MQ来处理
- 启用yaml配置:
开启发布者确认机制
作用:保证消息成功到达队列,避免生产端丢失
yaml
spring:
rabbitmq:
publisher-confirm-type: correlated # 开启 confirm
publisher-returns: true # 开启 return(路由失败)
listener:
simple:
acknowledge-mode: manual # 手动Ack
prefetch: 30 # prefetch(每个消费者拉多少);根据日志量调整,建议 5~50
concurrency: 5 # concurrency(最小消费者数);可选,消费者并发数
max-concurrency: 10 # max-concurrency(最大消费者数)
# 5~10个消费者 × 每个拉30条
prefetch: 30:让 RabbitMQ 一次"推送 30 条未 ACK 的消息"给这个消费者,作为本地缓存,代码依然是"一条一条处理",但消费者内部会同时"持有"最多 30 条未ACK的消息,减少网络IO,然后代码内部ACK了RabbitMQ会把消息标记为已消费 然后从Queue中移除,同时补充本地的未ACK的消息到30条
PS:这种设置了30的数据,一般来说单条数据内存占用都比较小,如果单条数据过大,本地缓存会爆炸
-
concurrency: 5和max-concurrency: 10:会使得项目中所有的队列消费者作用如下方写的@RabbitListener(queues = "api.log.queue")以及@RabbitListener(queues = "api.log.dlx.queue")均会创建5~10个消费者线程,然后每个线程本地会预存30个,预存的各个消费者不会重复
api.log.queue → 5~10 个消费者线程(独占)
api.log.dlx.queue → 5~10 个消费者线程(独占)
────────────────────────────────────────────
总计:10~20 个消费者线程
PS:concurrency/max-concurrency 是针对每个 listener container 生效,@RabbitListener(queues = "api.log.queue")这种其实对应的是一个listener container
- RabbitTemplate配置:
java
@Configuration
@Slf4j
public class RabbitLogConfig {
@Bean
public Queue logQueue() {
return QueueBuilder.durable("api.log.queue")
// RabbitMQ 允许你给队列设置各种参数,使用withArgument
// x-dead-letter-exchange:指定队列的死信交换机
// 当消息变成死信 → 发到哪个交换机
.withArgument("x-dead-letter-exchange", "api.log.dlx")
// x-dead-letter-routing-key:指定死信消息的 routingKey
// 发过去用哪个 routingKey
.withArgument("x-dead-letter-routing-key", "api.log.dlx.key")
.build();
}
@Bean
public DirectExchange logExchange() {
return new DirectExchange("api.log.exchange", true, false);
}
// Spring创建时,会自己注入Queue类型和DirectExchange的Bean初始化
@Bean
public Binding binding(Queue logQueue, DirectExchange logExchange) {
return BindingBuilder
.bind(logQueue)
.to(logExchange)
.with("api.log");
}
// 日志死信交换机
@Bean
public DirectExchange dlxExchange() {
return new DirectExchange("api.log.dlx");
}
// 日志死信队列
@Bean
public Queue dlxQueue() {
return new Queue("api.log.dlx.queue");
}
// 绑定:死信交换机-队列
@Bean
public Binding dlxBinding() {
return BindingBuilder.bind(dlxQueue())
.to(dlxExchange())
.with("api.log.dlx.key");
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
// confirm 回调(是否到达 exchange)
// 需要yaml配置:publisher-confirm-type
template.setConfirmCallback((correlationData, ack, cause) -> {
String id = correlationData == null ? "null" : correlationData.getId();
if (ack) {
log.debug("消息到达 exchange id={}", id);
} else {
log.error("消息未到达 exchange id={} cause={}", id, cause);
}
});
// return 回调(是否路由到队列)
// 需要yaml配置publisher-returns: true
// 同时代码设置:template.setMandatory(true);
template.setMandatory(true);
template.setReturnsCallback(returned -> {
log.error("消息路由失败:msg={} replyCode={} replyText={} exchange={} routingKey={}",
new String(returned.getMessage().getBody()),
returned.getReplyCode(),
returned.getReplyText(),
returned.getExchange(),
returned.getRoutingKey());
});
return template;
}
}
配置信息:
- 创建正常的队列及交换机和绑定
- 创建死信的队列及交换机和绑定
- 在正常队列withArgument方法配置死信相关配置,配置好后RabbitMQ会自己处理死信的发送
- RabbitTemplate配置confirm及return回调
PS:
-
正常链路
Producer ↓ api.log.exchange ↓ (api.log) api.log.queue ↓ Consumer -
异常链路(死信)
api.log.queue ↓(失败/过期/拒绝) api.log.dlx ↓ (api.log.dlx.key) api.log.dlx.queue
PS:死信产生的完整条件是(满足任一即可):
- 消息被
basicNack/basicReject且requeue=false - 消息 TTL 过期
- 队列达到最大长度(溢出)
- 发送者端
java
@Component
@Slf4j
@RequiredArgsConstructor
public class ApiLogCollector {
private final RabbitTemplate rabbitTemplate;
public void collect(ApiLogEvent event) {
// 日志入库
try {
String msgId = UUID.randomUUID().toString();
CorrelationData correlationData = new CorrelationData(msgId);
// 存入当做唯一id
event.setMsgId(msgId);
// 发送到MQ处理
rabbitTemplate.convertAndSend("api.log.exchange", "api.log", event, correlationData);
} catch (Exception e) {
log.error("发送日志到MQ失败", e);
// 降级:直接打印日志
log.info("API_LOG_FALLBACK {}", JSON.toJSONString(event));
}
}
}
- 消费者LogCreatedConsumer
java
@Component
@RequiredArgsConstructor
@Slf4j
public class LogCreatedConsumer {
private final ApiLogService apiLogService;
@RabbitListener(queues = "api.log.queue")
public void handle(Message message, Channel channel) throws Exception {
// 消息唯一标识(当前 channel 内)
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
log.info("收到消息:{}", message.getBody());
// 转换数据
ApiLogEvent apiLogEvent = JSON.parseObject(message.getBody(), ApiLogEvent.class);
// 入库数据
apiLogService.save(new ApiLog(apiLogEvent));
// 手动确认ACK
// 参数一:deliveryTag:当前消息在 channel 内的唯一 ID
// 参数二:multiple:是否"批量确认"
channel.basicAck(deliveryTag, false);
} catch (DuplicateKeyException e) {
log.warn("重复消息,忽略");
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
log.error("日志消费失败 msg={}", message, e);
// 处理失败
// 第三个参数:requeue = true → 重新入队(可能死循环)
// 第三个参数:requeue = false → 进入死信队列(如果配置了 DLX)
channel.basicNack(deliveryTag, false, false);
}
}
// 死信队列消费
@RabbitListener(queues = "api.log.dlx.queue")
public void handleDlx(Message message) {
log.error("死信消息:{}", new String(message.getBody()));
// TODO 做其他处理
}
}
PS:
| 方法 | 含义 |
|---|---|
| basicAck | 我处理成功了 |
| basicNack | 我处理失败了 |
| basicReject | 拒绝(单条版 Nack) |