NSQ源码剖析——主要结构方法和消息生产消费过程
1 概述
NSQ包含3个组件:
- nsqd:每个nsq实例运行一个nsqd进程,负责接收生产者消息、向nsqlookupd注册、向消费者推送消息
- nsqlookupd:集群注册中心,可以有多个,负责接收nsqd的注册信息,向消费者提供服务发现
- nsqadmin:用于监控和管理的web ui
生产者将消息写入到指定的主题Topic,同一个Topic下则可以关联多个管道Channel,每个Channel都会传输对应Topic的完整副本。
消费者则订阅Channel的消息。于是多个消费者订阅不同的Channel的话,他们各自都能拿到完整的消息副本;但如果多个消费者订阅同一个Channel,则是共享的,即消息会随机发送给其中一个消费者。
接下来我们来分析下nsq的源码:
- 源码地址:https://github.com/nsqio/nsq
- 2020年1月18日最新的master分支
nsq各组件均使用上述代码仓库,通过apps目录下的不同的main包启动。比如nsqd的main函数在apps/nsqd目录下,其他类同。
本文档主要分析nsqd的主要结构体和方法,及消息生产和消费的过程。主要以TCP api为例来分析,HTTP/HTTPS的api类同。
2 主要结构体及方法
2.1 NSQD
nsqd/nsqd.go文件,NSQD是主实例,一个nsqd进程创建一个nsqd结构体实例,并通过此结构体的Main()方法启动所有的服务。
type NSQD struct {
clientIDSequence int64 // 递增的客户端ID,每个客户端连接均从这里取一个递增后的ID作为唯一标识
sync.RWMutex
opts atomic.Value // 参数选项,真实类型是apps/nsqd/option.go:Options结构体
dl *dirlock.DirLock
isLoading int32
errValue atomic.Value
startTime time.Time
topicMap map[string]*Topic // 保存当前所有的topic
clientLock sync.RWMutex
clients map[int64]Client
lookupPeers atomic.Value
tcpServer *tcpServer
tcpListener net.Listener
httpListener net.Listener
httpsListener net.Listener
tlsConfig *tls.Config
poolSize int // 当前工作协程组的协程数量
notifyChan chan interface{}
optsNotificationChan chan struct{}
exitChan chan int
waitGroup util.WaitGroupWrapper
ci *clusterinfo.ClusterInfo
}
主要方法
/*
程序启动时调用本方法,执行下面的动作:
- 启动TCP/HTTP/HTTPS服务
- 启动工作协程组:NSQD.queueScanLoop
- 启动服务注册:NSQD.lookupLoop
*/
func (n *NSQD) Main() error
// 负责管理工作协程组的数量,每调用一次NSQD.queueScanWorker()方法启动一个工作协程
func (n *NSQD) queueScanLoop()
// 由queueScanLoop()调用,负责启动工作协程组并动态调整协程数量。工作协程的数量为当前的channel数 * 0.25
func (n *NSQD) resizePool(num int, workCh chan *Channel, responseCh chan bool, closeCh chan int)
// 这是具体的工作协程,监听workCh,对收到的待处理Channel做两个动作,一是将超时的消息重新入队;二是将到时间的延时消息入队
func (n *NSQD) queueScanWorker(workCh chan *Channel, responseCh chan bool, closeCh chan int)
/*
lookupLoop()方法与nsqlookupd建立连接,负责向nsqlookupd注册topic,并定时发送心跳包
*/
func (n *NSQD) lookupLoop()
2.2 tcpServer
nsqd/tcp.go文件,tcpServer通过Handle()方法接收TCP请求。
tcpServer是nsqd结构的成员,全局也就只有一个实例,但在protocol包的TCPServer方法中,每创建一个新的连接,均会调用一次tcpServer.Handle()
type tcpServer struct {
ctx *context
conns sync.Map
}
主要方法
/*
p.nsqd.Main()启动protocol.TCPServer(),这个方法里会为每个客户端连接创建一个新协程,协程执行tcpServer.Handle()方法
本方法首先对新连接读取4字节校验版本,新连接必须首先发送4字节" V2"。
然后阻塞调用nsqd.protocolV2.IOLoop()处理客户端接下来的请求。
*/
func (p *tcpServer) Handle(clientConn net.Conn)
2.3 protocolV2
nsqd/protocol_v2.go文件,protocolV2负责处理过来的具体的用户请求。
每个连接均会创建一个独立的protocolV2实例(由tcpServer.Handle()创建)
type protocolV2 struct {
ctx *context
}
主要方法
/*
tcpServer.Handle()阻塞调用本方法
1. 启用一个独立协程向消费者推送消息protocolV2.messagePump()
2. for循环接收并处理客户端请求protocolV2.Exec()
*/
func (p *protocolV2) IOLoop(conn net.Conn) error
// 组装消息并调用protocolV2.Send()发送给消费者
func (p *protocolV2) SendMessage(client *clientV2, msg *Message) error
// 向客户端发送数据帧
func (p *protocolV2) Send(client *clientV2, frameType int32, data []byte) error
// 解析客户端请求的指令,调用对应的指令方法
func (p *protocolV2) Exec(client *clientV2, params [][]byte) ([]byte, error)
// 负责向消费者推送消息
func (p *protocolV2) messagePump(client *clientV2, startedChan chan bool)
// 下面这组方法是NSQD支持的指令对应的处理方法
func (p *protocolV2) IDENTIFY(client *clientV2, params [][]byte) ([]byte, error)
func (p *protocolV2) AUTH(client *clientV2, params [][]byte) ([]byte, error)
func (p *protocolV2) SUB(client *clientV2, params [][]byte) ([]byte, error)
func (p *protocolV2) RDY(client *clientV2, params [][]byte) ([]byte, error)
func (p *protocolV2) FIN(client *clientV2, params [][]byte) ([]byte, error)
func (p *protocolV2) REQ(client *clientV2, params [][]byte) ([]byte, error)
func (p *protocolV2) CLS(client *clientV2, params [][]byte) ([]byte, error)
func (p *protocolV2) NOP(client *clientV2, params [][]byte) ([]byte, error)
func (p *protocolV2) PUB(client *clientV2, params [][]byte) ([]byte, error)
func (p *protocolV2) MPUB(client *clientV2, params [][]byte) ([]byte, error)
func (p *protocolV2) DPUB(client *clientV2, params [][]byte) ([]byte, error)
func (p *protocolV2) TOUCH(client *clientV2, params [][]byte) ([]byte, error)
2.4 clientV2
nsqd/client_v2.go文件,保存每个客户端的连接信息。
clientV2实例由protocolV2.IOLoop()创建,每个连接均有一个独立的实例。
type clientV2 struct {
// 64bit atomic vars need to be first for proper alignment on 32bit platforms
ReadyCount int64
InFlightCount int64
MessageCount uint64
FinishCount uint64
RequeueCount uint64
pubCounts map[string]uint64
writeLock sync.RWMutex
metaLock sync.RWMutex
ID int64
ctx *context
UserAgent string
// original connection
net.Conn
// connections based on negotiated features
tlsConn *tls.Conn
flateWriter *flate.Writer
// reading/writing interfaces
Reader *bufio.Reader
Writer *bufio.Writer
OutputBufferSize int
OutputBufferTimeout time.Duration
HeartbeatInterval time.Duration
MsgTimeout time.Duration
State int32
ConnectTime time.Time
Channel *Channel
ReadyStateChan chan int
ExitChan chan int
ClientID string
Hostname string
SampleRate int32
IdentifyEventChan chan identifyEvent
SubEventChan chan *Channel
TLS int32
Snappy int32
Deflate int32
// re-usable buffer for reading the 4-byte lengths off the wire
lenBuf [4]byte
lenSlice []byte
AuthSecret string
AuthState *auth.State
}
2.5 Topic
nsqd/topic.go文件,对应于每个topic实例,由系统启动时创建或者发布消息/消费消息时自动创建。
type Topic struct {
messageCount uint64 // 累计消息数
messageBytes uint64 // 累计消息体的字节数
sync.RWMutex
name string // topic名,生产和消费时需要指定此名称
channelMap map[string]*Channel // 保存每个channel name和channel指针的映射
backend BackendQueue // 磁盘队列,当内存memoryMsgChan满时,写入硬盘队列
memoryMsgChan chan *Message // 消息优先存入这个内存chan
startChan chan int
exitChan chan int
channelUpdateChan chan int
waitGroup util.WaitGroupWrapper
exitFlag int32
idFactory *guidFactory
ephemeral bool
deleteCallback func(*Topic)
deleter sync.Once
paused int32
pauseChan chan int
ctx *context
}
主要方法
/*
下面两个方法负责将消息写入topic,底层均调用topic.put()方法
1. topic.memoryMsgChan未满时,优先写入内存memoryMsgChan
2. 否则,写入磁盘topic.backend
*/
func (t *Topic) PutMessage(m *Message) error
func (t *Topic) PutMessages(msgs []*Message) error
/*
NewTopic创建新的topic时会为每个topic启动一个独立线程来处理消息推送,即messagePump()
此方法循环随机从内存memoryMsgChan和磁盘队列backend中取消息写入到topic下每一个chnnel中
*/
func (t *Topic) messagePump()
2.6 channel
nsqd/channel.go文件,对应于每个channel实例
type Channel struct {
requeueCount uint64
messageCount uint64
timeoutCount uint64
sync.RWMutex
topicName string
name string
ctx *context
backend BackendQueue // 磁盘队列,当内存memoryMsgChan满时,写入硬盘队列
memoryMsgChan chan *Message // 消息优先存入这个内存chan
exitFlag int32
exitMutex sync.RWMutex
// state tracking
clients map[int64]Consumer
paused int32
ephemeral bool
deleteCallback func(*Channel)
deleter sync.Once
// Stats tracking
e2eProcessingLatencyStream *quantile.Quantile
// TODO: these can be DRYd up
deferredMessages map[MessageID]*pqueue.Item // 保存尚未到时间的延迟消费消息
deferredPQ pqueue.PriorityQueue // 保存尚未到时间的延迟消费消息,最小堆
deferredMutex sync.Mutex
inFlightMessages map[MessageID]*Message // 保存已推送尚未收到FIN的消息
inFlightPQ inFlightPqueue // 保存已推送尚未收到FIN的消息,最小堆
inFlightMutex sync.Mutex
}
主要方法
/*
将消息写入channel,逻辑与topic的一致,内存未满则优先写内存chan,否则写入磁盘队列
*/
func (c *Channel) PutMessage(m *Message) error
func (c *Channel) put(m *Message) error
// 消费超时相关
func (c *Channel) StartInFlightTimeout(msg *Message, clientID int64, timeout time.Duration) error
func (c *Channel) pushInFlightMessage(msg *Message) error
func (c *Channel) popInFlightMessage(clientID int64, id MessageID) (*Message, error)
func (c *Channel) addToInFlightPQ(msg *Message)
func (c *Channel) removeFromInFlightPQ(msg *Message)
func (c *Channel) processInFlightQueue(t int64) bool
// 延时消费相关
func (c *Channel) StartDeferredTimeout(msg *Message, timeout time.Duration) error
func (c *Channel) pushDeferredMessage(item *pqueue.Item) error
func (c *Channel) popDeferredMessage(id MessageID) (*pqueue.Item, error)
func (c *Channel) addToDeferredPQ(item *pqueue.Item)
func (c *Channel) processDeferredQueue(t int64) bool
3 启动过程
nsqd的main函数在apps/nsqd/main.go文件。
启动时调用了一个第三方包svc,主要作用是拦截syscall.SIGINT/syscall.SIGTERM这两个信号,最终还是调用了main.go下的3个方法:
- program.Init():windows下特殊操作
- program.Start():加载参数和配置文件、加载上一次保存的Topic信息并完成初始化、创建nsqd并调用p.nsqd.Main()启动
- program.Stop():退出处理
p.nsqd.Main()的逻辑也很简单,代码不贴了,依次启动了TCP服务、HTTP服务、HTTPS服务这3个服务。除此之外,还启动了以下两个协程:
- queueScanLoop:消息延时/超时处理
- lookupLoop:服务注册
TCPServer
protocol包的TCPServer的核心代码就是下面这几行,循环等待客户端连接,并为每个连接创建一个独立的协程:
func TCPServer(listener net.Listener, handler TCPHandler, logf lg.AppLogFunc) error {
for {
// 等待生产者或消费者连接
clientConn, err := listener.Accept()
// 每创建一个连接wg +1
wg.Add(1)
go func() {
// 每个连接均启动一个独立的协程来接收处理请求
handler.Handle(clientConn)
wg.Done()
}()
}
// 等待所有协程退出
wg.Wait()
return nil
}
TCPServer的核心是为每个连接启动的协程处理方法handler.Handle(clientConn),实际调用的是下面这个方法,连接建立时先读取4字节,必须是" V2",然后启动prot.IOLoop(clientConn)处理接下来的客户端请求:
func (p *tcpServer) Handle(clientConn net.Conn) {
// 无论是生产者还是消费者,建立连接时,必须先发送4字节的" V2"进行版本校验
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()
p.ctx.nsqd.logf(LOG_ERROR, "client(%s) bad protocol magic '%s'",
clientConn.RemoteAddr(), protocolMagic)
return
}
// 版本校验通过,保存连接信息,key-是ADDR,value是当前连接指针
p.conns.Store(clientConn.RemoteAddr(), clientConn)
// 启动
err = prot.IOLoop(clientConn)
if err != nil {
p.ctx.nsqd.logf(LOG_ERROR, "client(%s) - %s", clientConn.RemoteAddr(), err)
}
p.conns.Delete(clientConn.RemoteAddr())
}
4 消费和生产过程
4.1 消息生产
生产者pub消息时,消息会首先写入对应topic的队列(内存优先,内存满了写磁盘),topic的messagePump()方法再将消息拷贝给每个channel。
每个channel均各执一份完整的消息。
1.消息写入topic
消息生产由生产者调用PUB/MPUB/DPUB这类指令实现,底层都是调用topic.PutMessage(msg),进一步调用topic.put(msg):
func (t *Topic) put(m *Message) error {
select {
case t.memoryMsgChan <- m: // 优先写入内存memoryMsgChan
default: // 当内存case失败即memoryMsgChan满时,走default,将msg以字节形式写入磁盘队列topic.backend
b := bufferPoolGet()
err := writeMessageToBackend(b, m, t.backend)
bufferPoolPut(b)
t.ctx.nsqd.SetHealth(err)
if err != nil {
t.ctx.nsqd.logf(LOG_ERROR,
"TOPIC(%s) ERROR: failed to write message to backend - %s",
t.name, err)
return err
}
}
return nil
}
消息写入topic的逻辑比较简单,优先写memoryMsgChan,如果memoryMsgChan满了,则写入磁盘队列topic.backend。
这里留个思考题:NSQ是否支持不写内存,全部写磁盘队列?
2.topic将消息复制给每个channel
第二章介绍结构体和方法时,介绍了topic结构体的messagePump()方法,正是这个方法将第1步写入的消息复制给每个channel的:
func (t *Topic) messagePump() {
/* 准备工作有代码我们略过 */
// 主消息处理循环
for {
select {
case msg = <-memoryMsgChan:
case buf = <-backendChan:
msg, err = decodeMessage(buf)
if err != nil {
t.ctx.nsqd.logf(LOG_ERROR, "failed to decode message - %s", err)
continue
}
case <-t.channelUpdateChan:
chans = chans[:0]
t.RLock()
for _, c := range t.channelMap {
chans = append(chans, c)
}
t.RUnlock()
if len(chans) == 0 || t.IsPaused() {
memoryMsgChan = nil
backendChan = nil
} else {
memoryMsgChan = t.memoryMsgChan
backendChan = t.backend.ReadChan()
}
continue
case <-t.pauseChan:
if len(chans) == 0 || t.IsPaused() {
memoryMsgChan = nil
backendChan = nil
} else {
memoryMsgChan = t.memoryMsgChan
backendChan = t.backend.ReadChan()
}
continue
case <-t.exitChan:
goto exit
}
for i, channel := range chans {
chanMsg := msg
/* channel消费消息时,需要处理延时/超时等问题,所以这里复制了消息,给每个channel传递的是独立的消息实例 */
if i > 0 {
chanMsg = NewMessage(msg.ID, msg.Body)
chanMsg.Timestamp = msg.Timestamp
chanMsg.deferred = msg.deferred
}
if chanMsg.deferred != 0 {
channel.PutMessageDeferred(chanMsg, chanMsg.deferred)
continue
}
err := channel.PutMessage(chanMsg)
if err != nil {
t.ctx.nsqd.logf(LOG_ERROR,
"TOPIC(%s) ERROR: failed to put msg(%s) to channel(%s) - %s",
t.name, msg.ID, channel.name, err)
}
}
}
}
topic.messagePump()方法代码还蛮长的,前面是些准备工作,主要就是后面的for循环。其中for循环中select的前两项,memoryMsgChan来源于topic.memoryMsgChan,而backendChan则是topic.backend.ReadChan(),分别对应于内存和磁盘队列。注意只有这两个case会往下传递消息,其他的case处理退出和更新机制的,会continue或exit外层的for循环。
虽然通道channel是有序的,但select的case具有随机性,这就决定了每轮循环读的是内存还是磁盘是随机的,消息的消费顺序是不可控的。
select语句获取的消息,交给第2层for循环处理,逻辑比较简单,遍历每一个chan,调用channel.PutMessage()写入。由于每个channel对应于不同的消费者,有不同的延时/超时和消费机制,所以这里拷贝了message实例。
4.2 消息消费
每个连接均会启动一个运行protocolV2.messagePump()方法的协程,这个协程负责监听channel的消息队列并向客户端推送消息。客户端只有触发SUB指令之后,才会将channel传递给protocolV2.messagePump(),这之后消费推送才会正式开启。
启动消息推送
前面讲Tcpserver时有提到,客户端创建连接时,会调用tcpserver.Handle(),里面再调用protocolV2.IOLoop()。protocolV2.IOLoop()方法开头有下面这行:
go p.messagePump(client, messagePumpStartedChan)
这行创建了一个独立线程,调用的protocolV2.messagePump()负责向消费者推送消息。
有个小细节是无论是生产者还是消费者,都会创建这个协程,protocolV2.messagePump()创建后并不会立即推送消息,而是需要调用SUB指令,以protocolV2.SUB()方法为例,方法末尾有这么一行:
client.SubEventChan <- channel
将当前消费者订阅的channel传入client.SubEventChan,这个会由protocolV2.messagePump()接收,这个方法核心是下面这个for循环(限于篇幅,我省略了大量无关代码):
func (p *protocolV2) messagePump(client *clientV2, startedChan chan bool) {
for {
if subChannel == nil || !client.IsReadyForMessages() {
// the client is not ready to receive messages...
memoryMsgChan = nil
backendMsgChan = nil
flusherChan = nil
// force flush
client.writeLock.Lock()
err = client.Flush()
client.writeLock.Unlock()
if err != nil {
goto exit
}
flushed = true
} else if flushed {
// last iteration we flushed...
// do not select on the flusher ticker channel
memoryMsgChan = subChannel.memoryMsgChan
backendMsgChan = subChannel.backend.ReadChan()
flusherChan = nil
}
select {
case subChannel = <-subEventChan:
// you can't SUB anymore
subEventChan = nil
case b := <-backendMsgChan:
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++
subChannel.StartInFlightTimeout(msg, client.ID, msgTimeout)
client.SendingMessage()
err = p.SendMessage(client, msg)
if err != nil {
goto exit
}
flushed = false
case msg := <-memoryMsgChan:
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:
goto exit
}
}
}
客户端建立连接初始,subChannel为空,循环一直走第1个if语句。直到客户端调用SUB指令,select语句执行"case subChannel = <-subEventChan:",此时subChannel非空,接下来backendMsgChan和memoryMsgChan被赋值,此后开始推送消息:
- 消息会随机从内存和磁盘队列取,因为如果内存和磁盘都有数据,select是随机的
- 消息通过protocolV2.SendMessage()推送给消费者
当多个消费者订阅同一个channel时情况会如何?
上面我们提到消费者发起SUB指令订阅消息,protocolV2.SUB()会将chan传给protocolV2.messagePump(),即这一行“client.SubEventChan <- channel”,那么我们来看下这个channel变量怎么来的:
func (p *protocolV2) SUB(client *clientV2, params [][]byte) ([]byte, error) {
...
topic := p.ctx.nsqd.GetTopic(topicName)
channel = topic.GetChannel(channelName)
...
client.SubEventChan <- channel
SUB方法包含多种逻辑:
- 当channel不存在时,topic.GetChannel()方法自动创建并与这个消费者绑定
- 当channel存在,比如事先通过http-api创建好了,但没有消费者订阅,则当前消费者独立绑定这个channel
- 当channel存在,且已经有消费者订阅了,topic.GetChannel()方法依然会返回这个channel,这时就有多个消费者同时订阅了这个channel,大家共用一个通道chan变量
由于是多个消费者共用一个通道chan变量,每个消费者都有一个for select在循环监听这个通道,根据chan变量的特性,消费会随机发送给一位消费者,且一条消息只会推送给一个消费者。
消费超时处理
protocolV2.messagePump()方法,无论是“case b := <-backendMsgChan:”还是“case msg := <-memoryMsgChan:”,在向消费者推送消息前都调用了下面这行代码:
func (p *protocolV2) messagePump(client *clientV2, startedChan chan bool) {
subChannel.StartInFlightTimeout(msg, client.ID, msgTimeout) // 省略其他代码
}
func (c *Channel) StartInFlightTimeout(msg *Message, clientID int64, timeout time.Duration) error {
msg.pri = now.Add(timeout).UnixNano() // pri成员保存本消息超时时间
err := c.pushInFlightMessage(msg)
c.addToInFlightPQ(msg)
}
channel.StartInFlightTimeout()将消息保存到channel的inFlightMessages和inFlightPQ队列中,这两个缓存是用来处理消费超时的。
值得注意的一个小细节是c.addToInFlightPQ(msg)将msg压入最小堆时,将msg在数组的偏移量保存到了msg.index成员中(最小堆底层是数组实现)
我们先简单看下FIN指令会做啥:
func (p *protocolV2) FIN(client *clientV2, params [][]byte) ([]byte, error) {
err = client.Channel.FinishMessage(client.ID, *id) // 省略其他代码
}
func (c *Channel) FinishMessage(clientID int64, id MessageID) error {
// 省略其他代码
msg, err := c.popInFlightMessage(clientID, id)
c.removeFromInFlightPQ(msg)
}
FIN的动作比较简单,主要就是调用channel.FinishMessage()方法把上面写入超时缓存的msg给删除掉。
FIN从inFlightMessages中删除消息比较容易,这是个map,key是msg.id。客户端发送FIN消息时附带了msg.id。但如何从最小堆inFlightPQ中删除对应的msg呢?前面提到在入堆时的一个细节,即保存了msg的偏移量,此时正好用上。通过msg.index直接定位到msg的位置并调整堆即可。
说了这么多,最小堆的作用是啥?别急,接下来我们看下超时逻辑:
超时逻辑由程序启动时开启的工作线程组来处理,即NSQD.queueScanLoop()方法:
func (n *NSQD) queueScanLoop() {
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
}
}
}
NSQD.queueScanLoop()方法主要有一个for循环,内层是一个select和一个loop循环。select中,第1个定时器case <-workTicker.C的作用是定时触发工作,只有这个case会跳出select走到下面的loop。第2个定时器负责启动工作协程组并动态调整协程数量,我们来看下第2个定时器调用的resizePool()方法:
func (n *NSQD) resizePool(num int, workCh chan *Channel, responseCh chan bool, closeCh chan int) {
idealPoolSize := int(float64(num) * 0.25) // 协程数量设定为channel数的1/4
if idealPoolSize < 1 {
idealPoolSize = 1
} else if idealPoolSize > n.getOpts().QueueScanWorkerPoolMax {
idealPoolSize = n.getOpts().QueueScanWorkerPoolMax
}
for {
if idealPoolSize == n.poolSize { // 当协程数量达到协程数量设定为channel数的1/4时,退出
break
} else if idealPoolSize < n.poolSize { // 否则如果当前协程数大于目标值,则通过closeCh通知部分协程退出
// contract
closeCh <- 1
n.poolSize--
} else { // 否则协程数不够,则启动新的协程
// expand
n.waitGroup.Wrap(func() {
n.queueScanWorker(workCh, responseCh, closeCh)
})
n.poolSize++
}
}
}
resizePool()方法上面的注释已经说的很清楚了,作用就是保持工作协程数量为当前channel数的1/4。
接下来我们看具体的工作逻辑,queueScanWorker()方法:
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()方法的代码很短,一是监听closeCh的退出信号;二是监听workCh的工作信号。workCh会将需要处理的channel传入,然后调用processInFlightQueue()清理超时的消息,调用processDeferredQueue()清理到时间的延时消息:
func (c *Channel) processInFlightQueue(t int64) bool {
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.put(msg)
}
exit:
return dirty
}
func (pq *inFlightPqueue) PeekAndShift(max int64) (*Message, int64) {
if len(*pq) == 0 {
return nil, 0
}
x := (*pq)[0]
if x.pri > max {
return nil, x.pri - max
}
pq.Pop()
return x, 0
}
前面提到msg.pri成员保存本消息超时时间,所以PeekAndShift()返回的是最小堆里已经超时且超时时间最长的那条消息。processInFlightQueue()则将消息从超时队列中删,同时将消息重新put进channel。注意此时超时的消息put进channel后实际是排在队尾的,消费顺序将发生改变。
processInFlightQueue()方法如果存在超时消息,返回值dirty标识true。queueScanWorker()将dirty写入responseCh。再往回看,queueScanLoop()方法统计了dirty的数量,超过一定比例会继续执行loop,而不是等待下一次定时执行。
4.2 延迟消费
生产者调用DPUB发布的消息,可以指定延时多少再推送给消费者。
我们来看下DPUB的逻辑:
func (p *protocolV2) DPUB(client *clientV2, params [][]byte) ([]byte, error) {
timeoutMs, err := protocol.ByteToBase10(params[2])
timeoutDuration := time.Duration(timeoutMs) * time.Millisecond
msg := NewMessage(topic.GenerateID(), messageBody)
msg.deferred = timeoutDuration
err = topic.PutMessage(msg)
}
从上面截取的PUB()方法代码可以看出,DPUB的消息会将延时时间写入msg.deferred成员。4.1章节第2部分介绍的Topic.messagePump()方法有下面这段:
func (t *Topic) messagePump() {
if chanMsg.deferred != 0 {
channel.PutMessageDeferred(chanMsg, chanMsg.deferred)
continue
}
}
当chanMsg.deferred != 0时表示延时消息,此时不是直接调用putMessage()方法写入channel,而是调用channel.PutMessageDeferred(chanMsg, chanMsg.deferred),消息被写入了延时队列Channel.deferredMessages和Channel.deferredPQ。之后的逻辑是在工作协程组NSQD.queueScanLoop()中被识别并put进channel,这与超时的处理逻辑是一样的,不展开说。
NSQ源码剖析——主要结构方法和消息生产消费过程的更多相关文章
- NSQ源码剖析之nsqd
NSQ简介 NSQ 是实时的分布式消息处理平台,其设计的目的是用来大规模地处理每天数以十亿计级别的消息.NSQ 具有分布式和去中心化拓扑结构,该结构具有无单点故障.故障容错.高可用性以及能够保证消息的 ...
- 转:【Java集合源码剖析】TreeMap源码剖析
前言 本文不打算延续前几篇的风格(对所有的源码加入注释),因为要理解透TreeMap的所有源码,对博主来说,确实需要耗费大量的时间和经历,目前看来不大可能有这么多时间的投入,故这里意在通过于阅读源码对 ...
- PART(Persistent Adaptive Radix Tree)的Java实现源码剖析
论文地址 Adaptive Radix Tree: https://db.in.tum.de/~leis/papers/ART.pdf Persistent Adaptive Radix Tree: ...
- C# Dictionary源码剖析---哈希处理冲突的方法有:开放定址法、再哈希法、链地址法、建立一个公共溢出区等
C# Dictionary源码剖析 参考:https://blog.csdn.net/exiaojiu/article/details/51252515 http://www.cnblogs.com/ ...
- jdk源码剖析一:OpenJDK-Hotspot源码包目录结构
开启正文之前,先说一下源码剖析这一系列,就以“死磕到底”的精神贯彻始终,JDK-->JRE-->JVM(以openJDK代替) 最近想看看JDK8源码,但JDK中JVM(安装在本地C:\P ...
- Netty 源码剖析之 unSafe.write 方法
前言 在 Netty 源码剖析之 unSafe.read 方法 一文中,我们研究了 read 方法的实现,这是读取内容到容器,再看看 Netty 是如何将内容从容器输出 Channel 的吧. 1. ...
- jQuery之Deferred源码剖析
一.前言 大约在夏季,我们谈过ES6的Promise(详见here),其实在ES6前jQuery早就有了Promise,也就是我们所知道的Deferred对象,宗旨当然也和ES6的Promise一样, ...
- Nodejs事件引擎libuv源码剖析之:高效线程池(threadpool)的实现
声明:本文为原创博文,转载请注明出处. Nodejs编程是全异步的,这就意味着我们不必每次都阻塞等待该次操作的结果,而事件完成(就绪)时会主动回调通知我们.在网络编程中,一般都是基于Reactor线程 ...
- Apache Spark源码剖析
Apache Spark源码剖析(全面系统介绍Spark源码,提供分析源码的实用技巧和合理的阅读顺序,充分了解Spark的设计思想和运行机理) 许鹏 著 ISBN 978-7-121-25420- ...
随机推荐
- dotnet 通过 WMI 拿到显卡信息
本文告诉大家如何通过 WMI 拿到显卡信息 如果使用的是 dotnet core 请先引用 Microsoft.Windows.Compatibility 才可以使用 WMI 代码 通过下面的代码可以 ...
- JNI相关使用记录
JNI 工作流程 java层调用system.load方法. 通过classloader拿到了so文件的绝对路径,然后调用nativeload()方法. 通过linux下的dlopen方法,加载并查找 ...
- CP防火墙导入.csv格式的对象
Step1:将.csv格式的对象上传到管理服务器,本例为/home/admin目录 [Expert@SZ-OFFICE-SMT:0]# pwd/home/admin[Expert@SZ-OFFICE- ...
- k8s故障总结
1.run pod的时候提示"Back-off pulling image \"registry.access.redhat.com/rhel7/pod-infrastructur ...
- 如何删除Word自动编号后文字和编号之间的空白距离
一.出现的现象:使用word进行自动编号之后,编号和其后的文字出现如下图所示的空白 二.如何解决问题 选中列表内容右键->调整列表缩进->选择“编号之后(W)"为不特别标注-&g ...
- Asp.NetCore3.1版本的CodeFirst与经典的三层架构与AutoFac批量注入
Core3.1 CodeFirst与AutoFac批量注入(最下面附GitHub完整 Demo,由于上传网速较慢,这里就直接压缩打包上传了) ===Core3.1 CodeFirst 数据库为远程阿里 ...
- 开发工具篇:JAVA和IntelliJ IDEA相恋
开发工具篇:JAVA和IntelliJ IDEA相恋 idea是什么? IDEA 全称 IntelliJ IDEA,是java语言开发的集成环境,IntelliJ在业界被公认为最好的java开发工具之 ...
- shiro整合springmvc
说明 代码及部分相关资料根据慕课网Mark老师的视频进行整理 其他资料: shiro官网 流程 配置 1) 配置web.xml整合shiro 把shiro整合到springMVC实质上是在we ...
- centos7+docker+elasticsearch 安装记录+踩坑
版本: cenos7 :3.10.0-957.21.3.el7.x86_64 (内核需>=3.10 才可以安装) docker: yum安装版本为1.13.1 elasticsearch: 6 ...
- 看完这篇HTTP,跟面试官扯皮就没问题了
我是一名程序员,我的主要编程语言是 Java,我更是一名 Web 开发人员,所以我必须要了解 HTTP,所以本篇文章就来带你从 HTTP 入门到进阶,看完让你有一种恍然大悟.醍醐灌顶的感觉. 最初在有 ...