157|消息丢失防护:生产、存储、消费三个阶段的确认与补偿

消息队列与事件驱动约 16 分钟阅读9,210 字符
知识库文档 159 / 251

阅读时间:约 45 分钟 难度:中高级 适用岗位:Java 高级工程师、架构师、中间件工程师


一、问题定义:消息到底丢在哪里?

在分布式消息系统中,一条消息从生产者发出到消费者处理完成,要跨越网络、磁盘、内存等多个边界。任何一环的疏漏,都可能导致消息永久丢失。

真实案例:某电商平台大促期间,订单创建后发送消息到下游履约系统。高峰期过后,运营发现约 0.3% 的订单未流转到仓库。排查发现:Kafka 生产者使用了 acks=0(不等待 Broker 确认),Broker 在 Leader 宕机时部分未刷盘的消息丢失,且消费者先提交 offset 再处理业务逻辑,处理失败的消息也被跳过了。

消息丢失的根因可以归纳为三个阶段的"坑":

阶段 典型丢失场景 核心矛盾
生产阶段 网络超时、序列化失败、Broker 宕机 生产者"以为发成功了"但实际没发出去
存储阶段 Broker 宕机、磁盘损坏、副本未同步 消息"存在 Broker 上"但宕机后没了
消费阶段 消费者宕机、offset 提交过早、业务处理失败 消息"被消费了"但实际没处理

本文将从这三个阶段分别展开,覆盖 Kafka、RocketMQ、RabbitMQ 三大主流消息队列的防护机制,并给出生产环境可落地的代码实践。

文章配图
文章配图


二、生产阶段:消息如何可靠送达 Broker?

2.1 发送流程拆解

一条消息从生产者到 Broker,通常经历以下步骤:

code
生产者应用
  → 序列化(Key/Value Serializer)
  → 分区选择(Partitioner)
  → 消息累加器(RecordAccumulator / 缓冲区)
  → 网络发送(Sender 线程)
  → Broker 接收
  → Broker 返回 ACK

丢失发生在哪里?

  • 序列化失败:消息根本没发出去(通常抛异常,可感知)
  • 网络超时:生产者发了,但 Broker 没收到,或 ACK 没返回
  • 缓冲区满:生产者内存缓冲区满,新消息被丢弃(Kafka buffer.memory)
  • Broker 返回 ACK 前宕机:Leader 宕机,未同步到副本

2.2 Kafka 生产者的可靠性配置

Kafka 生产者有三个关键配置直接影响消息可靠性:

①**acks**(确认机制)

acks 值 含义 可靠性 性能
acks=0 不等待任何确认 最低,消息可能丢失 最高
acks=1 Leader 写入即确认 中等,Leader 宕机未同步时丢失 中等
acks=all(或 -1) 所有 ISR 副本确认 最高 较低

生产推荐:**acks=all**,配合 min.insync.replicas ≥ 2 使用。

文章配图
文章配图

②**retries**与**retry.backoff.ms**

java
Properties props = new Properties();
props.put("acks", "all");
props.put("retries", Integer.MAX_VALUE);  // 无限重试(Kafka ≥ 2.0 推荐)
props.put("retry.backoff.ms", 100);       // 重试间隔 100ms
props.put("max.in.flight.requests.per.connection", 5);  // 注意顺序性问题

关键陷阱:重试可能导致消息乱序。如果 max.in.flight.requests.per.connection > 1(默认 5),且 retries > 0,则批次 1 失败重试时批次 2 可能已成功写入,导致顺序颠倒。解决方案:

  • 如果严格要求顺序:设置 max.in.flight.requests.per.connection=1(性能下降)
  • Kafka 0.11+ 引入幂等生产:enable.idempotence=true,此时即使 max.in.flight.requests.per.connection ≤ 5,也能保证顺序和精确一次

③**enable.idempotence**(幂等生产)

java
props.put("enable.idempotence", true);

原理:Broker 为每个 Producer 分配 PID(Producer ID),每条消息带序列号(Sequence Number)。Broker 检查序列号,丢弃重复消息。但这只保证单分区内的幂等,跨分区和跨会话不保证。

④ 事务生产(精确一次语义)

java
producer.initTransactions();
try {
    producer.beginTransaction();
    producer.send(record1);
    producer.send(record2);
    producer.commitTransaction();
} catch (Exception e) {
    producer.abortTransaction();
}

