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小时。
知识体系总览
下面这张图总结了生产者端保证消息可靠性的核心逻辑:
总结一下
生产者端保证消息可靠性,说白了就是三件事:
- ACK机制:让Broker告诉你“收到了”,别自己瞎猜
- 重试策略:失败时优雅地再试,别蛮干也别放弃
- 幂等设计:确保重复的消息不会造成重复的业务影响
这三者缺一不可。没有ACK,你连消息丢没丢都不知道;没有重试,一次网络抖动就导致数据不一致;没有幂等,重试又可能带来重复数据。嗯,环环相扣,一个都不能少。