使用 Kotlin+RocketMQ 實現延時訊息

airland發表於2021-09-09

一. 延時訊息

延時訊息是指訊息被髮送以後,並不想讓消費者立即拿到訊息,而是等待指定時間後,消費者才拿到這個訊息進行消費。

使用延時訊息的典型場景,例如:

  • 在電商系統中,使用者下完訂單30分鐘內沒支付,則訂單可能會被取消。
  • 在電商系統中,使用者七天內沒有評價商品,則預設好評。

這些場景對應的解決方案,包括:

  • 輪詢遍歷資料庫記錄
  • JDK 的 DelayQueue
  • ScheduledExecutorService
  • 基於 Quartz 的定時任務
  • 基於 Redis 的 zset 實現延時佇列。

除此之外,還可以使用訊息佇列來實現延時訊息,例如 RocketMQ。

二. RocketMQ

RocketMQ 是一個分散式訊息和流資料平臺,具有低延遲、高效能、高可靠性、萬億級容量和靈活的可擴充套件性。RocketMQ 是2012年阿里巴巴開源的第三代分散式訊息中介軟體。

圖片描述

三. RocketMQ 實現延時訊息

3.1 業務背景

我們的系統完成某項操作之後,會推送事件訊息到業務方的介面。當我們呼叫業務方的通知介面返回值為成功時,表示本次推送訊息成功;當返回值為失敗時,則會多次推送訊息,直到返回成功為止(保證至少成功一次)。

當我們推送失敗後,雖然會進行多次推送訊息,但並不是立即進行。會有一定的延遲,並按照一定的規則進行推送訊息。

例如:1小時後嘗試推送、3小時後嘗試推送、1天后嘗試推送、3天后嘗試推送等等。因此,考慮使用延時訊息實現該功能。

3.2 生產者(Producer)

生產者負責產生訊息,生產者向訊息伺服器傳送由業務應用程式系統生成的訊息。

首先,定義一個支援延時傳送的 AbstractProducer。

abstract class AbstractProducer :ProducerBean() {
    var producerId: String? = null
    var topic: String? = null
    var tag: String?=null
    var timeoutMillis: Int? = null
    var delaySendTimeMills: Long? = null

    val log = LogFactory.getLog(this.javaClass)

    open fun sendMessage(messageBody: Any, tag: String) {
        val msgBody = JSON.toJSONString(messageBody)
        val message = Message(topic, tag, msgBody.toByteArray())

        if (delaySendTimeMills != null) {
            val startDeliverTime = System.currentTimeMillis() + delaySendTimeMills!!
            message.startDeliverTime = startDeliverTime
            log.info( "send delay message producer startDeliverTime:${startDeliverTime}currentTime :${System.currentTimeMillis()}")
        }
        val logMessageId = buildLogMessageId(message)
        try {
            val sendResult = send(message)
            log.info(logMessageId + "producer messageId: " + sendResult.getMessageId() + "n" + "messageBody: " + msgBody)
        } catch (e: Exception) {
            log.error(logMessageId + "messageBody: " + msgBody + "n" + " error: " + e.message, e)
        }

    }

    fun buildLogMessageId(message: Message): String {
        return "topic: " + message.topic + "n" +
                "producer: " + producerId + "n" +
                "tag: " + message.tag + "n" +
                "key: " + message.key + "n"
    }
}

根據業務需要,增加一個支援重試機制的 Producer

@Component
@ConfigurationProperties("mqs.ons.producers.xxx-producer")
@Configuration
@Data
class CleanReportPushEventProducer :AbstractProducer() {

    lateinit var delaySecondList:List<Long>

    fun sendMessage(messageBody: CleanReportPushEventMessage){
        //重試超過次數之後不再發事件
        if (delaySecondList!=null) {

            if(messageBody.times>=delaySecondList.size){
                return
            }
            val msgBody = JSON.toJSONString(messageBody)
            val message = Message(topic, tag, msgBody.toByteArray())
            val delayTimeMills = delaySecondList[messageBody.times]*1000L
            message.startDeliverTime =  System.currentTimeMillis() + delayTimeMills
            log.info( "messageBody: " + msgBody+ "startDeliverTime: "+message.startDeliverTime )
            val logMessageId = buildLogMessageId(message)
            try {
                val sendResult = send(message)
                log.info(logMessageId + "producer messageId: " + sendResult.getMessageId() + "n" + "messageBody: " + msgBody)
            } catch (e: Exception) {
                log.error(logMessageId + "messageBody: " + msgBody + "n" + " error: " + e.message, e)
            }
        }
    }
}

在 CleanReportPushEventProducer 中,超過了重試的次數就不會再傳送訊息了。

每一次延時訊息的時間也會不同,因此需要根據重試的次數來獲取這個delayTimeMills 。

透過 System.currentTimeMillis() + delayTimeMills 可以設定 message 的 startDeliverTime。然後呼叫 send(message) 即可傳送延時訊息。

