RabbitMQ的消息可靠性

消息可靠性

保證數據併發安全性,保證數據最終一致性的方式:
1、分佈式鎖
優點:強一致性
缺點:不適合高併發
2、消息隊列
優點:異步、高併發
缺點:有延時、弱一致性,必須能確保該業務操作肯定能夠成功完成,不可能失敗。

生產者發消息給MQ,MQ持久化成功後返回ACK,
MQ把消息給消費者,消費者消費成功後返回ACK。
MQ做高可用,MQ限流

我們可以從以下幾方面來保證消息的可靠性:
1、客戶端代碼中的異常捕獲,包括生產者和消費者
2、AMQP/RabbitMQ的事務機制
3、發送確認機制
4、消息持久化機制
5、Broker高可用機制(鏡像隊列)
6、消費者確認機制
7、消費端限流
8、消息冪等性

異常捕獲機制

先處理業務邏輯,業務邏輯處理完,在try-catch塊裏處理髮送消息,如果有發送異常,重試或者延遲發送,或者回滾。

boolean result = doBiz();
if(result){
	try{
        sendMsg();
	}catch(Exception e){
        //retrySend()
        //delaySend()
    	rollbackBiz();
    }
}

另外,也可以通過spring.

AMQP/RabbitMQ事務機制

解決的問題:沒有捕獲到異常,並不能代表消息一定投遞成功
事務提交機制,事務提交成功後都沒有異常,就說明投遞成功來。但是,這種方式在性能方面開銷比較大,一般也不推薦使用。

try{
	//將channel設置爲事務模式
    channel.txSelect();
    //發佈消息到交換器,routingKey爲空
    channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes("UTF-8"));
    //提交事務,只有消息成功被Broker接收了才能提交成功
    channel.txCommit();
}catch(Exception e) {
	//事務回滾
    channel.txRollback();
}

發送端確認機制

上面提到了用事務機制,解決
生產者將信 道設置成confirm(確認)模式,一旦信道進入confirm 模式,所有在該信道上⾯面發佈的消息都會被指派 一個唯一的ID(從1 開始),一旦消息被投遞到所有匹配的隊列之後(如果消息和隊列是持久化的,那麼 確認消息會在消息持久化後發出),RabbitMQ 就會發送一個確認(Basic.Ack)給生產者(包含消息的唯一 ID),這樣生產者就知道消息已經正確送達了。

RabbitMQ 回傳給生產者的確認消息中的deliveryTag 字段包含了確認消息的序號,另外,通過設 置channel.basicAck方法中的multiple參數,表示到這個序號之前的所有消息是否都已經得到了處理 了。生產者投遞消息後並不需要一直阻塞着,可以繼續投遞下一條消息並通過回調方式處理ACK響應。如果 RabbitMQ 因爲自身內部錯誤導致消息丟失等異常情況發生,就會響應一條nack(Basic.Nack) 命令,生產者應用程序同樣可以在回調方法中處理理該 nack 命令。

發送端確認機制實戰

安裝RabbitMQ

# 安裝依賴
yum install -y socat
# 下載和安裝erlang
wget https://github.com/rabbitmq/erlang-rpm/releases/download/v23.0.2/erlang-23.0.2-1.el7.x86_ 64.rpm
rpm -ivh erlang-23.0.2-1.el7.x86_64.rpm
# 下載和安裝rabbitmq
wget https://github.com/rabbitmq/rabbitmq-server/releases/download/v3.8.5/rabbitmq-server-3. 8.5-1.el7.noarch.rpm
rpm -ivh rabbitmq-server-3.8.4-1.el7.noarch.rpm

# 啓用RabbitMQ的管理插件
rabbitmq-plugins enable rabbitmq_management

# 開啓RabbitMQ
systemctl start rabbitmq-server
# rabbitmq-server -detached後臺啓動

# 添加用戶
rabbitmqctl add_user root 123456

# 給用戶添加權限,給root用戶在虛擬主機"/"上的配置、寫、讀的權限
rabbitmqctl set_permissions root -p / ".*" ".*" ".*"

