19、事件驱动与消息可靠性:消息持久化、消息确认(ACK)、死信队列、重试队列、Kafka与RabbitMQ实践
各位同学,今天我们来聊聊事件驱动架构里最让人头疼,也最绕不开的话题——消息可靠性。
说白了,消息中间件就像快递系统。你寄了个包裹,怎么确保它一定送到?半路丢了怎么办?收件人不在家怎么办?这些问题,在分布式系统里一个不少。
我早年做电商订单系统时,就吃过消息丢失的亏。用户下单成功了,结果积分服务没收到消息,积分没加上。用户投诉过来,我们查了半天日志,才发现是消息在中间件里丢了。从那以后,我对消息可靠性就特别敏感。
19.1 消息持久化:把消息写在磁盘上
消息持久化,就是把消息存到磁盘里。这样即使Broker宕机重启,消息也不会丢。
RabbitMQ的持久化
RabbitMQ的持久化需要做三件事:
- Exchange设置持久化:
durable=true - Queue设置持久化:
durable=true - 消息设置持久化:
delivery_mode=2
// RabbitMQ 持久化示例
channel.exchangeDeclare("order.exchange", "direct", true); // durable=true
channel.queueDeclare("order.queue", true, false, false, null); // durable=true
String message = "订单创建成功";
AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
.deliveryMode(2) // 持久化消息
.build();
channel.basicPublish("order.exchange", "order.create", props, message.getBytes());
Kafka的持久化
Kafka天生就是持久化的。所有消息都会写到磁盘的Segment文件中。你不需要额外配置。
Kafka的持久化靠的是日志文件。每个分区对应一个日志目录,消息按顺序追加。这种顺序写磁盘的方式,性能比随机写高很多。
# Kafka 服务端配置,控制日志保留时间
log.retention.hours=168 # 保留7天
log.retention.bytes=1073741824 # 单个日志文件最大1GB
我个人习惯把Kafka的日志保留时间设长一点,比如7天。这样即使消费者挂了几天,重启后还能从头消费。
19.2 消息确认(ACK):你到底收到没?
消息确认机制,就是消费者告诉Broker:“我收到了,处理完了,你可以删了。” 如果没确认,Broker会重新投递。
RabbitMQ的ACK
RabbitMQ支持手动ACK和自动ACK。自动ACK就是消费者一收到消息就确认,不管处理成功与否。手动ACK是消费者处理完业务逻辑后,再调用basicAck。
// RabbitMQ 手动ACK
channel.basicConsume("order.queue", false, new DefaultConsumer(channel) {
@Override
public void handleDelivery(String consumerTag, Envelope envelope,
AMQP.BasicProperties properties, byte[] body) throws IOException {
try {
// 处理业务逻辑
processOrder(body);
// 处理成功,手动ACK
channel.basicAck(envelope.getDeliveryTag(), false);
} catch (Exception e) {
// 处理失败,拒绝消息,重新入队
channel.basicNack(envelope.getDeliveryTag(), false, true);
}
}
});
Kafka的ACK
Kafka的ACK机制在生产者端和消费者端都有。
- 生产者ACK:控制生产者发送消息后,等待多少个副本确认。
- 消费者ACK:消费者处理完消息后,提交offset。
// Kafka 生产者ACK配置
Properties props = new Properties();
props.put("acks", "all"); // 等待所有副本确认
props.put("retries", 3); // 重试次数
// Kafka 消费者手动提交offset
consumer.subscribe(Arrays.asList("order-topic"));
while (true) {
ConsumerRecords<String, String> records = consumer.poll(1000);
for (ConsumerRecord<String, String> record : records) {
processRecord(record);
}
consumer.commitSync(); // 手动提交
}
Kafka的acks=all是最安全的配置,但延迟也最高。我一般用在核心交易链路,非核心链路用acks=1就够了。
19.3 死信队列:处理不了的消息放这里
死信队列,就是消息的“垃圾桶”。当消息处理失败,或者过期了,就会被扔进死信队列。
RabbitMQ的死信队列
RabbitMQ的死信队列需要额外配置。给普通队列绑定一个死信交换机,消息变成死信后,会被路由到死信队列。
// 声明死信交换机
channel.exchangeDeclare("dlx.exchange", "direct", true);
// 声明死信队列
channel.queueDeclare("dlx.queue", true, false, false, null);
channel.queueBind("dlx.queue", "dlx.exchange", "dlx.routingkey");
// 声明普通队列,绑定死信交换机
Map<String, Object> args = new HashMap<>();
args.put("x-dead-letter-exchange", "dlx.exchange");
args.put("x-dead-letter-routing-key", "dlx.routingkey");
channel.queueDeclare("order.queue", true, false, false, args);
死信队列有什么用?我举个例子。订单支付超时了,消息变成死信,进入死信队列。然后有个专门的消费者监听死信队列,把超时订单取消掉。这样就把异常处理和正常业务分开了。
Kafka的死信队列
Kafka没有内置的死信队列概念。但我们可以自己实现。比如消费者处理失败后,把消息写入另一个Topic(比如order-dlq),然后单独处理。
// Kafka 手动实现死信队列
try {
processRecord(record);
} catch (Exception e) {
// 处理失败,写入死信Topic
producer.send(new ProducerRecord<>("order-dlq", record.key(), record.value()));
// 提交offset,避免重复消费
consumer.commitSync();
}
19.4 重试队列:失败了再试一次
有些失败是暂时的,比如数据库连接超时、网络抖动。这时候重试一下可能就成功了。重试队列就是干这个的。
RabbitMQ的重试队列
RabbitMQ的重试队列通常结合死信队列实现。消息处理失败后,不直接进死信队列,而是先进入一个“重试队列”,等待一段时间后再重新投递。
// 重试队列配置:消息过期后重新投递
Map<String, Object> args = new HashMap<>();
args.put("x-dead-letter-exchange", "order.retry.exchange");
args.put("x-message-ttl", 5000); // 5秒后过期
channel.queueDeclare("order.retry.queue", true, false, false, args);
我习惯设置3次重试。第一次失败后等5秒,第二次等10秒,第三次等30秒。如果还失败,就进死信队列人工处理。
Kafka的重试队列
Kafka的重试队列也是自己实现。消费者处理失败后,把消息写入一个“重试Topic”,并设置一个延迟时间。然后另一个消费者监听重试Topic,延迟处理。
// Kafka 重试队列实现思路
// 1. 处理失败,写入重试Topic,带上重试次数
producer.send(new ProducerRecord<>("order-retry", record.key(), record.value()));
// 2. 重试消费者,检查重试次数,决定是否继续重试
if (retryCount < 3) {
// 延迟5秒后重新处理
Thread.sleep(5000);
processRecord(record);
} else {
// 超过重试次数,进死信队列
producer.send(new ProducerRecord<>("order-dlq", record.key(), record.value()));
}
19.5 Kafka与RabbitMQ实践对比
这两个中间件我都用过。简单说说我的感受。
| 特性 | RabbitMQ | Kafka |
|---|---|---|
| 消息模型 | 队列模型,点对点或发布订阅 | 日志模型,分区顺序消费 |
| 持久化 | 需要显式配置 | 默认持久化 |
| ACK机制 | 手动/自动ACK,支持NACK | 生产者acks配置,消费者offset提交 |
| 死信队列 | 内置支持,配置简单 | 需要自己实现 |
| 重试队列 | 结合死信队列和TTL实现 | 需要自己实现 |
| 适用场景 | 低延迟、复杂路由、事务消息 | 高吞吐、日志收集、流处理 |
我个人习惯:业务系统内部的消息用RabbitMQ,因为它的路由灵活,死信队列和重试队列配置方便。大数据和日志场景用Kafka,因为它的吞吐量高,持久化可靠。
19.6 核心知识体系
下面这张图总结了事件驱动与消息可靠性的核心逻辑。你可以看到消息从生产者到消费者,中间经历了持久化、ACK、重试、死信等环节。
从这张图你可以看到,消息从生产者发出,经过持久化存储到Broker,然后投递给消费者。消费者处理成功后发送ACK,Broker删除消息。如果处理失败,消息进入重试队列,重试几次后如果还失败,就进入死信队列,最终人工处理。
嗯,这就是事件驱动与消息可靠性的核心。说白了,就是“确保消息不丢,处理失败有兜底”。
- 消息持久化:RabbitMQ显式配置,Kafka默认持久化
- 消息确认:手动ACK比自动ACK更可靠
- 死信队列:处理不了的消息放这里,人工介入
- 重试队列:临时失败先重试,别急着扔垃圾桶
- Kafka和RabbitMQ各有优势,选型看场景
我在项目中见过太多因为消息丢失导致的数据不一致问题。每次排查起来都特别痛苦。所以,从一开始就把消息可靠性设计好,后面能省很多事。
公众号:蓝海资料掘金营,微信deep3321