目录
主 goroutine
G2_TCP 即 nsqd 的tcp 侦听goroutine
G2_TCP_SubG处理的命令
IDENTIFY
Sub 消息处理
RDY count
FIN msgid
REQ id timeoutMs
pqueue.PriorityQueue
inFlightPqueue
G2_TCP_SubPump
n.queueScanLoop G24
n.lookupLoop G25
优雅的退出
diskqueue
简称为 G1
(ol "~/go/gopath/src/github.com/nsqio/nsq/apps/nsqd/main.go" 41)
main() 函数启动后 读取配置
然后调用 p.nsqd.LoadMetadata()
依照保存的topic信息创建 topic
创建 topic 及 topic 的 channel
NewTopic() 的过程中会新建个 goroutine 调用 t.messagePump 简称 G1_TopicPump
messagePump() 调用后阻塞在 t.startChan ,一直到nsqd 在别处调用 topic.Start()。
Start()不要手贱调多次,messagePump() 只会读一次,缓冲尺寸1个,多调用1次没事 缓冲吃掉了,多2次就会永远卡死
messagePump() 激活后 读写锁 读保护过程中 把 t.channelMap 存的 channels 全部拷贝到自己的 chans 数组中
然后看看各下面5个管道
t.channelUpdateChan 把 chans 和 memoryMsgChan backendChan 都更新下
t.pauseChan 有消息就 把 memoryMsgChan 和 backendChan 给置空, 这样就可以暂时不再读取这2个管道的消息
t.exitChan 有消息就退出
memoryMsgChan 和 backendChan 有消息就 读取消息
拿到消息后, 遍历 chans 管道
把消息 逐个发送给 chans
p.nsqd.PersistMetadata()
把topic channel 信息又保存到磁盘中
接下来新建个gorotine G2 调用 p.nsqd.Main()
(ol "~/go/gopath/src/github.com/nsqio/nsq/nsqd/nsqd.go" 244)
nsqd.Main()
开启 tcp 开启协议v2 消息泵 新开启goroutine G2_TCP
开启 http 如果有则开启 https http G22 https G23
n.queueScanLoop G24
n.lookupLoop G25
n.statsdLoop G26
topic就是订阅发布的主题, 一个topic下可以有1到多个channel
topic的消息不会直接发给客户端,它只是把消息发给它的channel们
每个channel都是一条独立的消息队列
每个topic都会有个单独的 messagePump goroutine
当客户端发布消息的时候,messagePump 把这条消息 拷贝到该topic所有的channel中
channel 并没有单独的 goroutine
nsqd为每个连上的客户端开启2个 goroutine, 一个专门处理客户端发上来的命令,一个侦听 channel的队列
channel下有多个客户端订阅了,消息就会随机派发给其中一个客户端。 这些客户端都有单独的messagePump goroutine,它们都阻塞在 select channel 的消息管道上。有消息来时,哪个goroutine最终获得消息是随机的。
举例:
某topic 下有 channel1 channel2 channel3
channel1 channel2 channel3 各自有一些客户端订阅了。
这时候有客户端给 topic 发布了 "hello" 消息
那么“hello” 会被传递给 channel1 channel2 channel3,
然后channel1 channel2 channel3 各自随机选择一个客户端,把 "hello" 发过去
因此 “hello” 被 投递给了3个客户端。
如果你不想有那么多客户端收到,那么让它们都订阅同一个channel就可以了。这样最终只会有一个客户端收到“hello”
为保证 G2_TCP 不阻塞, 每accept()到一个连接 便创建一个 groutine 以下简称 G2_TCP_SubG
G2_TCP_SubG 目前仅支持 V2 协议,如果用户请求的是v2协议,则 调用 prot.IOLoop(clientConn)
于是具体的活交给了 protocol_v2.go 来完成
protocol_v2.IOLoop()
记录下 连接上来的client信息,nsqd保存了个 nsqd.clientIDSequence int64 变量,每一个新client连上来就加1
这个变量作为每个client的唯一id
新建 goroutine G2_TCP_SubGPump, 注意,每个连上nsqd的客户端都会有一个单独的G2_TCP_SubGPump
然后开始io主循环,接收命令,处理命令,中途有通信失败的则退出循环,结束自己
参考
https://nsq.io/clients/tcp_protocol_spec.html
客户端stateInit状态时才可以发这个命令
配置客户端的一些选项, 选项修改后 通过一些管道通知过相关的 gourotine
返回当前的一些服务器配置信息
PUB
解析参数中的topicName,读取整条报文 先长度 后内容
topic := p.ctx.nsqd.GetTopic(topicName) 如果 topic 不存在则创建
topic.PutMessage(msg) 把消息推送给 topic
如果 topic的memoryMsgChan channel 没满,直接写入 chan就行
满了的话就 写入 backend 队列, 目前就是 diskqueue
简单点说, 放的下就放memoryMsgChan,放不下 交给diskqueue,写到磁盘里去
diskqueue 其实也有部分缓存,并不是马上就写到了磁盘,而是累计到一定条数,或者到一定时间才会去写入
然后client.PublishedMessage(topicName, 1),pubCounts 计数加1
G2_TCP_Sub 接收并处理客户端的 pub 命令, 把 pub 上来的 message投递到 topic 中
每一个 topic 都会有一个专门的 goroutine (本文简称 GTopicPump) 来处理消息
回顾前文, GTopicPump 会一直侦听5个管道的消息,其中2个是memoryMsgChan 和 backendChan
memoryMsgChan没塞满的话, 消息直接 通过它 传递给了 GTopicPump
塞满的话 消息 先送到了 topic 的 diskqueue,然后由 backendChan 投递给 GTopicPump
GTopicPump 收到消息后, 把消息转发给它所有的 channel
为了尽量减少拷贝 memoryMsgChan 存的是指针类型
G2_TCP_Sub 收到 客户端的 pub 命令后 调用 NewMessage(topic.GenerateID(), messageBody) 来创建msg变量
然后把 msg 的地址 投递给了 topic的memoryMsgChan, 这样避免了整个对象的拷贝
当把 msg 投递到每一个topic的channel时
第一个channel,并不会创建新的 msg 对象,而是直接使用 G2_TCP_Sub 创建好的对象
后面的channel, 才会创建新的msg 副本
于是 msg 最终被投递到了 一个个channel的memoryMsgChan中,如果满了,则存到 channel的diskqueue中
回想前面提到一个客户端和nsqd 建立连接后会创建 G2_TCP_Sub 和 G2_TCP_SubPump 2个goroutine
其中 G2_TCP_Sub 处理 客户端 和 nsqd 的各种命令
G2_TCP_SubPump 则会侦听该客户端 订阅的 topic的 channel, 从而继续处理msg
nsqd 的 G2_TCP_Sub 收到 客户端的 sub 命令
客户端的状态必须为stateInit 才可以接受 SUB 命令
每个客户端只可以订阅一个topic的一个 channel
client.Channel = channel
// update message pump
client.SubEventChan <- channel
完成后 client.State 被改成stateSubscribed
一旦改成stateSubscribed 就回不到 stateInit , 就再也无法订阅别的channel了
ready 的简写
SUB后的客户端才可以发送 RDY 命令
客户端通过这个命令告诉服务器: 我现在可以接收count条消息啦
消息已处理, nsqd 会把这条消息 踢出 inFlightPqueue(本文后面有详细描述) 队列
这条消息我处理不了,帮我放回去
timeoutMs == 0 马上放回去
timeoutMs > 0 先放到 defer 队列中, 但是超时的话 就得马上放回去了 超时的检测由 n.queueScanLoop 的goroutine池完成
和 inFlightPqueue 类似,也是个小顶堆,区别在于
inFlightPqueue 的小顶堆是作者人肉写的,
PriorityQueue 是借助go 的库 heap 实现的, 源码实现也差不多
我猜 可能是 go 库的 heap 性能没有作者人肉写的好吧, 仅仅是猜测
用于存放 客户端来不及处理后 发送REQ命令 要求nsqd重新放入 队列的 消息
是一个小顶堆,按 msg.pri 进行排序, pri存的就是msg创建的时间, 时间越早排越前面
用于存放 飞行中的消息(已发给客户端,还没收到客户端的确认(FIN)报文)
上述2个队列都在 n.queueScanLoop 中处理。处理过程参考下文
客户端 发送 Sub 命令后,G2_TCP_SubPump开始侦听相应 topic 下的 channel的 memoryMsgChan 和 backendChan
有消息到来后, 把msg 插入到 inFlightPQ (inFlightPqueue 最小堆)中,并登记到 inFlightMessages中 (map key是msg.ID msg.ID是唯一的GUID,不会重复)
之后把msg 发送给 客户端
msg 丢到 inFlightPQ 后,需要查看msg是否超时,超时的话 统计下超时次数,重新放回消息队列
nsqd 中每个 channel 都有 inFlightPQ , 每条inFlightPQ都需要被扫描
G24就是专门扫描inFlightPQ队列的
作者借鉴了 redis 的 Redis's probabilistic expiration algorithm
G24 首先创建了 goroutine 池,池子大小 1/4 的channel 数量
每隔 n.getOpts().QueueScanRefreshInterval 默认5秒 池子根据channel数量动态调整一次
总是让池子的goroutine数量 保持在 channel 数的 四分之一
池子里工人的干活函数: queueScanWorker()
一直在等待 workCh chan派发过来的channel
收到channel就开始扫描队列的里 msg 的超时情况,只要有一个msg超时了,就表明这个队列脏了
G24 随机选中 n.getOpts().QueueScanSelectionCount 个 channel 默认20个
把这些channel 投递到 workCh 管道(池子工人一直在侦听的)
然后根据 池子工人的反馈,统计下多少个channel 脏了 (有msg超时就是脏了)
如果脏的channel 超过了 n.getOpts().QueueScanDirtyPercent 默认1/4
马上再开启一次新的扫描
否则每隔n.getOpts().QueueScanInterval (默认100ms) 扫描一次
池子工人搞多些,可以扫快点,但是浪费了资源。我猜四分之一是个统计值吧?
随机选取 一些 channel, 脏的比例不到四分之一,是不是意味着超时不多,就不用一直扫描了
脏的比例高了就立马再扫描一次,直到 脏的比例达标
有些channel特别背,会不会一直得不到扫描,我觉得这是可能的,但是channel 数量少的时候,几乎是全扫描的。
饿死就饿死吧,概率也不高。
nsqd.Main() 中启动 单goroutine运行
nsqd 与 nsqlookup 的通信都由它完成,其它模块不会直接和nsqlookup进行通信。
每隔一段时间发送心跳包给 nsqlookup
侦听n.notifyChan管道,发送相关命令黑nsqlookup, 主要就是 channel 和 topic 的register和unregister
n.optsNotificationChan如果有消息,更新lookup服务器
nsqd http.go 中 可以通过 /config/:opt 命令来重新设置 nsqd 的远端lookup服务器信息
nsqd 仅仅 通过 atomic 来 保护 n.getOpts().NSQLookupdTCPAddresses 的读和写的那一刻
接下来的操作并没有进行锁保护,其实也就是存了个 字符串数组
不保护的话,不会导致程序奔溃就行,老数据也没事。
syscall.SIGINT syscall.SIGTERM 可以让程序优雅退出
程序收到上面信号量后执行 program.Stop()
然后转交给 nsqd.Exit() (借助 sync.Once 确保 nsqd.Exit() 只被调用一次)
nsqd.Exit() 先关闭 n.tcpListener n.httpListener n.httpsListener 的网络侦听
然后 n.PersistMetadata() 把topic channel 信息又保存到磁盘中
再遍历 topic 逐个关闭, 关闭topic的具体过程如下, 每个topic都执行一次
设置 t.exitFlag, 这样 topic.PutMessage() 之类的函数就可以尽快结束
然后遍历 nsqloopup ,逐个注销本 topic
注销后又会保存一次n.PersistMetadata()
然后关闭 t.exitChan
有些操作是卡在了 select 中, 无法通过判断 t.exitFlag 变量来提前中止
所以 select 的时候把 t.exitChan 也加到case里
这样 t.exitChan 就可以让 GTopic_Pump(topic的 messgaPump)提前中止
然后关闭所有的channel
设置 c.exitFlag (和 topic类似)
到所有的 lookup 注销 channel
client.Close() 关闭所有的 client, 其实就是把 client 和 tcp 连接给断了
client其实就是 clientV2 的对象, 找了好久没找到clientV2.Close()方法,后来仔细看了clientV2类声明
原来 net.Conn 类被直接嵌到了 clientV2 中,这样 clientV2.Close() 就是 net.Conn.Close() 了
每个 client 都会有2个 goroutine : G2_TCP_Sub 和 G2_TCP_SubPump
client.Close() 调用后,nsqd 和 客户端的 tcp连接就断了,
连接都断了,基于该连接读写的 G2_TCP_Sub ,就会感觉到,退出 退出前close(client.ExitChan)
G2_TCP_SubPump select 到client.ExitChan 后也跟着退出了
然后执行 c.flush(), 把c.memoryMsgChan c.inFlightMessages 和 c.deferredMessages 中的msg统统保存到 diskqueue 中
最后把 diskqueue 也给关了
所有的channel都退出后,执行 topic的 flush(),t.memoryMsgChan 中的msg 写入到 diskqueue中
然后 把 topic.diskqueue 也给关了
topic channel 都关完后, close(n.exitChan)
nsqd的 n.queueScanLoop n.lookupLoop n.statsdLoop(如果有在运行) select 到n.exitChan后也就跟着退出了
是先进先出的 队列
topic 和 channel 的 后端队列都由diskqueue实现
New()的时候
创建基本的disque结构,并执行开启个 goroutine 执行 ioLoop()
sync() 确保内容写入到文件系统
并把 文件偏移 写入情况等 记录到另一个文件中
写记录文件的时候 先建立临时文件,写完后再重命名覆盖原先的文件
这样的好处是可以减少文件锁定时间? 不容易出错?
配置文件啊 这些的 都是采用这个技巧的
sync() 在 ioLoop() 中调用
为了减少 sync() 调用的次数
以下情况会标记d.needSync
当 count 达到d.syncEvery配置的次数时
当外部调用 put 有数据加入时 count++
当 d.readOne() 能读到数据时, count++
每隔d.syncTimeout 时间,数据又更新过
每次循环,检测到 d.needSync 为true时, 调用 sync() 并 count = 0
diskqueue 的文件相关操作全部集中在 ioLoop routine 中
别的routine要操作文件时找到合适的 channel 发起,
ioLoop routine 收到后 执行后 通过相应的 response chan 返回执行情况
所有的 这些交互chan都是非缓存的,这样确保在多routine同时调用下 不干扰。
a, b routine 同时发起了 writeChan, 他们阻塞在了 writeChan <-
由于 writeChan 是非缓存的
ioLoop 一次只能处理一个 writeChan 信息, 假定 a 先往 writeChan 写入了数据
那么a 开始激活 然后 阻塞在了 <- writeResponseChan
当 ioLoop 处理完 writeChan 那条消息后, 往 writeResponseChan 写入信息
然后a激活
ioLoop 下一次循环 接收了 b 写入 writeChan 的信息