# 給用戶設置標籤
rabbitmqctl set_user_tags root administrator

打開瀏覽器,訪問IP:15672,使用剛纔創建的用戶登錄
如果訪問不了,檢查防火牆配置,把防火牆關掉

引入依賴

<dependencies>
        <dependency>
            <groupId>com.rabbitmq</groupId>
            <artifactId>amqp-client</artifactId>
            <version>5.9.0</version>
        </dependency>
    </dependencies>

<build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>8</source>
                    <target>8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>

同步發送消息

public class PublisherConfirmProducer {

    public static void main(String[] args) throws Exception {
        ConnectionFactory connectionFactory = new ConnectionFactory();
        connectionFactory.setUri("amqp://root:[email protected]:5672/%2f");
        Connection connection = connectionFactory.newConnection();
        Channel channel = connection.createChannel();

        //設置信道爲發送方確認模式
        AMQP.Confirm.SelectOk selectOk = channel.confirmSelect();

        channel.queueDeclare("queue.pc", true, false, false, null);
        channel.exchangeDeclare("ex.pc", "direct",true, false, null);
        channel.queueBind("queue.pc", "ex.pc", "key.pc");

        //發送消息
        channel.basicPublish("ex.pc", "key.pc", null, "hello world".getBytes());

        try{
            //同步的方式等待
            channel.waitForConfirmsOrDie(5_000);
            System.out.println("發送的消息已經得到確認");
        }catch (IOException ex) {
            System.out.println("消息被拒絕");
        }catch (InterruptedException ex) {
            System.out.println("發送消息的通道不是PublisherConfirms通道");
        }catch (TimeoutException ex) {
            System.out.println("等待消息確認超時");
        }
        channel.close();
        connection.close();
    }
}

waitForConfirm方法有個重載的,可以自定義timeout超時時間,超時後會拋 TimeoutException。類似的有幾個waitForConfirmsOrDie方法,Broker端在返回nack(Basic.Nack)之 後該方法會拋出java.io.IOException。需要根據異常類型來做區別處理理, TimeoutException超時是 屬於第三狀態(無法確定成功還是失敗),而返回Basic.Nack拋出IOException這種是明確的失敗。上 面的代碼主要只是演示confirm機制,實際上還是同步阻塞模式的,性能並不不是太好。

實際上,我們也可以通過“批處理”的方式來改善整體的性能(即批量量發送消息後僅調用一次 waitForConfirms方法)。正常情況下這種批量處理的方式效率會高很多,但是如果發生了超時或者 nack(失敗)後那就需要批量量重發消息或者通知上游業務批量回滾(因爲我們只知道這個批次中有消 息沒投遞成功,而並不知道具體是那條消息投遞失敗了,所以很難針對性處理),如此看來,批量重發 消息肯定會造成部分消息重複。

另外,我們可以通過異步回調的方式來處理Broker的響應。 addConfirmListener 方法可以添加ConfirmListener 這個回調接口,這個 ConfirmListener 接口包含 兩個方法:handleAck 和handleNack,分別用來處理 RabbitMQ 回傳的 Basic.Ack 和 Basic.Nack。

更高效的批處理形式

public class PublisherConfirmsProducer2 {
    public static void main(String[] args) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUri("amqp://root:[email protected]:5672/%2f");
        final Connection connection = factory.newConnection();
        final Channel channel = connection.createChannel();

        // 向RabbitMQ服務器發送AMQP命令,將當前通道標記爲發送方確認通道
        final AMQP.Confirm.SelectOk selectOk = channel.confirmSelect();

        channel.queueDeclare("queue.pc", true, false, false, null);
        channel.exchangeDeclare("ex.pc", "direct", true, false, null);
        channel.queueBind("queue.pc", "ex.pc", "key.pc");

