用500行 Golang 程式碼實現高效能的訊息回撥中介軟體

zamia發表於2017-09-24

用 500 行 Golang 程式碼實現高效能的訊息回撥中介軟體

本文描述瞭如何實現一個訊息回撥中介軟體,得益於 golang 管道和協程的程式設計思想,通過巧妙的設計,只需要約 500 行程式碼就可以實現高效能、優雅關閉、自動重連等特性,全部程式碼也已經開源在 github/fishtrip/watchman

問題

隨著業務複雜度的增加,服務拆分後服務數量不斷增加,非同步訊息佇列的引入是必不可少的。當服務較少的時候,比如業務早期,很多時候就是一個比較大的單體應用或者少量幾個服務,訊息佇列(之後寫做 MQ,Message Queue)的使用方法如下:

  • 傳送端,直接連線 MQ,根據業務需求傳送訊息;
  • 消費端,通過一個後臺程式,通過長連線連線至 MQ,然後實時消費訊息,然後進行相應的處理;

相對來說,傳送端比較簡單,消費端比較複雜,需要處理的邏輯比較多。比如目前我們公司使用的 sneakers 需要處理如下的邏輯:

  1. 消費端需要長連線,需要獨立的程式實時消費訊息(某些語言可能是一個獨立的執行緒);
  2. 消費訊息之後,需要載入業務框架(比如 Sneakers 需要加入 Rails 環境執行業務程式碼)呼叫相關程式碼來消費訊息;
  3. MQ 無法連線時,需要自動重連,同時應用也需要能夠優雅重啟,不至於丟訊息。
  4. 消費訊息很可能處理失敗,這個時候需要比較安全可靠的機制保證不能丟失訊息,同時也要求能夠過一段時間對訊息進行重試,重試多次之後也需要能夠對訊息進一步做處理;

這個時候的系統架構一般如下:

而隨著服務增多,如果每個需要消費訊息的服務都部署一個這樣的後臺程式顯然不夠環保:

  1. 每個服務增加一個程式,增加了部署運維的成本;
  2. 對於佇列的管理(建立、銷燬、binding)以及訊息重試機制,每個服務來自己負責的話,很容易造成標準不統一;
  3. 如果不同的服務是不同的語言、不同的框架,每個語言又都要實現一遍,會浪費不少開發資源;

那有沒有更好的辦法呢?

其中一般辦法是打造一個全域性的、高效能的訊息回撥中介軟體,中介軟體來負責佇列的管理、訊息的收發、重試以及出錯處理,這樣就不再需要每個服務去考慮諸如訊息丟失、訊息重試等問題了,基本解決了上面的缺點。具體這個訊息回撥中心應該具備哪些功能呢?

  1. 統一管理所有 MQ 佇列的建立和訊息監聽;
  2. 當有訊息接收到時,中介軟體呼叫相關服務的回撥地址,因為回撥中心負責所有的服務,該中介軟體必須是高效能、高併發的;
  3. 中介軟體應當具備訊息重試的功能,同時重試訊息的時候不應該丟失訊息;
  4. 中介軟體應當具備「重連」和「優雅關閉」等基礎功能,這樣才能保證不丟訊息;

這時候架構如下:

這樣的話,每個服務的工作就變得輕量了很多。本文的目的就是來實現一版生產環境可用的訊息回撥中介軟體。當然,我們第一版的回撥中心也不需要太多功能,有如下的限制:

  1. 整個重試流程需要 RabbitMQ 內建功能支援,所以暫時只支援 RabbitMQ;
  2. 目前只支援 HTTP 回撥方式;

基本的需求有了,如何實現一個這樣的訊息回撥中介軟體呢?

解決思路

開發語言選擇

Golang 作為「系統級開發語言」,非常適合開發這類中介軟體。內建的 goroutine/channel 機制非常容易實現高併發。而作為 Golang 新手,這個專案也不復雜,很適合練手和進一步學習。

訊息可靠性

關於重試和出錯處理呢?我們從 Sneakers 的實現中借鑑了它的方法,通過利用 RabbitMQ 內建的機制,也就是通過 x-dead-letter 機制來保證訊息在重試時可以做到高可靠性,具體可以參考前段時間我寫的這篇文章。簡單總結一下文中的思路:

  1. 訊息正常被處理時,直接 ack 訊息就好;
  2. 當訊息處理出錯,需要重試時,reject 訊息,此時訊息會進入到單獨的 retry 佇列;
  3. retry 佇列配置好了 ttl 超時時間,等到超時時,訊息會進入到 requeue Exchange(RabbitMQ 的概念,用來做訊息的路由);
  4. 訊息會再次進入工作佇列,等待被下次重試;
  5. 如果訊息的重試次數超過了一定的值,那麼訊息會進入到錯誤佇列等待進一步處理;

