接手同事代码1小时后,我发现了这个隐藏的return...

27 阅读10分钟

预发布环境测试,5条消息只处理了2条,剩下的3条神秘失踪。排查了一个小时才发现,罪魁祸首竟然是一个不起眼的return...

01 事故现场

周一下午,我正在预发布环境进行新功能验证。这次的需求是修改奖品同步逻辑:从原来只同步特定商家ID的奖品,改为同步所有UU状态的奖品。

我构造了一批测试数据,满怀信心地执行测试:

发送5条测试消息:

- offset 1000: 奖品A, 状态UU
- offset 1001: 奖品B, 状态UU  
- offset 1002: 奖品C, 状态USED(已使用)
- offset 1003: 奖品D, 状态UU
- offset 1004: 奖品E, 状态UU

查看日志:

[INFO] 收到批量消息,数量:5
[INFO] 奖品状态为UU,保存到Redis,prizeId: 10001
[INFO] 奖品状态为UU,保存到Redis,prizeId: 10002
[INFO] 奖品状态非UU,不处理,status: USED
[INFO] 批量消息处理完成,提交offset

检查Redis:

redis-cli GET prize:10001  # 存在 ✅
redis-cli GET prize:10002  # 存在 ✅
redis-cli GET prize:10003  # 不存在 ✅(USED状态不需要)
redis-cli GET prize:10004  # 不存在 ❌(UU状态,应该存在!)
redis-cli GET prize:10005  # 不存在 ❌(UU状态,应该存在!)

数据丢失!offset 1003和1004的UU状态奖品没有保存到Redis!

02 问题代码

这个消费方法是同事之前写的,已经运行了半年多:


@Component
@Slf4j
public class PrizeMessageConsumer {

    @KafkaListener(
        topics = {"prize_binlog_topic"},
        containerFactory = "batchKafkaListenerContainerFactory"
    )
    public void batchConsume(List<ConsumerRecord<String, String>> records, 
                             Acknowledgment acknowledgment) {
        log.info("收到批量消息,数量:{}", records.size());

        try {
            for (ConsumerRecord<String, String> record : records) {
                // 解析并转换消息
                PrizeInstance prizeInstance = convertToPrizeInstance(record);

                // 我修改后的逻辑:处理所有UU状态的奖品
                if ("UU".equals(prizeInstance.getStatus())) {
                    log.info("保存到Redis,prizeId: {}", prizeInstance.getPrizeId());
                    redisTemplate.opsForValue().set(
                        "prize:" + prizeInstance.getPrizeId(), 
                        JSON.toJSONString(prizeInstance)
                    );
                } else {
                    log.info("状态非UU,不处理,status: {}", prizeInstance.getStatus());
                    // ⚠️ 致命错误:这里用了return
                    return;
                }
            }

            // 提交offset
            acknowledgment.acknowledge();
            log.info("批量消费完成");

        } catch (Exception e) {
            log.error("批量消费失败", e);
            throw new RuntimeException(e);
        }
    }
}

看到问题了吗?当遇到非UU状态时,代码执行了return,直接退出了整个方法!

03 执行流程图解

正常预期的流程(应该用continue)


┌─────────────────────────────────────────────────────────────┐
 第1步:拉取5条消息 [1000, 1001, 1002, 1003, 1004]          
└─────────────────────────────────────────────────────────────┘
                              
┌─────────────────────────────────────────────────────────────┐
 第2步:处理 offset 1000 (状态: UU)                          
  保存到Redis                                               
└─────────────────────────────────────────────────────────────┘
                              
┌─────────────────────────────────────────────────────────────┐
 第3步:处理 offset 1001 (状态: UU)                          
  保存到Redis                                               
└─────────────────────────────────────────────────────────────┘
                              
┌─────────────────────────────────────────────────────────────┐
 第4步:处理 offset 1002 (状态: USED)                        
 ⚠️ 状态不匹配,执行continue,跳过本条,继续下一条           
└─────────────────────────────────────────────────────────────┘
                              
