nsq 部署 / 投遞 / 消費 / 叢集 示例

big_cat發表於2022-03-08
docker pull nsqio/nsq

服務埠及關係

image.png

topic & channel
image.png

叢集模式
image.png

預準備

因為是在單機上通過 docker 容器實現多節點部署,nsqd/nsqadmin 的容器想要與 nsqlookup 通訊,需要訪問 nsqlookup 在宿主機上暴露的服務埠,所以我們在建立nsqd/nsqadmin 容器時與 nsqlookup 的通訊相關的地址都要填寫宿主機 ip

# 獲取宿主機內網ip
ifconfig -a|grep inet|grep -v 127.0.0.1|grep -v inet6|awk '{print $2}'|tr -d "addr:"

比如是我的是 10.10.31.147,後續注意替換。

nsqlookupd

# 4160 tcp 供 nsqd 註冊用
# 4161 http 供 nsqdadmin 和 consumer 查詢服務名字
docker run -d --name nsqlookupd \
-p 4160:4160 -p 4161:4161 \
nsqio/nsq /nsqlookupd

nsqd

建立兩個 nsqd 節點

nsq 有兩個 producer 服務埠 tcp-address 和 http-address
# --broadcast-address 節點主機地址 用來供外放訪問
# 下文設為宿主機IP 以便admin訪問統計例項狀態
# --tcp-address tcp 協議的 producer 埠
# --http-address http 協議的 producer 埠
# --lookupd-tcp-address lookupd 的 tcp 地址
# --data-path 資料持久化儲存路徑

# nsq0 tcp://127.0.0.1:4150/ http://127.0.0.1:4151/
docker run -d -v /tmp/nsq0:/tmp/nsq \
-p 4150:4150 -p 4151:4151 \
--name nsqd0 nsqio/nsq /nsqd \
--tcp-address :4150 \
--http-address :4151 \
--broadcast-address=10.10.31.147 \
--lookupd-tcp-address=10.10.31.147:4160 \
--data-path /tmp/nsq

# nsq1 tcp://127.0.0.1:4250/ http://127.0.0.1:4251/
docker run -d -v /tmp/nsq1:/tmp/nsq \
-p 4250:4250 -p 4251:4251 \
--name nsqd1 nsqio/nsq /nsqd \
--tcp-address :4250 \
--http-address :4251 \
--broadcast-address=10.10.31.147 \
--lookupd-tcp-address=10.10.31.147:4160 \
--data-path /tmp/nsq

nsqadmin

# 4171 admin管理平臺服務埠
# --lookupd-http-address lookupd 的 http 地址
docker run -d --name nsqadmin \
-p 4171:4171 nsqio/nsq /nsqadmin \
--lookupd-http-address=10.10.31.147:4161

http://127.0.0.1:4171/nodes

image.png

topic

# 建立主題
curl -X POST http://127.0.0.1:4151/topic/create?topic=test
curl -X POST http://127.0.0.1:4251/topic/create?topic=test

channel

# channel 相當於消費組 channel 與 topic 之間相當於訂閱釋出的關係
curl -X POST 'http://127.0.0.1:4151/channel/create?topic=test&channel=chan_4151_1'
curl -X POST 'http://127.0.0.1:4151/channel/create?topic=test&channel=chan_4151_2'

curl -X POST 'http://127.0.0.1:4251/channel/create?topic=test&channel=chan_4251_1'
curl -X POST 'http://127.0.0.1:4251/channel/create?topic=test&channel=chan_4251_2'

消費者

這裡借用 nsqlookupd 容器內的 nsq 相關命令指令碼。
nsq_to_file 作為消費者,通過查詢 lookupd 來獲取所有包含指定 topic 的節點,並繫結 channel,當有生產者向此 topic 傳送訊息時,訂閱的 channel 繼而消費。

# 通過查詢 lookupd 獲取所有的 topic
# 訂閱 topic > channel

docker exec -it nsqlookupd nsq_to_file \
--topic=test --channel=chan_4151_1 \
--output-dir=/tmp/chan_4151_1 \
--lookupd-http-address=127.0.0.1:4161

docker exec -it nsqlookupd nsq_to_file  \
--topic=test --channel=chan_4251_1 \
--output-dir=/tmp/chan_4251_1 \
--lookupd-http-address=127.0.0.1:4161

生產者

# topic 釋出訊息 每個 channel 會受到此訊息
# 且負載輪訓分配給channel下的其中一個消費者
curl -d 'hello world 4151' 'http://127.0.0.1:4151/pub?topic=test'
curl -d 'hello world 4251' 'http://127.0.0.1:4251/pub?topic=test'

高可用場景

副本冪等消費

高可用叢集,自然少不了副本的概念,但 nsq 的叢集沒有節點資料同步機制,不像其他高階佇列一樣有同步資料維護副本的概念,所以 nsq 的副本需要我們在程式碼層面維護實現。

