3. 消息可靠性(上):生产者端如何保证消息不丢失?ACK机制、重试策略与幂等性设计

聊到消息队列,大家最关心的问题之一就是:消息到底会不会丢?

说实话,这个问题没有绝对的“是”或“否”。消息丢失的风险贯穿整个链路——生产者发送、Broker存储、消费者消费,每个环节都可能出问题。今天我们先聚焦生产者端,看看消息从业务系统发出到抵达Broker这段路,怎么才能走得稳当。

消息丢失的第一道坎:网络与异步

生产者发送消息,本质上是一次网络调用。网络是不可靠的,丢包、超时、连接断开,这些情况在分布式系统里太常见了。

我见过不少团队,刚开始用消息队列时,直接调了send()方法就以为万事大吉。结果线上偶尔出现数据对不上的情况,查了半天才发现是消息根本没发出去,但业务代码里没有任何感知。

为什么会这样?因为很多客户端默认是异步发送的。你调用send(),它把消息扔到本地缓冲区就返回了,真正的网络传输在后台线程里进行。如果此时进程崩溃,或者网络闪断,消息就无声无息地丢了。

⚠️ 避坑指南
我曾经接手过一个支付对账项目,生产端用异步发送,结果每天有几笔订单的状态没同步到下游。排查后发现是凌晨的JVM GC停顿导致缓冲区里的消息没来得及发送。从那以后,涉及资金的消息我全部改成同步发送,或者至少加上回调确认。

ACK机制:Broker说“收到了”才算数

要解决消息丢失,最直接的办法就是让Broker给个确认——也就是ACK(Acknowledgment)。

不同的消息队列,ACK的语义不太一样。以Kafka为例,生产者可以设置acks参数来控制可靠性级别:

acks值 含义 可靠性 性能
0 生产者发完就认为成功,不等待任何确认 最低 最高
1 等待Leader副本写入成功即返回ACK 中等 较高
all(或-1) 等待Leader和所有ISR副本都写入成功才返回ACK 最高 较低

我个人习惯,在核心交易链路里,acks必须设置为all。虽然性能会下降一些,但换来的是数据不丢的底气。你想想看,如果Leader刚写完就挂了,而Follower还没同步,这条消息就永远丢了——这谁能接受?

💡 关键点
acks=all并不保证100%不丢,它依赖于ISR(In-Sync Replicas)机制。如果所有副本都挂了,消息还是会丢。所以还需要配合min.insync.replicas参数,确保至少有N个副本同步,才能写入。

重试策略:失败后怎么办?

有了ACK,我们就能知道消息是否发送成功。但失败之后呢?直接抛异常?还是默默重试?

这里有个常见的误区:重试不是万能的。不加控制的重试,可能导致消息重复、顺序错乱,甚至把Broker打挂。

我建议的重试策略是这样的:

  • 设置合理的重试次数:比如3次,太多会阻塞业务线程,太少又容易失败
  • 指数退避:第一次失败等100ms,第二次等200ms,第三次等400ms。避免雪崩
  • 区分错误类型:网络超时可以重试,但消息格式错误、权限不足这种,重试一万次也没用,直接记录失败日志
// 伪代码示例:带重试的生产者发送
int maxRetries = 3;
int retryCount = 0;
long backoff = 100;

while (retryCount < maxRetries) {
    try {
        producer.send(record).get(5, TimeUnit.SECONDS);
        break; // 成功则跳出
    } catch (TimeoutException e) {
        retryCount++;
        if (retryCount >= maxRetries) {
            // 记录到死信队列或告警
            log.error("消息发送失败,已重试{}次", maxRetries);
            break;
        }
        Thread.sleep(backoff);
        backoff *= 2; // 指数退避
    } catch (Exception e) {
        // 不可重试的错误,直接抛出
        throw e;
    }
}
📌 小技巧
重试时最好带上递增的retryCount标识,这样下游可以识别这是第几次重试。我在做风控系统时,就靠这个字段区分了“首次请求”和“重试请求”,避免重复计算。

幂等性设计:重复消息的终结者

重试带来了一个新问题:消息重复。比如第一次发送成功了,但ACK在网络传输中丢了,生产者以为失败,于是重试——结果Broker收到了两条一模一样的消息。

怎么办?答案是幂等性

所谓幂等,就是同一个操作执行多次,结果和执行一次一样。在消息队列里,有两种实现思路:

1. 生产者端幂等(Kafka的Idempotent Producer)

Kafka从0.11版本开始支持幂等生产者。原理很简单:每个生产者有一个唯一的Producer ID(PID),每条消息带一个递增的序列号。Broker会检查序列号,如果发现重复,直接丢弃。

// 开启幂等生产者
Properties props = new Properties();
props.put("enable.idempotence", true);
// 注意:开启幂等后,acks会自动被设置为all
KafkaProducer<String, String> producer = new KafkaProducer<>(props);

开启后,你就不用担心重试导致的消息重复了。但要注意,幂等只保证单分区内的严格顺序和去重,跨分区或跨会话的情况它管不了。

2. 业务端幂等(去重表/唯一键)

如果消息队列本身不支持幂等,或者你需要跨分区的去重,那就得在业务层自己实现。

我常用的做法是:在消息体里加一个全局唯一的业务ID,消费者处理时先查去重表(比如Redis或数据库),如果ID已存在,直接跳过。

// 消费者端幂等处理
public void handleMessage(Message msg) {
    String bizId = msg.getBizId();
    // 尝试写入去重表,如果已存在则返回false
    if (!dedupService.tryInsert(bizId)) {
        log.info("消息已处理,跳过: {}", bizId);
        return;
    }
    // 真正的业务处理
    processOrder(msg);
}
⚠️ 注意
去重表要考虑过期时间。比如订单消息,可能一天后就不会再重试了,那去重记录就可以删掉。否则表越来越大,查询性能会下降。我一般设置TTL为24小时。

知识体系总览

下面这张图总结了生产者端保证消息可靠性的核心逻辑:

生产者端消息可靠性保障体系 生产者 网络传输 Broker 发送消息 转发 ACK确认 ACK返回 策略一:ACK机制 acks=0 / 1 / all 等待Broker确认才算成功 策略二:重试策略 有限次数 + 指数退避 区分可重试/不可重试错误 策略三:幂等性 生产者端:PID + 序列号 业务端:去重表 + 唯一键 核心目标:消息不丢、不重、不乱 ACK保证不丢 → 重试应对失败 → 幂等解决重复

总结一下

生产者端保证消息可靠性,说白了就是三件事:

  • ACK机制:让Broker告诉你“收到了”,别自己瞎猜
  • 重试策略:失败时优雅地再试,别蛮干也别放弃
  • 幂等设计:确保重复的消息不会造成重复的业务影响

这三者缺一不可。没有ACK,你连消息丢没丢都不知道;没有重试,一次网络抖动就导致数据不一致;没有幂等,重试又可能带来重复数据。嗯,环环相扣,一个都不能少。


专注资料整理