RabbitMQ学习笔记08:Publisher Confirms — RabbitMQ
参考资料:RabbitMQ tutorial - Reliable Publishing with Publisher Confirms
Overview
在这篇 tutorial 中,官方仅提供了 Java 的客户端。
发布者确认(Publisher Confirms)是一种 RabbitMQ 扩展用于实现可靠的发布。当在一个channel
上启用了publisher confirm
的话,客户端发送的消息会被 mq 服务器异步确认,这表明在服务器端已经接收到这些消息了。
我们有几种方案(strategies)来实现 publish confirms 用来确保消息安全抵达 mq 服务器。我们会阐释每种方案各自的优缺点。
Enabling Publisher Confirms on a Channel
Publish confirm 是 AMQP 0.9.1 协议的扩展,因此默认情况下它不会被启用。Publish confirm 的启用是在 channel 级别使用 confirmSelect 方法。
Channel channel = connection.createChannel();
channel.confirmSelect();
在每一个我们期望使用 publish confirm 功能的 channel 中都需要这么调用该方法。我们不需要在每次发送消息的时候都调用该方法,在 channel 级别中调用一次即可。
Strategy #1: Publishing Message Individually
我们从最简单的方法开始:发布一个带 confirm 的消息,然后等待异步的确认。
while (thereAreMessagesToPublish()) {
byte[] body = ...;
BasicProperties properties = ...;
channel.basicPublish(exchange, queue, properties, body);
// uses a 5 second timeout
channel.waitForConfirmsOrDie(5_000);
}
在这个例子中,我们像往常一样发布了一个消息并且等待确认,使用了channel.waitForConfirmsOrDie(long)
方法。只要消息被确认,这个方法就会被返回。如果在超时时间内没有确认或者消息被 nack-ed(意味着 mq 服务器出于某些原因没有办法处理该消息),那么这个方法就会抛出一个异常。异常的处理通常包含了错误日志的记录以及消息重发。
不同的客户端程序有不同的用于处理 publisher confirms 的方法,请仔细阅读相关的文档。
这种方案很直接但是缺点也很明显:它会显著降低消息发布的速率,因为每次发布消息之后都需要等待确认或者其他异常,后续的消息发布必须处于阻塞的状态。这种方案不适合吞吐量达到每秒几百条消息的情况。但是它可能适用于某些应用程序。
Are Publisher Confirms Asynchronous?
刚开始我们提到 mq 确认消息发布的方式是异步地,但是从第一个方案来看却是同步地(因为消息必须被确认才会继续发布下一个消息,否则处于阻塞状态)。客户端实际上是异步接收确认然后相应地疏通(阻塞 block 的反义词)针对waitForConfirmsOrDie
的调用。把waitForConfirmsOrDie
想象成一个同步的助手,它在底层基于异步通知的机制。
Strategy #2: Publishing Messages in Batches
在方案2中我们改善之前的示例,我们发送一整批消息并且等待整批消息的确认。假设每一批消息是100条。
int batchSize = 100;
int outstandingMessageCount = 0;
while (thereAreMessagesToPublish()) {
byte[] body = ...;
BasicProperties properties = ...;
channel.basicPublish(exchange, queue, properties, body);
outstandingMessageCount++;
if (outstandingMessageCount == batchSize) {
channel.waitForConfirmsOrDie(5_000);
outstandingMessageCount = 0;
}
}
if (outstandingMessageCount > 0) {
channel.waitForConfirmsOrDie(5_000);
}
等待一整批的消息被确认极大地提高了相对于等待每个消息被确认的吞吐量(大概20-30倍,对于远程 mq 服务器来说)。缺点是我们不知道具体哪条消息除了问题,因此我们可能需要将整批消息都放在内存中去记录一些有意义的日志或者重新发布整批消息。这个方案依然是同步的,所以还是会阻塞,只不过是整批阻塞。
Strategy #3: Handling Publisher Confirms Asychronously
要想实现 mq 异步确认消息,只需要在客户端上注册一个用于通知这些消息被确认的回调接口即可:
Channel channel = connection.createChannel();
channel.confirmSelect();
channel.addConfirmListener((sequenceNumber, multiple) -> {
// code when message is confirmed
}, (sequenceNumber, multiple) -> {
// code when message is nack-ed
});
这里有两个回调函数,一个用于已经 confirmed 的消息,另一个用于 nack-ed 的消息。nack-ed 指的是消息被 mq 认为已经丢失了。每个回调函数有两个参数:
- sequence number: 消息的ID,用来识别 confirmed 或者 nack-ed 的消息。一会我们就会看到如何将其和发布的消息关联。
- multiple: 这是一个布尔值。如果是 false 的话,只有一个消息会被 confirmed/nack-ed。如果是 true 的话,所有的带有更低或者相同 sequence number 的消息会被confirmed/nack-ed。
sequence number 可以在消息被发布之前使用Channel#getNextPublishSeqNo()
获取到:
int sequenceNumber = channel.getNextPublishSeqNo());
ch.basicPublish(exchange, queue, properties, body);
将消息和 sequence number 关联起来的一种简单的方法是使用映射(map)。假设我们想要发布一些字符串消息,因为它们比较容易转换成字节数组用于发送。以下是代码示例:
ConcurrentNavigableMap<Long, String> outstandingConfirms = new ConcurrentSkipListMap<>();
// ... code for confirm callbacks will come later
String body = "...";
outstandingConfirms.put(channel.getNextPublishSeqNo(), body);
channel.basicPublish(exchange, queue, properties, body.getBytes());
发布代码现在会基于映射来追踪发出的消息。我们需要清理映射关系,当确认消息已经发布或者当消息 nack-ed 的时候记录错误日志:
ConcurrentNavigableMap<Long, String> outstandingConfirms = new ConcurrentSkipListMap<>();
ConfirmCallback cleanOutstandingConfirms = (sequenceNumber, multiple) -> {
if (multiple) {
ConcurrentNavigableMap<Long, String> confirmed = outstandingConfirms.headMap(
sequenceNumber, true
);
confirmed.clear();
} else {
outstandingConfirms.remove(sequenceNumber);
}
};
channel.addConfirmListener(cleanOutstandingConfirms, (sequenceNumber, multiple) -> {
String body = outstandingConfirms.get(sequenceNumber);
System.err.format(
"Message with body %s has been nack-ed. Sequence number: %d, multiple: %b%n",
body, sequenceNumber, multiple
);
cleanOutstandingConfirms.handle(sequenceNumber, multiple);
});
// ... publishing code
刚才的代码包含了清理映射关系的回调函数。这个回调函数在消息发布被确认后会被使用。针对 nack-ed 消息的回调函数会检索消息的 body 并且放出警告信息。然后它会重新使用之前的回调函数去清理与未完成的确认之间的映射关系(无论消息是否被 confirm or nack-ed,在 map 中的关系一定要被移除)。
How to Track Outstanding Confirms?
我们的示例使用
ConcurrentNavigableMap
去追踪未完成的confirms
。这种结构出于一些原因是很方便的。它允许简单地关联sequence number
和消息(无论消息数据是什么),取消关联也很简单(只要提供sequence id
)。最后,它支持并发访问,因为消息确认的回调函数是在线程中被调用的,这个线程是被客户端库所拥有,它和发布消息的线程是不同的。(可能意思是想表示不同的线程?)存在其他的方式(而不是精妙的映射实现)用于追踪未确认的发布消息,比如使用简单的并发 hash 映射和一个变量去追踪更低的发布序列界限,但是它们更复杂并且不属于此 tutorial。
总之,异步处理发布者确认通常要求以下步骤:
- 提供一种方式关联发布序列号码和消息。
- 在channel上注册一个确认监听器,当消息 acks/nacks 抵达的时候做出响应,比如记录日志或者重新发送一条 nack-ed 的消息。消息和序列号之间的映射关系可能在这步中需要被解除。
- 在发出消息之前就要追踪需要被发布的消息的序列号。
Re-publishing nack-ed Messages?
可以尝试从关联的回调函数重新发送一个 nack-ed 消息,但是应该尽量避免这么做,因为 confirm 回调函数是位于 I/O 线程中的,在这里 channels 不被期望做操作。一个更好的解决方案是将消息推入一个内存队列中,该队列会被发送消息的线程轮询。像
ConcurrentLinkedQueue
类就是一个比较好的候选者用于在 confirm 回调函数和发送消息的线程之间传输消息。
总结
在某些应用程序中,确保消息被发送到mq服务器是必不可少的。Publisher confirms 是RabbitMQ的特性用于帮助满足这个要求。Publisher confirms 本质上是异步的但是可以以同步的方式处理它们。没有最好的实现 publisher confirms 的方式,这通常要取决于程序或者系统中的约束。典型的技术有:
- 逐一发送消息,同步等待(阻塞)已发送的每一条消息的确认信息:简单,但是吞吐量最差。
- 整批发送消息,同步等待(阻塞)已发送的整批消息的确认信息:简单,合理的吞吐量,但是当消息丢失的时候很难定位具体的有问题的消息。
- 异步处理(非阻塞,无需等待):最佳的性能和资源利用,报错时可定位具体的消息从而做出适当的处理,但是实现的难度较大,需要一定的技术要求。
译者注:虽然这篇 tutorial 理论不会很难,但是使用 Java 实现我看不太懂,特别是异步处理那部分的代码,因此剩余的代码演示我这里就没有做了。