NSQ源码阅读与常见问题分析-NSQD

1,711 阅读23分钟

NSQ 是实时的分布式消息处理平台,其设计的目的是用来大规模地处理每天数以十亿计级别的消息。NSQ 具有分布式去中心化拓扑结构,该结构具有无单点故障、故障容错、高可用性以及能够保证消息的可靠传递的特征。上面是NSQ的官方介绍,简单的来说,NSQ就是一个消息队列(MQ)。由于秃毛哥(本人) 的工作环境大量用到NSQ,为了理解在使用过程中遇到的一些坑,也出于兴趣,因此研究一下nsq的源码。

1. NSQ组件简介

实际上,NSQ组件包括nsqdnsqlookupdnsqadmin。nsqd主要用于处理服务端与客户端之间的消息收发,是NSQ的关键组件。当我们将nsqd运行起来之后,它将监听两个端口,默认配置是4150和4151,前者为客户端提供了TCP服务,后者则提供了HTTP API服务。nsqd可以单独运行不依赖于其他NSQ组件,但是我们一般会与nsqlookupd搭配使用。nsqlookupd主要用于管理NSQ的网络拓扑信息。客户端通过查询nsqlookupd可以发现新的nsqd节点,新的topic或者channel信息。nsqadmin是一个web服务,它为用户提供了可视化界面用于实时的查询nsq集群中不同消息队列的详细信息。

2. NSQD

nsqd作为NSQ的关键组件,承担了系统中最重要的消息分发功能,因此接下的内容主要围绕nsqd展开。下图是NSQ官方文档上的一张gif图,为我们描绘的是nsqd的整体架构以及消息传递过程。不同的消息队列在NSQ中是通过topic定义的,在NSQ实例中,我们可以定义多个队列,相当于多个topic,每个topic我们可以理解为是一个队列,而每个队列下可以定义多个管道,即多个channel。客户端通过订阅channel,可以消费队列中的消息。可以有多个客户端消费同一个channel,由nsqd保证将消息传递到至少一个客户端中。因为队列中的每一条消息都会分发到该队列关联的所有channel中,因此,channel实际上起到了分隔业务的作用,不同的业务类型通过订阅同一个topic的不同channel可以做到互不干扰。

f1434dc8-6029-11e3-8a66-18ca4ea10aca.gif

2.1 NSQD源码宏观

获取到NSQ源码包之后,我们先看一下NSQ的目录结构。为便于查看,下面是只保留目录的项目结构,其余的文件都没有展示出来。其中nsqd,nsqlookupd,nsqadmin三个目录存放了三个组件的主要代码。但是,三个组件的入口都不在各自的目录中,即这三个目录都不是main package。main package在apps目录下。比如nsq/apps/nsqd为nsqd的main package,而nsq/nsqd为nsqd的nsqd package。NSQ提供了make编译方式,直接在nsq目录下执行make会在根目录下生成一个build目录,编译生成的可执行文件都存在里面。

└── nsq
    ├── apps
    │   ├── nsq_pubsub
    │   ├── nsq_stat
    │   ├── nsq_tail
    │   ├── nsq_to_file
    │   ├── nsq_to_http
    │   ├── nsq_to_nsq
    │   ├── nsqadmin
    │   ├── nsqd
    │   ├── nsqlookupd
    │   └── to_nsq
    ├── bench
    │   ├── bench_channels
    │   ├── bench_reader
    │   └── bench_writer
    ├── contrib
    ├── internal
    │   ├── app
    │   ├── auth
    │   ├── clusterinfo
    │   ├── dirlock
    │   ├── http_api
    │   ├── lg
    │   ├── pqueue
    │   ├── protocol
    │   ├── quantile
    │   ├── statsd
    │   ├── stringy
    │   ├── test
    │   ├── util
    │   ├── version
    │   └── writers
    ├── nsqadmin
    │   ├── static
    │   └── test
    ├── nsqd
    │   └── test
    ├── nsqlookupd
    └── vendor
        ├── github.com
        └── golang.org

阅读源码,我梳理了nsqd中几个比较重要的类,绘制了UML图。当然这个图不够专业也不够全,这里只罗列了我认为比较重要的一些类,以及类中的成员变量和方法,但是对理解nsqd的整体架构还是有一定帮助的。其中最重要的类应该是NSQD,内部封装了TCP和HTTP服务,并且提供了几个比较重要的方法。在nsqd的生命周期中只会有一个NSQD的实例。因为这个类很重要,所以nsqd中其他几个比较重要的类都有一个成员变量指向这个NSQD的实例。看UML图可以发现,除了Message这个类,其余的类中都有一个成员变量ctx *context,而ctx中存的就是NSQD实例的指针变量。NSQD中还存有一个topicMap,顾名思义,其中存放的是topic名称和Topic实例的映射,这样NSQD就能很方便的管理各个topic。Topic和Channel类其实我们应该是比较清晰的,从消息数据的流动来看,topic中应该包含各个channel的信息,因为一个topic可以包含多个channel;当然channel中也应该包含多个client的信息,因为一个channel可以有多个消费者,在这里我们理解一个消费者就是一个TCP连接,在真实的场景中可能就是我们有一个多进程的消费服务,每个进程维持了一个TCP长连接。所以我们看到在Topic这个类中有一个变量是channelMap,它是一个字典,存储了channel名称和Channel实例的映射关系;在Channel类中有一个变量clients,这也是一个字典,存储了clientID和Consumer之间的映射。Consumer在这里面实际上是一个接口定义,由于clientV2实现了这个接口,所以channel中实际上存储的是clientID和clientV2实例的映射。

nsqd.png

2.2 NSQD源码微观

其实深入到源码之后我也不知道应该怎么写了,想了想还是站在使用者的角度来梳理可能会比较容易理解一些。作为使用者,我们最常用的肯定就是往nsq发送消息,然后消费消息,其实这也是nsqd的最主要功能。所以我们将源码微观分为以下几块进行梳理:nsqd启动,nsqd接收消息,nsqd发送消息。当然这不是nsqd的所有功能,但可以说是主要功能,我们将梳理nsqd在每个模块都做了什么事情,以及怎么做的。

2.2.1 nsqd启动

nsqd的启动代码之前已经提到,位置在nsq/apps/nsqd/nsqd.go#main。nsqd使用了go-svc包提供的服务让其能够在windows和linux以守护线程的方式运行,并且能做到优雅的启动和退出。main函数内部实现很简单,program内部封装NSQD类,然后使用svc让程序跑起来。

