很多同學在專案中遇到類似這樣的需求,觸發某一個事件之後在指定的時間後觸發其它事件。比如說使用者下單之後,如果超過30分支關閉訂單;或者下單使用了促銷,促銷活動在指定的時間過期瞭如若未付款需要關閉訂單。 解決這樣的問題,我們通常的做法是加一個job來做這件事情,設定job每隔幾分鐘跑一次把需要處理的資料線查詢出來,然後再去執行。這種解決辦法自然是最簡單又可靠的,只要job和DB正常執行不會存在漏掉任何任務。這種方法針對不同的使用場景也存在問題,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有版本要求,同時需要安裝外掛,使用簡單、靈活。
RocketMQ
- 使用方法
- 優缺點
使用簡單,效能強悍可靠。不過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介面的物件,其中的物件只能在其到期時才能從佇列中取走。這種佇列是有序的,即隊頭物件的延遲到期時間最長。整體架構如下。
實現流程
- 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刷庫的壓力並且提高了任務執行的精確度,在整個過程中訊息也不會丟失,簡單易用,對於普通的生產應用需求是足夠的。