承接上文基於redis,redisson的延遲佇列實踐,今天介紹下基於rabbitmq延遲外掛rabbitmq_delayed_message_exchange實現延遲任務。
一、延遲任務的使用場景
1、下單成功,30分鐘未支付。支付超時,自動取消訂單
2、訂單簽收,簽收後7天未進行評價。訂單超時未評價,系統預設好評
3、下單成功,商家5分鐘未接單,訂單取消
4、配送超時,推送簡訊提醒
5、三天會員試用期,三天到期後準時準點通知使用者,試用產品到期了
......
對於延時比較長的場景、實時性不高的場景,我們可以採用任務排程的方式定時輪詢處理。如:xxl-job。
今天我們講解延遲佇列的實現方式,而延遲佇列有很多種實現方式,普遍會採用如下等方式,如:
- 1.如基於RabbitMQ的佇列ttl+死信路由策略:通過設定一個佇列的超時未消費時間,配合死信路由策略,到達時間未消費後,回會將此訊息路由到指定佇列
- 2.基於RabbitMQ延遲佇列外掛(rabbitmq-delayed-message-exchange):傳送訊息時通過在請求頭新增延時引數(headers.put("x-delay", 5000))即可達到延遲佇列的效果。(順便說一句阿里雲的收費版rabbitMQ當前可支援一天以內的延遲訊息),侷限性:目前該外掛的當前設計並不真正適合包含大量延遲訊息(例如數十萬或數百萬)的場景,詳情參見 #/issues/72 另外該外掛的一個可變性來源是依賴於 Erlang 計時器,在系統中使用了一定數量的長時間計時器之後,它們開始爭用排程程式資源。
- 3.使用redis的zset有序性,輪詢zset中的每個元素,到點後將內容遷移至待消費的佇列,(redisson已有實現)
- 4.使用redis的key的過期通知策略,設定一個key的過期時間為延遲時間,過期後通知客戶端(此方式依賴redis過期檢查機制key多後延遲會比較嚴重;Redis的pubsub不會被持久化,伺服器當機就會被丟棄)。
二、元件安裝
安裝rabbitMQ需要依賴erlang語言環境,所以需要我們下載erlang的環境安裝程式。網上有很多安裝教程,這裡不再貼圖累述,需要注意的是:該延遲外掛支援的版本匹配。
外掛Git官方地址:https://github.com/rabbitmq/rabbitmq-delayed-message-exchange
當你成功安裝好外掛後執行起rabbitmq管理後臺,在新建exchange裡就可以看到type型別中多出了這個選項
三、RabbitMQ延遲佇列外掛的延遲佇列實現
1、基本原理
通過 x-delayed-message 宣告的交換機,它的訊息在釋出之後不會立即進入佇列,先將訊息儲存至 Mnesia(一個分散式資料庫管理系統,適合於電信和其它需要持續執行和具備軟實時特性的 Erlang 應用。目前資料介紹的不是很多)
這個外掛將會嘗試確認訊息是否過期,首先要確保訊息的延遲範圍是 Delay > 0, Delay =< ?ERL_MAX_T(在 Erlang 中可以被設定的範圍為 (2^32)-1 毫秒),如果訊息過期通過 x-delayed-type 型別標記的交換機投遞至目標佇列,整個訊息的投遞過程也就完成了。
2、核心元件開發走起
引入maven依賴
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>
application.yml簡單配置
rabbitmq:
host: localhost
port: 5672
virtual-host: /
RabbitMqConfig配置檔案
package com.example.code.bot_monomer.config; import org.springframework.amqp.core.Binding; import org.springframework.amqp.core.BindingBuilder; import org.springframework.amqp.core.CustomExchange; import org.springframework.amqp.core.Exchange; import org.springframework.amqp.core.ExchangeBuilder; import org.springframework.amqp.core.Queue; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.HashMap; import java.util.Map; /** * @author: shf description: date: 2022/1/5 15:00 */ @Configuration public class RabbitMQConfig { /** * 普通 */ public static final String EXCHANGE_NAME = "test_exchange"; public static final String QUEUE_NAME = "test001_queue"; public static final String NEW_QUEUE_NAME = "test002_queue"; /** * 延遲 */ public static final String DELAY_EXCHANGE_NAME = "delay_exchange"; public static final String DELAY_QUEUE_NAME = "delay001_queue"; public static final String DELAY_QUEUE_ROUT_KEY = "key001_delay"; //由於阿里rabbitmq增加佇列要額外收費,現改為各業務延遲任務共同使用一個queue:delay001_queue //public static final String NEW_DELAY_QUEUE_NAME = "delay002_queue"; @Bean public CustomExchange delayMessageExchange() { Map<String, Object> args = new HashMap<>(); args.put("x-delayed-type", "direct"); //自定義交換機 return new CustomExchange(DELAY_EXCHANGE_NAME, "x-delayed-message", true, false, args); } @Bean public Queue delayMessageQueue() { return new Queue(DELAY_QUEUE_NAME, true, false, false); } @Bean public Binding bindingDelayExchangeAndQueue(Queue delayMessageQueue, Exchange delayMessageExchange) { return new Binding(DELAY_QUEUE_NAME, Binding.DestinationType.QUEUE, DELAY_EXCHANGE_NAME, DELAY_QUEUE_ROUT_KEY, null); //return BindingBuilder.bind(delayMessageQueue).to(delayMessageExchange).with("key001_delay").noargs(); } /** * 交換機 */ @Bean public Exchange orderExchange() { return ExchangeBuilder.topicExchange(EXCHANGE_NAME).durable(true).build(); //return new TopicExchange(EXCHANGE_NAME, true, false); } /** * 佇列 */ @Bean public Queue orderQueue() { //return QueueBuilder.durable(QUEUE_NAME).build(); return new Queue(QUEUE_NAME, true, false, false, null); } /** * 佇列 */ @Bean public Queue orderQueue1() { //return QueueBuilder.durable(NEW_QUEUE_NAME).build(); return new Queue(NEW_QUEUE_NAME, true, false, false, null); } /** * 交換機和佇列繫結關係 */ @Bean public Binding orderBinding(Queue orderQueue, Exchange orderExchange) { //return BindingBuilder.bind(queue).to(exchange).with("#.delay").noargs(); return new Binding(QUEUE_NAME, Binding.DestinationType.QUEUE, EXCHANGE_NAME, "test001_common", null); } /** * 交換機和佇列繫結關係 */ @Bean public Binding orderBinding1(Queue orderQueue1, Exchange orderExchange) { //return BindingBuilder.bind(queue).to(exchange).with("#.delay").noargs(); return new Binding(NEW_QUEUE_NAME, Binding.DestinationType.QUEUE, EXCHANGE_NAME, "test001_common", null); } }
MqDelayQueueEnum列舉類
package com.example.code.bot_monomer.enums; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; /** * @author: shf description: 延遲佇列業務列舉類 * date: 2021/8/27 14:03 */ @Getter @NoArgsConstructor @AllArgsConstructor public enum MqDelayQueueEnum { /** * 業務0001 */ YW0001("yw0001", "測試0001", "yw0001"), /** * 業務0002 */ YW0002("yw0002", "測試0002", "yw0002"); /** * 延遲佇列業務區分唯一Key */ private String code; /** * 中文描述 */ private String name; /** * 延遲佇列具體業務實現的 Bean 可通過 Spring 的上下文獲取 */ private String beanId; public static String getBeanIdByCode(String code) { for (MqDelayQueueEnum queueEnum : MqDelayQueueEnum.values()) { if (queueEnum.code.equals(code)) { return queueEnum.beanId; } } return null; } }
模板介面處理類:MqDelayQueueHandle
package com.example.code.bot_monomer.service.mqDelayQueue; /** * @author: shf description: RabbitMQ延遲佇列方案處理介面 * date: 2022/1/10 10:46 */ public interface MqDelayQueueHandle<T> { void execute(T t); }
具體業務實現處理類
@Slf4j @Component("yw0001") public class MqTaskHandle01 implements MqDelayQueueHandle<String> { @Override public void execute(String s) { log.info("MqTaskHandle01.param=[{}]",s); //TODO } }
注意:@Component("yw0001") 要和業務列舉類MqDelayQueueEnum中對應的beanId保持一致。
統一訊息體封裝類
/** * @author: shf description: date: 2022/1/10 10:51 */ @Data @NoArgsConstructor @AllArgsConstructor @Builder public class MqDelayMsg<T> { /** * 業務區分唯一key */ @NonNull String businessCode; /** * 訊息內容 */ @NonNull T content; }
統一消費分發處理Consumer
package com.example.code.bot_monomer.service.mqConsumer; import com.alibaba.fastjson.JSONObject; import com.example.code.bot_monomer.config.common.MqDelayMsg; import com.example.code.bot_monomer.enums.MqDelayQueueEnum; import com.example.code.bot_monomer.service.mqDelayQueue.MqDelayQueueHandle; import org.apache.commons.lang3.StringUtils; import org.springframework.amqp.core.Message; import org.springframework.amqp.rabbit.annotation.RabbitHandler; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Component; import lombok.extern.slf4j.Slf4j; /** * @author: shf description: date: 2022/1/5 15:12 */ @Slf4j @Component //@RabbitListener(queues = "test001_queue") @RabbitListener(queues = "delay001_queue") public class TestConsumer { @Autowired ApplicationContext context; /** * RabbitHandler 會自動匹配 訊息型別(訊息自動確認) * * @param msgStr * @param message */ @RabbitHandler public void taskHandle(String msgStr, Message message) { try { MqDelayMsg msg = JSONObject.parseObject(msgStr, MqDelayMsg.class); log.info("TestConsumer.taskHandle:businessCode=[{}],deliveryTag=[{}]", msg.getBusinessCode(), message.getMessageProperties().getDeliveryTag()); String beanId = MqDelayQueueEnum.getBeanIdByCode(msg.getBusinessCode()); if (StringUtils.isNotBlank(beanId)) { MqDelayQueueHandle<Object> handle = (MqDelayQueueHandle<Object>) context.getBean(beanId); handle.execute(msg.getContent()); } else { log.warn("TestConsumer.taskHandle:MQ延遲任務不存在的beanId,businessCode=[{}]", msg.getBusinessCode()); } } catch (Exception e) { log.error("TestConsumer.taskHandle:MQ延遲任務Handle異常:", e); } } }
最後簡單封裝個工具類
package com.example.code.bot_monomer.utils; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import com.example.code.bot_monomer.config.RabbitMQConfig; import com.example.code.bot_monomer.config.common.MqDelayMsg; import org.apache.commons.lang3.StringUtils; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.Objects; import lombok.extern.slf4j.Slf4j; /** * @author: shf description: MQ分散式延遲佇列工具類 date: 2022/1/10 15:20 */ @Slf4j @Component public class MqDelayQueueUtil { @Autowired private RabbitTemplate template; @Value("${mqdelaytask.limit.days:2}") private Integer mqDelayLimitDays; /** * 新增延遲任務 * * @param bindId 業務繫結ID,用於關聯具體訊息 * @param businessCode 業務區分唯一標識 * @param content 訊息內容 * @param delayTime 設定的延遲時間 單位毫秒 * @return 成功true;失敗false */ public boolean addDelayQueueTask(@NonNull String bindId, @NonNull String businessCode, @NonNull Object content, @NonNull Long delayTime) { log.info("MqDelayQueueUtil.addDelayQueueTask:bindId={},businessCode={},delayTime={},content={}", bindId, businessCode, delayTime, JSON.toJSONString(content)); if (StringUtils.isAnyBlank(bindId, businessCode) || Objects.isNull(content) || Objects.isNull(delayTime)) { return false; } try { //TODO 延時時間大於2天的先加入資料庫表記錄,後由定時任務每天拉取2次將低於2天的延遲記錄放入MQ中等待到期執行 if (ChronoUnit.DAYS.between(LocalDateTime.now(), LocalDateTime.now().plus(delayTime, ChronoUnit.MILLIS)) >= mqDelayLimitDays) { //TODO } else { this.template.convertAndSend( RabbitMQConfig.DELAY_EXCHANGE_NAME, RabbitMQConfig.DELAY_QUEUE_ROUT_KEY, JSONObject.toJSONString(MqDelayMsg.<Object>builder().businessCode(businessCode).content(content).build()), message -> { //注意這裡時間可使用long型別,毫秒單位,設定header message.getMessageProperties().setHeader("x-delay", delayTime); return message; } ); } } catch (Exception e) { log.error("MqDelayQueueUtil.addDelayQueueTask:bindId={}businessCode={}異常:", bindId, businessCode, e); return false; } return true; } /** * 撤銷延遲訊息 * @param bindId 業務繫結ID,用於關聯具體訊息 * @param businessCode 業務區分唯一標識 * @return 成功true;失敗false */ public boolean cancelDelayQueueTask(@NonNull String bindId, @NonNull String businessCode) { if (StringUtils.isAnyBlank(bindId,businessCode)) { return false; } try { //TODO 查詢DB,如果訊息還存在即可刪除 } catch (Exception e) { log.error("MqDelayQueueUtil.cancelDelayQueueTask:bindId={}businessCode={}異常:", bindId, businessCode, e); return false; } return true; } /** * 修改延遲訊息 * @param bindId 業務繫結ID,用於關聯具體訊息 * @param businessCode 業務區分唯一標識 * @param content 訊息內容 * @param delayTime 設定的延遲時間 單位毫秒 * @return 成功true;失敗false */ public boolean updateDelayQueueTask(@NonNull String bindId, @NonNull String businessCode, @NonNull Object content, @NonNull Long delayTime) { if (StringUtils.isAnyBlank(bindId, businessCode) || Objects.isNull(content) || Objects.isNull(delayTime)) { return false; } try { //TODO 查詢DB,訊息不存在返回false,存在判斷延遲時長入庫或入mq //TODO 延時時間大於2天的先加入資料庫表記錄,後由定時任務每天拉取2次將低於2天的延遲記錄放入MQ中等待到期執行 if (ChronoUnit.DAYS.between(LocalDateTime.now(), LocalDateTime.now().plus(delayTime, ChronoUnit.MILLIS)) >= mqDelayLimitDays) { //TODO } else { this.template.convertAndSend( RabbitMQConfig.DELAY_EXCHANGE_NAME, RabbitMQConfig.DELAY_QUEUE_ROUT_KEY, JSONObject.toJSONString(MqDelayMsg.<Object>builder().businessCode(businessCode).content(content).build()), message -> { //注意這裡時間可使用long型別,毫秒單位,設定header message.getMessageProperties().setHeader("x-delay", delayTime); return message; } ); } } catch (Exception e) { log.error("MqDelayQueueUtil.updateDelayQueueTask:bindId={}businessCode={}異常:", bindId, businessCode, e); return false; } return true; } }
附上測試類:
/** * description: 延遲佇列測試 * * @author: shf date: 2021/8/27 14:18 */ @RestController @RequestMapping("/mq") @Slf4j public class MqQueueController { @Autowired private MqDelayQueueUtil mqDelayUtil; @PostMapping("/addQueue") public String addQueue() { mqDelayUtil.addDelayQueueTask("00001",MqDelayQueueEnum.YW0001.getCode(),"delay0001測試",3000L); return "SUCCESS"; } }
貼下DB記錄表的欄位設定
配合xxl-job定時任務即可。
由於投遞後的訊息無法修改,設定延遲訊息需謹慎!並需要與業務方配合,如:延遲時間在2天以內(該時間天數可調整,你也可以設定閾值單位為小時,看業務需求)的訊息不支援修改與撤銷。2天之外的延遲訊息支援撤銷與修改,需要注意的是,需要繫結關聯具體操作業務唯一標識ID以對應關聯操作撤銷或修改。(PS:延遲時間設定在2天以外的會先儲存到DB記錄表,由定時任務每天拉取到時2天內的投放到延遲對列)。
再穩妥點,為了防止進入DB記錄的訊息有操作時間誤差導致的不一致問題,可在消費統一Consumer消費分發前,查詢DB記錄表,該訊息是否已被撤銷刪除(增加個刪除標記欄位記錄),並且當前時間大於等於DB表中記錄的到期執行時間才能分發出去執行,否則棄用。
此外,利用rabbitmq的死信佇列機制也可以實現延遲任務,有時間再附上實現案例。