type program struct {
	nsqd *nsqd.NSQD
}

func main() {
	prg := &program{}
	if err := svc.Run(prg, syscall.SIGINT, syscall.SIGTERM); err != nil {
		log.Fatal(err)
	}
}

接下来我们看nsqd启动都做了哪些事情,位置在nsq/apps/nsqd/nsqd.go#Start。我把源码精简一下,如下:

func (p *program) Start() error {
	opts := nsqd.NewOptions()

	flagSet := nsqdFlagSet(opts)
	flagSet.Parse(os.Args[1:])

	var cfg config
	configFile := flagSet.Lookup("config").Value.String()
	cfg.Validate()

	options.Resolve(opts, flagSet, cfg)
	nsqd := nsqd.New(opts)
	
	// 加载元数据
	err := nsqd.LoadMetadata()
	// 重新持久化元数据
	err = nsqd.PersistMetadata()
	// 启动nsqd
	nsqd.Main()

	p.nsqd = nsqd
	return nil
}

nsqd启动主要做了如下几个事情:加载配置,初始化NSQD实例,加载nsqd元数据,重新持久化nsqd元数据到磁盘,启动NSQD实例。在退出nsqd服务时,nsqd会将当时的topic,channel信息持久化到磁盘,其中包含所有topic和各个topic的包含的channel以及是否被暂停的信息,以便在下次启动时可以重新加载这些信息。这些信息以json文本的形式存储在磁盘中,如下是一个包含两个topic(test, name),其中一个topic包含一个channel(test_channel)的元数据信息。

{
    "topics":[
        {
            "channels":[
                {
                    "name":"test_channel",
                    "paused":false
                }
            ],
            "name":"test",
            "paused":false},
        {
            "channels":[],
         	"name":"name",
            "paused":false
        }
    ],
    "version":"1.0.0-alpha"
}

所以在我们启动nsqd时,它会先加载一次元数据,文件名为nsqd.dat。加载完元数据之后,nsqd还会立马再重新生成一份新的元数据覆盖原来的文件。这么做的目的是为了确保在nsqd重启时,还能正确的将当时的数据持久化到磁盘。为了安全的重写元数据,nsqd的做法并不是直接去写原始的文件,而是先写入一个tmp文件,然后重命名这个文件来覆盖原始文件。另外,在存放nsqd.dat文件相同的目录中,还有另外一个文件,比如nsqd.686.dat。这个文件的内容在不出意外的情况下,与nsqd.dat是一模一样的。在windows中,nsqd.686.dat也是一个文件,而在linux中则是一个软链,软链至nsqd.dat。接下来看一下最重要的nsqd.Main()做了哪些事情,同样把Main精简一下,如下。

func (n *NSQD) Main() {
	var httpListener net.Listener
	var httpsListener net.Listener

	ctx := &context{n}

	tcpListener, err := net.Listen("tcp", n.getOpts().TCPAddress)
	
	n.tcpListener = tcpListener
	tcpServer := &tcpServer{ctx: ctx}
	// tcp服务
	n.waitGroup.Wrap(func() {
		protocol.TCPServer(n.tcpListener, tcpServer, n.logf)
	})

	httpListener, err = net.Listen("tcp", n.getOpts().HTTPAddress)
	
	n.httpListener = httpListener
	httpServer := newHTTPServer(ctx, false, n.getOpts().TLSRequired == TLSRequired)
	// http服务
	n.waitGroup.Wrap(func() {
		http_api.Serve(n.httpListener, httpServer, "HTTP", n.logf)
	})
	
	// 队列监控服务
	n.waitGroup.Wrap(func() { n.queueScanLoop() })
	// 网络拓扑发现服务
	n.waitGroup.Wrap(func() { n.lookupLoop() })
}

上面的代码其实已经很清晰了,Main中其实主要做了以下几件事:启动tcp服务,启动http服务,启动队列监控,启动网络拓扑发现。根据配置,Main中还有可能启动https服务(nsqd支持https)和统计监控服务,在这里我们只关心前面的四个服务,这四个服务都被n.waitGroup.Wrap包裹起来。这个Wrap函数的作用,其实是启动一个单独的goroutine来运行包裹起来的函数,所以,nsqd的这四个服务都分别运行在不同的goroutine中。Wrap中用了sync.WaitGroup来阻塞主线程,直到所有goroutine执行完成。

type WaitGroupWrapper struct {
	sync.WaitGroup
}

func (w *WaitGroupWrapper) Wrap(cb func()) {
	w.Add(1)
	go func() {
		cb()
		w.Done()
	}()
}

http服务其实跟大多数web后端一样,定义了router和handler之间的关系,结构清晰,位置在nsq/nsqd/http.go#newHTTPServer,这里就不展开说了。tcp服务由于不存在路由接口的概念,因此在官方文档TCP PROTOCOL SPEC中已经把服务器和客户端之间传递的字节码消息格式定义好了。之前我们已经大致看过http提供有哪些服务,其中没有一个接口能让客户端获取到消息,而实际上客户端消费nsq消息是通过tcp服务,并且是由nsqd主动推送到客户端而不是客户端主动获取。nsqd不提供http api方式的获取消息,我个人认为主要是因为http是无状态无连接的,如果消息获取走http方式,不仅效率不高,也不利于nsqd实现可靠的服务。nsqd为了提供一个高可用高可靠的服务,它实现了数据流控制和心跳机制。数据流控制可以理解为客户端告诉nsqd服务端当前可以处理的最大消息数,避免nsqd推送大量消息,实现方式主要就是客户端向服务器更新其RDY状态值。比如初次连接时,客户端的RDY设置为0,表示还没准备好接收数据,当客户端准备好了之后,可以向服务器发送命令更新RDY至某个值,比如说100,服务器则直接将100条消息推送至客户端。心跳机制则要求客户端定时回复nsqd服务端发送的心跳消息。以上两点实际上要求nsqd服务端和客户端之间必须维护一个有状态的长连接来保证服务可靠性,综合来说使用tcp比较合适。为了了解nsqd的tcp服务是如何实现的,我们把相关代码粘贴如下,同样是做过精简的。

type TCPHandler interface {
	Handle(net.Conn)
}