┌─────────────────────────────────────────────────────────────┐
 第5步:处理 offset 1003 (状态: UU)                          
  保存到Redis                                               
└─────────────────────────────────────────────────────────────┘
                              
┌─────────────────────────────────────────────────────────────┐
 第6步:处理 offset 1004 (状态: UU)                          
  保存到Redis                                               
└─────────────────────────────────────────────────────────────┘
                              
┌─────────────────────────────────────────────────────────────┐
 第7步:循环结束,提交offset到1005                           
  5条消息全部处理完成,3条保存成功                         
└─────────────────────────────────────────────────────────────┘

实际发生的流程(错误使用了return)

┌─────────────────────────────────────────────────────────────┐
 第1步:拉取5条消息 [1000, 1001, 1002, 1003, 1004]          
└─────────────────────────────────────────────────────────────┘
                              
┌─────────────────────────────────────────────────────────────┐
 第2步:处理 offset 1000 (状态: UU)                          
  保存到Redis                                               
└─────────────────────────────────────────────────────────────┘
                              
┌─────────────────────────────────────────────────────────────┐
 第3步:处理 offset 1001 (状态: UU)                          
  保存到Redis                                               
└─────────────────────────────────────────────────────────────┘
                              
┌─────────────────────────────────────────────────────────────┐
 第4步:处理 offset 1002 (状态: USED)                        
 ⚠️ 状态不匹配,执行return                                   
└─────────────────────────────────────────────────────────────┘
                              
┌─────────────────────────────────────────────────────────────┐
 第5步:❌ 方法立即退出                                       
  offset 1003、1004 根本不处理                             
└─────────────────────────────────────────────────────────────┘
                              
┌─────────────────────────────────────────────────────────────┐
 第6步:自动提交offset到1005                                 
  offset 1003、1004 的数据永久丢失                         
└─────────────────────────────────────────────────────────────┘

04 为什么同事之前没问题?

我第一时间找到同事:“你这代码有问题啊,return导致后面的消息不处理了!”

同事一脸困惑:“不可能啊,我这代码跑了半年了,一直没问题。”

经过对比,终于发现了真相:

同事的配置(单条消费)

┌─────────────────────────────────────────────────────────────┐
│ 第1次拉取:1条消息 [1000]                                   │
│ 处理 offset 1000 → 遇到return → 退出 → 自动提交            │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│ 第2次拉取:1条消息 [1001]                                   │
│ 处理 offset 1001 → 正常处理 → 提交                         │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│ 结果:return只影响当前这一条消息,不会造成批量丢失          │
└─────────────────────────────────────────────────────────────┘

我的配置(批量消费)

┌─────────────────────────────────────────────────────────────┐
│ 一次性拉取:100条消息 [1000-1099]                           │
│ 处理到第50条 → 遇到return → 退出整个方法                    │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│ 结果:后面50条消息根本不处理,永久丢失                      │
└─────────────────────────────────────────────────────────────┘

对比表格

对比项同事的场景我的场景
消费模式单条消费 (SINGLE)批量消费 (BATCH)
每次拉取1条消息100条消息
return影响只影响当前1条影响后面99条
问题表现偶尔丢失1条,不明显批量丢失,明显暴露
为什么没问题单条消费掩盖了bug批量消费暴露了bug

同样的代码,不同的配置,结果天差地别!

05 排查过程

1小时排查时间线

时间段动作思考过程迷惑点
0-10分钟查看日志,发现只处理了2条"是不是Redis连接有问题?"日志显示保存成功,但只有2条
10-20分钟检查Redis,确认正常"难道是数据没发过来?"Redis正常,但确实少了数据
20-30分钟查看Kafka,确认消息完整"消息都到了,为什么没处理完?"5条消息都到了,只处理了2条
30-40分钟联系同事,他说代码没问题"跑了半年都没问题,难道是我改错了?"历史代码"正确"的假象
40-50分钟仔细review代码,发现return"等等,这里怎么用的是return?"发现可疑点
50-60分钟本地复现,确认问题"原来如此!单条消费和批量消费的差异!"终于找到根本原因

关键对话