事务生产保证跨分区、跨会话的原子写入。代价是性能下降约 30%。

2.3 生产者的发送回调与异常处理

同步发送(阻塞等待结果):

java
try {
    RecordMetadata metadata = producer.send(record).get(5000, TimeUnit.MILLISECONDS);
    log.info("消息发送成功: topic={}, partition={}, offset={}", 
             metadata.topic(), metadata.partition(), metadata.offset());
} catch (TimeoutException e) {
    // 网络超时:重试或写入本地死信表
    saveToLocalDeadLetterTable(record);
} catch (Exception e) {
    // 其他异常:记录并告警
    log.error("消息发送失败", e);
    alertToOncall();
}

异步发送(推荐,生产环境主流):

java
producer.send(record, (metadata, exception) -> {
    if (exception != null) {
        // 发送失败:降级方案
        log.error("消息发送失败: key={}", record.key(), exception);
        fallbackToDatabase(record);  // 降级写入数据库
    } else {
        log.debug("消息发送成功: offset={}", metadata.offset());
    }
});

生产环境最佳实践——发送失败兜底:

java
public class ReliableMessageSender {
    private final KafkaProducer<String, String> producer;
    private final DeadLetterDao deadLetterDao;
    
    public void sendWithFallback(String topic, String key, String message) {
        ProducerRecord<String, String> record = 
            new ProducerRecord<>(topic, key, message);
        
        producer.send(record, (metadata, exception) -> {
            if (exception != null) {
                // 1. 写入本地死信表(同一事务保证原子性)
                deadLetterDao.insert(topic, key, message, exception.getMessage());
                // 2. 触发告警
                metrics.counter("mq.send.failure").increment();
                // 3. 定时任务扫描死信表,重试发送
            }
        });
    }
}

2.4 RocketMQ 生产者的可靠性

RocketMQ 提供同步、异步、单向三种发送方式:

java
// 同步发送——等待 Broker 确认(推荐用于关键业务)
SendResult result = producer.send(msg);
// 返回 SendStatus: SEND_OK / FLUSH_DISK_TIMEOUT / FLUSH_SLAVE_TIMEOUT / SLAVE_NOT_AVAILABLE

// 带重试的同步发送
producer.setRetryTimesWhenSendFailed(3);          // 同步发送失败重试 3 次
producer.setRetryTimesWhenSendAsyncFailed(3);     // 异步发送失败重试 3 次

RocketMQ 的可靠性机制:

  • 同步刷盘(flushDiskType=SYNC_FLUSH):消息写入磁盘后才返回成功,性能低但可靠
  • 同步复制(brokerRole=SYNC_MASTER):等待 Slave 同步后才返回成功
  • 发送失败自动重试(默认 2 次)

2.5 RabbitMQ 生产者的可靠性

RabbitMQ 的可靠性核心是 Publisher Confirm 机制:

java
// 开启 Publisher Confirm
channel.confirmSelect();

// 方式一:逐条确认(性能差)
channel.basicPublish(exchange, routingKey, null, body);
if (channel.waitForConfirms(5000)) {
    // 发送成功
} else {
    // 发送失败,重试
}

// 方式二:批量确认(推荐)
channel.addConfirmListener(new ConfirmListener() {
    @Override
    public void handleAck(long deliveryTag, boolean multiple) {
        // 成功确认
    }
    @Override
    public void handleNack(long deliveryTag, boolean multiple) {
        // 失败:重试或告警
    }
});

RabbitMQ 的额外保障:

  • Mandatory 标志:消息无法路由到任何队列时,返回给生产者(而非丢弃)
  • 消息持久化:deliveryMode=2,消息写入磁盘
  • 队列持久化:durable=true,Broker 重启后队列不丢失

三、存储阶段:Broker 如何保证消息不丢?

3.1 消息在 Broker 上的生命周期

code
Producer → Broker 接收
  → 写入 Page Cache(操作系统页缓存)
  → 刷盘(fsync / flush)到磁盘
  → 复制到副本(Replica / Slave)
  → 返回 ACK 给 Producer

丢失发生在哪里?

  • 只写 Page Cache 未刷盘,宕机后丢失
  • Leader 宕机,Follower 未完全同步,新 Leader 缺少消息
  • 磁盘损坏

3.2 Kafka 的存储可靠性

① 副本机制