我們使用商用版的 RocketMQ,因此支援精度為秒級別的延遲訊息。在開源版本中,RocketMQ 只支援18個特定級別的延遲訊息。:(

3.3 消費者(Consumer)

消費者負責消費訊息,消費者從訊息伺服器拉取資訊並將其輸入使用者應用程式。

定義 Push 型別的 AbstractConsumer:

@Data
abstract class AbstractConsumer ():MessageListener{

    var consumerId: String? = null

    lateinit var subscribeOptions: List<SubscribeOptions>

    var threadNums: Int? = null

    val log = LogFactory.getLog(this.javaClass)

    override  fun consume(message: Message, context: ConsumeContext): Action {
        val logMessageId = buildLogMessageId(message)
        val body = String(message.body)
        try {
            log.info(logMessageId + " body: " + body)
            val result = consumeInternal(message, context, JSON.parseObject(body, getMessageBodyType(message.tag)))
            log.info(logMessageId + " result: " + result.name)
            return result
        } catch (e: Exception) {
            if (message.reconsumeTimes >= 3) {
                log.error(logMessageId + " error: " + e.message, e)
            }
            return Action.ReconsumeLater
        }

    }

    abstract fun getMessageBodyType(tag: String): Type?

    abstract fun consumeInternal(message: Message, context: ConsumeContext, obj: Any): Action

    protected fun buildLogMessageId(message: Message): String {
        return "topic: " + message.topic + "n" +
                "consumer: " + consumerId + "n" +
                "tag: " + message.tag + "n" +
                "key: " + message.key + "n" +
                "MsgId:" + message.msgID + "n" +
                "BornTimestamp" + message.bornTimestamp + "n" +
                "StartDeliverTime:" + message.startDeliverTime + "n" +
                "ReconsumeTimes:" + message.reconsumeTimes + "n"
    }
}

再定義具體的消費者,並且在消費失敗之後能夠再傳送一次訊息。

@Configuration
@ConfigurationProperties("mqs.ons.consumers.clean-report-push-event-consumer")
@Data
class CleanReportPushEventConsumer(val cleanReportService: CleanReportService,val eventProducer:CleanReportPushEventProducer):AbstractConsumer() {

    val logger: Logger = LoggerFactory.getLogger(this.javaClass)

    override fun consumeInternal(message: Message, context: ConsumeContext, obj: Any): Action {
        if(obj is  CleanReportPushEventMessage){
            //清除事件
            logger.info("consumer clean-report event report_id:${obj.id} ")

            //消費失敗之後再傳送一次訊息
            if(!cleanReportService.sendCleanReportEvent(obj.id)){
                val times = obj.times+1
                eventProducer.sendMessage(CleanReportPushEventMessage(obj.id,times))
            }
        }
        return Action.CommitMessage
    }

    override fun getMessageBodyType(tag: String): Type? {
        return CleanReportPushEventMessage::class.java
    }
}

其中,cleanReportService 的 sendCleanReportEvent() 會透過 http 的方式呼叫業務方提供的介面,進行事件訊息的推送。如果推送失敗了,則會進行下一次的推送。(這裡使用了 eventProducer 的 sendMessage() 方法再次投遞訊息,是因為要根據呼叫的http介面返回的內容來判斷訊息是否傳送成功。)

最後,定義 ConsumerFactory

@Component
class ConsumerFactory(val consumers: List<AbstractConsumer>,val aliyunOnsOptions: AliyunOnsOptions) {

    val logger: Logger = LoggerFactory.getLogger(this.javaClass)


    @PostConstruct
    fun start() {
        CompletableFuture.runAsync{
            consumers.stream().forEach {
                val properties = buildProperties(it.consumerId!!, it.threadNums)
                val consumer = ONSFactory.createConsumer(properties)
                if (it.subscribeOptions != null && !it.subscribeOptions!!.isEmpty()) {
                    for (options in it.subscribeOptions!!) {
                        consumer.subscribe(options.topic, options.tag, it)
                    }
                    consumer.start()
                    val message = "n".plus(
                            it.subscribeOptions!!.stream().map{ a -> String.format("topic: %s, tag: %s has been started", a.topic, a.tag)}
                                    .collect(Collectors.toList<Any>()))
                    logger.info(String.format("consumer: %sn", message))
                }
            }
        }
    }

    private fun buildProperties(consumerId: String,threadNums: Int?): Properties {
        val properties = Properties()
        properties.put(PropertyKeyConst.ConsumerId, consumerId)
        properties.put(PropertyKeyConst.AccessKey, aliyunOnsOptions.accessKey)
        properties.put(PropertyKeyConst.SecretKey, aliyunOnsOptions.secretKey)
        if (StringUtils.isNotEmpty(aliyunOnsOptions.onsAddr)) {
            properties.put(PropertyKeyConst.ONSAddr, aliyunOnsOptions.onsAddr)
        } else {
            // 測試環境接入RocketMQ
            properties.put(PropertyKeyConst.NAMESRV_ADDR, aliyunOnsOptions.nameServerAddress)
        }
        properties.put(PropertyKeyConst.ConsumeThreadNums, threadNums!!)
        return properties
    }
}

總結

正如本文開頭曾介紹過,可以使用多種方式來實現延時訊息。然而,我們的系統本身就大量使用了 RocketMQ,藉助成熟的 RocketMQ 實現延時訊息不失為一種可靠而又方便的方式。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/964/viewspace-2823181/,如需轉載,請註明出處,否則將追究法律責任。

相關文章