簡單延時訊息替代改造JOB實現

m65536發表於2019-03-03

很多同學在專案中遇到類似這樣的需求,觸發某一個事件之後在指定的時間後觸發其它事件。比如說使用者下單之後,如果超過30分支關閉訂單;或者下單使用了促銷,促銷活動在指定的時間過期瞭如若未付款需要關閉訂單。 解決這樣的問題,我們通常的做法是加一個job來做這件事情,設定job每隔幾分鐘跑一次把需要處理的資料線查詢出來,然後再去執行。這種解決辦法自然是最簡單又可靠的,只要job和DB正常執行不會存在漏掉任何任務。這種方法針對不同的使用場景也存在問題,job過快資料庫壓力大,job慢了業務上不能精確的處理。

簡單延時訊息替代改造JOB實現

本人的專案中最近剛剛改造過一個這樣的場景,順便記錄下來。專案初期業務簡單粗暴使用者下單後訂單未付款訂單30分鐘關閉,使用了促銷的訂單,每次在支付的時候去驗單必須驗證促銷活動資訊,如果活動過期攔截終止支付請求並且關閉訂單,顯然這麼做使用者體驗不好。本次改動方向簡單(減少資料庫查詢,業務上更加精確),解決思路同樣簡單使用延時訊息。

常見的幾種延時訊息

開源MQ

RabbitMQ

RabbitMQ本身不支援延時訊息或者定時訊息,不過可以利用其特性來模擬延時訊息實現。

死信模式

RabbitMQ可以針對Queue設定x-expires 或者 針對Message設定 x-message-ttl,來控制訊息的生存時間,如果超時(兩者同時設定以最先到期的時間為準),則訊息變為dead letter(死信),RabbitMQ的Queue可以配置x-dead-letter-exchange 和x-dead-letter-routing-key(可選)兩個引數,如果佇列內出現了dead letter,則按照這兩個引數重新路由轉發到指定的佇列。程式碼如下:

  • producer
    <rabbit:queue name="orderFifteenMinutesDelayQueue" durable="true" auto-delete="false" exclusive="false">
        <rabbit:queue-arguments>
            <entry key="x-message-ttl">
                <value  type="java.lang.Long">900000</value>
            </entry>
            <entry key="x-dead-letter-exchange" value="orderFifteenMinutesExchange"/>
        </rabbit:queue-arguments>
    </rabbit:queue>
    <rabbit:fanout-exchange name="orderFifteenMinutesDelayExchange" durable="true" auto-delete="false" id="orderFifteenMinutesDelayExchange">
        <rabbit:bindings>
            <rabbit:binding queue="orderFifteenMinutesDelayQueue"/>
        </rabbit:bindings>
    </rabbit:fanout-exchange>
    <rabbit:queue name="orderFifteenMinutesQueue" durable="true" auto-delete="false" exclusive="false" />
    <rabbit:direct-exchange name="orderFifteenMinutesExchange" durable="true" auto-delete="false" id="orderFifteenMinutesExchange">
        <rabbit:bindings>
            <rabbit:binding queue="orderFifteenMinutesQueue" key="orderFifteenMinutes" />
        </rabbit:bindings>
    </rabbit:direct-exchange>
    <rabbit:template exchange="orderFifteenMinutesExchange" id="orderFifteenMinutesTemplate" connection-factory="connectionFactory" message-converter="jsonMessageConverter" />

複製程式碼
  • consumer
    <rabbit:queue name="orderFifteenMinutesQueue" durable="true" auto-delete="false" exclusive="false"/>
    <rabbit:direct-exchange name="orderFifteenMinutesExchange" durable="true" auto-delete="false" id="orderFifteenMinutesExchange">
        <rabbit:bindings>
            <rabbit:binding queue="orderFifteenMinutesQueue" key="orderFifteenMinutes"/>
        </rabbit:bindings>
    </rabbit:direct-exchange>
    <bean id="orderFifteenMinutesListener" class="com.chinaredstar.ordercenter.mq.OrderFifteenMinutesListener"/>
    <rabbit:listener-container
            connection-factory="connectionFactory"
            acknowledge="manual"
            channel-transacted="false"
            message-converter="jsonMessageConverter">
        <rabbit:listener queues="orderFifteenMinutesQueue" ref="orderFifteenMinutesListener" method="onMessage"/>
    </rabbit:listener-container>
複製程式碼
  • 優缺點

