十二、消息可靠性
持久化可以提高RabbitMQ的可靠性,以防在异常情况(重启、关闭、右机等)下的数据丢失。
RabbitMQ的持久化分为三个部分:
-
交换器的持久化:通过在声明交换器时将
durable
参数设置为true
实现持久化。不设置持久化,在RabbitMQ服务重启后,相关的交换器元数据会丢失。不过消息因为存储在队列,不会丢失,只是不能将消息发送到这个交换器,需要重新声明交换器。对于长期使用,建议设置为持久化。 -
队列的持久化:通过在声明队列时将
durable
参数设置为true
实现持久化。队列不设置持久化,相关队列的元数据会丢失,其中存储的消息也会丢失。但设置了持久化,也就只能保持元数据不丢失,不能保证存储的消息不丢失。要消息不丢失,还要设置消息的持久化。 -
消息的持久化:通过在声明交换器时将
durable
参数设置为true
实现持久化。通过将消息的投递模式(BasicProperties中的deliveryMode属性)设置为2来实现消息持久化:AMQP.BasicProperties basicProperties = new AMQP.BasicProperties .Builder() .deliveryMode(2) .build();
RabbitMQ Java Client对上面的消息持久化进行了封装(如下):
MessageProperties.PERSISTENT_TEXT_PLAIN
查看源码:
public static final BasicProperties PERSISTENT_TEXT_PLAIN = new BasicProperties("text/plain", null, null, 2, //deleveryMode 0, null, null, null, null, null, null, null, null, null);
只有同时设置了队列的持久化和消息的持久化,才能保证消息在RabbitMQ重启后不会丢失。
持久化客户端代码:
public class Send {
final static String EXCHANGE = "durable";
final static String QUEUE= "durable";
public static void main(String[] args) {
Connection connection = null;
Channel channel;
try {
connection = ConnectionUtils.getConnection();
channel = connection.createChannel();
//设置exchange参数durable为true来持久化,这个Exchange持久化不影响消息的持久化
channel.exchangeDeclare(EXCHANGE, BuiltinExchangeType.DIRECT, true);
//设置queue参数durable为true来持久化,同时需要设置消息的持久化才能保证消息不丢失。
channel.queueDeclare(QUEUE, true, false, false, null);
//exchange与queue绑定
channel.queueBind(QUEUE, EXCHANGE, EXCHANGE);
//发送消息,设置第三个参数来保持消息的持久化
channel.basicPublish(EXCHANGE, EXCHANGE, MessageProperties.PERSISTENT_TEXT_PLAIN, "durable".getBytes("utf-8"));
} catch (IOException e) {
e.printStackTrace();
} catch (TimeoutException e) {
e.printStackTrace();
}finally {
try {
connection.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
客户端发送消息后,消费端没有消费消息,重启RabbitMQ服务后,消息还在,说明消息已经实现持久化。
可以将所有的消息都设置持久化,但影响RabbitMQ的性能。因为写入磁盘速度比内存慢。对于可靠性不高的消息可以不采用持久化来提高整体吞吐量。
将队列、消息都设置持久化后不能保证数据百分百不丢失,因为如果在消费者订阅队列时将autoAck
参数设置为true
,当消费者接收到消息后,还没来得及处理就宕机,数据也会丢失。要解决这种情况,需要设置参数autoAck
为false
进行手动确认:
public class Recvier {
final static String EXCHANGE = "durable";
final static String QUEUE= "durable";
public static void main(String[] args) {
Connection connection;
Channel channel;
try {
connection = ConnectionUtils.getConnection();
channel = connection.createChannel();
channel.queueDeclare(QUEUE, true, false, false, null);
//持久化最好要设置手动消息确认
boolean autoAck = false;
channel.basicConsume(QUEUE, autoAck, new DefaultConsumer(channel){
@Override
public void handleDelivery(String consumerTag,
Envelope envelope,
AMQP.BasicProperties properties,
byte[] body) throws IOException {
System.out.println("接收到:" + new String(body, "utf-8"));
long deliveryTag = envelope.getDeliveryTag();
channel.basicAck(deliveryTag, false);
}
});
} catch (IOException e) {
e.printStackTrace();
} catch (TimeoutException e) {
e.printStackTrace();
}
}
}
即使设置了消费者手动确认,也只是减少了数据丢失少,还不能百分百持久化。在持久化消息存入内存后,需要一点点时间(时间很短,但不可忽视)才能存入磁盘。如果在宕机、重启时,消息还是可能没来得及存入磁盘而丢失。RabbitMQ还提供有:
- RabbitMQ引入了镜像队列机制,相当于配置了副本,如果主节点(master)挂掉,可以自动切换到从节点(slave),保证了高可用性,除非整个集群都挂掉。
- 也可以在发送端引入事务机制或者发送方确认机制来保证消息已经正确发送并存储至RabbitMQ中。
生产者确认
默认情况下,发送消息的操作是不会返回任何消息给生产者,也即生产者是不知道消息有没有正确地到达服务器。
如果在消息到达服务器之前已经丢失,持久化操作也解决不了这个问题,因为消息根本没有到达服务器。
RabbitMQ在生产者确认中提供两种方式:
- 通过事务机制实现
- 通过发送方确认(publisher confirm)机制实现
事务机制
RabbitMQ客户端与事务机制相关的方法有三个:
- channel.txSelect:设置当前信道为事务模式
- channel.txCommit:用于提交事务
- channel.txRollback:用于事务回滚
开启事务后,发送消息到RabbitMQ。如果事务提交成功,则消息一定到达RabbitMQ中;如果在事务提交之前由于RabbitMQ异常崩溃或其他原因抛出异常,将其捕获,从而进行回滚:
public class Send {
final static String EXCHANGE = "tx_test";
public static void main(String[] args) {
Connection connection = null;
Channel channel;
try {
connection = ConnectionUtils.getConnection();
channel = connection.createChannel();
channel.exchangeDeclare(EXCHANGE, BuiltinExchangeType.DIRECT);
channel.queueDeclare(EXCHANGE, true, false, false, null);
channel.queueBind(EXCHANGE, EXCHANGE, EXCHANGE);
try {
//开启事务
channel.txSelect();
//发送消息
channel.basicPublish(EXCHANGE,
EXCHANGE,
MessageProperties.PERSISTENT_TEXT_PLAIN,
"测试事务机制".getBytes("utf-8"));
//提交事务
channel.txCommit();
} catch (IOException e) {
e.printStackTrace();
//在提交事务之前发生异常回滚
channel.txRollback();
}
} catch (IOException e) {
e.printStackTrace();
} catch (TimeoutException e) {
e.printStackTrace();
} finally {
if (connection != null) {
try {
connection.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
如果要发送多条消息,则将channel.basicPublic和channel.txCommit等方法放进循环体内:
//开启事务
channel.txSelect();
try {
for (int i = 0; i < 5; i++) {
//发送消息
channel.basicPublish(EXCHANGE,
EXCHANGE,
MessageProperties.PERSISTENT_TEXT_PLAIN,
"测试事务机制".getBytes("utf-8"));
//提交事务
channel.txCommit();
}
} catch (IOException e) {
e.printStackTrace();
//在提交事务之前发生异常回滚
channel.txRollback();
}
使用事务机制能够解决消息发送方和RabbitMQ之间消息确认的问题,但是使用事务机制消耗RabbitMQ太多的性能,所以RabbitMQ还提供了一个改进方案,即发送方确认机制。
发送方确认机制
前面使用事务机制解决消息发送方来确认消息到达RabbitMQ,但该事务机制会严重降低RabbitMQ的消息吞吐量,所以推荐使用另一方式---发送方确认(publisher confirm)机制。
生产者将信道设置成confirm
确认模式,所有在该信道上面发布的消息都会被指派一个唯一的ID(从1开始),一旦消息被投递到所有匹配的队列之后,RabbitMQ就会发送一个确认(Basic.Ack)给生产者(包含消息的唯一ID),这就使得生产者知晓消息已经正确到达了目的地了。如果消息和队列是可持久化的,那么确认消息会在消息写入磁盘之后发出。
RabbitMQ回传给生产者的确认消息中的deliveryTag包含了确认消息序号,此外RAbbitMQ也可以设置channel.basicAck方法中的multiple参数,表示这个序号之前的所有消息已经处理。
事务机制在一条消息发送之后会使发送端阻塞,以等待RabbitMQ的回应,之后才能继续发送下一条消息。相比之下,发送方确认机制最大的好处在于它是异步的,一旦发布一条消息,生产者应用程序就可以在等信道返回确认的同时继续发送下一条消息,当消息最终得到确认之后,生产者应用程序便可以通过回调方法来处理该确认消息,如果RabbitMQ因为自身内部错误导致消息丢失,就会发送一条nack(Basic.Nack)命令,生产者应用程序同样可以在回调方法中处理该nack命令。
发送方确认机制:
public class Send {
final static String EXCHANGE = "confirm_test";
public static void main(String[] args) {
Connection connection = null;
Channel channel;
try {
connection = ConnectionUtils.getConnection();
channel = connection.createChannel();
channel.exchangeDeclare(EXCHANGE, BuiltinExchangeType.DIRECT, true);
channel.queueDeclare(EXCHANGE, true, false, false, null);
channel.queueBind(EXCHANGE, EXCHANGE, EXCHANGE);
try {
//将信道设置为发送方确认模式
channel.confirmSelect();
//发送消息
channel.basicPublish(EXCHANGE,
EXCHANGE,
MessageProperties.PERSISTENT_TEXT_PLAIN,
"测试发送发确认模式".getBytes("utf-8"));
//接收返回的Ack/Nack或中断状态
if (!channel.waitForConfirms()) {
System.out.println("发送消息失败!!!");
}else {
System.out.println("发现消息成功!!!");
}
channel.waitForConfirmsOrDie();
} catch (InterruptedException e) {
e.printStackTrace();
}
} catch (IOException e) {
e.printStackTrace();
} catch (TimeoutException e) {
e.printStackTrace();
} finally {
if (connection != null) {
try {
connection.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
如果要发送多条消息,修改部分如下:
//将信道设置为发送方确认模式
channel.confirmSelect();
try {
for(int i = 0; i < 5; i++){
//发送消息
channel.basicPublish(EXCHANGE,
EXCHANGE,
MessageProperties.PERSISTENT_TEXT_PLAIN,
"测试发送发确认模式".getBytes("utf-8"));
//接收返回的Ack/Nack或中断状态
if (!channel.waitForConfirms()) {
System.out.println("发送消息失败!!!");
}else {
System.out.println("发现消息成功!!!");
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
对于waitForConfirms
方法,如果信道没有开启confirm
模式,抛出InterruptedException
。不带参的waitForConfirms
,其返回的条件是客户端收到相应的Basic.Ack/Nack
或被中断。带timeout
参的waitForConfirms
表示超过指定时间抛出TimeoutException
。对于另外两个waitForConfirmsOrDie
方法,在接收到RabbitMQ返回的Basic.Nack
之后会抛出IOException
。
publish confirm
模式是每发送一条消息后就调用channel.waitForConfirms
方法,之后等待服务端的确认,这实际上是一种串行同步等待的方式。事务机制和它一样,发送消息之后等待服务端确认,之后再发送消息。两者的存储确认原理相同,尤其对于持久化的消息来说,两者都需要等待消息确认存盘之后才会返回(调用Linux内核的fsync方法)。在同步等待的方式下,publisherconfum机制发送一条消息需要通信交互的命令是2条:Basic.Publish和Basic.Ack;事务机制是3条:Basic.Publish、Tx.Commmit!.Commit-Ok(或者Tx.Rollback!.Rollback-Ok),事务机制多了一个命令帧报文的交互,所以QPS会略微下降。
注意:
- 事务机制和
publisher confirm
机制两者是互斥的- 事务机制和
pubisher confirm
机制确保的是消息能够正确地发送到RabbitMQ(交换器),如果交换器没有队列,则消息仍然会丢失。所以需要配合mandatory
参数或者备份交换器使用提高消息传输的可靠性。
publisher confirm
的优势在于并不一定需要同步确认。改进使用方式有以下两种:
- 批量
confirm
方法:每发送一批消息后,调用channel.waitForConfirms
方法,等待服务器的确认返回 - 异步
confirm
方法:提高一个回调方法,服务器确认了一条或多条消息后,客户端回调这个方法进行处理
批量confirm
批量confirm
方法中,客户端程序需要定期或者定量(达到多少条),亦或者两者结合起来调用channel.waitForConfirms
来等待RabbitMQ的确认返回。相比于前面示例中的普通confirm
方法,批量极大地提升了confirm
的效率,但是问题在于出现返回Basic.Nack
或者超时情况时,客户端需要将这一批次的消息全部重发,这会带来明显的重复消息数量,并且当消息经常丢失时,批量confirm的性能应该是不升反降的。(不推荐使用,了解即可)
try {
channel.confirmSelect();
intMsgCount = 0;
while (true) {
channel.basicPublish("exchange", "routingKey", null, "batchconfirmtest".getBytes());
//将发送出去的消息存入缓存中,缓存可以是
// 一个ArrayList或者BlockingQueue之类的
if (++MsgCount >= BATCHCOUNT) {
MsgCount = 0;
}
try {
if(channel.waitForConfirms()) {
//将缓存中的消息清空
}
// 将缓存中的消息重新发送
}catch(InterruptedExceptione) {
e.printStackTrace();
// 将缓存中的消息重新发送
}
} catch (IOExceptione) {
e.printStackTrace();
异步confirm
异步confirm
方法的编程实现最为复杂。在客户端Channel
接口中提供的addConfirmListener
方法可以添加ConfirmListener
这个回调接口,这个ConfirmListener
接口包含两个方法:handleAck
和handleNack
,分别用来处理RabbitMQ
回传的Basic.Ack
和Basic.Nack
。在这两个方法中都包含有一个参数deliveryTag
(在publisher confirm
模式下用来标记消息的唯一有序序号)。
我们需要为每一信道维护一个"unconfirm"的消息序号集合,每发送一条消息,集合中的元素加1。每当调用ConfirmListener
中的handleAck
方法时,"unconfirm"集合中删掉相应的一条(multiple
设置为false
)或者多条(multiple
设置为true
)记录。从程序运行效率上来看,这个"unconfirm"集合最好采用有序集合SortedSet
的存储结构。事实上,Java客户端SDK中的waitForConfirms
方法也是通过SortedSet
维护消息序号的。
public class Send_2 {
private static final String QUEUE_NAME = "publisher-confirms";
public static void main(String[] args) {
Connection connection = null;
Channel channel;
try {
connection = ConnectionUtils.getConnection();
channel = connection.createChannel();
channel.exchangeDeclare(QUEUE_NAME, BuiltinExchangeType.DIRECT, true);
channel.queueDeclare(QUEUE_NAME, true, false, false, null);
channel.queueBind(QUEUE_NAME, QUEUE_NAME, QUEUE_NAME);
//开启confirm模式
channel.confirmSelect();
//消息序号(deliveryTag)集合
SortedSet<Long> ackSet = Collections.synchronizedSortedSet(new TreeSet<Long>());
for (long i = 0; i < 2; ++i) {
long nextPublishSeqNo = channel.getNextPublishSeqNo();
ackSet.add(nextPublishSeqNo);
channel.basicPublish(QUEUE_NAME,
QUEUE_NAME,
MessageProperties.PERSISTENT_TEXT_PLAIN,
("nop" + i).getBytes());
System.out.println("send msg : " + "nop");
}
//监听confirm返回信息,Ack被接收,Nack异常
channel.addConfirmListener(new ConfirmListener() {
//被接收信息,multiple为true为多个消息,被接收信息后移出集合相应序号
@Override
public void handleAck(long deliveryTag, boolean multiple) throws IOException {
if (multiple) {
for (long i = ackSet.first(); i <= deliveryTag; ++i) {
System.out.println("handle ack multiple, tag : " + deliveryTag);
ackSet.remove(i);
}
} else {
System.out.println("handle ack, tag : " + deliveryTag);
ackSet.remove(deliveryTag);
}
}
//异常不能被接收,可以从这重新设置进行发送
@Override
public void handleNack(long deliveryTag, boolean multiple) throws IOException {
System.out.println("handle nack, tag : " + deliveryTag);
}
});
} catch (IOException e) {
e.printStackTrace();
} catch (TimeoutException e) {
e.printStackTrace();
}
}
}
对于在实际生产环境中,建议使用异步confirm
。