        String message = "hello-";
        // 批處理的大小
        int batchSize = 10;
        // 用於對需要等待確認消息的計數
        int outstrandingConfirms = 0;
        for (int i = 0; i < 103; i++) {
            channel.basicPublish("ex.pc", "key.pc", null, (message + i).getBytes());

            outstrandingConfirms++;
            if (outstrandingConfirms == batchSize) {
                // 此時已經有一個批次的消息需要同步等待broker的確認消息
                // 同步等待
                channel.waitForConfirmsOrDie(5_000);
                System.out.println("消息已經被確認了");
                outstrandingConfirms = 0;
            }
        }

        if (outstrandingConfirms > 0) {
            channel.waitForConfirmsOrDie(5_000);
            System.out.println("剩餘消息已經被確認了");
        }

        channel.close();
        connection.close();
    }
}

回調形式的發送端確認機制實現

public class PublisherConfirmsProducer3 {
    public static void main(String[] args) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUri("amqp://root:[email protected]:5672/%2f");
        final Connection connection = factory.newConnection();
        final Channel channel = connection.createChannel();

        // 向RabbitMQ服務器發送AMQP命令,將當前通道標記爲發送方確認通道
        final AMQP.Confirm.SelectOk selectOk = channel.confirmSelect();

        channel.queueDeclare("queue.pc", true, false, false, null);
        channel.exchangeDeclare("ex.pc", "direct", true, false, null);
        channel.queueBind("queue.pc", "ex.pc", "key.pc");

        //取出前面確認的幾條記錄,清空他們,保留未確認的
        ConcurrentNavigableMap<Long, String> outstandingConfirms = new ConcurrentSkipListMap<>();

        ConfirmCallback clearOutstandingConfirms = (deliveryTag, multiple) -> {
            if (multiple) {
                System.out.println("編號小於等於 " + deliveryTag + " 的消息都已經被確認了");
                final ConcurrentNavigableMap<Long, String> headMap
                        = outstandingConfirms.headMap(deliveryTag, true);
                // 清空outstandingConfirms中已經被確認的消息信息
                headMap.clear();

            } else {
                // 移除已經被確認的消息
                outstandingConfirms.remove(deliveryTag);
                System.out.println("編號爲:" + deliveryTag + " 的消息被確認");
            }
        };

        // 設置channel的監聽器,處理確認的消息和不確認的消息
        channel.addConfirmListener(clearOutstandingConfirms, (deliveryTag, multiple) -> {
            if (multiple) {
                // todo 將沒有確認的消息記錄到一個集合中
                // outstandingConfirms
                // 此處省略實現
                System.out.println("消息編號小於等於:" +  deliveryTag + " 的消息 不確認");
            } else {
                System.out.println("編號爲:" + deliveryTag + " 的消息不確認");
            }
        });

        String message = "hello-";
        for (int i = 0; i < 1000; i++) {
            // 獲取下一條即將發送的消息的消息ID
            final long nextPublishSeqNo = channel.getNextPublishSeqNo();
            channel.basicPublish("ex.pc", "key.pc", null, (message + i).getBytes());
            System.out.println("編號爲:" + nextPublishSeqNo + " 的消息已經發送成功,尚未確認");
            outstandingConfirms.put(nextPublishSeqNo, (message + i));
        }

        // 等待消息被確認
        Thread.sleep(10000);

        channel.close();
        connection.close();
    }
}

SpringBoot案例

todo

持久化存儲機制

持久化是提高RabbitMQ可靠性的基礎,否則當RabbitMQ遇到異常時(如:重啓、斷電、停機 等)數據將會丟失。主要從以下幾個方面來保障消息的持久性:

  1. Exchange的持久化。通過定義時設置durable 參數爲ture來保證Exchange相關的元數據不不丟失。

  2. Queue的持久化。也是通過定義時設置durable 參數爲ture來保證Queue相關的元數據不丟失。

  3. 消息的持久化。通過將消息的投遞模式 (BasicProperties 中的 deliveryMode 屬性)設置爲 2 即可實現消息的持久化,保證消息自身不丟失。

持久化機制實戰

