"消息丢了"最难排查,因为它不报错。生产端 send() 返回成功、Broker 回了 ACK、消费者位点也前进了——三步都对,链路里却少了几条。因为三个"成功"各有语义,任一环节把"交给下一跳"当成"已落地",账就对不上。
不丢消息没有单点开关,它是三段独立保障,再靠幂等兜住重复的组合。按段拆。
1. 三个环节,各自的丢法
| 环节 | 丢失动作 | 典型后果 | 可观测信号 |
|---|---|---|---|
| 生产端 | 异步发送未回调就返回、缓冲区未刷出进程退出 | 少量消息静默消失 | 无异常,只有业务对账差异 |
| Broker | 收到即回 ACK 但未落盘、主副本切换丢未同步数据 | 一批消息消失 | 副本切换时间点与丢失量吻合 |
| 消费端 | 先提交位点再处理、异常被 catch 后不抛出 | 位点前进、业务未执行 | 消费速率正常但业务无记录 |
| 通用 | 重试未做、重试中消息被丢弃 | 偶发 | 重试次数指标为空 |
第四行最容易被忽略:很多"丢失"其实是重试次数用完被静默丢弃。先查客户端有没有配重试上限,以及达到上限后是抛异常还是打日志了事。
2. 生产端:确认语义决定一切
关键不是"调没调 send",而是返回值代表什么。
| 发送方式 | 返回值含义 | 丢失窗口 | 适用 |
|---|---|---|---|
异步 send() 无回调 |
只是放进本地缓冲区 | 缓冲区满、进程退出、网络断 | 可丢的日志类 |
| 异步 + 回调 | 回调收到才算 Broker 确认 | 回调异常未处理 | 大多数业务 |
同步 send().get() |
阻塞到 Broker 确认 | 几乎无(超时除外) | 强一致、量不大 |
| 事务消息 | 本地事务与消息发送原子化 | 需事务回查兜底 | 本地写库 + 发消息 |
Spring Boot 3.3(spring-kafka 3.2.x)下的标准写法:
@Service
public class OrderEventPublisher {
private final KafkaTemplate<String, String> kafkaTemplate;
public OrderEventPublisher(KafkaTemplate<String, String> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publish(OrderEvent event) {
kafkaTemplate.send("order-created", event.orderId(), event.toJson())
.whenComplete((result, ex) -> {
if (ex != null) {
// 必须落库或进重试队列,只打日志等于丢
failedEventRepository.save(event, ex.getMessage());
}
});
}
}
whenComplete 的失败分支决定消息是否真的不丢;只写 log.error,等于把可靠性交给日志系统。
重试与幂等的耦合:重试必然带来重复,前提是下游幂等。spring.kafka.producer.retries 在 Kafka 2.1 之后是"重试到 delivery.timeout.ms 为止",配 retries=0 是主动放弃。
3. Broker 端:刷盘与副本是两件事
Broker 侧的丢失来自两个完全不同的机制,配置项也分开:
| 机制 | 相关配置 | 语义 | 代价 |
|---|---|---|---|
| 刷盘 | log.flush.interval.messages、log.flush.interval.ms |
内存页写到磁盘 | 频繁刷盘拉低吞吐 |
| 副本同步 | acks、min.insync.replicas、unclean.leader.election.enable |
数据在几个副本上 | 同步副本增加写延迟 |
| 主副本选举 | unclean.leader.election.enable=false |
不允许落后副本当选 | 不可用时宁可拒绝写入 |
# broker 端(server.properties)
default.replication.factor=3
min.insync.replicas=2
unclean.leader.election.enable=false
log.flush.interval.messages=10000
# producer 端对应项
# acks=all, enable.idempotence=true
acks 三个取值的实际含义:
| 取值 | Broker 何时回 ACK | 丢失风险 | 吞吐 |
|---|---|---|---|
acks=0 |
发出即算成功(不等待) | 高,网络抖动即丢 | 最高 |
acks=1 |
主副本写入即回 | 主副本宕机未同步部分丢失 | 中 |
acks=all |
ISR 中满足 min.insync.replicas 才回 |
低,但 ISR 不足时写入失败 | 最低 |
最常见的错误组合是 acks=all 配 replication.factor=1:ISR 只有一个副本,min.insync.replicas=2 会让写入直接失败;调成 1,acks=all 就退化成 acks=1 的效果。两个参数必须一起看。
unclean.leader.election.enable=true 是另一个隐性丢失源:它允许落后副本当选 leader,代价是丢掉未同步的尾部数据。可用性优先时这是合理选择,但要写明"允许丢多少"。
RocketMQ 的对应关系是 brokerRole=SYNC_MASTER + flushDiskType=SYNC_FLUSH,等价于 acks=all + 同步刷盘;ASYNC_FLUSH 把可靠性交给 OS 页缓存。
4. 消费端:位点提交是唯一的开关
消费端丢失只有一个原因:位点提交早于业务完成。
| 提交方式 | 语义 | 风险 |
|---|---|---|
自动提交(enable.auto.commit=true) |
按 auto.commit.interval.ms 定时提交 |
处理中崩溃即丢 |
手动 commitSync 在处理后 |
处理完才提交 | 正确处理下不丢,但阻塞 |
手动 commitAsync 在处理后 |
异步提交 | 提交失败需重试,顺序易乱 |
| 批量处理后提交 | 一批完成再提交 | 批中部分失败会整批重投 |
@Component
public class OrderCreatedConsumer {
@KafkaListener(topics = "order-created", groupId = "settle",
containerFactory = "manualAckFactory")
public void onMessage(ConsumerRecord<String, String> record, Acknowledgment ack) {
try {
settleService.handle(record.value()); // 业务真正完成
ack.acknowledge(); // 完成之后才提交
} catch (RetryableException e) {
throw e; // 交给容器重试策略
} catch (Exception e) {
deadLetterService.save(record, e); // 不可重试 → 死信
ack.acknowledge();
}
}
}
三个细节:catch 里吞异常后照样 acknowledge() 等于丢;不可重试的异常进死信;ackMode 必须是 MANUAL。
5. "至少一次"与"恰好一次"的现实边界
| 语义 | 怎么实现 | 代价 | 现实可达性 |
|---|---|---|---|
| 至多一次 | 先提交位点再处理 | 会丢 | 简单,但业务上几乎不可接受 |
| 至少一次 | 处理完再提交 + 失败重试 | 会重 | 绝大多数系统的实际选择 |
| 恰好一次(端到端) | 消费 + 位点在同一事务内(Kafka 事务 / sendOffsetsToTransaction) |
要求下游也在同一事务域 | 仅在同源系统内成立 |
| 恰好一次(业务层) | 至少一次 + 幂等写 | 需要唯一键或去重表 | 跨系统时的唯一可行路径 |
第四行才是工程答案。消费端要写 MySQL、调支付网关时,"恰好一次"在协议层不可能,只能靠幂等把重复吃掉。Kafka 的事务语义只覆盖"读 Kafka → 处理 → 写 Kafka → 提交位点"这条闭环。
幂等落地的两种常见形态:
-- 形态一:唯一索引兜底(推荐,DB 层保证)
INSERT INTO t_settle_record (biz_no, order_id, amount, created_at)
VALUES (?, ?, ?, NOW());
-- 重复投递时抛 DuplicateKeyException,捕获后视为成功
// 形态二:状态机前置校验(有状态流转的场景)
if (order.getStatus() != OrderStatus.WAIT_SETTLE) {
log.info("skip duplicated settle, orderId={}, status={}", order.getId(), order.getStatus());
return;
}
形态一比形态二更可靠:状态机校验在并发下仍有窗口,唯一索引没有。
6. 不丢与不重的取舍
| 目标 | 手段 | 成本 | 延迟 |
|---|---|---|---|
| 不丢 | acks=all + 副本 ≥3 + 同步刷盘 + 手动提交 + 重试 |
吞吐下降、磁盘 IO 上升 | 增加一次副本同步 |
| 不重 | 幂等键 + 唯一索引 + 去重表 | 多一次写、要清理历史 | 增加一次 DB 写入 |
| 两者兼得 | 至少一次 + 幂等 | 复杂度集中在幂等表 | 同上 |
| 只重不丢 | 允许重复、业务侧容忍 | 最低 | 最低 |
只有第三行能落地:单追不丢会得到重复,单追不重会得到丢失。
7. 可观测:三个必须有的指标
| 指标 | 来源 | 阈值判断 |
|---|---|---|
| 消费积压(lag) | kafka-consumer-groups.sh --describe |
持续单调上升即异常 |
| 生产失败率 | 客户端回调计数 / Micrometer | 任何非零都该告警 |
| 死信数量 | 死信 topic / 死信表 | 出现即需人工介入 |
| 重试次数分布 | 容器重试策略埋点 | 单条消息重试次数逼近上限 |
kafka-consumer-groups.sh --bootstrap-server broker:9092 --describe --group settle
# 关注 LAG 与 CONSUMER-ID(为空说明无活跃消费者)
CONSUMER-ID 为空而 LAG 在涨是最危险的状态:消息在堆积但没人消费,重启消费者会一次性拉取大量消息,容易打爆下游。
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 生产端无异常但下游没数据 | 异步发送失败只打了日志 | 失败落库 + 定时补偿 |
| 主副本切换后丢一批 | acks=1 或 unclean.leader.election=true |
改 acks=all、关闭 unclean 选举 |
配了 acks=all 仍丢 |
副本数为 1,或 min.insync.replicas=1 |
副本 ≥3 且 min.insync.replicas=2 |
| 消费速率正常但业务无记录 | 异常被 catch 后仍提交位点 | 移除吞异常逻辑,异常要抛出或进死信 |
| 重启后大量重复 | 位点提交滞后于处理 | 下游加幂等,接受重复 |
| 重试打爆下游 | 无上限重试 | 设最大重试次数 + 死信 + 退避 |
9. 边界
| 场景 | 建议 | 理由 |
|---|---|---|
| 日志、埋点类消息 | acks=1、可丢 |
可靠性成本高于数据价值 |
| 订单、支付、账务 | acks=all + 幂等 + 死信 |
丢失不可接受,重复可兜 |
| 单机开发环境 | 副本 1、acks=1 |
别在生产照抄 |
| 跨系统(DB + 支付网关) | 至少一次 + 业务幂等 | 端到端恰好一次不成立 |
| 消息体巨大 | 存引用、消息只带 ID | 大消息拖垮 Broker |
| 强顺序要求 | 单分区 + max.in.flight=1 |
并发会破坏顺序 |
一句话:不丢靠三段的确认语义,不重靠幂等,两者不能互相替代。把 acks=all 当成"不会丢"的许可证,是这套体系里最常见的一次误判。
10. 自测
acks=all配上replication.factor=1,实际可靠性等价于哪个取值?- 为什么"处理完再提交位点"仍可能重复消费?
- Kafka 的事务语义为什么不能覆盖"消费 Kafka 然后写 MySQL"?
- 死信队列解决了什么问题,又没解决什么问题?
- 消费积压在涨但
CONSUMER-ID为空,意味着什么? - 唯一索引幂等和状态机幂等,哪个在并发下更可靠?