• NSQ源码剖析(二):golang客户端源码及性能和故障分析


    使用示例

    NSQ的golang版客户端:https://github.com/nsqio/go-nsq
    提供了生产者和消费者的接口封装。
    doc.go文件描述的蛮清楚的:
    生产者Producer

    	// 创建一个生产者实例
    	// 值得注意的是,Producer与Nsqd是一对一的关系,消息不会在Nsqd间传输
    	// 所以如果要实现多Nsqd实例负载
    	config := nsq.NewConfig()
    	producer, err := nsq.NewProducer("127.0.0.1:4150", config)
    	if err != nil {
    		log.Fatal(err)
    	}
    
    	messageBody := []byte("hello")
    	topicName := "topic"
    
    	// 以同步的方式推送消息
    	// 用户也可以选择其他的api方法以异步或批量的方式推送
    	err = p.Publish(topicName, messageBody)
    	if err != nil {
    		log.Fatal(err)
    	}
    
    	// 优雅退出
    	producer.Stop()
    

    消费者Consumer

    	type myMessageHandler struct {} // 自定义结构体,用于实现nsq.Handler接口
    
    	// 由用户实现的方法,用于实现nsq.Handler接口
    	// nsq.Handler接口只有一个方法,即'HandleMessage(message *Message) error'
    	// Consumer收到消息后,会回调此方法完成处理
    	func (h *myMessageHandler) HandleMessage(m *nsq.Message) error {
    		if len(m.Body) == 0 {
    			// 空消息返回nil,将自动向Nsqd响应FIN用于销毁消息
    			return nil
    		}
    
    		err := processMessage(m.Body)
    
    		// 消息消费异常时可以返回err,将向Nsqd响应REQ。此时本条消息将会重新入队继续推送
    		return err
    	}
    
    	func main() {
    		// 创建Consumer实例
    		config := nsq.NewConfig()
    		consumer, err := nsq.NewConsumer("topic", "channel", config)
    		if err != nil {
    			log.Fatal(err)
    		}
    
    		// 指定自定义的nsq.Handler接口,收到的消息通过回调此接口来消费
    		consumer.AddHandler(&myMessageHandler{})
    
    		// 通过nsqlookupd来实现服务自动发现,会定时获取新的注册信息。
    		// 类似的还有:ConnectToNSQD, ConnectToNSQDs, ConnectToNSQLookupds.
    		err = consumer.ConnectToNSQLookupd("localhost:4161")
    		if err != nil {
    			log.Fatal(err)
    		}
    
    		// 优雅退出
    		consumer.Stop()
    	}
    

    配置参数

    无论是生产者还是消费者,创建时均需要提供一个Config结构体,nsq.NewConfig()方法可以创建并生成一个有默认参数的Config。
    Config源码见config.go。

    注意:

    • 只能通过nsq.NewConfig()方法来创建Config实例,该方法提供Config的默认初始化和验证句柄。否则使用时可能会抛Panic
    • 可以调用Config.Set()方法来修改参数值。注意config实例传递给Producer或Consumer时是深度拷贝的,所以传递之后修改原有的config实例是无效的。
    • 参数有默认值和可设置的范围限制,具体可参考下表。

    下面是所有可选参数的介绍:
    下表仅供参考,不同版本的代码可能会有改动,请查看自己的config.go文件

    参数 参数名 描述 类型 默认值 最小值 最大值
    DialTimeout dial_timeout 创建连接的超时时间 time.Duration 1s
    ReadTimeout read_timeout 读超时时间 time.Duration 60s 100ms 5m
    WriteTimeout write_timeout 写超时时间 time.Duration 1s 100ms 5m
    LocalAddr local_addr 本机地址 net.Addr 系统自动分配
    LookupdPollInterval lookupd_poll_interval lookup间隔 time.Duration 60s 100ms 5m
    LookupdPollJitter lookupd_poll_jitter 启动lookupdLoop前的随机抖动系数 float64 0.3 0 1
    MaxRequeueDelay max_requeue_delay 允许的DPUB指令最大延时 time.Duration 15m 0 60m
    DefaultRequeueDelay default_requeue_delay 默认的DPUB延时 time.Duration 90s 0 60m
    BackoffStrategy backoff_strategy 出错时的退避策略相关 BackoffStrategy ExponentialStrategy
    MaxBackoffDuration max_backoff_duration 出错时的退避策略相关 time.Duration 2m 0 60m
    BackoffMultiplier backoff_multiplier 出错时的退避策略相关 time.Duration 1s 0 60m
    MaxAttempts max_attempts 最大的消费次数,超过的消息将丢弃 uint16 5 0 65535
    LowRdyIdleTimeout low_rdy_idle_timeout 超过这个时间未收到消息则重置RDY time.Duration 10s 1s 5m
    LowRdyTimeout low_rdy_timeout 超过这个时间未发送RDY则重置RDY time.Duration 30s 1s 5m
    RDYRedistributeInterval rdy_redistribute_interval rdyLoop()检查重置RDY条件的间隔 time.Duration 5s 1ms 5s
    ClientID client_id string short hostname
    Hostname hostname string
    UserAgent user_agent string
    HeartbeatInterval heartbeat_interval time.Duration 30s
    SampleRate sample_rate int32 0 0 99
    TlsV1 tls_v1 bool false
    TlsConfig tls_config *tls.Config
    Deflate deflate bool false
    DeflateLevel deflate_level int 6 1 9
    Snappy snappy bool false
    OutputBufferSize output_buffer_size int64 16384
    OutputBufferTimeout output_buffer_timeout time.Duration 250ms
    MaxInFlight max_in_flight 允许的最大的处理中的消息数 int 1 0
    MsgTimeout msg_timeout 消息超时时间,超过这个时间Nsqd会将消息重新入队 time.Duration 0 0
    AuthSecret auth_secret string

    生产者Producer

    总结

    Producer源码见producer.go。

    • Producer支持并发,底层通过一个channel将消息发送给一个独立的协程来最终发布给Nsqd,从而解决并发冲突。
    • 一个Producer只支持一个Nsqd。如果要支持集群负载均衡,需要自己实现。

    Producer提供了6个方法用于发布消息:

    • Publish():阻塞发布1条消息。底层调用"PUB"指令
    • PublishAsync():非阻塞发布1条消息。相比Publish(),多了一个额外的doneChan参数,通过此chan来异步接收发布结果。
    • MultiPublish():阻塞发布多条消息。底层调用"MPUB"指令
    • MultiPublishAsync():非阻塞发布多条消息。通过doneChan来异步接收发布结果。
    • DeferredPublish():阻塞发布1条带延时的消息。相比Publish(),多了一个delay参数来指定延时多久才推送给消费者。底层调用"DPUB"指令
    • DeferredPublishAsync():非阻塞发布1条带延时的消息。通过doneChan来异步接收发布结果。

    Producer结构体

    每一个Producer实例,均是一个结构体:

    type Producer struct {
    	id     int64			// 用于打印日志时标示实例。由instCount全局变量控制,从0开始,每创建一个Producer或Consumer时+1
    	addr   string			// 连接的Nsqd的地址
    	conn   producerConn		// 连接实例
    	config Config			// 配置参数
    
    	logger   []logger
    	logLvl   LogLevel
    	logGuard sync.RWMutex
    
    	responseChan chan []byte	// conn收到Nsqd生产成功的响应后,通过此chan告知router(),router再通知到生产消息的线程
    	errorChan    chan []byte	// conn收到Nsqd错误信息的响应后,通过此chan告知router(),router再通知到生产消息的线程
    	closeChan    chan int		// conn断开时通过此chan告知router()结束
    
    	transactionChan chan *ProducerTransaction	// 生产过程将消息推送到这个chan,再异步接收成功的结果
    	transactions    []*ProducerTransaction		// 一个先入先出队列,用于router协程处理消息写入结果
    	state           int32						// 连接状态,初始状态/连接断开/已连接
    
    	concurrentProducers int32	// 统计正在等待发往transactionChan的消息数,Producer在退出前会将这些消息置为ErrNotConnected
    	stopFlag            int32
    	exitChan            chan int
    	wg                  sync.WaitGroup	// 用于等待router()协程退出
    	guard               sync.Mutex		// Producer全局锁
    }
    

    Producer发布消息的方法介绍

    Producer提供了6个方法用于发布消息:

    • Publish():阻塞发布1条消息。底层调用"PUB"指令
    • PublishAsync():非阻塞发布1条消息。相比Publish(),多了一个额外的doneChan参数,通过此chan来异步接收发布结果。
    • MultiPublish():阻塞发布多条消息。底层调用"MPUB"指令
    • MultiPublishAsync():非阻塞发布多条消息。通过doneChan来异步接收发布结果。
    • DeferredPublish():阻塞发布1条带延时的消息。相比Publish(),多了一个delay参数来指定延时多久才推送给消费者。底层调用"DPUB"指令
    • DeferredPublishAsync():非阻塞发布1条带延时的消息。通过doneChan来异步接收发布结果。

    上述6个方法,最终均调用sendCommandAsync()方法来完成发送。
    对于阻塞调用的3个方法,会先调用sendCommand(),创建一个临时chan,再调用sendCommandAsync()。

    • sendCommand()方法创建一个临时的doneChan来接收发布结果。
    • sendCommandAsync()负责将消息写入Producer.transactionChan。下一章节的router协程负责接收并将消息发往Nsqd,并将发布结果通过doneChan返回。如果连接尚未创建,这里会自动重建连接。
    func (w *Producer) sendCommand(cmd *Command) error {
    	doneChan := make(chan *ProducerTransaction)		// 临时的doneChan用于接收发布结果
    	err := w.sendCommandAsync(cmd, doneChan, nil)	// 发送消息
    	if err != nil {
    		close(doneChan)
    		return err
    	}
    	t := <-doneChan									// 等待发送结果
    	return t.Error
    }
    
    func (w *Producer) sendCommandAsync(cmd *Command, doneChan chan *ProducerTransaction,
    	args []interface{}) error {
    	// keep track of how many outstanding producers we're dealing with
    	// in order to later ensure that we clean them all up...
    	atomic.AddInt32(&w.concurrentProducers, 1)
    	defer atomic.AddInt32(&w.concurrentProducers, -1)
    
    	if atomic.LoadInt32(&w.state) != StateConnected {
    		err := w.connect()	// 未连接时自动重建连接
    		if err != nil {
    			return err
    		}
    	}
    
    	t := &ProducerTransaction{
    		cmd:      cmd,
    		doneChan: doneChan,
    		Args:     args,
    	}
    
    	select {
    	case w.transactionChan <- t:	// 将消息发送给router协程
    	case <-w.exitChan:
    		return ErrStopped
    	}
    
    	return nil
    }
    

    sendCommand()方法是Producer实现并发的核心,即并发的消息发布,最终都会写入transactionChan channel,由router协程独立处理,不存在并发冲突。

    Producer主要的辅助方法介绍

    自动创建连接
    nsq.NewProducer()方法只创建Producer实例,连接会在后续自动管理。
    每次发送指令Ping()或sendCommandAsync()时,如果尚未连接,会自动调用Producer.connect()方法。

    // 创建连接,修改连接状态,启动router()协程
    func (w *Producer) connect() error
    

    router协程异步发送和接收消息响应
    Producer通过起一个router协程异步发送和接收消息响应的方式来实现Producer并发写入的问题。无论用户有多少个线程在生产消息,最终都得调用sendCommandAsync()方法将消息写入一个chan,并由router单协程处理,这就避免了并发冲突。
    router协程在上文的Producer.connect()方法被启动。
    Router()方法起了个for循环持续监听几个chan,我们重点关注:

    • transactionChan:所有消息最终均通过sendCommandAsync()方法写入这个chan。router协程负责将从transactionChan收到的消息,写入Producer.conn,并最终发送到Nsqd。
    • responseChan:Nsqd每正确接收到一个消息,会响应一个确认帧回来。Producer.conn则通过此chan来告知router消息写入成功。
    • errorChan:同responseChan,收到错误信息时,通过此chan告知router。

    无论是写入成功还是有错误,router协程均调用popTransaction()方法来处理。这个方法有个细节,Nsqd并没有告知写入成功或失败的消息是哪条,Producer又是怎么知道的呢?原理是底层使用的TCP通讯,同学们可以回想下TCP的特点,TCP是有序的。写入消息的只有router一个协程,所以消息是按顺序写入的,恰恰Nsqd端也是单线程处理同一个生产者。所以router收到的响应,必然是针对transactions队列中第1条消息的(这是一个用切片实现的先入先出队列,router会在写入conn的同时将消息写入这个队列)。

    收到Nsqd的响应后,router将结束写入ProducerTransaction.doneChan,用于通知消息的写入协程。

    func (w *Producer) router() {
    	for {
    		select {
    		case t := <-w.transactionChan:	// 这是待发布的消息
    			w.transactions = append(w.transactions, t)	// 先入先出队列,用于处理消息发布结果
    			err := w.conn.WriteCommand(t.cmd)			// 发布消息
    			if err != nil {
    				w.log(LogLevelError, "(%s) sending command - %s", w.conn.String(), err)
    				w.close()
    			}
    		case data := <-w.responseChan:	// 发布成功的响应
    			w.popTransaction(FrameTypeResponse, data)	// 处理发布结果,将结果写入doneChan
    		case data := <-w.errorChan:		// 发布失败的响应
    			w.popTransaction(FrameTypeError, data)		// 处理发布结果,将结果写入doneChan
    		case <-w.closeChan:
    			goto exit
    		case <-w.exitChan:
    			goto exit
    		}
    	}
    
    exit:
    	w.transactionCleanup()
    	w.wg.Done()
    	w.log(LogLevelInfo, "exiting router")
    }
    
    func (w *Producer) popTransaction(frameType int32, data []byte) {
    	t := w.transactions[0]
    	w.transactions = w.transactions[1:]		// 发布成功或失败的消息,出队
    	if frameType == FrameTypeError {
    		t.Error = ErrProtocol{string(data)}	// 发布失败的错误信息
    	}
    	t.finish()								// 通知到doneChan
    }
    
    func (t *ProducerTransaction) finish() {
    	if t.doneChan != nil {
    		t.doneChan <- t
    	}
    }
    

    Ping

    // Ping方法一般用于刚创建的Producer实例。自动connect()方法创建连接,并发送一条Nop指令,以确认连接是否正常
    func (w *Producer) Ping() error
    

    优雅退出
    注意:主动退出Producer时,建议使用Stop()方法,用于结束正在等待发送的消息,同时结束router协程。否则两者将可能一直阻塞

    // Stop()方法用于优雅退出当前Producer。
    // 正在等待发送的消息将被置为ErrNotConnected或ErrStopped
    func (w *Producer) Stop()
    

    消费者Consumer

    总结

    Producer源码见producer.go。

    • Producer支持并发,底层通过一个channel将消息发送给一个独立的协程来最终发布给Nsqd,从而解决并发冲突。
    • 一个Producer只支持一个Nsqd。如果要支持集群负载均衡,需要自己实现。

    Producer提供了6个方法用于发布消息:

    • Publish():阻塞发布1条消息。底层调用"PUB"指令
    • PublishAsync():非阻塞发布1条消息。相比Publish(),多了一个额外的doneChan参数,通过此chan来异步接收发布结果。
    • MultiPublish():阻塞发布多条消息。底层调用"MPUB"指令
    • MultiPublishAsync():非阻塞发布多条消息。通过doneChan来异步接收发布结果。
    • DeferredPublish():阻塞发布1条带延时的消息。相比Publish(),多了一个delay参数来指定延时多久才推送给消费者。底层调用"DPUB"指令
    • DeferredPublishAsync():非阻塞发布1条带延时的消息。通过doneChan来异步接收发布结果。

    Consumer源码见consumer.go。

    • Consumer支持并发,调用AddConcurrentHandlers()方法指定创建多个handlerLoop协程进行处理即可。
    • ConnectToNSQD()和ConnectToNSQDs()方法可以指定1个或多个Nsqd创建连接,但不具备服务发现和动态调整。
    • ConnectToNSQLookupd()和ConnectToNSQLookupds()方法可以指定1个或多个lookup创建连接,支持服务发现和定时动态调整。

    Consumer结构体

    每个Consumer实例对应于一个Consumer结构体:

    type Consumer struct {
    	// 64bit atomic vars need to be first for proper alignment on 32bit platforms
    	messagesReceived uint64
    	messagesFinished uint64
    	messagesRequeued uint64
    	totalRdyCount    int64
    	backoffDuration  int64
    	backoffCounter   int32
    	maxInFlight      int32
    
    	mtx sync.RWMutex
    
    	logger   []logger
    	logLvl   LogLevel
    	logGuard sync.RWMutex
    
    	behaviorDelegate interface{}
    
    	id      int64
    	topic   string
    	channel string
    	config  Config
    
    	rngMtx sync.Mutex
    	rng    *rand.Rand
    
    	needRDYRedistributed int32
    
    	backoffMtx sync.Mutex
    
    	incomingMessages chan *Message
    
    	rdyRetryMtx    sync.Mutex
    	rdyRetryTimers map[string]*time.Timer
    
    	pendingConnections map[string]*Conn
    	connections        map[string]*Conn
    
    	nsqdTCPAddrs []string
    
    	// used at connection close to force a possible reconnect
    	lookupdRecheckChan chan int
    	lookupdHTTPAddrs   []string
    	lookupdQueryIndex  int
    
    	wg              sync.WaitGroup
    	runningHandlers int32
    	stopFlag        int32
    	connectedFlag   int32
    	stopHandler     sync.Once
    	exitHandler     sync.Once
    
    	// read from this channel to block until consumer is cleanly stopped
    	StopChan chan int
    	exitChan chan int
    }
    

    注册回调函数和消息消费

    Consumer每收到一条消息,会调用我们指定的回调接口来消费消息。
    这个回调接口如下:

    // 返回nil表示消费成功,Consumer将向Nsqd发送FIN指令销毁消息。
    // 非nil表示消费失败或需要重复消费,Consumer将向Nsqd发送REQ指令将消息重新入队推送。
    type Handler interface {
    	HandleMessage(message *Message) error
    }
    

    我们在启动Consumer,需要先用一个结构体实现上述Handler接口,将调用AddHandler()方法将该结构体传给Consumer:

    /*自定义结构体示例*/
    type myMessageHandler struct {} // 自定义结构体,用于实现nsq.Handler接口
    
    func (h *myMessageHandler) HandleMessage(m *nsq.Message) error {
    	if len(m.Body) == 0 {
    		return nil
    	}
    
    	err := processMessage(m.Body)
    
    	return err
    }
    
    // 在启动Consumer前调用:consumer.AddHandler(&myMessageHandler{})方法将结构体传递给Consumer
    

    每调用一次consumer.AddHandler()方法会启动一个handlerLoop协程用于循环接收消息:

    // 必须在Consumer连接之前调用AddHandler()方法,否则会抛panic
    // 每调用一次AddHandler()就创建一个handlerLoop协程,可以多次调用来创建多个协程
    func (r *Consumer) AddHandler(handler Handler) {
    	r.AddConcurrentHandlers(handler, 1)
    }
    
    func (r *Consumer) AddConcurrentHandlers(handler Handler, concurrency int) {
    	if atomic.LoadInt32(&r.connectedFlag) == 1 {
    		panic("already connected") // 必须在Consumer连接之前调用AddHandler()方法,否则会抛panic
    	}
    
    	atomic.AddInt32(&r.runningHandlers, int32(concurrency))
    	for i := 0; i < concurrency; i++ {
    		go r.handlerLoop(handler) // 每调用一次AddHandler()就创建一个handlerLoop协程,可以多次调用来创建多个协程
    	}
    }
    

    如果消费速度较慢,可以在连接之前直接调用AddConcurrentHandlers()以指定创建多个协程来并发处理

    每个handlerLoop协程都在监听incomingMessages,收到消息则调用Handler消费:

    func (r *Consumer) handlerLoop(handler Handler) {
    	r.log(LogLevelDebug, "starting Handler")
    
    	for {
    		message, ok := <-r.incomingMessages // 可以创建多个协程并发监听这个channel
    		if !ok {
    			goto exit
    		}
    
    		if r.shouldFailMessage(message, handler) {
    			message.Finish()
    			continue
    		}
    
    		err := handler.HandleMessage(message)
    		if err != nil {
    			r.log(LogLevelError, "Handler returned error (%s) for msg %s", err, message.ID)
    			if !message.IsAutoResponseDisabled() {
    				message.Requeue(-1)
    			}
    			continue
    		}
    
    		if !message.IsAutoResponseDisabled() {
    			message.Finish()
    		}
    	}
    
    exit:
    	r.log(LogLevelDebug, "stopping Handler")
    	if atomic.AddInt32(&r.runningHandlers, -1) == 0 {
    		r.exit()
    	}
    }
    

    创建连接

    Consumer提供了2个方法用于创建连接:

    • ConnectToNSQD():指定一个Nsqd连接。推荐使用lookupd以便自动服务发现。
    • ConnectToNSQDs():指定多个Nsqd连接。推荐使用lookupd以便自动服务发现。

    注意,直接连接Nsqd的方法不推荐使用,建议使用下一章节的lookup服务发现,以便自动发现和管理。

    ConnectToNSQDs()方法就是个for循环,调用多次ConnectToNSQD()。
    ConnectToNSQD()方法比较长,关键的点是,如果未注册回调方法,会报错;置状态为已连接;新建一个连接,建立连接时会发送"IDENTIFY"指令通告此连接的相关参数;向Nsqd发送Sub指令开始订阅;订阅前连接暂时放在pendingConnections中,订阅成功后从pendingConnections移到connections中。
    ConnectToNSQD()最终对connections中所有连接执行maybeUpdateRDY()方法用于调整RDY计数。

    func (r *Consumer) ConnectToNSQD(addr string) error {
    	if atomic.LoadInt32(&r.stopFlag) == 1 {
    		return errors.New("consumer stopped")
    	}
    
    	if atomic.LoadInt32(&r.runningHandlers) == 0 { // 如果未注册回调方法,会报错
    		return errors.New("no handlers")
    	}
    
    	atomic.StoreInt32(&r.connectedFlag, 1)
    
    	conn := NewConn(addr, &r.config, &consumerConnDelegate{r})
    	conn.SetLoggerLevel(r.getLogLevel())
    	format := fmt.Sprintf("%3d [%s/%s] (%%s)", r.id, r.topic, r.channel)
    	for index := range r.logger {
    		conn.SetLoggerForLevel(r.logger[index], LogLevel(index), format)
    	}
    	r.mtx.Lock()
    	_, pendingOk := r.pendingConnections[addr]
    	_, ok := r.connections[addr]
    	if ok || pendingOk {
    		r.mtx.Unlock()
    		return ErrAlreadyConnected
    	}
    	r.pendingConnections[addr] = conn
    	if idx := indexOf(addr, r.nsqdTCPAddrs); idx == -1 {
    		r.nsqdTCPAddrs = append(r.nsqdTCPAddrs, addr)
    	}
    	r.mtx.Unlock()
    
    	r.log(LogLevelInfo, "(%s) connecting to nsqd", addr)
    
    	cleanupConnection := func() {
    		r.mtx.Lock()
    		delete(r.pendingConnections, addr)
    		r.mtx.Unlock()
    		conn.Close()
    	}
    
    	resp, err := conn.Connect()
    	if err != nil {
    		cleanupConnection()
    		return err
    	}
    
    	if resp != nil {
    		if resp.MaxRdyCount < int64(r.getMaxInFlight()) {
    			r.log(LogLevelWarning,
    				"(%s) max RDY count %d < consumer max in flight %d, truncation possible",
    				conn.String(), resp.MaxRdyCount, r.getMaxInFlight())
    		}
    	}
    
    	cmd := Subscribe(r.topic, r.channel)
    	err = conn.WriteCommand(cmd)
    	if err != nil {
    		cleanupConnection()
    		return fmt.Errorf("[%s] failed to subscribe to %s:%s - %s",
    			conn, r.topic, r.channel, err.Error())
    	}
    
    	r.mtx.Lock()
    	delete(r.pendingConnections, addr)
    	r.connections[addr] = conn
    	r.mtx.Unlock()
    
    	// pre-emptive signal to existing connections to lower their RDY count
    	for _, c := range r.conns() {
    		r.maybeUpdateRDY(c)
    	}
    
    	return nil
    }
    

    maybeUpdateRDY()方法调用perConnMaxInFlight()获取当前连接的最大允许RDY计数,最大允许RDY计数为配置参数maxInFlight / 当前连接数。其中maxInFligh如果未主动配置的话默认为1,那么所有连接的RDY均为1。之后再调用updateRDY()方法来调整RDY计数。最终每个连接会向Nsqd发送RDY指令调整计数。
    整段代码调用较长,也没太多可分析的点,所以代码就不贴了。
    maybeUpdateRDY()只是初调整,还有一个由NewConsumer()启动的rdyLoop()协程会定时根据每个连接的状态进一步调整,调整原则是将长时间未收到消息的连接的RDY置为最小值1

    讲了这么多,那么消息是怎么接收并传递过来的呢?
    前面的handlerLoop协程,监听incomingMessages通道。所以我们全局搜incomingMessages通道,发现Consumer.onConnMessage()方法会向这个通道写入消息。而这个方法由consumerConnDelegate.OnMessage()调用。进一步定位到每个连接的Conn.readLoop()方法。
    Conn.readLoop()方法的逻辑比较简单,就是一个for循环阻塞等待Nsqd推送消息,解析后如果是一条消息,则调用consumerConnDelegate.OnMessage()将消息写入到incomingMessages通道。

    func (c *Conn) readLoop() {
    	for {
    		frameType, data, err := ReadUnpackedResponse(c) // 阻塞等待Nsqd推送消息,并完成消息解析
    		
    		switch frameType {
    		case FrameTypeResponse:
    			c.delegate.OnResponse(c, data)
    		case FrameTypeMessage:
    			msg, err := DecodeMessage(data)
    			if err != nil {
    				c.log(LogLevelError, "IO error - %s", err)
    				c.delegate.OnIOError(c, err)
    				goto exit
    			}
    			msg.Delegate = delegate
    			msg.NSQDAddress = c.String()
    
    			atomic.AddInt64(&c.messagesInFlight, 1)
    			atomic.StoreInt64(&c.lastMsgTimestamp, time.Now().UnixNano())
    
    			c.delegate.OnMessage(c, msg)	// 将消息写入incomingMessages通道
    		case FrameTypeError:
    			c.log(LogLevelError, "protocol error - %s", data)
    			c.delegate.OnError(c, data)
    		default:
    			c.log(LogLevelError, "IO error - %s", err)
    			c.delegate.OnIOError(c, fmt.Errorf("unknown frame type %d", frameType))
    		}
    	}
    }
    

    服务发现和自动连接管理

    Consumer提供了2个方法用于服务发现:

    • ConnectToNSQLookupd():通过lookupd服务发现,再自动创建连接。支持定时更新服务发现。
    • ConnectToNSQLookupds():通过多个lookupd服务发现,再自动创建连接。支持定时更新服务发现。

    ConnectToNSQLookupds()方法就是为每个lookupd调用一次ConnectToNSQLookupd()。
    我们来关注ConnectToNSQLookupd()做了啥。ConnectToNSQLookupd()也要求必须先注册回调。对于第1个lookup,创建lookupdLoop协程完成自动的服务发现。后续的lookup只需要加入到lookupdHTTPAddrs中,由lookupdLoop协程来协调。

    func (r *Consumer) ConnectToNSQLookupd(addr string) error {
    	if atomic.LoadInt32(&r.stopFlag) == 1 {
    		return errors.New("consumer stopped")
    	}
    	if atomic.LoadInt32(&r.runningHandlers) == 0 {
    		return errors.New("no handlers") // 同ConnectToNSQD(),必须先注册回调
    	}
    
    	if err := validatedLookupAddr(addr); err != nil {
    		return err
    	}
    
    	atomic.StoreInt32(&r.connectedFlag, 1) // 置连接位
    
    	r.mtx.Lock()
    	for _, x := range r.lookupdHTTPAddrs {
    		if x == addr {
    			r.mtx.Unlock()
    			return nil
    		}
    	}
    	r.lookupdHTTPAddrs = append(r.lookupdHTTPAddrs, addr)	// 将lookupd的addr加入到lookupdHTTPAddrs切片
    	numLookupd := len(r.lookupdHTTPAddrs)
    	r.mtx.Unlock()
    
    	// 只有第1个loopupd才会创建lookupdLoop协程。
    	if numLookupd == 1 {
    		r.queryLookupd()	// 执行一次服务发现,里面最多重复3次,如果都失败了,也不影响程序继续运行
    		r.wg.Add(1)
    		go r.lookupdLoop()	// 创建lookupdLoop协程完成自动的服务发现
    	}
    
    	return nil
    }
    

    我们重点看下lookupdLoop协程的工作,这是服务发现和自动管理的关键:
    lookupdLoop协程启动时先用jitter变量执行了一段随机延时,用于防止多个消费者监听同一Topic且同时重启的情况。由r.config.LookupdPollInterval变量创建了一个定时器,这是定时服务发现的间隔,查看config.go可知这个参数最小10ms,最大5m,默认为60s。
    接着lookupdLoop协程进入for循环,主要做两件事,一是通过ticker.C定时做一次服务发现,二是通过r.lookupdRecheckChan做一次服务发现,这个通道是在连接异常断开时会触发。

    func (r *Consumer) lookupdLoop() {
    	// add some jitter so that multiple consumers discovering the same topic,
    	// when restarted at the same time, dont all connect at once.
    	r.rngMtx.Lock()
    	jitter := time.Duration(int64(r.rng.Float64() *
    		r.config.LookupdPollJitter * float64(r.config.LookupdPollInterval)))
    	r.rngMtx.Unlock()
    	var ticker *time.Ticker
    
    	select {
    	case <-time.After(jitter):
    	case <-r.exitChan:
    		goto exit
    	}
    
    	ticker = time.NewTicker(r.config.LookupdPollInterval)
    
    	for {
    		select {
    		case <-ticker.C:
    			r.queryLookupd()
    		case <-r.lookupdRecheckChan:
    			r.queryLookupd()
    		case <-r.exitChan:
    			goto exit
    		}
    	}
    
    exit:
    	if ticker != nil {
    		ticker.Stop()
    	}
    	r.log(LogLevelInfo, "exiting lookupdLoop")
    	r.wg.Done()
    }
    

    具体的服务发现由queryLookupd()方法完成,该方法每次执行调用nextLookupdEndpoint()方法取下个lookup并通过http的方式发送lookup指令,参数包含需要消费的Topic。最多请求3个lookup,有1次成功即停止,如果3次都失败就退出等待下一次服务发现。lookup返回lookupResp结构体,内容为包含请求的Topic的Nsqd相关信息列表:

    type lookupResp struct {
    	Channels  []string    `json:"channels"`
    	Producers []*peerInfo `json:"producers"`
    	Timestamp int64       `json:"timestamp"`
    }
    

    服务发现信息包含该lookup的channel列表和生产者列表。
    然后对每一个生产者的Nsqd调用ConnectToNSQD()方法,该方法会完成去重,已经连接的Nsqd不会重复连接。

    性能和故障分析

    Consumer

    消费者Consumer客户端对并发和负载较友好,可以通过以下手段来增强并发和负载:

    • 同时连接多个Nsqd。注意Nsqd之间不共享数据,需要生产者端将消息写入不同的Nsqd。
    • 调用AddConcurrentHandlers来启用多个消息处理协程,增大消息消费的能力。
    • 合适配置参数增加网络吞吐量。注意参数配置需要配套合理,比如如果将Flight消息数提高很高,但消费能力不行,就有可能出现Nsqd端大量消息超时的问题。可以提升网络吞吐量的参数主要有这几个:
      • MaxInFlight:该参数控制Nsqd端允许的已发送但尚未收到FIN帧的消息数,增大该值可以增大网络中正在发送的消息数。
      • OutputBufferSize:增大Nsqd端发送缓冲区的大小。

    Producer

    一条一条发布消息较慢,如果可以,建议用MPUB相关的接口批量发布。

    但一个Nsqd处理能力有限,有时我们需要集群来负载。遗憾的是,Producer与Nsqd是一对一的关系。可以有以下思路优化:

    • 不同的Topic发布到不同的Nsqd上。
    • 将消息分散发布到多个Nsqd上,需要自行实现。

    重复消费

    即使只发布一份的消息,在极端情况下也存在重复消费的可能,当某条消息超时(回顾:NSQ源码剖析(一):NSQD主要结构方法和消息生产消费过程 ),消息会重新入队。入队之后如果消费者端因网络或性能问题现在才完成消费,此时FIN指令将被响应"E_FIN_FAILED"。消息还在队列中等待第二次推送。

    消费顺序

    Nsqd不保证消息推送顺序,这与延时/超时/磁盘队列等设计有关,这里不展开讨论,请回顾:NSQ源码剖析(一):NSQD主要结构方法和消息生产消费过程

    单点问题

    存在两种单点问题,一是异常退出的Nsqd会丢失掉尚在内存的Msg;二是与宕机的Nsqd相连的Producer将无法工作。

    宕机时丢失内存Msg
    可以有两种思路进行规避:

    • 降低内存channel的大小,甚至是0,让所有数据都压入磁盘队列。这种方式会降低性能,同时磁盘队列本身也有内存缓冲,存在丢失可能。
    • 消息副本。生产者发布消息时,创建副本发布到不同的Nsqd上。这要求消费者有能力识别出重复消费的情况,或者实现消费幂等。

    Nsqd宕机导致Producer不工作
    前面介绍过Producer与Nsqd是一对一的关系,一旦Nsqd宕机,与之相连的Producer也将无法工作。这种情况可以自行实现异常检测和故障转移,比如检测到宕机时连接到另一台Nsqd实例;或者实现一个自动的负载均衡机制。
    无论使用哪种方式,都要求Consumer端支持服务发现,这点上官方api就支持,使用ConnectToNSQLookupd()或ConnectToNSQLookupds()即可。

    由于时间因素,本来打算实现一个Producer的负载均衡算法的,现在来不及。有兴趣的同学可以尝试下,或者关注我的github,欢迎参与进来讨论和研究。

  • 相关阅读:
    数据库导出_导入
    vc6如何调试带参数的main函数
    配置mes
    vc调试大全
    C#中out和ref之间的区别
    TSQL 控制流语句
    删除文件夹及文件夹内的文件
    数据库 存储 研究方向 关键词
    apache 监听多端口
    js 产生随机数
  • 原文地址:https://www.cnblogs.com/JoZSM/p/12443544.html
Copyright © 2020-2023  润新知