func TCPServer(listener net.Listener, handler TCPHandler, logf lg.AppLogFunc) {
	for {
		clientConn, err := listener.Accept()
		go handler.Handle(clientConn)
	}

}

TCPServer的第二个参数接收一个TCPHandler类型的对象,然后在监听到连接之后,启动一个goroutine调用handler.Handle来处理这个连接。TCPHandler是一个接口定义,内部只有一个函数Handle,所以任何实现了Handle方法的类都可以作为TCPServer的第二个参数。如Main中实现的,在调用TCPServer时,nsqd将tcpServer作为TCPServer的第二个参数,因此我们知道,tcpServer必然实现了Handle方法,我们看看Handle中都做了哪些事情。客户端在连接到nsqd时,需要携带协议版本信息完成初始化工作,初始化主要是生成一个protocolV2对象,然后将连接对象作为参数传递近IOLoop中,最后在IOLoop中对tcp连接进行处理。只有当客户端发起tcp连接时,nsqd才会单独启一个goroutine来处理此连接,所以关于tcp连接的处理逻辑放在介绍消息发送和消息接收时在详细展开。

type tcpServer struct {
	ctx *context
}

func (p *tcpServer) Handle(clientConn net.Conn) {

	buf := make([]byte, 4)
	_, err := io.ReadFull(clientConn, buf)
	protocolMagic := string(buf)
    
	var prot protocol.Protocol
	switch protocolMagic {
	// 新建的连接需要指定协议的版本来完成初始化
	case "  V2":
		prot = &protocolV2{ctx: p.ctx}
	default:
		protocol.SendFramedResponse(clientConn, frameTypeError, []byte("E_BAD_PROTOCOL"))
		clientConn.Close()
		return
	}
	
	// 每个连接对应一个Protocol对象
	err = prot.IOLoop(clientConn)
}

最后,我们分析一下queueScanLoop。queueScanLoop主要用于处理各个channel的inFlight队列和Deferred队列,是nsqd中一个比较重要的函数。每个channel都维护了inFlight和Deferred两个队列。其中inFlight队列存放了已经投递出去,但是还没有被客户端确认的消息,Deferred队列则存放了延迟投递的消息。nsqd将消息主动推送给某个客户端之后,会将这个消息移到inFlight队列,直到客户端处理完这个消息,并告知nsqd服务器可以结束这个消息(finish),此时nsqd会将此消息从inFlight队列删除。除了finish之外,nsqd还支持其他的操作,比如重新排队(requeue)。如果消息投递到客户端之后,过了很久还没收到确认,queueScanLoop负责将此消息重新投递。这样做的好处是即使因为网络原因导致消息没有投递成功,nsqd也能保证此消息再次投递(requeue),换句话说每个消息至少会被投递一次。当然, 从这个策略来说,nsq消息队列不是有序的。如果是强依赖消息顺序的业务,不应该使用nsq。Deferred队列用于存放延迟发送的消息,即我们可以指定一个消息在多久之后投递出去,一旦queueScanLoop发现某个延迟发送的消息可被投递时,会将其从Deferred队列移除并且再投递给某个客户端之后添加到inFlight队列。

queueScanLoop实现了类似redis的概率失效算法(probabilistic expiration algorithm): 每隔QueueScanInterval秒(默认100)从nsqd包含的所有channel中随机选择QueueScanSelectionCount个(默认20)进行处理。如果在选择的QueueScanSelectionCount个channel中超过QueueScanDirtyPercent(默认25%)的channel需要处理inFlight或者Deferred队列,则在处理完这些channel之后,继续选择QueueScanSelectionCount个channel处理它们的inFlight和Deferred队列,直到不足QueueScanDirtyPercent,queueScanLoop会停下来,等待下一个QueueScanInterval时间的到来。在queueScanLoop内部维护了一个goroutine池子,我们称之为queueScanWorker pool,每个queueScanWorker是一个goroutine,这些worker的工作就是之前提到的用于实际处理每个channel的inFlight和Deferred队列。queueScanWorker pool内部worker数量是随着channel数量动态调整的,范围是1 <= pool <= min(num*0.25, QueueScanWorkerPoolMax),每隔QueueScanRefreshInterval秒(默认5)就会动态调整一次pool中worker的数量。接下来我们看一下源码,queueScanLoop是如何优雅的实现概率失效算法,以及如何优雅的实现worker数量的动态调整。

func (n *NSQD) queueScanLoop() {
	workCh := make(chan *Channel, n.getOpts().QueueScanSelectionCount)
	responseCh := make(chan bool, n.getOpts().QueueScanSelectionCount)
	// 用于动态关闭worker的golang channel
	closeCh := make(chan int)

	workTicker := time.NewTicker(n.getOpts().QueueScanInterval)
	refreshTicker := time.NewTicker(n.getOpts().QueueScanRefreshInterval)

	channels := n.channels()
	// 进入for循环之前,先调整一次worker数量
	n.resizePool(len(channels), workCh, responseCh, closeCh)

	for {
		select {
		// 每隔QueueScanInterval秒,随机选择QueueScanSelectionCount个channel进行处理
		case <-workTicker.C:
			if len(channels) == 0 {
				continue
			}
		// 每隔QueueScanRefreshInterval秒重新调整一次worker数量
		case <-refreshTicker.C:
			channels = n.channels()
			n.resizePool(len(channels), workCh, responseCh, closeCh)
			continue
		case <-n.exitChan:
			// 收到退出信号,跳到退出代码
			goto exit
		}

		num := n.getOpts().QueueScanSelectionCount
		if num > len(channels) {
			num = len(channels)
		}

	loop:
		// 从所有channel中随机选择QueueScanSelectionCount个进行处理
		for _, i := range util.UniqRands(num, len(channels)) {
			workCh <- channels[i]
		}

		numDirty := 0
		for i := 0; i < num; i++ {
			if <-responseCh {
				// 如果某个channel的inFlight或者Deferred
				// 队列存在超时或者到期的消息,则标记一次
				numDirty++
			}
		}
		
		// 如果超过QueueScanDirtyPercent的channel被标记为需要处理,
		// 则继续选择QueueScanSelectionCount个channel重复上述步骤
		if float64(numDirty)/float64(num) > n.getOpts().QueueScanDirtyPercent {
			goto loop
		}
	}

exit:
	n.logf(LOG_INFO, "QUEUESCAN: closing")
	close(closeCh)
	workTicker.Stop()
	refreshTicker.Stop()
}