nsqd0 nsqd1 兩個節點舉例,如何實現叢集高可用呢?在 nsqd0 nsqd1 建立同名的 topic_ha & channel_replic,並在投遞訊息時,同時向兩個節點都傳送。

消費者通過 lookupd 模式訂閱消費時,可以訂閱所有包含此 topic_ha 的節點的 channel_replic。這時通過向 nsqd0nsqd1 傳送相同訊息時,topic_ha 就維護出一個備份副本來,做訊息冪等消費,防止重複處理,在其中一個 nsqd 節點掛掉時,我們仍可以正常的投遞和消費業務訊息。

curl -X POST http://127.0.0.1:4151/topic/create?topic=test_ha
curl -X POST http://127.0.0.1:4251/topic/create?topic=test_ha

curl -X POST 'http://127.0.0.1:4151/channel/create?topic=test_ha&channel=chan_replic'
curl -X POST 'http://127.0.0.1:4251/channel/create?topic=test_ha&channel=chan_replic'

# 或者使用文中最後的 go 程式碼體驗叢集投遞/消費的概念
docker exec -it nsqlookupd nsq_to_file  \
--topic=test_ha --channel=chan_replic \
--output-dir=/tmp/chan_replic \
--lookupd-http-address=127.0.0.1:4161

可以看到通過 nsqlookupd 獲取到所有含有此 topic&channel 的節點

2022/03/03 09:49:34 INF    1 [test_ha/chan_replic] querying nsqlookupd http://127.0.0.1:4161/lookup?topic=test_ha
2022/03/03 09:49:34 INF    1 [test_ha/chan_replic] (10.10.31.147:4150) connecting to nsqd
2022/03/03 09:49:34 INF    1 [test_ha/chan_replic] (10.10.31.147:4250) connecting to nsqd

基礎概念

  1. nsq 的高可用叢集,並沒有自動同步副本的功能,即你有N個節點,則你需要在N個節點上建立同名的 topic,在投遞訊息時也需要向這N個節點分別投遞一次訊息。
  2. nsq 的消費,最佳方法為消費者連線 lookupd 服務,查詢訂閱的 topic 都分佈在哪些節點,消費者以 topic 為主,會訂閱所有的包含 topic 節點的訊息資料。
  3. channel 就是消費組,組內負載均衡訊息佇列,組間互為訂閱釋出。好比 kafka 的低階消費組一樣。加入相同消費組,負載均衡消費,不同消費組之前互為 topic 的訂閱者。
  4. 同一節點,訂閱 相同 topic 相同 channel 則為加入消費組,組內負載均衡消費。
  5. 同一節點,訂閱 相同 topic 不同 channel 則為訂閱釋出,每個消費組內至少有一個消費者能得到訊息。

例項(叢集投遞/消費)

nsqProducer
我這裡也封裝了通過 lookupd 自動獲取所有包含 topicnsqd 並建立 tcpProducer。這樣向一個分佈在多個 nsqd 節點上 topic 投遞訊息時,就不需要挨個手寫投遞了。

package main

import (
    "encoding/json"
    "errors"
    "flag"
    "github.com/nsqio/go-nsq"
    "io/ioutil"
    "log"
    "net/http"
    "os"
    "os/signal"
    "strconv"
    "syscall"
    "time"
)

var TopicProducers map[string][]*nsq.Producer

type LookupTopicRes struct {
    Channels  []string       `json:"channels"`
    Producers []ProducerInfo `json:"producers"`
}

type ProducerInfo struct {
    RemoteAddress    string `json:"remote_address"`
    Hostname         string `json:"hostname"`
    BroadcastAddress string `json:"broadcast_address"`
    TcpPort          int    `json:"tcp_port"`
    HttpPort         int    `json:"http_port"`
    Version          string `json:"version"`
}

func main() {
    var topic string

    flag.StringVar(&topic, "topic", "test", "topic name default test")
    flag.Parse()

    NewTopicProducer(topic)

    go func() {
        timerTicker := time.Tick(2 * time.Second)
        for {
            <-timerTicker
            totalNode, failedNode, err := PublishTopicMsg(topic, []byte("hello nsq "+time.Now().Format("2006-01-02 15:04:05")))
            if err != nil {
                log.Fatalln("PublishTopicMsg err topic", topic, "err", err.Error())
            }
            log.Println("PublishTopicMsg ok topic", topic, "totalNode", totalNode, "failedNode", failedNode)
        }
    }()

    // wait for signal to exit
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
    sigMsg := <-sigChan
    log.Println("sigMsg", sigMsg)

    // Gracefully stop the producer.
    for _, producers := range TopicProducers {
        for _, producer := range producers {
            producer.Stop()
        }
    }
}

