本博客与RayXXZhang的博客保持同步更新,转载请注明来自RayXXZhang的博客-nsq源码阅读笔记之nsqd(四)——Channel
与Channel相关的代码主要位于nsqd/channel.go
, nsqd/nsqd.go
中。
Channel是消费者订阅特定Topic的一种抽象。对于发往Topic的消息,nsqd向该Topic下的所有Channel投递消息,而同一个Channel只投递一次,Channel下如果存在多个消费者,则随机选择一个消费者做投递。这种投递方式可以被用作消费者负载均衡。
Channel从属于特定Topic,可以认为是Topic的下一级。在同一个Topic之下可以有零个或多个Channel。
和Topic一样,Channel同样有永久和临时之分,永久的Channel只能通过显式删除销毁,临时的Channel在最后一个消费者断开连接的时候被销毁。
与服务于生产者的Topic不同,Channel直接面向消费者。
生产者 -> 消息 -> Topic -> Channel -> 消费者1
-> Channel2 -> 消费者2
-> Channel3 -> 消费者3
在代码上Channel和Topic有许多相似之处,对于和Topic相同或者相似的部分,以下不再赘述,可以参考Topic相关博文。
func NewChannel(topicName string, channelName string, ctx *context,
deleteCallback func(*Channel)) *Channel {
c := &Channel{
topicName: topicName,
name: channelName,
memoryMsgChan: make(chan *Message, ctx.nsqd.getOpts().MemQueueSize),
clientMsgChan: make(chan *Message),
exitChan: make(chan int),
clients: make(map[int64]Consumer),
deleteCallback: deleteCallback,
ctx: ctx,
}
if len(ctx.nsqd.getOpts().E2EProcessingLatencyPercentiles) > 0 {
c.e2eProcessingLatencyStream = quantile.New(
ctx.nsqd.getOpts().E2EProcessingLatencyWindowTime,
ctx.nsqd.getOpts().E2EProcessingLatencyPercentiles,
)
}
c.initPQ()
if strings.HasSuffix(channelName, "#ephemeral") {
c.ephemeral = true
c.backend = newDummyBackendQueue()
} else {
// backend names, for uniqueness, automatically include the topic...
backendName := getBackendName(topicName, channelName)
c.backend = newDiskQueue(backendName,
ctx.nsqd.getOpts().DataPath,
ctx.nsqd.getOpts().MaxBytesPerFile,
int32(minValidMsgLength),
int32(ctx.nsqd.getOpts().MaxMsgSize)+minValidMsgLength,
ctx.nsqd.getOpts().SyncEvery,
ctx.nsqd.getOpts().SyncTimeout,
ctx.nsqd.getOpts().Logger)
}
go c.messagePump()
c.ctx.nsqd.Notify(c)
return c
}
Channel和Topic在创建的时候都会初始化结构,初始化backend,创建消息循环,不同的是Channel在创建时多了给e2eProcessingLatencyStream
赋值的以及initPQ
部分。
其中e2eProcessingLatencyStream
主要用于统计消息投递的延迟等,将在以后的博文中叙述。
func (c *Channel) initPQ() {
pqSize := int(math.Max(1, float64(c.ctx.nsqd.getOpts().MemQueueSize)/10))
c.inFlightMessages = make(map[MessageID]*Message)
c.deferredMessages = make(map[MessageID]*pqueue.Item)
c.inFlightMutex.Lock()
c.inFlightPQ = newInFlightPqueue(pqSize)
c.inFlightMutex.Unlock()
c.deferredMutex.Lock()
c.deferredPQ = pqueue.New(pqSize)
c.deferredMutex.Unlock()
}
initPQ
函数创建了两个字典inFlightMessages
、deferredMessages
和两个队列inFlightPQ
、deferredPQ
。在nsq中inFlight指的是正在投递但还没确认投递成功的消息,defferred指的是投递失败,等待重新投递的消息。initPQ
创建的字典和队列主要用于索引和存放这两类消息。其中两个字典使用消息ID作索引。
inFlightPQ
使用newInFlightPqueue
初始化,InFlightPqueue
位于nsqd\in_flight_pqueue.go
。nsqd\in_flight_pqueue.go
是nsq实现的一个优先级队列,提供了常用的队列操作,值得学习。
deferredPQ
使用pqueue.New
初始化,pqueue
位于nsqd\pqueue.go
,也是一个优先级队列。
在分析Topic时提到,消息进入Topic的消息循环后会被投递到该Topic下所有的Channel,由Channel的PutMessage
函数进行处理。
// PutMessage writes a Message to the queue
func (c *Channel) PutMessage(m *Message) error {
c.RLock()
defer c.RUnlock()
if atomic.LoadInt32(&c.exitFlag) == 1 {
return errors.New("exiting")
}
err := c.put(m)
if err != nil {
return err
}
atomic.AddUint64(&c.messageCount, 1)
return nil
}
PutMessage
判断当前Channel是否已经被销毁,若未销毁,则调用put
函数进行处理,最后,自增消息计数器。
func (c *Channel) put(m *Message) error {
select {
case c.memoryMsgChan <- m:
default:
b := bufferPoolGet()
err := writeMessageToBackend(b, m, c.backend)
bufferPoolPut(b)
c.ctx.nsqd.SetHealth(err)
if err != nil {
c.ctx.nsqd.logf("CHANNEL(%s) ERROR: failed to write message to backend - %s",
c.name, err)
return err
}
}
return nil
}
Channel的put
函数与Topic的同名函数相似,可以参考Topic。
// messagePump reads messages from either memory or backend and sends
// messages to clients over a go chan
func (c *Channel) messagePump() {
var msg *Message
var buf []byte
var err error
for {
// do an extra check for closed exit before we select on all the memory/backend/exitChan
// this solves the case where we are closed and something else is draining clientMsgChan into
// backend. we don't want to reverse that
if atomic.LoadInt32(&c.exitFlag) == 1 {
goto exit
}
select {
case msg = <-c.memoryMsgChan:
case buf = <-c.backend.ReadChan():
msg, err = decodeMessage(buf)
if err != nil {
c.ctx.nsqd.logf("ERROR: failed to decode message - %s", err)
continue
}
case <-c.exitChan:
goto exit
}
msg.Attempts++
atomic.StoreInt32(&c.bufferedCount, 1)
c.clientMsgChan <- msg
atomic.StoreInt32(&c.bufferedCount, 0)
// the client will call back to mark as in-flight w/ its info
}
exit:
c.ctx.nsqd.logf("CHANNEL(%s): closing ... messagePump", c.name)
close(c.clientMsgChan)
}
进入Channel的消息在messagePump
函数中处理,该函数也与Topic的同名函数相似:消息都从memory和backend两个来源接收,然后解码消息后处理。与Topic不同的是,channel在投递消息前,会自增msg.Attempts
,该变量用于保存投递尝试的次数。
在消息投递前会将bufferedCount
置为1,在投递后置为0。该变量在Depth
函数中被调用。
func (c *Channel) Depth() int64 {
return int64(len(c.memoryMsgChan)) + c.backend.Depth() + int64(atomic.LoadInt32(&c.bufferedCount))
}
Deepth
函数返回内存,磁盘以及正在投递的消息数量之和,也就是尚未投递成功的消息数。
messagePump
函数在投递消息时将消息送入clientMsgChan
,随后被nsqd\protocol_v2.go
的messagePump
函数处理。
// nsqd\protocol_v2.go messagePump Line 303
case msg, ok := <-clientMsgChan:
if !ok {
goto exit
}
if sampleRate > 0 && rand.Int31n(100) > sampleRate {
continue
}
subChannel.StartInFlightTimeout(msg, client.ID, msgTimeout)
client.SendingMessage()
err = p.SendMessage(client, msg, &buf)
if err != nil {
goto exit
}
flushed = false
在protocolV2的messagePump
函数中,消息被通过投送到相应消费者。投递时首先调用Channel的StartInFlightTimeout
函数
func (c *Channel) StartInFlightTimeout(msg *Message, clientID int64, timeout time.Duration) error {
now := time.Now()
msg.clientID = clientID
msg.deliveryTS = now
msg.pri = now.Add(timeout).UnixNano()
err := c.pushInFlightMessage(msg)
if err != nil {
return err
}
c.addToInFlightPQ(msg)
return nil
}
该函数填充消息的消费者ID、投送时间、优先级,然后调用pushInFlightMessage
函数将消息放入inFlightMessages
字典中。最后调用addToInFlightPQ
将消息放入inFlightPQ
队列中。
至此,消息投递流程完成,接下来需要等待消费者对投送结果的反馈。消费者通过发送FIN
、REQ
、TOUCH
来回复对消息的处理结果。
关于TCP protocol相关的内容,在后续博文分析。以下只分析与Channel相关的部分。
消费者发送FIN
,表明消息已经被接收并正确处理。
// FinishMessage successfully discards an in-flight message
func (c *Channel) FinishMessage(clientID int64, id MessageID) error {
msg, err := c.popInFlightMessage(clientID, id)
if err != nil {
return err
}
c.removeFromInFlightPQ(msg)
if c.e2eProcessingLatencyStream != nil {
c.e2eProcessingLatencyStream.Insert(msg.Timestamp)
}
return nil
}
FIN消息在与Channel相关的部分交由FinishMessage
处理。最后调用addToInFlightPQ
将消息放入inFlightPQ
队列中。FinishMessage
分别调用popInFlightMessage
和removeFromInFlightPQ
将消息从inFlightMessages
和inFlightPQ
中删除。最后,统计该消息的投递情况。
客户端发送REQ
,表明消息投递失败,需要再次被投递。
// RequeueMessage requeues a message based on `time.Duration`, ie:
//
// `timeoutMs` == 0 - requeue a message immediately
// `timeoutMs` > 0 - asynchronously wait for the specified timeout
// and requeue a message (aka "deferred requeue")
//
func (c *Channel) RequeueMessage(clientID int64, id MessageID, timeout time.Duration) error {
// remove from inflight first
msg, err := c.popInFlightMessage(clientID, id)
if err != nil {
return err
}
c.removeFromInFlightPQ(msg)
if timeout == 0 {
return c.doRequeue(msg)
}
// deferred requeue
return c.StartDeferredTimeout(msg, timeout)
}
Channel在RequeueMessage
函数对消息投递失败进行处理。该函数将消息从inFlightMessages
和inFlightPQ
中删除,随后进行重新投递。
发送REQ
时有一个附加参数timeout,该值为0时表示立即重新投递,大于0时表示等待timeout时间之后投递。
// doRequeue performs the low level operations to requeue a message
func (c *Channel) doRequeue(m *Message) error {
c.RLock()
defer c.RUnlock()
if atomic.LoadInt32(&c.exitFlag) == 1 {
return errors.New("exiting")
}
err := c.put(m)
if err != nil {
return err
}
atomic.AddUint64(&c.requeueCount, 1)
return nil
}
立即投递使用doRequeue
函数,该函数简单地调用put
函数重新进行消息的投递,并自增requeueCount
,该变量在统计消息投递情况时用到。
func (c *Channel) StartDeferredTimeout(msg *Message, timeout time.Duration) error {
absTs := time.Now().Add(timeout).UnixNano()
item := &pqueue.Item{Value: msg, Priority: absTs}
err := c.pushDeferredMessage(item)
if err != nil {
return err
}
c.addToDeferredPQ(item)
return nil
}
如果timeout大于0,则调用StartDeferredTimeout
进行延迟投递。首先计算延迟投递的时间点,然后调用pushDeferredMessage
将消息加入deferredMessage
字典,最后将消息放入deferredPQ
队列。延迟投递的消息会被专门的worker扫描并在延迟投递的时间点后进行投递。
需要注意的是,立即重新投递的消息不会进入deferredPQ
队列。
消费者发送TOUCH
,表明该消息的超时值需要被重置。
// TouchMessage resets the timeout for an in-flight message
func (c *Channel) TouchMessage(clientID int64, id MessageID, clientMsgTimeout time.Duration) error {
msg, err := c.popInFlightMessage(clientID, id)
if err != nil {
return err
}
c.removeFromInFlightPQ(msg)
newTimeout := time.Now().Add(clientMsgTimeout)
if newTimeout.Sub(msg.deliveryTS) >=
c.ctx.nsqd.getOpts().MaxMsgTimeout {
// we would have gone over, set to the max
newTimeout = msg.deliveryTS.Add(c.ctx.nsqd.getOpts().MaxMsgTimeout)
}
msg.pri = newTimeout.UnixNano()
err = c.pushInFlightMessage(msg)
if err != nil {
return err
}
c.addToInFlightPQ(msg)
return nil
}
这个过程比较简单,从inFlightPQ
中取出消息,设置新的超时值后重新放入队列,新的超时值由当前时间、客户端通过IDENTIFY
设置的超时值、配置中允许的最大超时值MaxMsgTimeout
共同决定。
消息超时和延迟投递的处理流程层次比较多:
n.waitGroup.Wrap(func() { n.queueScanLoop() })
首先是在nsqd\nsqd.go
中启动的用于定时扫描的goroutine。该goroutine执行queueScanLoop
函数
// queueScanLoop runs in a single goroutine to process in-flight and deferred
// priority queues. It manages a pool of queueScanWorker (configurable max of
// QueueScanWorkerPoolMax (default: 4)) that process channels concurrently.
//
// It copies Redis's probabilistic expiration algorithm: it wakes up every
// QueueScanInterval (default: 100ms) to select a random QueueScanSelectionCount
// (default: 20) channels from a locally cached list (refreshed every
// QueueScanRefreshInterval (default: 5s)).
//
// If either of the queues had work to do the channel is considered "dirty".
//
// If QueueScanDirtyPercent (default: 25%) of the selected channels were dirty,
// the loop continues without sleep.
func (n *NSQD) queueScanLoop() {
workCh := make(chan *Channel, n.getOpts().QueueScanSelectionCount)
responseCh := make(chan bool, n.getOpts().QueueScanSelectionCount)
closeCh := make(chan int)
workTicker := time.NewTicker(n.getOpts().QueueScanInterval)
refreshTicker := time.NewTicker(n.getOpts().QueueScanRefreshInterval)
channels := n.channels()
n.resizePool(len(channels), workCh, responseCh, closeCh)
for {
select {
case <-workTicker.C:
if len(channels) == 0 {
continue
}
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:
for _, i := range util.UniqRands(num, len(channels)) {
workCh <- channels[i]
}
numDirty := 0
for i := 0; i < num; i++ {
if <-responseCh {
numDirty++
}
}
if float64(numDirty)/float64(num) > n.getOpts().QueueScanDirtyPercent {
goto loop
}
}
exit:
n.logf("QUEUESCAN: closing")
close(closeCh)
workTicker.Stop()
refreshTicker.Stop()
}
该函数使用若干个worker来扫描并处理当前在投递中以及等待重新投递的消息。worker的个数由配置和当前Channel数量共同决定。
首先,初始化3个gochannel:workCh、responseCh、closeCh,分别控制worker的输入、输出和销毁。
然后获取当前的Channel集合,并且调用resizePool
函数来启动指定数量的worker。
最后进入扫描的循环。在循环中,等待两个定时器,workTicker
和refreshTicker
,定时时间分别由由配置中的QueueScanInterval
和QueueScanRefreshInterval
决定。这种由等待定时器触发的循环避免了函数持续的执行影响性能,而Golang的特性使得这种机制在写法上非常简洁。
workTicker
定时器触发扫描流程。 workerChan
中,并且等待反馈结果,结果有两种,dirty和非dirty,如果dirty的比例超过配置中设定的QueueScanDirtyPercent
,那么不进入休眠,继续扫描,如果比例较低,则重新等待定时器触发下一轮扫描。这种机制可以在保证处理延时较低的情况下减少对CPU资源的浪费。refreshTicker
定时器触发更新Channel列表流程。 resizePool
重新分配worker。接下来再看看resizePool
的实现。
// resizePool adjusts the size of the pool of queueScanWorker goroutines
//
// 1 <= pool <= min(num * 0.25, QueueScanWorkerPoolMax)
//
func (n *NSQD) resizePool(num int, workCh chan *Channel, responseCh chan bool, closeCh chan int) {
idealPoolSize := int(float64(num) * 0.25)
if idealPoolSize < 1 {
idealPoolSize = 1
} else if idealPoolSize > n.getOpts().QueueScanWorkerPoolMax {
idealPoolSize = n.getOpts().QueueScanWorkerPoolMax
}
for {
if idealPoolSize == n.poolSize {
break
} else if idealPoolSize < n.poolSize {
// contract
closeCh <- 1
n.poolSize--
} else {
// expand
n.waitGroup.Wrap(func() {
n.queueScanWorker(workCh, responseCh, closeCh)
})
n.poolSize++
}
}
}
这个部分比较简单。注意一点,当需要的worker数量超过之前分配的数量时,通过向closeCh
投递消息使多余的worker销毁,如果需要的数量比之前的多,则通过queueScanWorker
创建新的worker。
// queueScanWorker receives work (in the form of a channel) from queueScanLoop
// and processes the deferred and in-flight queues
func (n *NSQD) queueScanWorker(workCh chan *Channel, responseCh chan bool, closeCh chan int) {
for {
select {
case c := <-workCh:
now := time.Now().UnixNano()
dirty := false
if c.processInFlightQueue(now) {
dirty = true
}
if c.processDeferredQueue(now) {
dirty = true
}
responseCh <- dirty
case <-closeCh:
return
}
}
}
queueScanWorker
接收workCh
发来的消息,处理,并且通过responseCh
反馈消息。收到closeCh
时则关闭。由于所有worker都监听相同的closeCh
,所以当向closeCh
发送消息时,随机关闭一个worker。且由于workCh
和closeCh
的监听是串行的,所以不存在任务处理到一半时被关闭的可能。这也是nsq中优雅关闭gochannel的的一个例子。
worker处理两件事:
一是处理inFlight消息
func (c *Channel) processInFlightQueue(t int64) bool {
c.exitMutex.RLock()
defer c.exitMutex.RUnlock()
if c.Exiting() {
return false
}
dirty := false
for {
c.inFlightMutex.Lock()
msg, _ := c.inFlightPQ.PeekAndShift(t)
c.inFlightMutex.Unlock()
if msg == nil {
goto exit
}
dirty = true
_, err := c.popInFlightMessage(msg.clientID, msg.ID)
if err != nil {
goto exit
}
atomic.AddUint64(&c.timeoutCount, 1)
c.RLock()
client, ok := c.clients[msg.clientID]
c.RUnlock()
if ok {
client.TimedOutMessage()
}
c.doRequeue(msg)
}
exit:
return dirty
}
processInFlightQueue
取出inFlightPQ
顶部的消息,如果当前消息已经超时,则将消息从队列中移除,并返回消息。由于队列是优先级队列,所以如果processInFlightQueue
取出的消息为空,则不需要再往后取了,直接返回false表示当前非dirty状态。如果取到了消息,则说明该消息投递超时,需要把消息传入doRequeue
立即重新投递。
二是处理deferred消息
func (c *Channel) processDeferredQueue(t int64) bool {
c.exitMutex.RLock()
defer c.exitMutex.RUnlock()
if c.Exiting() {
return false
}
dirty := false
for {
c.deferredMutex.Lock()
item, _ := c.deferredPQ.PeekAndShift(t)
c.deferredMutex.Unlock()
if item == nil {
goto exit
}
dirty = true
msg := item.Value.(*Message)
_, err := c.popDeferredMessage(msg.ID)
if err != nil {
goto exit
}
c.doRequeue(msg)
}
exit:
return dirty
}
该处理流程与处理inFlight基本相同,不再详述。
Channel中还有些其他函数如Exiting
、Delete
、Close
、exit
、Empty
、flush
、Pause
、UnPause
、doPause
等与Topic中很接近,不再详述。
AddClient
和RemoveClient
将在分析Client时讨论。
Topic/Channel是发布/订阅模型的一种实现。Topic对应于发布,Channel对应于订阅。消费者通过在Topic下生成不同的Channel来接收来自该Topic的消息。通过生成相同的Channel来实现消费者负载均衡。
Channel本身在投递消息给消费者时维护两个队列,一个是inFlight队列,该队列存储正在投递,但还没被标记为投递成功的消息。另一个是deferred队列,用来存储需要被延时投递的消息。
inFlight队列中消息可能因为投递超时而失败,deferred队列中的消息需要在到达指定时间后进行重新投递。如果为两个队列中的每个消息都分别指定定时器,无疑是非常消耗资源的。因此nsq采用定时扫描队列的做法。
在扫描时采用多个worker分别处理。这种类似多线程的处理方式提高了处理效率。nsq在扫描策略上使用了Redis的probabilistic expiration算法,同时动态调整worker的数量,这些优化平衡了效率和资源占用。