// 调整worker数量
func (n *NSQD) resizePool(num int, workCh chan *Channel, responseCh chan bool, closeCh chan int) {
	// 理想状态下的worker数量为channel总量的25%。
	// worker数量满足1 <= pool <= min(num * 0.25, QueueScanWorkerPoolMax)
	idealPoolSize := int(float64(num) * 0.25)
	if idealPoolSize < 1 {
		idealPoolSize = 1
	} else if idealPoolSize > n.getOpts().QueueScanWorkerPoolMax {
		idealPoolSize = n.getOpts().QueueScanWorkerPoolMax
	}
	for {
		// 当前worker数量符合要求
		if idealPoolSize == n.poolSize {
			break
		} else if idealPoolSize < n.poolSize {
			// 当前worker数量过多,需要关闭一个worker
			// 往closeCh管道中发送一个消息,监听到这个消息的worker将退出
			closeCh <- 1
			n.poolSize--
		} else {
			// 当前worker数量过少,需要增加一个worker
			n.waitGroup.Wrap(func() {
				n.queueScanWorker(workCh, responseCh, closeCh)
			})
			n.poolSize++
		}
	}
}

func (n *NSQD) queueScanWorker(workCh chan *Channel, responseCh chan bool, closeCh chan int) {
	for {
		select {
		// 获取到在queueScanLoop中随机选择的一个channel
		case c := <-workCh:
			now := time.Now().UnixNano()
			dirty := false
			// 扫描inFlight队列,将已过期消息移除,
			// 并且重新添加到channel中,等待重新推送到客户端
			if c.processInFlightQueue(now) {
				dirty = true
			}
			// 扫描Deferred队列,将已到期消息移除,
			// 并且重新添加到channel中,等待重新推送到客户端
			if c.processDeferredQueue(now) {
				dirty = true
			}
			// 通知queueScanLoop该channel存在需要处理的消息
			responseCh <- dirty
		case <-closeCh:
			return
		}
	}
}

queueScanLoop内部维护三个通道,用于动态调整worker数量以及与worker之间进行通信。分别是带缓存的workCh和responseCh,以及不带缓存的closeCh。queueScanLoop将随机选择的channel放入workCh中,任何一个worker在监听到workCh有消息时拿到一个channel进行处理,处理的结果通过responseCh返回给queueScanLoop,以便queueScanLoop决策是否要继续循环处理剩余的channel。为了实现动态调整worker数量,所有worker都会监听closeCh,当worker数量过多时,queueScanLoop往closeCh中发送一个退出消息,任何一个worker在监听到退出消息时结束退出;当worker数量过少时,则启动一个新的worker。

概率失效算法的策略可能会导致inFlight队列中已过期的消息或者Deferred队列中已经到期的消息没有被及时投递。因为inFlight中的消息实际上已经被至少投递过一次,它的存在主要是为了应对消息投递失败和消息处理超时的情况,所以没有被及时重新投递问题不大。但是,deferred队列中延迟发送的消息可能会比用户预期的时间更长。如果是对延迟发送的时间有严格要求的应用场景,laopan 认为不应该强依赖nsq的deferred功能。当然,通过调整配置我们能够减少概率失效算法的副作用,为此我们可以选择改变以下某个配置或者多个并行:减小QueueScanInterval的值让worker工作的更加频繁,加大QueueScanSelectionCount的值让queueScanLoop每次随机选择的channel更多,减小QueueScanDirtyPercent的值让worker一直循环直到需要处理的channel数量占总量的比例低于这个值。

2.2.2 nsqd接收消息

nsqd提供了tcp和http两种方式来接收客户端发送的消息,向nsqd发送消息的客户端这里称之为消息生产者。以下我们将分别介绍一下nsqd在接收消息时,http和tcp的方式分别是如何实现的。

2.2.2.1 HTTP方式接收消息

我们知道HTTP是基于TCP/IP,具有无连接无状态等特点的通信协议。这里说的无连接并不是真的指客户端与服务器之间不建立连接,而是说二者每次连接只处理一个请求。nsqd提供了HTTP的方式来支持接收消息,这意味着客户端在需要异步处理时,将消息体格式化之后请求一次nsqd服务器即可完成消息的发送。在我接触的大部分应用场景中,客户端往nsqd发送消息主要也是通过http方式。因为大部分的异步处理场景是穿插在我们的业务流程中的。前面我们说过,nsqd在启动时,会启动一个http服务。接下来我们看一下nsqd是如何使用http服务来接收消息的。

// 在Main中启动http服务
func (n *NSQD) Main() {
    ...  
    httpServer := newHTTPServer(ctx, false, n.getOpts().TLSRequired == TLSRequired)
	n.waitGroup.Wrap(func() {
		http_api.Serve(n.httpListener, httpServer, "HTTP", n.logf)
	})
    ...
}

// 配置http服务的路由以及handler
func newHTTPServer(ctx *context, tlsEnabled bool, tlsRequired bool) *httpServer {
	router := httprouter.New()
	...
    
	s := &httpServer{
		ctx:         ctx,
		tlsEnabled:  tlsEnabled,
		tlsRequired: tlsRequired,
		router:      router,
	}
	...
    
	// 配置路由以及handler
	router.Handle("POST", "/pub", http_api.Decorate(s.doPUB, http_api.V1))
	router.Handle("POST", "/mpub", http_api.Decorate(s.doMPUB, http_api.V1))
	...

	return s
}

// http接收消息handler
func (s *httpServer) doPUB(w http.ResponseWriter, req *http.Request, ps httprouter.Params) (interface{}, error) {
	...
    
	readMax := s.ctx.nsqd.getOpts().MaxMsgSize + 1

	// 从请求中获取消息体
	body, err := ioutil.ReadAll(io.LimitReader(req.Body, readMax))
	
    ...

	// 从请求中解析出topic name,然后根据topic name从nsqd实例中获取topic实例。
	// 如果还没有此topic则新建一个。
	reqParams, topic, err := s.getTopicFromQuery(req)
	
	var deferred time.Duration
	// 请求中可以带上defer参数,表示消息可以延迟多长时间再投递出去
	if ds, ok := reqParams["defer"]; ok {
        
		var di int64
		di, err = strconv.ParseInt(ds[0], 10, 64)
		deferred = time.Duration(di) * time.Millisecond
	}
	// 生成一个唯一id构建Message对象
	msg := NewMessage(topic.GenerateID(), body)
	msg.deferred = deferred
	// 将消息加入topic中
	err = topic.PutMessage(msg)
    
	return "OK", nil
}

