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

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

01 事故现场

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

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

发送5条测试消息:

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

查看日志:

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

检查Redis:

bash 复制代码
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 问题代码

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

java 复制代码
@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)

yaml 复制代码
┌─────────────────────────────────────────────────────────────┐
│ 第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)

yaml 复制代码
┌─────────────────────────────────────────────────────────────┐
│ 第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导致后面的消息不处理了!"

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

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

同事的配置(单条消费)

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

我的配置(批量消费)

kotlin 复制代码
┌─────────────────────────────────────────────────────────────┐
│ 一次性拉取: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(推荐)

java 复制代码
@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(更优雅,完全避免手写循环)

java 复制代码
@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后)

yaml 复制代码
┌─────────────────────────────────────────────────────────────┐
│ 拉取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避免手写循环

  1. 代码审查Checklist
sql 复制代码
□ 批量消费中是否有return语句?
□ 循环中的条件判断是否完整?
□ offset提交是否在所有消息处理后?
□ 单条消费改批量消费是否review过?
□ 异常处理是否会影响offset提交?
  1. 单元测试覆盖
java 复制代码
@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();
}
  1. 监控告警优化

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

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

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

写在最后

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

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

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

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

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

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

相关推荐
zb2006412020 小时前
CVE-2024-38819:Spring 框架路径遍历 PoC 漏洞复现
java·后端·spring
uzong20 小时前
AI Agent 是什么,如何理解它,未来挑战和思考
人工智能·后端·架构
追逐时光者20 小时前
DotNetGuide突破了10K + Star,一份全面且免费的C#/.NET/.NET Core学习、工作、面试指南知识库!
后端·.net
yuweiade21 小时前
springboot和springframework版本依赖关系
java·spring boot·后端
ywf121521 小时前
springboot设置多环境配置文件
java·spring boot·后端
小马爱打代码21 小时前
SpringBoot + 消息生产链路追踪 + 耗时分析:从创建到发送,全链路性能可视化
java·spring boot·后端
小码哥_常21 小时前
MyBatis批量插入:从5分钟到3秒的逆袭之路
后端
烛之武1 天前
SpringBoot基础
java·spring boot·后端
橙序员小站1 天前
Harness Engineering:从 OpenClaw 看 AI 助理的基础设施建设
后端·aigc·openai
小陈工1 天前
2026年3月28日技术资讯洞察:5G-A边缘计算落地、低延迟AI推理革命与工业智造新范式
开发语言·人工智能·后端·python·5g·安全·边缘计算