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"
    "strings"
    "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"`
}

// nsq 内部日志
type nsqServerLogger struct {
}

func (nsl *nsqServerLogger) Output(callDepth int, s string) error {
    log.Println("nsqServerLogger", callDepth, s[:3], strings.Trim(s[3:], " "))
    return nil
}

func main() {
    var topic string

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

    // 为每个包含topic的nsqd节点 创建1个生产者
    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()
    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())
        }
        producer.SetLogger(&nsqServerLogger{}, nsq.LogLevelDebug)
        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{})
        // 并发模式的消费处理 消费者会启用 n 个协程处理消息
        consumer.AddConcurrentHandlers(&nsqMessageHandler{}, 10)
        consumer.SetLogger(&nsqServerLogger{}, nsq.LogLevelDebug)

        // 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


big_cat
1.7k 声望130 粉丝

规范至上