public class Producer {
    public static void main(String[] args) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUri("amqp://root:[email protected]:5672/%2f");
        final Connection connection = factory.newConnection();
        final Channel channel = connection.createChannel();
        // durable:true表示是持久化消息隊列
        channel.queueDeclare("queue.persistent", true, false, false, null);
        // 持久化的交換器
        channel.exchangeDeclare("ex.persistent", "direct", true, false, null);

        channel.queueBind("queue.persistent", "ex.persistent", "key.persistent");

        final AMQP.BasicProperties properties = new AMQP.BasicProperties.Builder()
                .deliveryMode(2) // 表示是持久化消息
                .build();

        channel.basicPublish("ex.persistent",
                "key.persistent",
                properties,  // 設置消息的屬性,此時消息是持久化消息
                "hello world".getBytes());

        channel.close();
        connection.close();
    }
}

RabbitMQ中的持久化消息都需要寫入磁盤(當系統內存不不足時,非持久化的消息也會被刷盤處 理理),這些處理理動作都是在“持久層”中完成的。持久層是一個邏輯上的概念,實際包含兩個部分:

  1. 隊列索引(rabbit_queue_index),rabbit_queue_index 負責維護Queue中消息的信息,包括 消息的存儲位置、是否已交給消費者、是否已被消費及Ack確認等,每個Queue都有與之對應 的rabbit_queue_index。

  2. 消息存儲(rabbit_msg_store),rabbit_msg_store 以鍵值對的形式存儲消息,它被所有隊列共享,在每個節點中有且只有一個。

下圖中, HOSTNAME/msg_stores/vhosts/$VHostId 這個路路徑下包含 queues、msg_store_persistent、 msg_store_transient 這 3 個目錄,這是實際存儲消息的位置。其中queues目錄中保存着 rabbit_queue_index相關的數據,而msg_store_persistent保存着持久化消息數據, msg_store_transient保存着⾮非持久化相關的數據。

另外,RabbitMQ通過配置queue_index_embed_msgs_below可以根據消息大小決定存儲位置, 默認queue_index_embed_msgs_below是4096字節(包含消息體、屬性及headers),小於該值的消息存在rabbit_queue_index中。

可以看到我們的消息,因爲小於4096字節,所以直接存儲在索引文件中了。

重啓後觀察交換器、隊列、消息是否恢復了

rabbitmqctl stop
rabbitmq-server -detached

Consumer ACK

如何保證消息被消費者成功消費?

前面我們講了生產者發送確認機制和消息的持久化存儲機制,然而這依然無法完全保證整個過程的可靠性,因爲如果消息被消費過程中業務處理失敗了但是消息卻已經出列了(被標記爲已消費了,前面都是默認的自動確認),我們又沒有任何重試,那結果跟消息丟失沒什麼分別。

RabbitMQ在消費端會有Ack機制,即消費端消費消息後需要發送Ack確認報文給Broker端,告知自己是否已消費完成,否則可能會一直重發消息直到消息過期(AUTO模式)。

這也是我們之前一直在講的“最終一致性”、“可恢復性” 的基礎。

一般而言,我們有如下處理手段:

  1. 採用NONE模式,消費的過程中自行捕獲異常,引發異常後直接記錄日誌並落到異常恢復表, 再通過後臺定時任務掃描異常恢復表嘗試做重試動作。如果業務不自行處理則有丟失數據的風險

  2. 採用AUTO(自動Ack)模式,不主動捕獲異常,當消費過程中出現異常時會將消息放回 Queue中,然後消息會被重新分配到其他消費者節點(如果沒有則還是選擇當前節點)重新被消費,默認會一直重發消息並直到消費完成返回Ack或者一直到過期

  3. 採用MANUAL(手動Ack)模式,消費者自行控制流程並手動調用channel相關的方法返回 Ack

SpringBoot方式消費端ACK實戰