code
Topic: orders (3 partitions, 3 replicas)
Partition 0: Leader=Broker1, ISR=[Broker1, Broker2, Broker3]
Partition 1: Leader=Broker2, ISR=[Broker2, Broker3, Broker1]
Partition 2: Leader=Broker3, ISR=[Broker3, Broker1, Broker2]

关键配置:

properties
# 每个 Topic 的副本数
default.replication.factor=3

# 最小 ISR 数量(acks=all 时,必须至少这么多副本确认才返回成功)
min.insync.replicas=2

# 落后多少毫秒被踢出 ISR
replica.lag.time.max.ms=30000

**min.insync.replicas=2**+**acks=all** 的含义:至少 2 个副本(含 Leader)确认写入,才算成功。如果可用的 ISR 少于 2,生产者会收到 NOT_ENOUGH_REPLICAS 异常,宁可不可用也不丢消息。

文章配图
文章配图

② 刷盘策略

Kafka 依赖操作系统页缓存,不主动 fsync:

properties
# 刷盘策略(通常不修改,交给 OS 管理)
log.flush.interval.messages=10000   # 每 10000 条消息刷一次盘
log.flush.interval.ms=1000          # 每 1000ms 刷一次盘

为什么不主动 fsync? Kafka 的设计哲学是依赖多副本而不是单机刷盘保证可靠性。只要 ISR 中有副本存活,即使一台机器宕机未刷盘,消息也不会丢。主动 fsync 会严重影响吞吐。

③ 不完全的 Leader 选举(Unclean Leader Election)

properties
# 默认 false:不允许非 ISR 副本成为 Leader
unclean.leader.election.enable=false

如果设为 true,当 ISR 全部宕机,会选择落后最多的副本当 Leader,导致消息丢失。生产环境必须设为 false。

3.3 RocketMQ 的存储可靠性

RocketMQ 的存储设计更倾向"单机可靠":

java
// Broker 配置
flushDiskType=SYNC_FLUSH    // 同步刷盘(默认 ASYNC_FLUSH)
brokerRole=SYNC_MASTER      // 同步复制(默认 ASYNC_MASTER)

同步刷盘 vs 异步刷盘:

配置 流程 可靠性 延迟
ASYNC&#95;FLUSH 写 Page Cache 即返回 宕机丢失未刷盘消息 低
SYNC&#95;FLUSH 等待 fsync 完成 不丢消息 高(约 +10ms)

同步复制 vs 异步复制:

配置 流程 可靠性
ASYNC&#95;MASTER Master 写成功即返回 Slave 落后时可能丢失
SYNC&#95;MASTER 等待 Slave 确认 Master 宕机不丢消息

生产环境推荐:关键业务使用**SYNC_FLUSH + SYNC_MASTER**,但需要评估性能影响。

RocketMQ 的 DLedger(Raft 协议): RocketMQ 4.5+ 引入 DLedger,基于 Raft 协议实现自动故障转移,CommitLog 通过 Raft 日志复制,保证强一致性。

3.4 RabbitMQ 的存储可靠性

RabbitMQ 的核心是 队列持久化 + 消息持久化:

java
// 声明持久化队列
boolean durable = true;
channel.queueDeclare("orders", durable, false, false, null);

// 发送持久化消息
AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
    .deliveryMode(2)  // 2 = 持久化
    .build();
channel.basicPublish("", "orders", props, body);

注意:deliveryMode=2 只保证消息写入磁盘,但 RabbitMQ 默认是异步刷盘——消息可能暂存在操作系统的 Page Cache 中,宕机后丢失。要完全保证不丢,需要配合:

  • Mirror Queue(镜像队列):消息同步到多个节点(RabbitMQ 3.x,已废弃)
  • Quorum Queue(仲裁队列):基于 Raft 协议,RabbitMQ 3.8+,推荐
java
// 声明仲裁队列(RabbitMQ 3.8+)
Map<String, Object> args = new HashMap<>();
args.put("x-queue-type", "quorum");
channel.queueDeclare("orders", true, false, false, args);

四、消费阶段:消息如何被可靠处理?

4.1 消费阶段丢失的典型场景

code
Broker → 消费者拉取消息
  → 消费者处理业务逻辑(写数据库、调用 RPC...)
  → 消费者提交 Offset(告知 Broker 已消费)