:张哥,你这消费代码里怎么用return啊?导致我后面的消息都不处理了。

同事:不可能啊,我这代码一直这么写的,跑了半年了,从来没出过问题。

:你看,我发了5条消息,处理到第3条return了,后面2条UU的都没处理。

同事:等等,你改成批量消费了?我之前是单条消费啊!

:对,我开启了批量消费,每次拉100条。

同事:哦!那难怪了,单条消费下return只影响当前这一条,批量消费下return会退出整个方法。

06 正确代码

方案一:使用continue(推荐)

@Component
@Slf4j
public class PrizeMessageConsumer {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @KafkaListener(
        topics = {"prize_binlog_topic"},
        containerFactory = "batchKafkaListenerContainerFactory"
    )
    public void batchConsume(List<ConsumerRecord<String, String>> records, 
                             Acknowledgment acknowledgment) {
        log.info("收到批量消息,数量:{}", records.size());

        // 记录处理结果
        int successCount = 0;
        int skipCount = 0;

        try {
            for (ConsumerRecord<String, String> record : records) {
                PrizeBinlogDTO binlog = JSON.parseObject(record.value(), PrizeBinlogDTO.class);
                PrizeInstance prizeInstance = convertToPrizeInstance(binlog);

                if ("UU".equals(prizeInstance.getStatus())) {
                    log.debug("奖品状态为UU,保存到Redis,prizeId: {}, offset: {}", 
                        prizeInstance.getPrizeId(), record.offset());
                    redisTemplate.opsForValue().set(
                        "prize:" + prizeInstance.getPrizeId(), 
                        JSON.toJSONString(prizeInstance)
                    );
                    successCount++;
                } else {
                    // ✅ 使用continue,跳过当前消息,继续处理下一条
                    log.debug("奖品状态非UU,跳过处理,status: {}, offset: {}", 
                        prizeInstance.getStatus(), record.offset());
                    skipCount++;
                    continue;
                }
            }

            // 所有消息处理完成(包括跳过的),统一提交offset
            acknowledgment.acknowledge();
            log.info("批量消费完成 - 成功: {}, 跳过: {}, 总计: {}", 
                successCount, skipCount, records.size());

        } catch (Exception e) {
            log.error("批量消息处理失败", e);
            throw new RuntimeException("批量处理失败", e);
        }
    }
}

方案二:使用Stream(更优雅,完全避免手写循环)

@Component
@Slf4j
public class PrizeMessageConsumerV2 {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @KafkaListener(
        topics = {"prize_binlog_topic"},
        containerFactory = "batchKafkaListenerContainerFactory"
    )
    public void batchConsume(List<ConsumerRecord<String, String>> records, 
                             Acknowledgment acknowledgment) {
        log.info("收到批量消息,数量:{}", records.size());

        try {
            // 使用Stream过滤,完全避免手写循环和return问题
            List<PrizeInstance> validPrizes = records.stream()
                .map(this::parseAndConvert)
                .filter(prize -> "UU".equals(prize.getStatus()))
                .collect(Collectors.toList());

            if (validPrizes.isEmpty()) {
                log.info("没有需要处理的UU状态奖品,直接提交offset");
                acknowledgment.acknowledge();
                return;  // 注意:这里的return是安全的,因为所有消息已经处理完
            }

            // 批量保存到Redis(使用Pipeline提高性能)
            saveToRedisBatch(validPrizes);

            // 提交offset
            acknowledgment.acknowledge();
            log.info("成功处理{}条UU状态奖品", validPrizes.size());

        } catch (Exception e) {
            log.error("批量消息处理失败", e);
            throw new RuntimeException("批量处理失败", e);
        }
    }

    private PrizeInstance parseAndConvert(ConsumerRecord<String, String> record) {
        PrizeBinlogDTO binlog = JSON.parseObject(record.value(), PrizeBinlogDTO.class);
        return convertToPrizeInstance(binlog);
    }