// NewTopicProducer
// 獲取 topic 所有的 nsqd 節點 並建立 tcp 連結
func NewTopicProducer(topic string) {
    TopicProducers = make(map[string][]*nsq.Producer)
    config := nsq.NewConfig()
    config.MaxInFlight = 10 // 一個消費者最多可同時處理的訊息數量
    topicNodeAddr := getTopicNodeAddrSet(topic)
    var producers []*nsq.Producer
    for _, addr := range topicNodeAddr {
        producer, err := nsq.NewProducer(addr, config)
        if err != nil {
            log.Fatalln("newProducer err topic", topic, "err", err.Error())
        }
        producers = append(producers, producer)
    }
    TopicProducers[topic] = producers
}

// PublishTopicMsg
// 向 topic 傳送訊息 會自動向每一個包含此 topic 的節點傳送 叢集模式
func PublishTopicMsg(topic string, msg []byte) (totalNode int, failedNode int, err error) {
    producers, ok := TopicProducers[topic]
    if !ok {
        return 0, 0, errors.New("PublishTopicMsg err topic not exists")
    }
    totalNode = len(producers)
    for _, producer := range producers {
        errPub := producer.Publish(topic, msg)
        if nil != errPub {
            failedNode++
        }
    }
    return
}

// 獲取 topic 的所在的 nsqd 節點集合
func getTopicNodeAddrSet(topic string) (topicNodeAddrArr []string) {
    resp, _ := http.Get("http://127.0.0.1:4161/lookup?topic=" + topic)
    defer func() {
        _ = resp.Body.Close()
    }()

    bodyRaw, _ := ioutil.ReadAll(resp.Body)
    lookupTopicRes := &LookupTopicRes{}
    _ = json.Unmarshal(bodyRaw, &lookupTopicRes)

    for _, producer := range lookupTopicRes.Producers {
        topicNodeAddrArr = append(topicNodeAddrArr, producer.BroadcastAddress+":"+strconv.Itoa(producer.TcpPort))
    }

    return topicNodeAddrArr
}

nsqConsumer
使用通過 lookupd 自動獲得 topic + channel 的所有 nsqd 節點,並訂閱消費。

package main

import (
    "flag"
    "github.com/nsqio/go-nsq"
    "log"
    "os"
    "os/signal"
    "syscall"
)

type nsqMessageHandler struct{}

// HandleMessage implements the Handler interface.
func (h *nsqMessageHandler) HandleMessage(m *nsq.Message) error {
    if len(m.Body) == 0 {
        // Returning nil will automatically send a FIN command to NSQ to mark the message as processed.
        // In this case, a message with an empty body is simply ignored/discarded.
        return nil
    }

    // do whatever actual message processing is desired
    log.Println("HandleMessage nsqd:", m.NSQDAddress, "msg:", string(m.Body))

    // Returning a non-nil error will automatically send a REQ command to NSQ to re-queue the message.
    return nil
}

func main() {
    var topic string
    var channel string
    var count int
    var consumerGroup []*nsq.Consumer

    flag.StringVar(&topic, "topic", "test", "topic name default test")
    flag.StringVar(&channel, "channel", "test", "channel name default test")
    flag.IntVar(&count, "count", 1, "consumer count default 1")
    flag.Parse()

    // Instantiate a consumer that will subscribe to the provided channel.
    config := nsq.NewConfig()
    config.MaxInFlight = 10 // 一個消費者最多可同時處理的訊息數量

    for i := 0; i < count; i++ {
        consumer, err := nsq.NewConsumer(topic, channel, config)
        if err != nil {
            log.Fatalln("NewConsumer err:", err.Error())
        }

        // Set the Handler for messages received by this Consumer. Can be called multiple times.
        // See also AddConcurrentHandlers.
        consumer.AddHandler(&nsqMessageHandler{})

        // Use nsqlookupd to discover nsqd instances.
        // See also ConnectToNSQD, ConnectToNSQDs, ConnectToNSQLookupds.
        // 會訂閱所有包含當前 topic 的 nsqd 例項
        // 多用於叢集模式時 生產者向多個含有topic的例項同時傳送訊息
        // 當其中部分例項掛到時 消費者仍可通過其它例項獲得訊息
        // !此處要做訊息冪等處理!
        err = consumer.ConnectToNSQLookupd("localhost:4161")
        if err != nil {
            log.Fatalln("ConnectToNSQLookupd err:", err.Error())
        } else {
            log.Println("ConnectToNSQLookupd success topic:", topic, "channel:", channel)
        }

        consumerGroup = append(consumerGroup, consumer)
    }

    // wait for signal to exit
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
    sigMsg := <-sigChan
    log.Println("sigMsg", sigMsg)

    // Gracefully stop the consumer.
    for _, consumer := range consumerGroup {
        consumer.Stop()
    }
}

執行

go run nsqConsumer.go -topic test_ha -channel chan_replic -count=2
go run nsqProducer.go -topic test_ha

兩個消費者同 chan 互為負載均衡構成 消費組A消費組A 依次訂閱 nsqd0, nsqd1chan_replic,生產者向 test_ha 叢集模式投遞,nsqd0, nsqd1 收到訊息後,會分別向 消費組A 投遞一次訊息,消費組A 內部至於由哪個消費者消費,取決於負載均衡。

image.png

相關文章