丢失发生在哪里?

  • 先提交 Offset 再处理:提交后消费者宕机,消息未处理但 Offset 已推进 → Kafka 认为已消费,消息丢失
  • 自动提交 Offset:enable.auto.commit=true,到时间就提交,不管业务是否成功
  • 异常被吞掉:try-catch 后仍然提交 Offset
  • Rebalance 时未提交:消费者被踢出组,Offset 未提交,新消费者从旧 Offset 开始 → 重复消费(不是丢失,但可能引发其他问题)

4.2 Kafka 消费者的可靠消费模式

核心原则:先处理,后提交。

java
// ❌ 错误模式:自动提交
props.put("enable.auto.commit", "true");  // 危险!
props.put("auto.commit.interval.ms", "1000");

// ✅ 正确模式:手动提交,先处理后提交
props.put("enable.auto.commit", "false");

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
    for (ConsumerRecord<String, String> record : records) {
        try {
            // 1. 处理业务逻辑
            processBusiness(record);
            // 2. 处理成功后提交 Offset
            consumer.commitSync(Collections.singletonMap(
                new TopicPartition(record.topic(), record.partition()),
                new OffsetAndMetadata(record.offset() + 1)
            ));
        } catch (Exception e) {
            log.error("消息处理失败: offset={}", record.offset(), e);
            // 3. 不提交 Offset,消息会被重新消费
            // 或者发送到死信队列
            sendToDeadLetterQueue(record);
            // 仍然提交 Offset 避免阻塞
            consumer.commitSync();
        }
    }
}

文章配图
文章配图

精细化控制——逐条提交 vs 批量提交:

java
// 批量提交(推荐,性能好)
consumer.poll(...);
records.forEach(r -> processBusiness(r));
consumer.commitSync();  // 批量提交当前批次

// 分区级别提交(更精细)
for (TopicPartition partition : records.partitions()) {
    List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
    long lastOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
    consumer.commitSync(Collections.singletonMap(
        partition, new OffsetAndMetadata(lastOffset + 1)
    ));
}

4.3 消费失败的处理策略

策略一:重试 + 死信队列

java
public class RetryableConsumer {
    private static final int MAX_RETRIES = 3;
    
    public void consume(ConsumerRecord<String, String> record) {
        int retries = 0;
        while (retries <= MAX_RETRIES) {
            try {
                processBusiness(record);
                consumer.commitSync();
                return;  // 成功,退出
            } catch (RetryableException e) {
                retries++;
                if (retries > MAX_RETRIES) {
                    // 超过重试次数,发送到死信队列
                    sendToDeadLetterTopic(record);
                    consumer.commitSync();  // 死信队列写入成功,提交 Offset
                    alertToOncall(record);  // 人工介入
                    return;
                }
                // 指数退避
                Thread.sleep((long) Math.pow(2, retries) * 1000);
            } catch (NonRetryableException e) {
                // 不可重试的异常(如数据格式错误),直接死信
                sendToDeadLetterTopic(record);
                consumer.commitSync();
                return;
            }
        }
    }
}

策略二:本地消息表 + 定时补偿

消费失败时将消息写入本地数据库,定时任务扫描重试:

java
@Service
public class MessageConsumer {
    
    @Transactional
    public void consume(ConsumerRecord<String, String> record) {
        try {
            // 业务处理
            orderService.createOrder(parseOrder(record.value()));
            // 成功:提交 Offset
            consumer.commitSync();
        } catch (Exception e) {
            // 失败:写入本地消息表(同一事务)
            localMessageDao.insert(
                record.topic(), record.partition(), record.offset(),
                record.value(), "FAILED", 0
            );
            // 仍然提交 Offset,避免阻塞(消息已持久化到本地)
            consumer.commitSync();
        }
    }
}

// 定时补偿任务
@Scheduled(fixedDelay = 60000)
public void retryFailedMessages() {
    List<LocalMessage> failed = localMessageDao.findFailed(MAX_RETRIES);
    for (LocalMessage msg : failed) {
        try {
            orderService.createOrder(parseOrder(msg.getBody()));
            localMessageDao.markSuccess(msg.getId());
        } catch (Exception e) {
            localMessageDao.incrementRetry(msg.getId());
            if (msg.getRetryCount() >= MAX_RETRIES) {
                alertToOncall(msg);
            }
        }
    }
}

4.4 RocketMQ 消费可靠性

RocketMQ 消费模式分为两种:

集群消费(CLUSTERING,默认)— 每条消息只被组内一个消费者处理:

java
consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context) -> {
    for (MessageExt msg : msgs) {
        try {
            processBusiness(msg);
        } catch (Exception e) {
            // 返回 RECONSUME_LATER,消息会进入重试队列
            return ConsumeConcurrentlyStatus.RECONSUME_LATER;
        }
    }
    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});

RocketMQ 的消费重试机制:

  • 消费失败返回 RECONSUME_LATER,消息进入重试 Topic(%RETRY%{consumerGroup})
  • 默认重试 16 次,间隔递增(10s, 30s, 1m, 2m, 3m, 4m, 5m, 6m, 7m, 8m, 9m, 10m, 20m, 30m, 1h, 2h)
  • 超过 16 次进入死信队列(%DLQ%{consumerGroup})

4.5 RabbitMQ 消费可靠性

RabbitMQ 使用 手动 ACK 保证消费可靠性:

java
// 关闭自动 ACK
boolean autoAck = false;
channel.basicConsume("orders", autoAck, (consumerTag, delivery) -> {
    try {
        processBusiness(delivery.getBody());
        // 业务处理成功后手动 ACK
        channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
    } catch (Exception e) {
        // 拒绝消息,requeue=true 重新入队
        channel.basicNack(delivery.getEnvelope().getDeliveryTag(), false, true);
    }
}, consumerTag -> {});

注意:basicNack 的 requeue=true 会将消息重新放回队列头部,如果持续失败可能导致死循环。建议:

  • 记录重试次数(通过消息头传递)
  • 超过上限后 requeue=false 或发送到死信队列

五、端到端的消息可靠性方案

5.1 可靠消息的完整链路

code
┌──────────┐    ┌──────────────────┐    ┌──────────┐
│  业务DB   │    │    消息队列       │    │  消费者   │
│           │    │                  │    │           │
│ 订单表    │───▶│  Topic: orders   │───▶│ 履约系统   │
│ 消息表    │    │  (3副本,acks=all) │    │           │
└──────────┘    └──────────────────┘    └──────────┘
       │                                        │
       │            ┌──────────┐                │
       └───────────▶│  定时对账  │◀───────────────┘
                    │  (补偿)   │
                    └──────────┘

文章配图
文章配图

5.2 事务消息(RocketMQ)

RocketMQ 的事务消息是解决"本地事务和消息发送原子性"的经典方案:

java
TransactionMQProducer producer = new TransactionMQProducer("tx-producer-group");
producer.setTransactionListener(new TransactionListener() {
    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        try {
            // 执行本地事务(如创建订单)
            orderService.createOrder((Order) arg);
            return LocalTransactionState.COMMIT_MESSAGE;
        } catch (Exception e) {
            return LocalTransactionState.ROLLBACK_MESSAGE;
        }
    }
    
    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        // 回调检查本地事务状态(事务未决时)
        String orderId = msg.getKeys();
        Order order = orderService.getById(orderId);
        if (order != null) {
            return LocalTransactionState.COMMIT_MESSAGE;
        }
        return LocalTransactionState.ROLLBACK_MESSAGE;
    }
});

// 发送事务消息
Message msg = new Message("orders", order.toBytes());
producer.sendMessageInTransaction(msg, order);

流程:

  1. 生产者发送 Half 消息(对消费者不可见)
  2. 执行本地事务
  3. 根据本地事务结果提交或回滚 Half 消息
  4. 如果未收到确认,Broker 回调 checkLocalTransaction 检查状态

文章配图
文章配图

5.3 本地消息表 + 定时补偿

不依赖消息队列的事务功能,而是通过本地数据库保证原子性:

sql
-- 业务表
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    status VARCHAR(20),
    created_at TIMESTAMP
);

-- 本地消息表
CREATE TABLE outbox_message (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    business_id BIGINT NOT NULL,
    business_type VARCHAR(50) NOT NULL,
    topic VARCHAR(100) NOT NULL,
    message_body TEXT NOT NULL,
    status ENUM('PENDING', 'SENT', 'FAILED') DEFAULT 'PENDING',
    retry_count INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_status (status, created_at)
);
java
@Service
public class OrderService {
    
    @Transactional
    public void createOrder(Order order) {
        // 1. 业务操作
        orderDao.insert(order);
        
        // 2. 同一事务写入消息表
        OutboxMessage msg = new OutboxMessage();
        msg.setBusinessId(order.getId());
        msg.setBusinessType("ORDER");
        msg.setTopic("orders");
        msg.setMessageBody(JSON.toJSONString(order));
        outboxMessageDao.insert(msg);
    }
}