    private void saveToRedisBatch(List<PrizeInstance> prizes) {
        // 使用Redis Pipeline批量写入
        redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            for (PrizeInstance prize : prizes) {
                String key = "prize:" + prize.getPrizeId();
                String value = JSON.toJSONString(prize);
                connection.stringCommands().set(key.getBytes(), value.getBytes());
            }
            return null;
        });
    }
}

正确执行流程(使用continue后)


┌─────────────────────────────────────────────────────────────┐
 拉取5条消息 [1000, 1001, 1002, 1003, 1004] 
└─────────────────────────────────────────────────────────────┘
 
┌─────────────────────────────────────────────────────────────┐
 offset 1000 (UU)  保存Redis  
 offset 1001 (UU)  保存Redis  
 offset 1002 (USED)  continue跳过 ⏭️ 
 offset 1003 (UU)  保存Redis  
 offset 1004 (UU)  保存Redis  
└─────────────────────────────────────────────────────────────┘
 
┌─────────────────────────────────────────────────────────────┐
 提交offset到1005 
  5条消息全部处理,3条保存成功,2条跳过 
└─────────────────────────────────────────────────────────────┘

07 经验教训

为什么这个bug这么隐蔽?

历史代码的迷惑性:同事说"跑了半年没问题",容易让人相信代码是正确的

配置差异:开发环境还是单条消费,预发布才改成批量

场景依赖:只有在批量消费+混合状态数据时才会触发

部分成功:前面的消息正常处理,容易误以为逻辑正确

关键教训

序号教训说明
1配置变更也是变更从单条消费改为批量消费,必须重新review消费逻辑
2历史代码不一定正确运行时间长不等于没bug,可能是场景没触发
3测试数据要充分不能只测试全成功场景,要包含各种边界情况
4环境配置要一致开发、测试、预发布、生产环境配置要保持一致
5代码review要细致特别关注循环控制、异常处理等关键逻辑

return、continue、break的区别

关键字作用在批量消费中的影响是否安全
continue跳过本次循环,继续下一次✅ 跳过当前消息,继续处理后续安全
break跳出当前循环⚠️ 跳出循环,但不提交offset,可能导致重复消费危险
return退出整个方法❌ 直接退出,后续消息丢失致命

08 预防措施

1. 代码规范

批量消费循环中禁止使用return

必须使用continue来跳过消息

建议使用Stream API避免手写循环

2. 代码审查Checklist

□ 批量消费中是否有return语句?
□ 循环中的条件判断是否完整?
□ offset提交是否在所有消息处理后?
□ 单条消费改批量消费是否review过?
□ 异常处理是否会影响offset提交?

3. 单元测试覆盖

@Test
public void testBatchConsumeWithMixedStatus() {
 // 构造混合状态的消息
 List<ConsumerRecord<String, String>> records = Arrays.asList(
 createRecord("UU"), // 应该保存
 createRecord("UU"), // 应该保存
 createRecord("USED"), // 应该跳过
 createRecord("UU") // 应该保存
 );

 // 执行消费
 consumer.batchConsume(records, acknowledgment);

 // 验证:4条消息都应该被处理(3条保存,1条跳过)
 verify(redisTemplate, times(3)).opsForValue().set(any(), any());
 verify(acknowledgment, times(1)).acknowledge();
}

4. 监控告警优化

不仅监控消费延迟,还要监控消费速率

统计每批次处理的成功数量与拉取数量的比例

如果比例异常(成功数远小于拉取数),需要及时告警

写在最后

这次事故最大的教训是:不要盲目相信历史代码。

同事的代码运行了半年,但那是建立在单条消费的基础上。当我改成批量消费后,这段代码的隐藏bug才真正暴露出来。

好在是在预发布环境发现的,如果是直接上线到生产环境,后果不堪设想。

有时候,最致命的bug不是新写的代码,而是那些"看起来没问题"的历史代码。在接手别人的代码时,一定要带着怀疑的态度去review,特别是当你改变运行环境或配置时。

今日话题:你有没有遇到过类似的坑?接手同事代码时,发现过哪些隐藏的bug?欢迎在评论区分享你的经历!

原文首发于公众号【经典小熊】,关注获取更多干货 image.png