// 往topic中投递消息
func (t *Topic) PutMessage(m *Message) error {
	t.RLock()
	defer t.RUnlock()
	// 判断此topic是否正处于关闭或者删除状态
	// 此状态可能维持的时间比较长,因为如果是关闭topic的话
	// nsqd需要将topic还在内存中的消息持久化到磁盘
	if atomic.LoadInt32(&t.exitFlag) == 1 {
		return errors.New("exiting")
	}
	// 将消息放入topic
	err := t.put(m)
	if err != nil {
		return err
	}
	// 增加topic中消息的个数,原子操作
	atomic.AddUint64(&t.messageCount, 1)
	return nil
}

// topic存放消息
func (t *Topic) put(m *Message) error {
	select {
	// 将消息存入topic的内存队列
	case t.memoryMsgChan <- m:
	// 如果内存队列已满,则写入磁盘队列
	// nsq内部实现了一个基于文件的FIFO队列
	default:
		b := bufferPoolGet()
		err := writeMessageToBackend(b, m, t.backend)
		bufferPoolPut(b)
		t.ctx.nsqd.SetHealth(err)
        ...
	}
	return nil
}

总的来说,nsqd接收消息的流程是从客户端的request中解析出topic name,以及消息体数据,然后获取topic实例,如果还没有此topic则新建一个,最后将消息存入topic中。nsq的每一个topic都有两个队列,分别是内存队列(memoryMsgChan)磁盘队列(backend)都是用于存放消息。topic在接收到一个消息时,首先会尝试将消息存入内存队列中。由于golang的语言特性,它的带缓冲channel天然适合作为内存队列。nsqd的默认配置中,memoryMsgChan的长度默认是10000。如果memoryMsgChan已经满了,则将消息存入backend。backend是一个基于文件的FIFO队列,过多的消息数据会以文件形式存储在磁盘中。nsqd在运行过程中如果关闭了某个topic,在退出的流程中topic会将memoryMsgChan的数据先存入backend中。backend的存在保证了数据的持久性。

前面说过,客户端在往某个topic发送消息时,nsqd会先从自己的topicMap中获取此topic实例,如果不存在则新建一个。获取topic实例的这段代码是线程安全的,值得我们学习,如下。

func (n *NSQD) GetTopic(topicName string) *Topic {
	// 加读锁,不限制读但是限制写
	n.RLock()
	// 从nsqd的topicMap中获取topic实例,如果存在直接返回
	t, ok := n.topicMap[topicName]
	n.RUnlock()
	if ok {
		return t
	}
	
	// 不存在此topic实例,这里加个写锁,保证后面的创建操作是线程安全的
	n.Lock()
	
	// 再次从topicMap中获取一次。为什么?
	// 考虑一种情况:在某一时刻,同时有线程A B C获取同一个topic D.
	// 此时A B C通过n.topicMap[D]没拿到实例,全部走到n.Lock(),
	// A先拿到锁成功创建了D的实例并且添加入topicMap中,然后释放锁返回。
	// B, C中某一个获取到A释放的锁进入临界区,
	// 如果没有再从topicMap中获取一次,则会重新创建一个topic实例,
	// 可能会造成数据丢失
	t, ok = n.topicMap[topicName]
	if ok {
		// 如果此时获取到topic实例
		// 说明几乎在同一时刻有另外一个线程也在获取该topic
		n.Unlock()
		return t
	}
	deleteCallback := func(t *Topic) {
		n.DeleteExistingTopic(t.name)
	}
	// 第二次没有从topicMap中获取到,
	// 则新建topic实例,并添加到topicMap中
	t = NewTopic(topicName, &context{n}, deleteCallback)
	n.topicMap[topicName] = t
	
	// 到这里已经完成了topic实例的新建工作
	// 为了提高性能做了一个锁替换操作,
	// 将nsqd维度的锁替换成topic维度的锁。
	// 替换的顺序是先加topic维度的写锁,然后释放nsqd维度的写锁
	t.Lock()
	n.Unlock()
  
	...
	t.Unlock()

	// 发送消息通知messagePump状态发生变更
	select {
	case t.channelUpdateChan <- 1:
	case <-t.exitChan:
	}
	return t
}
2.2.2.2 TCP方式接收消息

总的来说TCP接收消息和HTTP接收消息方式除了使用的协议不同,在本质上是一样的。但是为了对TCP处理流程有一个大致的了解,我们还是深入源码看一下从TCP服务启动,到最终接收消息nsqd是怎么处理的。

// nsqd启动
func (n *NSQD) Main() {
	
	ctx := &context{n}

	tcpListener, err := net.Listen("tcp", n.getOpts().TCPAddress)

	n.Lock()
	n.tcpListener = tcpListener
	n.Unlock()
    
	tcpServer := &tcpServer{ctx: ctx}
	// nsqd启动时会在一个单独的goroutine中启动tcp服务
	n.waitGroup.Wrap(func() {
		protocol.TCPServer(n.tcpListener, tcpServer, n.logf)
	})

	...
}


// tcp handler
func (p *tcpServer) Handle(clientConn net.Conn) {
	
	buf := make([]byte, 4)
	_, err := io.ReadFull(clientConn, buf)
	protocolMagic := string(buf)

	var prot protocol.Protocol
	switch protocolMagic {
	// 客户端与nsqd的tcp服务连接之后,需要在body中指定协议版本
	case "  V2":
		prot = &protocolV2{ctx: p.ctx}
	default:
		protocol.SendFramedResponse(clientConn, frameTypeError, []byte("E_BAD_PROTOCOL"))
		clientConn.Close()
		
		return
	}
	// 连接建立,在IOLoop中进行通信
	err = prot.IOLoop(clientConn)
}