/**
     * NONE模式,則只要收到消息後就立即確認(消息出列,標記已消費),有丟失數據的風險
     * AUTO模式,看情況確認,如果此時消費者拋出異常則消息會返回到隊列中
     * MANUAL模式,需要顯式的調用當前channel的basicAck方法
     * @param channel
     * @param deliveryTag
     * @param message */
    @RabbitListener(queues = "lagou.topic.queue", ackMode = "AUTO")
    public void handleMessageTopic(Channel channel,
                                   @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag,
                                   @Payload byte[] message) {
        System.out.println("RabbitListener消費消息,消息內容:" + new String((message)));
        try {
            // 手動ack,deliveryTag表示消息的唯一標誌,multiple表示是否是批量確認
            channel.basicAck(deliveryTag, false);

            // 手動nack,告訴broker消費者處理失敗,最後一個參數表示是否需要將消息重新入列
            channel.basicNack(deliveryTag, false, true);

            // 手動拒絕消息。第二個參數表示是否重新入列 
            channel.basicReject(deliveryTag, true); 
        } catch (IOException e) { 
            e.printStackTrace(); 
        }
    }

basicNack和basicReject的區別
basicNack可以用於拒收多條消息
basicReject用於拒收一條消息

原生API形式消費端ACK實戰

<dependencies>
        <dependency>
            <groupId>com.rabbitmq</groupId>
            <artifactId>amqp-client</artifactId>
            <version>5.9.0</version>
        </dependency>
    </dependencies>
public class MyConsumer {
    public static void main(String[] args) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUri("amqp://root:[email protected]:5672/%2f");

        final Connection connection = factory.newConnection();
        final Channel channel = connection.createChannel();

        channel.queueDeclare("queue.ca", false, false, false, null);

        // 拉消息的模式
//        final GetResponse getResponse = channel.basicGet("queue.ca", false);
//        channel.basicReject(getResponse.getEnvelope().getDeliveryTag(), true);

        // 推消息模式
        // autoAck:false表示手動確認消息
        channel.basicConsume("queue.ca", false, "myConsumer", new DefaultConsumer(channel) {
            @Override
            public void handleDelivery(String consumerTag,
                                       Envelope envelope,
                                       AMQP.BasicProperties properties,
                                       byte[] body) throws IOException {

                System.out.println(new String(body));

                // 確認消息
//                channel.basicAck(envelope.getDeliveryTag(), false);

                // 第一個參數是消息的標籤,第二個參數表示不確認多個消息還是一個消息
                // 第三個參數表示不確認的消息是否需要重新入列,然後重發
                // 可以用於拒收多條消息
//                channel.basicNack(envelope.getDeliveryTag(), false, true);
                // 用於拒收一條消息
                // 對於不確認的消息,是否重新入列,然後重發
//                channel.basicReject(envelope.getDeliveryTag(), true);
                channel.basicReject(envelope.getDeliveryTag(), false);
            }
        });

//
//        channel.close();
//        connection.close();
    }
}
public class MyProducer {
    public static void main(String[] args) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUri("amqp://root:[email protected]:5672/%2f");

        final Connection connection = factory.newConnection();
        final Channel channel = connection.createChannel();

        channel.queueDeclare("queue.ca", false, false, false, null);
        channel.exchangeDeclare("ex.ca", "direct", false, false, null);
        channel.queueBind("queue.ca", "ex.ca", "key.ca");

        for (int i = 0; i < 5; i++) {
            channel.basicPublish("ex.ca", "key.ca", null, ("hello-" + i).getBytes());
        }

        channel.close();
        connection.close();

    }
}

消費端限流

對大量併發寫,進行寫緩衝,寫限流
當消息投遞速度遠快於消費速度時,隨着時間積累就會出現“消息積壓”。消息中間件本身是具備一 定的緩衝能力的,但這個能力是有容量限制的,如果長期運行並沒有任何處理,最終會導致Broker崩 潰,而分佈式系統的故障往往會發生上下游傳遞,連鎖反應那就會很悲劇...

下面我將從多個角度介紹QoS與限流,防止上面的悲劇發生。