// 定时任务:扫描并发送未发送的消息
@Scheduled(fixedDelay = 5000)
public void sendPendingMessages() {
    List<OutboxMessage> pending = outboxMessageDao.findByStatus("PENDING", 100);
    for (OutboxMessage msg : pending) {
        try {
            producer.send(toProducerRecord(msg));
            outboxMessageDao.updateStatus(msg.getId(), "SENT");
        } catch (Exception e) {
            outboxMessageDao.incrementRetry(msg.getId());
        }
    }
}

文章配图
文章配图

5.4 对账与补偿体系

即使所有机制都做了,仍然可能因为极端情况丢失消息。最终的兜底是对账:

java
@Component
public class MessageReconciliation {
    
    @Scheduled(cron = "0 0 2 * * ?")  // 每天凌晨 2 点
    public void reconcile() {
        // 1. 查询业务系统中状态未同步的记录
        List<Order> unsynced = orderDao.findUnsynced(
            LocalDateTime.now().minusDays(1),  // 昨天
            LocalDateTime.now()
        );
        
        for (Order order : unsynced) {
            // 2. 检查下游是否已处理
            if (!fulfillmentService.existsByOrderId(order.getId())) {
                // 3. 补偿:重新发送消息
                producer.send(toProducerRecord(order));
            }
        }
    }
}

文章配图
文章配图


六、总结:三个阶段的防护清单

阶段 防护措施 Kafka RocketMQ RabbitMQ
生产 同步确认 acks=all 同步发送 Publisher Confirm
生产 重试机制 retries=MAX retryTimesWhenSendFailed 应用层重试
生产 幂等/事务 enable.idempotence / 事务 事务消息 -
生产 失败兜底 降级写 DB / 死信表 降级写 DB 降级写 DB
存储 多副本 replication.factor=3 DLedger (Raft) Quorum Queue
存储 最小 ISR min.insync.replicas≥2 SYNC&#95;MASTER 仲裁节点
存储 同步刷盘 依赖副本(非单机) SYNC&#95;FLUSH 持久化消息
消费 手动确认 手动提交 Offset CONSUME&#95;SUCCESS 手动 ACK
消费 重试队列 应用层实现 RECONSUME&#95;LATER basicNack
消费 死信队列 应用层实现 %DLQ% DLX
全局 事务消息 Kafka 事务 RocketMQ 事务消息 -
全局 本地消息表 ✓ ✓ ✓
全局 定时对账 ✓ ✓ ✓

核心原则:

  1. 生产阶段:宁可重试,不可漏发。发送失败必须落盘(死信表)或告警
  2. 存储阶段:多副本 + 最小 ISR > 单机刷盘。不要依赖单机可靠性
  3. 消费阶段:先处理、后提交。消费失败不提交,重试超限进死信
  4. 全局兜底:对账,对账,还是对账。没有 100% 可靠的消息系统,只有持续对账的补偿机制

文章配图
文章配图



面试官提问

以下问题从面试官视角出发,考察对消息可靠性原理的深度理解和工程判断力。建议先独立思考,再对照参考答案。

Q1:你说生产环境配置了**acks=all**和**min.insync.replicas=2**,但某天监控显示生产者发送超时大量报错。你排查后发现 ISR 只剩 Leader 一个副本了。为什么 ISR 会缩到只剩 Leader?你如何在不丢消息的前提下恢复服务?

点击查看参考答案

ISR 收缩的原因:

  1. Follower 副本所在 Broker 宕机或网络分区,导致 replica.lag.time.max.ms(默认 30s)内未同步,被踢出 ISR
  2. Follower 副本所在 Broker 磁盘 I/O 故障,导致同步落后超过阈值
  3. 突发流量导致 Follower 消费跟不上 Leader 的写入速度

不丢消息恢复方案:

  1. 立即排查 Follower 异常原因——是 Broker 宕机则重启,是网络分区则修复网络,是磁盘 I/O 瓶颈则扩容
  2. 等待 Follower 重新追平并加入 ISR——此时 min.insync.replicas=2 重新满足,生产者恢复正常
  3. 如果 Follower 短期无法恢复,考虑临时调整 min.insync.replicas=1 让生产者恢复(这会丢失 ISR ≥ 2 的保护,需评估风险),同时紧急修复 Follower
  4. 根本原则:宁可短时间不可用,也不丢消息。如果业务允许降级,可以临时降低 min.insync.replicas,但必须同步开启补偿机制(如写本地死信表)