func (p *protocolV2) IOLoop(conn net.Conn) error {
	var err error
	var line []byte
	var zeroTime time.Time
	
	// 为每个连接生成一个clientID
	clientID := atomic.AddInt64(&p.ctx.nsqd.clientIDSequence, 1)
        // 使用生成的ClientID创建一个client对象
	client := newClientV2(clientID, conn, p.ctx)
	
	messagePumpStartedChan := make(chan bool)
        // 为每个连接启动一个goroutine,通过golang channel进行消息通信
        // 完成消息接收,消息投递,订阅channel,发送心跳包等工作
	go p.messagePump(client, messagePumpStartedChan)
        // messagePumpStartedChan作为messagePump的参数
        // 用来阻塞当前进程,直到messagePump完成初始化工作,
        // 关闭messagePumpStartedChan后,当前进程才能继续
	<-messagePumpStartedChan

	for {
		...
                // 读取直到第一次遇到'\n',
                // 返回缓冲里的包含已读取的数据和'\n'字节的切片
		line, err = client.Reader.ReadSlice('\n')

		// trim the '\n'
		line = line[:len(line)-1]
		// optionally trim the '\r'
		if len(line) > 0 && line[len(line)-1] == '\r' {
			line = line[:len(line)-1]
		}
                // 从数据中解析出命令
		params := bytes.Split(line, separatorBytes)

		var response []byte
                // 执行命令
		response, err = p.Exec(client, params)

		if response != nil {
			err = p.Send(client, frameTypeResponse, response)
		}
                ...
	}
	
        // 如果在上面的for中出现error,则退出循环
	conn.Close()
	close(client.ExitChan)
	if client.Channel != nil {
		client.Channel.RemoveClient(client.ID)
	}

	return err
}

// tcp命令执行
func (p *protocolV2) Exec(client *clientV2, params [][]byte) ([]byte, error) {
	if bytes.Equal(params[0], []byte("IDENTIFY")) {
		return p.IDENTIFY(client, params)
	}
	err := enforceTLSPolicy(client, p, params[0])
	if err != nil {
		return nil, err
	}
	switch {
	case bytes.Equal(params[0], []byte("FIN")):
		return p.FIN(client, params)
	...
	case bytes.Equal(params[0], []byte("PUB")):
		return p.PUB(client, params)
	case bytes.Equal(params[0], []byte("MPUB")):
		return p.MPUB(client, params)
	...
	}
	return nil, protocol.NewFatalClientErr(nil, "E_INVALID", fmt.Sprintf("invalid command %s", params[0]))
}


func (p *protocolV2) PUB(client *clientV2, params [][]byte) ([]byte, error) {
	var err error
	// 从TCP参数中解析出topic name
	topicName := string(params[1])
	
	bodyLen, err := readLen(client.Reader, client.lenSlice)
	messageBody := make([]byte, bodyLen)
	_, err = io.ReadFull(client.Reader, messageBody)
    
	// 与http接收消息一样,获取topic实例之后,存入一条消息
	topic := p.ctx.nsqd.GetTopic(topicName)
	msg := NewMessage(topic.GenerateID(), messageBody)
	err = topic.PutMessage(msg)

	return okBytes, nil
}

以上就是nsqd使用tcp方式接收消息的步骤。不难看出,http和tcp在本质上没有什么差别,都是先获取或者创建一个topic,然后用NewMessage构建一个消息对象,最后调用topic.PutMessage将消息存入topic中。

2.2.3 nsqd发送消息

之前说过nsqd往客户端投递消息走的是tcp方式,并且是在连接建立之后由nsqd服务器主动向客户端投递。因此我们可以写一个简单的消费脚本,负责打印从nsqd接受到的消息。nsqd官方提供了几种封装好的客户端代码,以下是用python实现的简单代码:

import nsq

# 每次接收到消息就打印出来
def handler(message):
    print message.body
    return True

r = nsq.Reader(
    message_handler=handler,
    nsqd_tcp_addresses=['127.0.0.1:4150'],
    topic='test', 
    channel='test_channel', 
    lookupd_poll_interval=15
)

nsq.run()

执行nsq.run之后,客户端就会与nsqd服务器建立连接。连接建立之后就会在tcpServer.Handle中为连接创建一个protocolV2对象,最后调用protocolV2.IOLoop进入循环。客户端发起连接之初,会依次向nsqd服务器发送以下3个命令:IDENTIFY,SUB,RDY。

  • IDENTIFY: 主要用于客户端与nsqd之间互相交换配置信息。当客户端初次连接时,在INDNTIFY命令的请求中会携带上json格式的配置信息,里面包含了客户端希望服务器使用的配置,比如HeartbeatInterval, OutputBufferSize。而nsqd根据请求中的配置修改本身的默认配置之后会将服务器端的重要配置返回给客户端,客户端拿到服务器的配置后可调整自身参数,比如:MaxMsgTimeout, MsgTimeout,MaxRdyCount。
  • SUB: 告知nsqd,此连接订阅的是哪个topic和channel。
  • RDY: 由于消息是从nsqd推送到客户端的,为了避免nsqd推送大量消息导致消费者来不及处理,客户端维护了一个RDY状态告知nsqd当前可以接受的消息数量。

消费客户端连接入nsqd之后,nsqd服务端的log如下:

[nsqd] 2018/06/09 15:30:38.356354 DEBUG: PROTOCOL(V2): [127.0.0.1:64083] [IDENTIFY]
[nsqd] 2018/06/09 15:30:38.356600 DEBUG: PROTOCOL(V2): [127.0.0.1:64083] {ClientID:vivi Hostname:vivi.local HeartbeatInterval:30000 OutputBufferSize:16384 OutputBufferTimeout:250 FeatureNegotiation:true TLSv1:false Deflate:false DeflateLevel:6 Snappy:false SampleRate:0 UserAgent:pynsq/0.8.2 MsgTimeout:0}
[nsqd] 2018/06/09 15:30:38.356638 INFO: [127.0.0.1:64083] IDENTIFY: {ClientID:vivi Hostname:vivi.local HeartbeatInterval:30000 OutputBufferSize:16384 OutputBufferTimeout:250 FeatureNegotiation:true TLSv1:false Deflate:false DeflateLevel:6 Snappy:false SampleRate:0 UserAgent:pynsq/0.8.2 MsgTimeout:0}
[nsqd] 2018/06/09 15:30:38.357431 DEBUG: PROTOCOL(V2): [127.0.0.1:64083] [SUB test test_channel]
[nsqd] 2018/06/09 15:30:38.357631 DEBUG: PROTOCOL(V2): [127.0.0.1:64083] [RDY 1]

protocolV2.IOLoop在前面已经介绍过了,每个新建立的连接在进入IOLoop之后会启动一个单独的goroutine跑messagePump,然后进入for循环,不断的接收客户端的命令,作为参数传入EXEC中执行。上述的三个命令也是通过EXEC调用对应的handle执行的。现在我们要详细介绍一下messagePump。