不需要任何依賴,配置佇列就行。最大的弊端就是無法動態傳入延遲時間,如果需要新增過期時間需要新增佇列配置,使用起來太不友好。

外掛(rabbitmq-delayed-message-exchange)
  • 使用方法

rabbitmq/rabbitmq-delayed-message-exchange

  • 優缺點

對rabbitMQ有版本要求,同時需要安裝外掛,使用簡單、靈活。

RocketMQ

  • 使用方法

Schedule example

  • 優缺點

使用簡單,效能強悍可靠。不過Apache RocketMQ對延遲的Level有限制只支援18個固定的Level(固定Level的含義是延遲是特定級別的,比如支援3秒、5秒的Level,那麼使用者只能傳送3秒延遲或者5秒延遲,不能傳送8秒延遲的訊息)。

Redis key過期事件

在redis 2.8版本以後對redis 中Key過期時間進行訂閱和釋出,可通過這種模式實踐。

  • 優缺點
    使用雖然簡單,但是不可靠無法訊息確認,分散式環境中處理麻煩。

本次改造

本次業務改動的時候,本來想直接使用支援訊息延時/定時訊息的MQ,但是受限(公司生產環境只使用了Rabbit MQ,那麼最好就是安裝外掛了,這還得求著架構組,並且還要一定的測試,整體麻煩,上線時間緊急還是使用其它的方式簡單實現可靠)。最後想到的是使用JAVA本身的佇列-DelayQueue。DelayQueue是一個無界的BlockingQueue,用於放置實現了Delayed介面的物件,其中的物件只能在其到期時才能從佇列中取走。這種佇列是有序的,即隊頭物件的延遲到期時間最長。整體架構如下。

簡單延時訊息替代改造JOB實現

實現流程

  • Service服務建立訂單成功後,把訂單號和訂單關閉延遲時間包裝成一個物件
public class DelayQueueTaskMessage<T extends Serializable> implements Serializable, Comparable<DelayQueueTaskMessage> {
    private Long id;//訂單id
    private int type;
    private Date endDate;
    private T message;
}
複製程式碼
  • job服務作為MQ consumer開啟ACK,接收到訊息後先持久化到MySQL資料庫。
CREATE TABLE `db_order_task` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT `主鍵,自增長,步長=1`,
  `task_type` int(4) DEFAULT NULL COMMENT `型別 1 定時關閉`,
  `task_value` varchar(1024) DEFAULT NULL COMMENT `執行內容`,
  `task_status` tinyint(2) DEFAULT `0` COMMENT `狀態 0 未執行 1成功`,
  `deadline_date` datetime DEFAULT NULL COMMENT `計劃執行時間`,
  `execute_date` datetime DEFAULT NULL COMMENT `執行時間`,
  `create_date` datetime NOT NULL COMMENT `建立時間`,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT=`訂單任務排程表`;
複製程式碼
  • job服務維護一個DelayQueue佇列,通過上一步操作,task任務落地之後,再把task任務放到DelayQueue佇列中(這裡面其它邏輯比如說防止記憶體爆掉,佇列元素超過閥值不再新增到佇列,延遲時間過長也不用新增到佇列等等),啟動一個執行緒執行操作佇列取佇列元素。
private static final DelayQueue<DelayQueueTask> delayQueue = new DelayQueue<>();

@PostConstruct
public void init() {
    Runnable task = () -> {
        try {
            DelayQueueTask delayQueueTask = delayQueue.take();
            orderTaskService.execute(delayQueueTask.getMsg());
        } catch (Exception e) {
            logger.error("訊息處理異常", e);
        }
    };
    Thread consumer = new Thread(task);
    consumer.start();
}
複製程式碼
  • 如果延續訊息到期執行成功後回寫db_order_task的狀態。
  • 新增一個補償job(排程頻率可以降低些,降低資料庫的壓力),這個job專門處理db_order_task表到期還未執行的資料(執行異常或者斷電關機等等都有可能導致佇列資料丟失等等),由於job是多型服務叢集,必須要有分散式作業排程系統完成如 [XXL-JOB](http://www.xuxueli.com/xxl-job/#/)(保證任務不會被多型機器同時排程)。

總結

本文中使用MQ結合DelayQueue再使用補償機制的實現是一個可靠安全的模型,不但減輕了job刷庫的壓力並且提高了任務執行的精確度,在整個過程中訊息也不會丟失,簡單易用,對於普通的生產應用需求是足夠的。

參考

相關文章