對內存和磁盤使用量設置閾值

  1. RabbitMQ 可以對內存和磁盤使用量設置閾值,當達到閾值後,生產者將被阻塞(block),直 到對應項指標恢復正常。全局上可以防止超大流量、消息積壓等導致的Broker被壓垮。當內 存受限或磁盤可用空間受限的時候,服務器都會暫時阻止連接,服務器將暫停從發佈消息的已 連接客戶端的套接字讀取數據。連接心跳監視也將被禁用。所有網絡連接將在rabbitmqctl和 管理插件中顯示爲“已阻止”,這意味着它們尚未嘗試發佈,因此可以繼續或被阻止,這意味着 它們已發佈,現在已暫停。兼容的客戶端被阻止時將收到通知。

在/etc/rabbitmq/rabbitmq.conf中配置磁盤可用空間大小:
image.png

基於credit flow 的流控機制

  1. RabbitMQ 還默認提供了一種基於credit flow 的流控機制,面向每一個連接進行流控。當單 個隊列達到最大流速時,或者多個隊列達到總流速時,都會觸發流控。觸發單個鏈接的流控可 能是因爲connection、channel、queue的某一個過程處於flow狀態,這些狀態都可以從監控 平臺看到。
    image.png

QoS保證機制

  1. RabbitMQ中有一種QoS保證機制,可以限制Channel上接收到的未被Ack的消息數量,如果 超過這個數量限制
    RabbitMQ將不會再往消費端推送消息。這是一種流控手段,可以防止大量 消息瞬時從Broker送達消費端造成消費端巨大壓力(甚至壓垮消費端)。比較值得注意的是 QoS機制僅對於消費端推模式有效,對拉模式無效。而且不支持NONE Ack模式。執行 channel.basicConsume 方法之前通過 channel.basicQoS 方法可以設置該數量。消息的發 送是異步的,消息的確認也是異步的。在消費者消費慢的時候,可以設置Qos的 prefetchCount,它表示broker在向消費者發送消息的時候,一旦發送了prefetchCount個消 息而沒有一個消息確認的時候,就停止發送。消費者確認一個,broker就發送一個,確認兩個就發送兩個。換句話說,消費者確認多少,broker就發送多少,消費者等待處理的個數永 遠限制在prefetchCount個。

如果對於每個消息都發送確認,增加了網絡流量,此時可以批量確認消息。如果設置了 multiple爲true,消費者在確認的時候,比如說id是8的消息確認了,則在8之前的所有消息都 確認了。

public class MyConsumer {
    public static void main(String[] args) throws Exception {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setUri("amqp://root:[email protected]:5672/%2f");
        final Connection connection = factory.newConnection();
        final Channel channel = connection.createChannel();

        channel.queueDeclare("queue.qos", false, false, false, null);

        // 使用basic做限流,僅對消息推送模式生效。
        // 表示Qos是10個消息,最多有10個消息等待確認
        channel.basicQos(10);
        // 表示最多10個消息等待確認。如果global設置爲true,則表示只要是使用當前的channel的Consumer,該設置都生效
        // false表示僅限於當前Consumer
        channel.basicQos(10, false);
        // 第一個參數表示未確認消息的大小,Rabbit沒有實現,不用管。一般用上面兩個
        channel.basicQos(1000, 10, true);

        channel.basicConsume("queue.qos", false, new DefaultConsumer(channel) {
            @Override
            public void handleDelivery(String consumerTag,
                                       Envelope envelope,
                                       AMQP.BasicProperties properties,
                                       byte[] body) throws IOException {
                // some code going on
                // 可以批量確認消息,減少每個消息都發送確認帶來的網絡流量負載。
                channel.basicAck(envelope.getDeliveryTag(), true);
            }
        });


        channel.close();
        connection.close();

    }
}

以上是客戶端通過QOS限流方式

其他限流手段