func (p *protocolV2) messagePump(client *clientV2, startedChan chan bool) {
	var err error
	var memoryMsgChan chan *Message
	var backendMsgChan chan []byte
	var subChannel *Channel
	var flusherChan <-chan time.Time
	var sampleRate int32

    subEventChan := client.SubEventChan
	identifyEventChan := client.IdentifyEventChan
	outputBufferTicker := time.NewTicker(client.OutputBufferTimeout)
	heartbeatTicker := time.NewTicker(client.HeartbeatInterval)
	heartbeatChan := heartbeatTicker.C
	msgTimeout := client.MsgTimeout

	flushed := true

	// 在这里关闭startedChan,通知messagePump此goroutine已经完成初始化
	close(startedChan)

	for {
		if subChannel == nil || !client.IsReadyForMessages() {
			// 客户端还没准备好接收数据
			memoryMsgChan = nil
			backendMsgChan = nil
			flusherChan = nil
			// 强制刷新一次,将client中Inflight的消息发出去。
			client.writeLock.Lock()
			err = client.Flush()
			client.writeLock.Unlock()
			if err != nil {
				goto exit
			}
			flushed = true
		} else if flushed {
			// 将memoryMsgChan, backendMsgChan设置为
			// 我们订阅的channel所属的memoryMsgChan和backend
			memoryMsgChan = subChannel.memoryMsgChan
			backendMsgChan = subChannel.backend.ReadChan()
			flusherChan = nil
		} else {
			// buffer中有数据了,设置flusherChan,
			// 保证在OutputBufferTimeout之后可以将消息发送出去
			memoryMsgChan = subChannel.memoryMsgChan
			backendMsgChan = subChannel.backend.ReadChan()
			flusherChan = outputBufferTicker.C
		}

		select {
		// OutputBufferTimeout时间到,刷新缓冲区,将消息发送出去
		case <-flusherChan:
			client.writeLock.Lock()
			err = client.Flush()
			client.writeLock.Unlock()
			if err != nil {
				goto exit
			}
			flushed = true
		case <-client.ReadyStateChan:
		case subChannel = <-subEventChan:
			// 订阅channel(EXEC(SUB))和更新RDY值时(EXEC(RDY))时
			// 会往subEventChan和client.ReadyStateChan中发送消息。
			// messagePump如果接收从这两个golang channel中接收到消息,
			// 则不可以再订阅nsq channel
			subEventChan = nil
		case identifyData := <-identifyEventChan:
			// 同样的,首次连接时执行EXEC(IDENTIRY)
			// 会将客户端的配置通知到identifyEventChan。
			// messagePump接收到后,根据客户端的配置调整心跳频率等。
			// 之后再发送IDENTIFY,messagePump接收不到
			identifyEventChan = nil
			
			// 如下,根据客户端配置,调整不同的参数
			outputBufferTicker.Stop()
			if identifyData.OutputBufferTimeout > 0 {
				outputBufferTicker = time.NewTicker(identifyData.OutputBufferTimeout)
			}

			heartbeatTicker.Stop()
			heartbeatChan = nil
			if identifyData.HeartbeatInterval > 0 {
				heartbeatTicker = time.NewTicker(identifyData.HeartbeatInterval)
				heartbeatChan = heartbeatTicker.C
			}

			if identifyData.SampleRate > 0 {
				sampleRate = identifyData.SampleRate
			}

			msgTimeout = identifyData.MsgTimeout
		case <-heartbeatChan:
			// 每隔HeartbeatInterval发送一次心跳包
			err = p.Send(client, frameTypeResponse, heartbeatBytes)
			if err != nil {
				goto exit
			}
		case b := <-backendMsgChan:
			// 如果backend中有数据,channel会将backend的消息从文件中读出,
			// 发送到golang channel,推送至客户端。
			if sampleRate > 0 && rand.Int31n(100) > sampleRate {
				continue
			}

			msg, err := decodeMessage(b)
			if err != nil {
				p.ctx.nsqd.logf(LOG_ERROR, "failed to decode message - %s", err)
				continue
			}
			// 每投递一次消息,则记录一次,超过一定次数就丢弃
			msg.Attempts++
			
			// 将消息移到InFlight队列,同时记录该消息在InFlight中的超时时间
			subChannel.StartInFlightTimeout(msg, client.ID, msgTimeout)
			// 增加客户端的InFlightCount和MessageCount
			client.SendingMessage()
			// 将消息写入buffer中
			err = p.SendMessage(client, msg)
			if err != nil {
				goto exit
			}
			// 由于buffer中有数据了,将flushed设置为false,可以打开flusherChan
			// 以便触发client.Flush()将消息发出去
			flushed = false
		case msg := <-memoryMsgChan:
			// 获取memoryMsgChan中的消息与获取backendMsgChan中的基本一致
			if sampleRate > 0 && rand.Int31n(100) > sampleRate {
				continue
			}
			msg.Attempts++

			subChannel.StartInFlightTimeout(msg, client.ID, msgTimeout)
			client.SendingMessage()
			err = p.SendMessage(client, msg)
			if err != nil {
				goto exit
			}
			flushed = false
		case <-client.ExitChan:
			// 接收到exit消息,则退出for
			goto exit
		}
	}

exit:
	heartbeatTicker.Stop()
	outputBufferTicker.Stop()
}

messagePump内部监听flusherChan,client.ReadyStateChan,subEventChan,identifyEventChan,heartbeatChan,backendMsgChan,memoryMsgChan,client.ExitChan这些事件。当监听到subEventChan后,客户端开始订阅某一个channel,之后不能再订阅其他channel。当客户端更新其RDY值到可接收消息状态时,将backendMsgChan和memoryMsgChan设置为该channel的backendMsgChan和memoryMsgChan,开始监听这两个队列事件。当从这两个队列接收到一定量的消息后,停止接收,并且发送至客户端,直到客户端重新更新其RDY值到可接收消息状态,nsqd才会继续推送。所以,每个tcp连接的内部都维护了两个工作协程。通过若干个golang channel在两个协程之间同步信息,如下图所示。

nsqd-IOLoop.png

messgePump在case b := <-backendMsgChancase msg := <-memoryMsgChan中往客户端发送消息。两个case中做的事情大体上是一样的,主要是:增加消息的被发送次数,移到InFlight队列,消息数据写入到缓冲区准备发送。我们看下移到InFlight队列是如何做的。

