SpringBoot API日志系统设计-02:线程池异步化与RabbitMQ解耦

书接上文:

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来处理

  1. 启用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

  1. 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 / basicRejectrequeue=false
  • 消息 TTL 过期
  • 队列达到最大长度(溢出)
  1. 发送者端
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));
        }
    }

}
  1. 消费者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)
相关推荐
SelectDB11 小时前
强行拍平?全表扫描? AI Agent 动态 JSON 的观测分析
数据库·人工智能·数据分析
叫我少年11 小时前
C#命名空间指南:概念、用法与实践
后端
万邦科技Lafite11 小时前
如何通过 item_search_img API 接口获取淘宝商品信息
java·前端·数据库
雨辰AI11 小时前
面试题:人大金仓事务隔离级别、MVCC 机制详解(与MySQL差异对比)
数据库·后端·mysql·面试·政务
辣椒HTTP11 小时前
代理池健康检查与TLS指纹伪装实践
后端
丑八怪大丑11 小时前
SQL新特性
数据库·sql
ClouGence12 小时前
豆包收费之后,我找到了更好用的 AI 工具
前端·人工智能·后端·ai·ai编程·ai写作
aircrushin12 小时前
音乐节结束前,拿手机📱搓了一个工具
前端·后端
磊 子12 小时前
cpu是如何执行程序的?
数据库·操作系统·cpu
赵渝强老师12 小时前
【赵渝强老师】金仓数据库的运行日志文件
数据库·postgresql·oracle·国产数据库