生產者往往是希望自己產生的消息能快速投遞出去,而當消息投遞太快且超過了下游的消費速度時 就容易出現消息積壓/堆積,所以,從上游來講我們應該在生產端應用程序中也可以加入限流、應急開關等控制手段,避免超過Broker端的極限承載能力或者壓垮下游消費者。

上游加入限流、應急開關

再看看下游,我們期望下游消費端能儘快消費完消息,而且還要防止瞬時大量消息壓垮消費端(推模式),我們期望消費端處理速度是最快、最穩定而且還相對均勻(比較理想化)。

提升下游應用的吞吐量和縮短消費過程的耗時,優化主要以下幾種方式:

  1. 優化應用程序的性能,縮短響應時間(需要時間)

  2. 增加消費者節點實例(成本增加,而且底層數據庫操作這些也可能是瓶頸)

  3. 調整併發消費的線程數(線程數並非越大越好,需要大量壓測調優至合理值)

下游三種優化手段

小結

消息可靠性保證的層級

前面對可靠性傳輸消息回顧:

  1. 消息傳輸保障
  2. 各種限流、應急手段
  3. 業務層面的一些容錯、補償、異常重試等手段

消息可靠傳輸一般是業務系統接入消息中間件時首要考慮的問題,一般消息中間件的消息傳輸保障 分爲三個層級:

  1. At most once:最多一次。消息可能會丟失,但絕不會重複傳輸
  2. At least once:最少一次。消息絕不會丟失,但可能會重複傳輸
  3. Exactly once:恰好一次。每條消息肯定會被傳輸一次且僅傳輸一次

RabbitMQ 支持其中的“最多一次”和“最少一次”。

其中“最少一次”投遞實現需要考慮以下這個幾個方面的內容:

  1. 消息生產者需要開啓事務機制或者publisher confirm 機制,以確保消息可以可靠地傳輸到 RabbitMQ 中。
  2. 消息生產者需要配合使用 mandatory 參數或者備份交換器來確保消息能夠從交換器路由到隊列中,進而能夠保存下來而不會被丟棄。
  3. 消息和隊列都需要進行持久化處理,以確保RabbitMQ 服務器在遇到異常情況時不會造成消息 丟失。
  4. 消費者在消費消息的同時需要將autoAck 設置爲false,然後通過手動確認的方式去確認已經 正確消費的消息,以避免在消費端引起不必要的消息丟失。

最多一次”的方式就無須考慮以上那些方面,生產者隨意發送,消費者隨意消費,不過這樣很難確保消息不會丟失。(估計有不少公司的業務系統都是這樣的,想想都覺得可怕)

由於最多一次會丟消息,所以一般不使用

恰好一次”是RabbitMQ 目前無法保障的。

考慮這樣一種情況,消費者在消費完一條消息之後向RabbitMQ 發送確認Basic.Ack 命令,此時由 於網絡斷開或者其他原因造成RabbitMQ 並沒有收到這個確認命令,那麼RabbitMQ 不會將此條消息標 記刪除。在重新建立連接之後,消費者還是會消費到這一條消息,這就造成了重複消費。

再考慮一種情況,生產者在使用publisher confirm機制的時候,發送完一條消息等待RabbitMQ返 回確認通知,此時網絡斷開,生產者捕獲到異常情況,爲了確保消息可靠性選擇重新發送,這樣 RabbitMQ 中就有兩條同樣的消息,在消費的時候消費者就會重複消費。

消息冪等性處理

剛剛我們講到,追求高性能就無法保證消息的順序,而追求可靠性那麼就可能產生重複消息,從而 導致重複消費...真是應證了那句老話:做架構就是權衡取捨。

RabbitMQ層面有實現“去重機制”來保證“恰好一次”嗎?答案是並沒有。而且這個在目前主流的消息 中間件都沒有實現。

借用淘寶沈洵的一句話:最好的解決辦法就是不去解決。當爲了在基礎的分佈式中間件中實現某種 相對不太通用的功能,需要犧牲到性能、可靠性、擴展性時,並且會額外增加很多複雜度,最簡單的辦 法就是交給業務自己去處理。事實證明,很多業務場景下是可以容忍重複消息的。例如:操作日誌收 集,而對一些金融類的業務則要求比較嚴苛。

