2687 字
约 8 分钟
15
S17-X08 · 消息不丢失:生产者、Broker、消费者三段各自的账

"消息丢了"最难排查,因为它不报错。生产端 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. 自测

  1. acks=all 配上 replication.factor=1,实际可靠性等价于哪个取值?
  2. 为什么"处理完再提交位点"仍可能重复消费?
  3. Kafka 的事务语义为什么不能覆盖"消费 Kafka 然后写 MySQL"?
  4. 死信队列解决了什么问题,又没解决什么问题?
  5. 消费积压在涨但 CONSUMER-ID 为空,意味着什么?
  6. 唯一索引幂等和状态机幂等,哪个在并发下更可靠?
S17-X08 · 消息不丢失:生产者、Broker、消费者三段各自的账
https://www.clxhxhhr.top/posts/5800/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。