关键判断:面试官想看到的不是"加机器"这种表面答案,而是你理解 min.insync.replicas 与 acks=all 的联动关系,以及能在"可用性"和"可靠性"之间做有意识的权衡决策。

Q2:你用了 Kafka 的幂等生产(**enable.idempotence=true**),但上线后发现消息还是出现了重复。分析一下可能的原因。

点击查看参考答案

幂等生产只在单分区 + 单 Producer 会话内有效。以下场景会失效:

  1. Producer 重启:重启后分配新的 PID(Producer ID),Broker 用 (PID, 序列号) 去重。新 PID 下序列号从 0 开始,无法识别旧 PID 已发送的消息,导致重复
  2. 跨分区:幂等性基于 (PID, TopicPartition, SequenceNumber),不同分区独立维护序列号。如果消息因重试被发到不同分区(如默认分区器对 key 为 null 的消息做轮询),两个分区各自认为该序列号是"新消息"
  3. 事务未开启:幂等生产只保证单分区内不重复,不保证跨分区、跨 Topic 的原子性。如果 Producer 发了 Topic A 和 Topic B,A 成功 B 失败重试,对 A 来说就是重复
  4. **max.in.flight.requests.per.connection**配置不当:Kafka 0.11 之前,幂等生产要求该值 ≤ 5(0.11+ 配合幂等可到 5),但如果没有开启幂等,该值 > 1 且 retries > 0 时,重试可能导致乱序和重复

追问:如果业务需要跨分区、跨会话的精确一次,应该怎么做? → 答案:使用 Kafka 事务(initTransactions + beginTransaction + commitTransaction),或在上游业务层做去重(如唯一业务键 + 数据库去重表)。

Q3:线上消费延迟持续增大,你发现消费者处理逻辑中有一次 RPC 调用超时,导致消息反复重试。你如何设计消费重试机制,既保证不丢消息,又避免消费积压雪崩?

点击查看参考答案

核心矛盾:重试保证不丢消息,但重试本身占用了消费能力,导致积压恶化。

设计方案:

  1. 区分可重试异常和不可重试异常:
    • 不可重试:数据格式错误、业务校验失败 → 直接进死信队列,不重试
    • 可重试:RPC 超时、数据库连接失败、临时网络抖动 → 重试
  2. 指数退避 + 最大重试次数:delay = min(2^retryCount * baseDelay, maxDelay),超过上限进死信队列
  3. 重试消息与正常消息隔离:RocketMQ 的做法——消费失败的消息进入 %RETRY% Topic,由独立的消费者线程处理,不影响正常消息的消费速度
  4. 限流 + 熔断:如果下游 RPC 持续超时,熔断器打开后直接降级处理(写本地死信表),避免无效重试耗尽资源
  5. 监控告警:对死信队列消息量、消费延迟、重试率设置告警,及时人工介入

关键点:面试官想听到的是"分层处理"思想——不是所有失败都重试,不是所有重试都等时长,不是所有异常都消耗同等资源。你的设计应该体现对系统容量的认知。

Q4:请你设计一个"订单创建 → 发送消息 → 下游履约"的端到端可靠方案。要求:订单创建成功消息必达,不允许消息丢失,但允许极少量重复(下游做幂等)。你不需要实现代码,画出架构并说明关键决策。

点击查看参考答案

推荐方案:本地消息表 + 定时补偿 + 下游幂等

code
订单服务 DB                         消息队列                       履约服务
┌─────────────────┐            ┌──────────────┐            ┌──────────────┐
│  orders 表       │            │ Topic: orders │            │ 消费 + 幂等   │
│  outbox_message  │──定时扫描──▶│ (acks=all,    │──消费者──▶│ 去重表/唯一键 │
│  (同一事务)       │            │  min.isr=2)   │            │              │
└─────────────────┘            └──────────────┘            └──────────────┘