一般解決重複消息的辦法是,在消費端讓我們消費消息的操作具備冪等性。

冪等性問題並不是消息系統獨有,而是(分佈式)系統中普遍存在的問題。例如:RPC框架調用超 後會重試,HTTP請求會重複發起(用戶手抖多點了幾下按鈕) 冪等(Idempotence)是一個數學上的概念,它是這樣定義的:

如果一個函數f(x) 滿足:f(f(x)) = f(x),則函數f(x) 滿足冪等性。這個概念被拓展到計算機領域,被

用來描述一個操作、方法或者服務。

一個冪等操作的特點是,其任意多次執行所產生的影響均與一次執行的影響相同。一個冪等的方 法,使用同樣的參數,對它進行多次調用和一次調用,對系統產生的影響是一樣的。

對於冪等的方法,不用擔心重複執行會對系統造成任何改變。

舉個簡單的例子(在不考慮併發問題的情況下):

select * from xx where id=1 
delete from xx where id=1

這兩條sql語句就是天然冪等的,它本身的重複執行並不會引起什麼改變。而update就要看情況 的,

update xxx set amount = 100 where id =1

這條語句執行1次和100次都是一樣的結果(最終餘額都還是100),所以它是滿足冪等性的。而它就不滿足冪等性的。

update xxx set amount = amount + 100 where id =1

如何做到冪等

業界對於冪等性的一些常見做法:

  1. 藉助數據庫唯一索引,重複插入直接報錯,事務回滾。還是舉經典的轉賬的例子,爲了保證不 重複扣款或者重複加錢,我們這邊維護一張“資金變動流水錶”,裏面至少需要交易單號、變動 賬戶、變動金額等3個字段。我們選擇交易單號和變動賬戶做聯合唯一索引(單號是上游生成 的可保證唯一性),這樣如果同一筆交易發生重複請求時就會直接報索引衝突,事務直接回 滾。現實中,數據庫唯一索引的方式通常做爲兜底保證;

  2. 前置檢查機制。這個很容易理解,並且有幾種實現辦法。還是引用上面轉賬的例子,當我在執 行更改賬戶餘額這個動作之前,我得先檢查下資金變動流水錶(或者Tair中)中是否已經存在 這筆交易相關的記錄了, select * from xxx where accountNumber=xxx and orderId=yyy ,如果已經存在,那麼直接返回,否則執行正常的更新餘額的動作。爲了防止 併發問題,我們通常需要藉助“排他鎖”來完成。在支付寶有一條鐵律叫:一鎖、二判、三操 作。當然,我們也可以使用樂觀鎖或CAS機制,樂觀鎖一般會使用擴展一個版本號字段做判斷 條件

  3. 唯一Id機制,比較通用的方式。對於每條消息我們都可以生成唯一Id,消費前判斷Tair中是否存在(MsgId做Tair排他鎖的key),消費成功後將狀態寫入Tair中,這樣就可以防止重複消費了。

對於接口請求類的冪等性保證要相對更復雜,我們通常要求上游請求時傳遞一個類GUID的請求號 (或TOKEN),如果我們發現已經存在了並且上一次請求處理結果是成功狀態的(有時候上游的重試請 求是正常訴求,我們不能將上一次異常/失敗的處理結果返回或者直接提示“請求異常”,如果這樣重試就 變得沒意義了)則不繼續往下執行,直接返回“重複請求”的提示和上次的處理結果(上游通常是由於請 求超時等未知情況才發起重試的,所以直接返回上次請求的處理結果就好了)。如果請求ID都不存在或 者上次處理結果是失敗/異常的,那就繼續處理流程,並最終記錄最終的處理結果。這個請求序號由上 遊自己生成,上游通用需要根據請求參數、時間間隔等因子來生成請求ID。同樣也需要利用這個請求ID 做分佈式鎖的KEY實現排他。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章