這裡面有兩個地方利用了 RabbitMQ 的 Dead-Letter 機制:

  1. 當訊息被 reject 之後,訊息進入到該佇列的 dead-letter Exchange ,也就是重試佇列;
  2. 當重試佇列的訊息,在超時時(佇列設定了 ttl-expires 時長),訊息進入該佇列的 dead-letter Exchange,也就是重新進入了工作佇列。

通過這種機制,可以保證在進行訊息處理的時候,不管是正常、還是出錯時,訊息都不會丟失。關於這裡進一步的細節可以參考上面的文章。

實現高併發

對於中介軟體,效能的要求比較高,效能也包含兩個方面:低延遲和高併發。低延遲在這個場景中我們無法解決,因為一個訊息回撥之後的延遲是其他業務服務決定的。所以我們更多的是追求高併發。

如何獲得高併發?首先是開發語言的選擇,這類底層的中介軟體很適合用 Golang 來實現,為什麼呢?因為回撥中心的主邏輯就是不斷回撥各個服務,而各個服務的延遲時間中介軟體無法控制,所以如果想獲得高併發,最好是使用非同步事件這種機制。而藉助於 Golang 內建的 Channel ,既可以獲得接近於非同步事件的效能,又可以讓整個開發變得簡單高效,是一個比較合適的選擇。

具體實現呢?其實對於一個回撥中心來說,大概分成這麼幾個步驟:

  1. 獲取訊息:連線訊息佇列( 目前我們只需要支援 RabbitMQ 即可),消費訊息;
  2. 回撥業務介面:消費訊息之後,根據配置資訊,不同的佇列可能需要呼叫不同的回撥地址,開始呼叫業務介面(目前我們只需要支援 HTTP 協議即可);
  3. 根據回撥結果處理訊息:如果呼叫業務介面如果成功,則直接 ack 訊息即可;如果呼叫失敗,則 reject 此訊息;如果超過最大重試次數,則進入出錯處理邏輯;
  4. 出錯處理邏輯:把原有訊息 ack,同時轉發此訊息進入 error 佇列,等待進一步處理(可能是報警,然後人工處理);

通過訊息這麼一個「實體」可以把所有上面的流程串聯起來,是不是很像 pipeline ?而 pipeline 的設計模式是 Golang 非常推薦的實現高併發的方式。上面的每一個步驟可以看做一組協程(goroutine),他們之間通過管道通訊,因此不存在資源競爭的情況,大大降低了開發成本。

而上面每個步驟可以通過設計不同的 Goroutine 模型來實現高併發:

  1. 獲取訊息:需要長連線 RabbitMQ,較好的實現方式是每個佇列有獨立的一組協程,這樣佇列之間的訊息接受互相不會干擾,如果出現了繁忙佇列和較閒的佇列時,也不會出現訊息處理不及時的情況;
  2. 回撥業務介面:每個訊息都會呼叫業務介面,但是業務介面的處理時長對於中介軟體來說是透明的。因此,這裡最好的模型是每個訊息一個協程。如果出現了較慢的介面,那麼通過 goroutine 的內部排程機制,並不會影響系統的吞吐,同時 goroutine 可以支援上百萬的併發,因此用這種模式最合適。
  3. 根據回撥結果處理訊息:這個步驟主要是要連線 RabbitMQ,傳送 ack/reject 訊息。預設我們認為 RabbitMQ 是可靠的,這裡統一用同一組協程來處理即可。
  4. 出錯處理邏輯:這裡的訊息量應該大大降低,因為多次失敗(超過重試次數)的訊息才會進入到這裡。我們也採用同一組協程處理即可。

上面四個步驟,我們用了三種協程的設計模型,細化一下上面的圖就是這個樣子的。

實現

有了上面的設計過程,程式碼並不複雜,大概分為幾部分:配置管理、主流程、訊息物件、重試邏輯以及優雅關閉等的實現。詳細的程式碼放在 github:fishtrip/watchman

配置管理

