生产环境排查重复消费问题,明明代码里 catch 住了异常并打了 log.warn,却在日志文件里死活找不到。控制台输出又因容器化部署而丢失,问题久久无法定位。本文记录一次真实排查过程,根因出在 logback 的 root logger 与 BIZ logger 配置差异上。

Kafka 消费者接收设备事件消息后,需要落库到本地 device_event_message 表。该表的 msg_service_business_id 字段上有唯一索引,用于防止 Kafka 重复消费导致的数据重复。
代码逻辑很标准:try 里执行 insert,catch (Exception e) 里 log.warn 打印日志并吞掉异常,不阻断后续推送流程。
@Service@Slf4jpublic class BizMsgServiceImpl { @KafkaListener(topics = "${kafka.consumer.deviceEventTopic}", ...) public void kafkaDeviceEventListen(ConsumerRecord<String, String> record) { // ... 解析事件 ... String msgServiceBusinessId = event.getDeviceId() + "_" + event.getDeviceSn() + "_" + event.getOccurTime(); // 保存到本地设备事件表 saveDeviceEventMessage(event, containerId, msgServiceBusinessId); // ... 后续推送消息中心 ... LogUtils.BIZ.info("发送设备事件消息=> {}", Collections.singletonList(msgInfoReqDTO)); msgInfoService.savaMsgInfoPOS(Collections.singletonList(msgInfoReqDTO)); } /** * 将Kafka设备事件保存到本地设备事件表 * business_id 存在唯一索引,重复事件插入失败时仅告警不阻断 */ private void saveDeviceEventMessage(DeviceEventEntity event, String containerId, String businessId) { try { DeviceEventMessagePO po = new DeviceEventMessagePO(); // ... set 各字段 ... deviceEventMessageMapper.insert(po); } catch (Exception e) { log.warn("保存设备事件到本地表失败 businessId={}", businessId, e); } }}线上一段时间后,用户反馈偶发重复推送。排查时翻遍 warn.log、error.log 文件,没有找到任何 保存设备事件到本地表失败 的日志。
但数据库里又能查到唯一索引冲突的痕迹(或通过其他途径确认确实发生了重复消费)。于是陷入"明明打了日志却找不到"的困境。
第一反应是怀疑 log.warn 级别在生产环境被调高过滤了。但查看 logback-spring.xml:
<root level="INFO"> <appender-ref ref="STDOUT"/> <appender-ref ref="ASYNC_INFO"/> <appender-ref ref="ASYNC_ERROR"/></root>
root level="INFO",WARN 级别高于 INFO,级别上不会被过滤。这条路走不通。
怀疑 insert 违反唯一约束时没有抛异常。但 MyBatis-Plus 的 BaseMapper.insert 执行的是标准 INSERT INTO,MySQL 唯一索引冲突会立即抛 SQLException,经 MyBatis 包装为 PersistenceException,再经 Spring 包装为 DuplicateKeyException。这些异常都会被 catch (Exception e) 捕获。
而且即使不抛异常,log.warn 这行代码至少会被执行(除非异常发生在 catch 之前)。所以异常没抛出也不是根因。
仔细对比 logback-spring.xml 中 root logger 和 BIZ logger 的 appender 配置:
<!-- root logger:只挂了 STDOUT、ASYNC_INFO、ASYNC_ERROR,没有 ASYNC_WARN! --><root level="INFO"> <appender-ref ref="STDOUT"/> <appender-ref ref="ASYNC_INFO"/> <appender-ref ref="ASYNC_ERROR"/></root><!-- BIZ logger:挂了 ASYNC_WARN --><logger name="BIZ" level="DEBUG" additivity="false"> <appender-ref ref="STDOUT"/> <appender-ref ref="ASYNC_INFO"/> <appender-ref ref="ASYNC_DEBUG"/> <appender-ref ref="ASYNC_WARN"/> <appender-ref ref="ASYNC_ERROR"/></logger>
再看各文件 appender 的 filter 配置——用的是 LevelFilter 精确匹配:
<appender name="WARN" class="ch.qos.logback.core.rolling.RollingFileAppender"> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <level>WARN</level> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> <!-- 非 WARN 一律拒绝 --> </filter> <file>${logPath}/log/warn.log</file> ...</appender><appender name="INFO" class="ch.qos.logback.core.rolling.RollingFileAppender"> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <level>INFO</level> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> <!-- WARN 会被拒绝,不进 info.log --> </filter> ...</appender>关键在于代码里用了哪个 logger:
| 调用方式 | logger 名称 | 走的 logger 配置 | warn.log | info.log | 控制台 STDOUT |
|---|---|---|---|---|---|
log.warn(...)(@Slf4j 注入) | com.dsa.hems.msg.service.BizMsgServiceImpl | root | ❌ 不写入 | ❌ LevelFilter 拒绝 WARN | ✅(但容器易丢失) |
LogUtils.BIZ.warn(...) | BIZ | BIZ logger | ✅ 写入 | ❌ | ✅ |
// LogUtils.javapublic interface LogUtils { Logger BIZ = LoggerFactory.getLogger("BIZ");}saveDeviceEventMessage 用的是 log.warn(走 root logger),而同一个 KafkaListener 方法里其他业务日志用的都是 LogUtils.BIZ.info(走 BIZ logger)。
root logger 没有挂载 ASYNC_WARN appender,所以 log.warn 的输出:
warn.log(只有 BIZ logger 才挂了 ASYNC_WARN)info.log(INFO appender 的 LevelFilter 精确匹配 INFO,WARN 被 DENY)error.log(同理)而生产环境是 Docker 容器部署,控制台日志不落盘、易丢失,排查时只看文件日志,自然就"找不到日志"了。
@KafkaListener(...)public void kafkaDeviceEventListen(ConsumerRecord<String, String> record) { LogUtils.BIZ.info("监听到设备事件消息 {} ", value); // ✅ 走 BIZ,写 info.log // ... saveDeviceEventMessage(...); // ⚠️ 内部用 log.warn,走 root // ... LogUtils.BIZ.info("发送设备事件消息=> {}", ...); // ✅ 走 BIZ,写 info.log}private void saveDeviceEventMessage(...) { try { deviceEventMessageMapper.insert(po); } catch (Exception e) { log.warn("保存设备事件到本地表失败 ...", e); // ❌ 走 root,不写任何文件 }}同一个方法里,BIZ logger 和 @Slf4j 的 log 混用,正是这次踩坑的直接原因。
把 log.warn 改成 LogUtils.BIZ.warn,与同方法其他日志保持一致:
private void saveDeviceEventMessage(DeviceEventEntity event, String containerId, String businessId) { try { DeviceEventMessagePO po = new DeviceEventMessagePO(); po.setMsgServiceBusinessId(businessId); // ... set 各字段 ... deviceEventMessageMapper.insert(po); } catch (DuplicateKeyException e) { // 唯一索引冲突:Kafka重复消费或同businessId事件重复推送,属于预期场景,仅告警不阻断 LogUtils.BIZ.warn("设备事件重复插入(唯一索引冲突), businessId={}, deviceSn={}, eventType={}, occurTime={}", businessId, event.getDeviceSn(), event.getEventType(), event.getOccurTime()); } catch (Exception e) { LogUtils.BIZ.warn("保存设备事件到本地表失败, businessId={}, deviceSn={}, eventType={}", businessId, event.getDeviceSn(), event.getEventType(), e); }}补充说明:
DuplicateKeyException 来自 org.springframework.dao,mybatis-plus-boot-starter 会传递引入 spring-tx,可放心使用。从 logback 配置层面兜底,让所有走 root 的 warn 日志都能落盘:
<root level="INFO"> <appender-ref ref="STDOUT"/> <appender-ref ref="ASYNC_INFO"/> <appender-ref ref="ASYNC_WARN"/> <!-- 补上这一行 --> <appender-ref ref="ASYNC_ERROR"/></root>
建议两个方案都做:方案一统一编码规范,方案二兜底防止其他类再踩坑。
// ❌ 用 @Slf4j 的 log,走 root logger,warn 不落盘log.warn("保存设备事件到本地表失败 businessId={}", businessId, e);// ❌ 异常被吞但无任何日志} catch (Exception e) { // 啥也不干}这次踩坑还暴露一个易混淆点——LevelFilter 与 ThresholdFilter 的区别:
| 过滤器 | 行为 | WARN 日志能否进入 INFO appender |
|---|---|---|
LevelFilter(onMatch=ACCEPT, onMismatch=DENY) | 精确匹配指定级别,其他一律 DENY | ❌ 不会(WARN ≠ INFO) |
ThresholdFilter(level=INFO) | 大于等于指定级别都通过 | ✅ 会(WARN ≥ INFO) |
本项目用的是 LevelFilter 精确匹配,所以 info.log 里只有 INFO,warn.log 里只有 WARN。这种"按级别分文件"的设计本身没问题,但前提是 logger 必须挂载对应的 appender,否则该级别的日志就无处可去。
log(@Slf4j)和 LogUtils.BIZ:二者走不同 logger 配置,行为差异巨大。统一用业务约定的 logger(本项目是 LogUtils.BIZ)。ASYNC_WARN,否则所有走 root 的 warn 日志只在控制台,不落盘。docker logs 或挂载 volume 收集 stdout,作为文件日志的兜底。deviceSn、eventType、occurTime 等字段,便于从日志直接定位是哪台设备、哪类事件重复。一句话避坑:log.warn 不一定写进 warn.log——取决于你用的是哪个 logger、它挂了哪些 appender。