关键决策:

  1. 为什么用本地消息表而不是事务消息? 事务消息要求消息队列支持(如 RocketMQ 事务消息),但本地消息表不依赖消息队列特性,对 Kafka/RabbitMQ/RocketMQ 都适用。缺点是多了定时扫描的延迟。
  2. 为什么允许重复? 网络超时导致生产者重试,但 Broker 可能已收到前一条消息(ACK 丢失),这时消息会重复。完全消除重复的代价是分布式事务(2PC/TCC),性能开销太大。让下游做幂等是更务实的选择。
  3. 定时扫描的频率和批量大小怎么定? 扫描频率 5-10 秒,批量 100 条。频率不能太高(避免数据库压力),不能太低(延迟过大)。批量大小要配合生产者吞吐量。
  4. 对账怎么做? 每天凌晨跑一次全量对账——对比订单服务的订单状态和履约服务的是否已处理,发现漏网之鱼补偿发送。

追问:如果订单量极大(每秒万级),本地消息表会成为瓶颈吗? → 答案:会。可以分库分表(按订单 ID 哈希),或改用 CDC(Change Data Capture)方案——监听订单表的 binlog 变化,直接发送到消息队列。Debezium + Kafka Connect 是常见选型。

Q5:RocketMQ 事务消息的回查机制(**checkLocalTransaction**)在什么情况下会被触发?如果回查逻辑写错了会有什么后果?

点击查看参考答案

回查触发条件:

  1. Producer 发送 Half 消息后,执行本地事务前宕机或网络中断,Broker 未收到 Commit/Rollback 指令
  2. Producer 执行了本地事务并返回了 UNKNOW 状态
  3. Producer 的本地事务执行时间超过了 Broker 的等待超时时间

回查逻辑写错的后果:

  1. 回查永远返回 COMMIT:本地事务实际已回滚,但消息被提交,消费者看到了"不该看到"的消息 → 数据不一致
  2. 回查永远返回 ROLLBACK:本地事务实际已提交,但消息被回滚,消费者漏掉了消息 → 业务断链
  3. 回查抛异常/超时:Broker 会重试回查(默认 15 次),期间消息处于"半消息"状态,消费者不可见。如果回查始终失败,消息最终可能被丢弃

正确做法:

  • 回查逻辑必须幂等(可能被多次调用)
  • 回查必须查询权威数据源(数据库),不能依赖内存状态
  • 回查必须有超时保护和失败兜底(如告警)
  • 回查逻辑应该简单可靠——只做一件事:查数据库确认事务状态

关键判断:面试官在考察你对分布式事务"不确定性窗口"的理解。事务消息不是银弹,它只是把"不确定性"从消息发送延迟到了回查阶段,回查逻辑的正确性才是关键。

Q6:你在 Kafka 消费端使用了手动提交 Offset,但某次 Rebalance 后,发现大量消息被重复消费了。分析 Rebalance 导致重复消费的原因,以及如何减少重复消费的范围。

点击查看参考答案

Rebalance 导致重复消费的典型流程:

  1. 消费者 A 拉取了 offset 100-200 的消息
  2. 消费者 A 处理了 offset 100-150,还未来得及提交
  3. 触发 Rebalance(心跳超时 / 新消费者加入 / 消费者主动离开)
  4. 消费者 A 被踢出消费组,分区被分配给消费者 B
  5. 消费者 B 从上次提交的 offset(100)开始消费,100-150 被重复处理

减少重复消费范围的方法:

  1. 减小**max.poll.records**(默认 500):每次拉取更少的消息,降低重复窗口
  2. 增大**max.poll.interval.ms**:处理时间上限,避免因处理慢触发 Rebalance
  3. 增大**session.timeout.ms**和**heartbeat.interval.ms**:减少因 GC 停顿或网络抖动导致的误判 Rebalance
  4. 提高提交频率:每处理一小批就提交一次,而不是等整个批次处理完再提交(代价是提交开销增加)
  5. 使用静态组成员(group.instance.id):消费者重启后不触发 Rebalance
  6. 业务层幂等:不管重复多少次,最终结果一致(最根本的解决办法)

关键点:面试官想听到的是"Rebalance 是无法完全避免的,所以重复消费也无法完全避免,最终要依赖业务幂等"。这个认知比具体配置更重要。


思考题:如果你负责设计一个金融交易系统的消息中间件,要求"消息零丢失 + 精确一次语义",你会选择哪种消息队列?为什么?在架构上需要做哪些额外的保障?(提示:没有标准答案,可以从 Kafka + 事务、RocketMQ + DLedger、Pulsar 等角度展开,重点考察你对不同方案的 trade-off 理解。)


本文是「Java 技术专家 / 架构师面试:250 个高频考点」系列的第 157 篇。