配置管理這部分,這個版本我們實現的比較簡單,就是讀取 yml 配置檔案。配置檔案主要包含的主要是三部分資訊:

  • 訊息佇列定義。要根據訊息佇列的配置呼叫 RabbitMQ 介面生成相關的佇列(重試佇列、錯誤佇列等);
  • 回撥地址配置。不同的訊息佇列需要不同的回撥地址;
  • 其他配置。如重試次數、超時等。
# config/queues.example.yml
projects:
  - name: test
    queues_default:
      notify_base: "http://localhost:8080"
      notify_timeout: 5
      retry_times: 40
      retry_duration: 300
      binding_exchange: fishtrip
    queues:
      - queue_name: "order_processor"
        notify_path: "/orders/notify" 
        routing_key:
          - "order.state.created"
          - "house.state.#"

我們使用 yaml.v2 包可以很方便的解析 yaml 配置檔案到 struct 之中,比如對於 queue 的定義,struct 實現如下:

// config.go 28-38

type QueueConfig struct {
    QueueName       string   `yaml:"queue_name"`
    RoutingKey      []string `yaml:"routing_key"`
    NotifyPath      string   `yaml:"notify_path"`
    NotifyTimeout   int      `yaml:"notify_timeout"`
    RetryTimes      int      `yaml:"retry_times"`
    RetryDuration   int      `yaml:"retry_duration"`
    BindingExchange string   `yaml:"binding_exchange"`

    project *ProjectConfig
}

上面之所以需要一個 ProjectConfig 的指標,主要是為了方便讀取 project 的配置,因此載入的時候需要把佇列指向 project。

// config.go
func loadQueuesConfig(configFileName string, allQueues []*QueueConfig) []*QueueConfig {
    // ......
    projects := projectsConfig.Projects
    for i, project := range projects {
        log.Printf("find project: %s", project.Name)


        // 這裡不能寫作  queue := project.Queues
        queues := projects[i].Queues

        for j, queue := range queues {
            log.Printf("find queue: %v", queue)

            // 這裡不能寫作  queues[j].project = &queue 
            queues[j].project = &projects[i]
            allQueues = append(allQueues, &queues[j])
        }
    }
   // .......
}

上面程式碼中有個地方容易出錯,就是在 for 迴圈內部設定指標的時候不能直接使用 queue 變數,因為此時獲取的 queue 變數是一份複製出來的資料,並不是原始資料。

另外,config.go 中大部分邏輯是按照物件導向的思考方式來書寫的,比如:

// config.go
func (qc QueueConfig) ErrorQueueName() string {
    return fmt.Sprintf("%s-error", qc.QueueName)
}
func (qc QueueConfig) WorkerExchangeName() string {
    if qc.BindingExchange == "" {
        return qc.project.QueuesDefaultConfig.BindingExchange
    }
    return qc.BindingExchange
}

通過這種方式,可以寫出更清晰可維護的程式碼。

訊息物件封裝

整個程式中,在 channel 中傳遞的資料都是 Message 物件,通過這種物件封裝,可以非常方便的在各種型別的 Goroutine 之間傳遞資料。

Message 類的定義如下:

type Message struct {
    queueConfig    QueueConfig // 訊息來自於哪個佇列
    amqpDelivery   *amqp.Delivery // RabbitMQ 的訊息封裝
    notifyResponse NotifyResponse // 訊息回撥結果
}

我們把 RabbitMQ 中的原生訊息以及佇列資訊、回撥結果封裝在一起,通過這種方式把 Message 物件在管道之間傳遞。同時 Message 封裝了眾多的方法來供其他協程方便的呼叫。

// Message 相關方法
func (m Message) CurrentMessageRetries() int {}
func (m *Message) Notify(client *http.Client) *Message {}
func (m Message) IsMaxRetry() bool {}
func (m Message) IsNotifySuccess() bool {}
func (m Message) Ack() error {}
func (m Message) Reject() error {}
func (m Message) Republish(out chan<- Message) error {}
func (m Message) CloneAndPublish(channel *amqp.Channel) error {}

注意上面方法的接收物件,帶指標的接收物件表示會修改物件的值。

主流程

主流程就是我們上面說的,通過 pipeline 的模式、把訊息的整條流程串聯起來。最核心的程式碼在這裡:

// main.go
<-resendMessage(ackMessage(workMessage(receiveMessage(allQueues, done))))

