预发布环境测试,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?欢迎在评论区分享你的经历!
原文首发于公众号【经典小熊】,关注获取更多干货