func (c *Channel) StartInFlightTimeout(msg *Message, clientID int64, timeout time.Duration) error {
	now := time.Now()
	// 指定msg.clientID为本次投递的client
	msg.clientID = clientID
	// 投递的时间
	msg.deliveryTS = now
	// 设置消息在InFlight队列的超时时间
	msg.pri = now.Add(timeout).UnixNano()
	// 将消息放入InFlight队列
	err := c.pushInFlightMessage(msg)
	if err != nil {
		return err
	}
	c.addToInFlightPQ(msg)
	return nil
}

func (c *Channel) pushInFlightMessage(msg *Message) error {    
	c.inFlightMutex.Lock()
	_, ok := c.inFlightMessages[msg.ID]
	if ok {
		c.inFlightMutex.Unlock()
		// 消息已经在InFlight队列
		return errors.New("ID already in flight")
	}
	c.inFlightMessages[msg.ID] = msg
	c.inFlightMutex.Unlock()
	return nil
}

最后,理想情况下所有投递出去的消息都被放入InFlight中,等待客户端确认。当nsqd收到客户端的确认后,将消息移出InFlight。如果一直没有收到确认,我们前面提过,会由queueScanWorker保证将InFlight中超时的消息重新投递。

3. 常见问题

文章中只介绍了nsqd启动,nsqd接收和发送消息,没有具体到其他细节,比如nsqd如何接收客户端确认,收到确认后做些什么。但是,已经涵盖了nsqd中比较重要的部分。接下来会对一些使用中常见的问题做一下分析。

3.1 ID not in flight

之前提过,每个channel有两个队列用来接收topic分发的消息,分别是memoryMsgChan和backend。其中memoryMsgChan是一个带缓冲的golang channel,其长度是有限的;backend是一个基于文件的队列。当memoryMsgChan满了之后,消息会被存储入backend中。nsqd首先会从这两个队列中获取消息,给消息设置本次投递的消费端ID,投递的次数,超时时间等信息之后移到InFlight队列中,最后推送给消费端。当接收到消费端确认之后,将消息从InFlight中移除。消费端确认的方式有以下几种:touch,finish,requeue。touch指更新某消息在InFlight队列中的超时时间,nsqd的做法是先将此消息移出InFlight,更新超时时间之后重新放入InFlight。finish指消费端正常接收并处理了消息,告知nsqd可以从InFlight中移除此消息。requeue指消费端接收到此消息,但是可能因为处理失败或者其他什么原因需要再次处理,希望nsqd将此消息从InFlight中重新移到memoryMsgChan或者backend中排队。从InFlight中移除消息的函数如下:

// popInFlightMessage atomically removes a message from the in-flight dictionary
func (c *Channel) popInFlightMessage(clientID int64, id MessageID) (*Message, error) {
	c.inFlightMutex.Lock()
	msg, ok := c.inFlightMessages[id]
	if !ok {
		c.inFlightMutex.Unlock()
		// 需要移除的消息已经不在InFlight中 
		return nil, errors.New("ID not in flight")
	}
	if msg.clientID != clientID {
		c.inFlightMutex.Unlock()
		// 消息在InFlight中,但是当前投递的客户端已经跟上一次不同
		return nil, errors.New("client does not own message")
	}
	delete(c.inFlightMessages, id)
	c.inFlightMutex.Unlock()
	return msg, nil
}

当消息已经不在InFlight队列,但是又收到客户端的确认消息时,nsqd会出现ID not in flight报警提示。我们来分析一下在什么场景中可能出现这种情况。假设当前有个消息M被推送给消费端C1,nsqd给M设置推送次数(M.Attempts++),消费端id(M.clientID=C1.id),投递时间(M.deliveryTS=now),超时时间(M.pri=now.Add(timeout),UnixNano())之后将消息移到InFlight。如果因为网络,计算量大等因素,导致C1迟迟没有给nsqd发送确认消息,一直到当前时刻大于M.pri,M被认为超时,由nsqd将消息移出InFlight队列,重新排队。在M重新排队期间,nsqd接收到C1的确认,由于此时M已经不在InFlight中,nsqd给消费端返回ID not in flight。所以我们知道,如果受外因影响严重的应用环境或者本身的计算量就很大,这种情况会被放大叠加,消息一直处于重新排队,重新投递的循环中,最终消息会不断堆积,即使增加消费能力也于事无补。解决方法除了提高程序的效率,应用环境的可靠性外,从nsqd的角度来说,可以通过配置增加每个消息在InFlight队列中的超时时间,或者在程序运行中间对消息进行touch,更新其在InFlight中的超时时间。

3.2 client does not own message

这个问题的本质跟ID not in flight一样。场景是这样:nsqd将M推送给C1之后,一直没有收到C1确认,超时时间到了之后将M移出InFlight重新排队。之后M被重新投递给C2,因此被移到InFlight中。此时C1往nsqd发送确认,这时候M确实在InFlight中,但是投递的客户端已经是C2了,所以nsqd给C1返回client does not own message。所以我们知道,如果M正在排队还没投递,出现的提示是ID not in flight,如果已经重新投递则是client does not own message。解决方法跟之前一样,也是增加消息待在InFlight队列的时间。

3.3 消费端从nsq获取消息的速度快吗

之前说过,nsq的消息并不是消费端主动获取,而是nsq主动推送。消费端代码需要有自己的一套机制去更新RDY值,告知nsqd最多可以接收多少的消息。以python的客户端代码pynsq为例,nsq.Reader中有一个参数max_in_flight,用于指定nsqd一次推送的消息数量,以便客户端可以一次获取较多消息提高传输效率。如果消费端与nsqd建立了多个连接,所有连接的RDY值之和不能超过max_in_flight。

比如客户端C更新其RDY值为100,并告知nsqd。nsqd负责收集100条消息准备发送至客户端,并且在收集第一条消息的时候就会开启一个定时器。如果在此定时器时间到来之前准备好100条数据,则直接发送出去,如果时间到了还没准备好数据,也会发出去。所以,对于消息生产很快的场景,nsqd收集到固定消息之后就会直接发送,延迟基本可以认为是网络延迟。消息生产较慢的场景,则依赖此定时器的时间,默认值是1s。当然,在客户端代码中增加output_buffer_timeout配置,可以修改此时间。

4. 参考

nsq官方文档