上面每個函式都接收相同的管道定義,因此可以串聯使用。其實每個函式的實現區別不大,不同的協程模型可能需要不同的寫法。

下面是 receiveMessage 的寫法,並進行了詳細的註釋。revceiveMessage 對每個訊息佇列都生成了 N 個協程,然後把讀取的訊息全部寫入管道。

// main.go
func receiveMessage(queues []*QueueConfig, done <-chan struct{}) <-chan Message {

    // 建立一個管道,這個管道會作為函式的返回值
    out := make(chan Message, ChannelBufferLength)

    // WaitGroup 用於同步,這裡來控制協程是否結束
    var wg sync.WaitGroup

    // 入參是佇列配置,這個見下文傳入的值
    receiver := func(qc QueueConfig) {
        defer wg.Done()

    // RECONNECT 標記用於跳出迴圈來重新連線 RabbitMQ
    RECONNECT:
        for {
            _, channel, err := setupChannel()
            if err != nil {
                PanicOnError(err)
            }

            // 消費訊息
            msgs, err := channel.Consume(
                qc.WorkerQueueName(), // queue
                "",                   // consumer
                false,                // auto-ack
                false,                // exclusive
                false,                // no-local
                false,                // no-wait
                nil,                  // args
            )
            PanicOnError(err)

            for {
                select {
                case msg, ok := <-msgs:
                    if !ok {
                        log.Printf("receiver: channel is closed, maybe lost connection")
                        time.Sleep(5 * time.Second)
                        continue RECONNECT
                    }

                    // 這裡生成訊息的 UUID,用來跟蹤整個訊息流,稍後會解釋
                    msg.MessageId = fmt.Sprintf("%s", uuid.NewV4())
                    message := Message{qc, &msg, 0}

                    // 這裡把訊息寫到出管道
                    out <- message

                    message.Printf("receiver: received msg")
                case <-done:

                    // 當主協程收到 done 訊號的時候,自己也退出
                    log.Printf("receiver: received a done signal")
                    return
                }
            }
        }
    }

    for _, queue := range queues {
        wg.Add(ReceiverNum)
        for i := 0; i < ReceiverNum; i++ {

            // 每個佇列生成 N 個協程共同消費
            go receiver(*queue)
        }
    }

    // 控制協程,當所有的消費協程退出時,出口管道也需要關閉,通知下游協程
    go func() {
        wg.Wait()
        log.Printf("all receiver is done, closing channel")
        close(out)
    }()

    return out
}

裡面有幾個關鍵點需要注意。

  1. 每個函式都是類似的結構,一組工作協程和協作協程,當全部工作協程退出時,關閉出口管道,通知下游協程。注意 golang 中,對於管道的使用,需要從寫入端關閉,否則很容易出現崩潰。
  2. 我們在每個訊息中,記錄了一個唯一的 uuid,這個 uuid 用來打日誌,來跟蹤一整條資訊流。
  3. 因為可能出現的網路狀況,我們要進行判斷,如果出現了連線失敗的情況,直接 sleep 一段時間,然後重連。
  4. done 這個管道是在主協程進行控制的,主要用作優雅關閉。優雅關閉的作用是在升級配置、升級主程式的時候可以保證不丟訊息(等待訊息真的完成之後才會結束協程,整個程式才會退出)。

總結

得益於 Golang 的高效的表達能力,通過大約 500 行程式碼實現了一個穩定的訊息回撥中介軟體,同時具備下面的特性:

  • 高效能。在 macbook pro 15 上簡單測試,每個佇列的處理能力可以輕鬆達到 3000 message/second 以上,多個佇列也可以做到線性的增加效能,整體應用達到幾萬每秒很輕鬆。同時,得益於 golang 的協程設計,如果下游出現了慢呼叫,那麼也不會影響併發。
  • 優雅關閉。通過對訊號的監聽,整個程式可以在不丟訊息的情況下優雅關閉,利於配置更改和程式重啟。這個在生產環境非常重要。
  • 自動重連。當 RabbitMQ 服務無法連線的時候,應用可以自動重連。

當然,我們團隊目前還都是 golang 新手,也沒有做太多的單元測試和效能測試,下一步可能會繼續優化,完善測試工作,並且優化配置的管理,歡迎各位去 github 圍觀原始碼。

更多原創文章乾貨分享,請關注公眾號
  • 用500行 Golang 程式碼實現高效能的訊息回撥中介軟體
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章