NSQ源碼剖析之nsqd
來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。## NSQ簡介NSQ 是即時的分布式訊息處理平台,其設計的目的是用來大規模地處理每天數以十億計層級的訊息。NSQ 具有分布式和去中心化拓撲結構,該結構具有無單點故障、故障容錯、高可用性以及能夠保證訊息的可靠傳遞的特徵,是一個成熟的、已在大規模產生環境下應用的產品。NSQ 由 3 個守護進程組成:nsqd 是接收、儲存和傳送訊息到用戶端的守護進程。nsqlookupd 是管理的拓撲資訊,維護著所有nsqd的狀態,並提供了最終一致探索服務的守護進程。nsqadmin 是一個 Web UI 來即時監控叢集和執行各種管理工作。![NSQ結構圖](http://7xj4dv.com1.z0.glb.clouddn.com/NSQ_Topology.png)這篇文章介紹主要介紹nsqd的實現。##Topic與ChannelTopic與Channel是NSQ中重要的兩個概念。生產者將訊息寫到Topic中,一個Topic下可以有多個Channel,每個Channel都是Topic的完整副本。消費者從Channel處訂閱訊息,如果有多個消費者訂閱同一個Channel,Channel中的訊息將被傳遞到一個隨機的消費者。![圖片標題](http://leanote.com/api/file/getImage?fileId=564c9b31ab6441711100005b)```type NSQD struct { //一個nsqd執行個體可以有多個Topic topicMap map[string]*Topic}type Topic struct {name string//一個Topic執行個體下有多個ChannelchannelMap map[string]*ChannelmemoryMsgChan chan *Message}//golang中goroutine之間的是通過chan來通訊的,如果想要往該topic發布訊息,只需要將訊息寫到Topic.memoryMsgChan中 //Topic建立成功後會開啟一個新的goroutine(messagePump)負責監聽Topic.memoryMsgChan,當有新訊息時會將將訊息複製N份發送到該Topic下的所有Channel中func NewTopic(topicName string) *Topic {t := &Topic{name: topicName,channelMap: make(map[string]*Channel),//該Topic下的所有ChannelmemoryMsgChan: make(chan *Message, ctx.nsqd.opts.MemQueueSize),exitChan: make(chan int),}//開啟一個goroutine負責監聽寫到該Topic的訊息t.waitGroup.Wrap(func() { t.messagePump() })return t}func (t *Topic) messagePump() {var msg *Messagevar chans []*Channelvar memoryMsgChan chan *Message//取出該Topic下所有的Channelfor _, c := range t.channelMap {chans = append(chans, c)}for { //從memoryMsgChan中取出一個訊息,並將訊息複製N份,發送到N個Channel中select { case msg = <-memoryMsgChan: case <-t.exitChan: return}for i, channel := range chans {chanMsg := NewMessage(msg.ID, msg.Body)chanMsg.Timestamp = msg.Timestamperr := channel.PutMessage(chanMsg)//訊息寫入到channel的Channel.memoryMsgChan中}}}//Channel.memoryMsgChan負責接收寫到該Channel的所有訊息//建立建立Channel時會開啟一個新的goroutine(messagePump)負責監聽Channel.memoryMsgChan,當有訊息時會將該訊息寫到Channel.clientMsgChan中,訂閱該channel的consumer都會試圖從clientMsgChan中取訊息,一條訊息只能被一個consumer搶到//Channel還負責訊息的可靠傳遞,當訊息發往consumer時,Channel會記錄下該訊息的發送時間,如果在一定時間內(msg-timeout參數)沒有接受到consumer對該訊息的確認,Channel會將該訊息重新寫到Channel.memoryMsgChan中,再次發送給用戶端。type Channel struct {name string //channel的名稱memoryMsgChan chan *MessageclientMsgChan chan *Messageclients map[int64]Consumer}func NewChannel(topicName string, channelName string)c := &Channel{topicName: topicName,name: channelName,memoryMsgChan: make(chan *Message, ctx.nsqd.opts.MemQueueSize),clientMsgChan: make(chan *Message),exitChan: make(chan int),}go c.messagePump()return c}//往channel中寫入訊息。func (c *Channel) put(m *Message) error {select {case c.memoryMsgChan <- m:}return nil}func (c *Channel) messagePump() {var msg *Messagefor {select {case msg = <-c.memoryMsgChan:case <-c.exitChan:goto exit}c.clientMsgChan <- msg //多個消費者會同時爭搶clientMsgChan中得訊息,但只有一個消費者爭搶到}exit:close(c.clientMsgChan)}```> 要理解Topic Channel中各種chan的作用,關鍵是要理解golang中如何在並發環境下如何操作一個結構體(多個goroutine同時操作topic),與C/C++多線程操作同一個結構體時加鎖(mutex,rwmutex)不同,go語言中一般是為這個結構體(topic,channel)開啟一個主goroutine(messagePump函數),所有對該結構體的改變的操作都應是該主goroutine完成的,也就不存在並發的問題了,其它goroutine如果想要改變這個結構體則應該向結構體提供的chan中發送訊息(msgchan)或者通知(exitchan,updatechan),主goroutine會一直監聽所有的chan,當有訊息或者通知到來時做相應的處理。## 資料的持久化瞭解資料的持久化之前,我們先來看兩個問題? 1. 往Topic中寫入訊息就是將訊息發送到Topic.memoryMsgChan中,但是memoryMsgChan是一個固定記憶體大小的記憶體隊列,如果隊列滿了怎麼辦呢?會阻塞嗎?2. 如果訊息都存放在memoryMsgChan這個記憶體隊列中,程式退出了訊息就全部丟失了嗎?NSQ是如何解決的呢,nsq在建立Topic、Channel的時候都會建立一個DiskQueue,DiskQueue負責向磁碟檔案中寫入訊息、從磁碟檔案中讀取訊息,是NSQ實現資料持久化的最重要結構。以Topic為例,如果向Topic.memoryMsgChan寫入訊息但是memoryMsgChan已滿時,nsq會將訊息寫到topic.DiskQueue中,DiskQueue會負責將訊息記憶體同步到磁碟上。如果從Topic.memoryMsgChan中讀取訊息時,但是memoryMsgChan並沒有訊息時,就從topic.DiskQueue中取出同步到磁碟檔案中的訊息。```func NewTopic(topicName string,ctx *context) *Topic { ... //其它初始化代碼 //ctx.nsqd.opts都是一些程式啟動時的命令列參數t.backend = newDiskQueue(topicName,ctx.nsqd.opts.DataPath,ctx.nsqd.opts.MaxBytesPerFile,ctx.nsqd.opts.SyncEvery,ctx.nsqd.opts.SyncTimeout,ctx.nsqd.opts.Logger)return t}//將訊息寫到topic的channel中,如果topic的memoryMsgChan已滿則將topic寫到磁碟檔案中func (t *Topic) put(m *Message) error {select {case t.memoryMsgChan <- m:default://從buffer池中取出一個buffer介面,將訊息寫到buffer中,再將buffer寫到topic.backend的wirteChan中 //buffer池是為了避免重複的建立銷毀buffer對象b := bufferPoolGet()t.backend.WriteChan <- bbufferPoolPut(b)}return nil}func (t *Topic) messagePump() { ...//參見上文代碼for { //從memoryMsgChan及DiskQueue.ReadChan中取訊息select { case msg = <-memoryMsgChan: case buf = <- t.backend.ReadChan(): msg, _ = decodeMessage(buf) case <-t.exitChan: return} ... //將msg複製N份,發送到topic下的N個Channel中}}```我們看到topic.backend(diskQueue)負責將訊息寫到磁碟並從磁碟中讀取訊息,diskQueue提供了兩個chan供外部使用:readChan與writeChan。我們來看下diskQueue實現中的幾個要點。1. diskQueue在建立時會開啟一個goroutine,從磁碟檔案中讀取訊息寫到readChan中,外部goroutine可以從readChan中擷取訊息;隨時監聽writeChan,當有訊息時從wirtechan中取出訊息,寫到本地磁碟檔案。2. diskQueue既要提供檔案的讀服務又要提供檔案的寫服務,所以要記錄下檔案的讀位置(readIndex),寫位置(writeIndex)。每次從檔案中讀取訊息時使用file.Seek(readindex)定位到檔案讀位置然後讀取訊息資訊,每次往檔案中寫入訊息時都要file.Seek(writeIndex)定位到寫位置再將訊息寫入。3. readIndex,writeIndex很重要,程式退出時要將這些資訊(meta data)寫到另外的磁碟檔案(元資訊檔)中,程式啟動時首先讀取元資訊檔,在根據元資訊檔中的readIndex writeIndex操作儲存資訊的檔案。4. 由於作業系統層也有緩衝,調用file.Write()寫入的資訊,也可能只是存在緩衝中並沒有同步到磁碟,需要顯示調用file.sync()才可以強制要求作業系統把緩衝同步到磁碟。可以通過指定建立diskQueue時傳入的syncEvery,syncTimeout來控制調用file.sync()的頻率。syncTimeout是指每隔syncTimeout秒調用一次file.sync(),syncEvery是指每當寫入syncEvery個訊息後調用一次file.sync()。這兩個參數都可以在啟動nsqd程式時通過命令列指定。##網路架構nsq是一個可靠的、高效能的服務端網路程式,通過閱讀nsqd的源碼來學習如何搭建一個可靠的網路服務端程式。```//首先是監聽連接埠,當有請求到來時開啟一個goroutine去處理該連結請求func TCPServer(listener net.Listener) {for {clientConn, err := listener.Accept()go Handle(clientConn)}}func Handle(clientConn net.Conn) {//用戶端首先需要發送一個四位元組的協議編號,表示用戶端當前所使用的協議//這樣便於以後平滑的協議升級,服務端可以根據用戶端的協議編號做不同的處理buf := make([]byte, 4)_, err := io.ReadFull(clientConn, buf)protocolMagic := string(buf)var prot util.Protocolswitch protocolMagic {case " V2":prot = &protocolV2{ctx: p.ctx}default:return}//成功建立串連,按照相應的協議編號去處理該連結err = prot.IOLoop(clientConn)return}}```用戶端已成功的與伺服器建立連結了,每一個用戶端建立串連後,nsqd都會建立一個Client介面體,該結構體內儲存一些client的狀態資訊。每一個Client都會有兩個goroutine,一個goroutine負責讀取用戶端主動發送的各種命令,解析命令,處理命令並將處理結果回複給用戶端。另一個goutine負責定時發送心跳資訊給用戶端,如果客訂閱某個channel的話則將channel中的將訊息通過網路發送給用戶端。> 如果服務端不需要主動推送大量訊息給用戶端,一個串連只需要開一個goroutine處理請求並發送回複就可以了,這是最簡單的方式。開啟兩個goroutine操作同一個conn的話就需要注意加鎖了。```func (p *protocolV2) IOLoop(conn net.Conn) error { //建立一個新的Client對象clientID := atomic.AddInt64(&p.ctx.nsqd.clientIDSequence, 1)client := newClientV2(clientID, conn, p.ctx)//開啟另一個goroutine,定時發送心跳資訊,用戶端收到心跳資訊後要回複。//如果nsqd長時間未收到該串連的心跳回複說明串連已出問題,會中斷連線,這就是nsq的心跳實現機制go p.messagePump(client)for {//如果超過client.HeartbeatInterval * 2時間間隔內未收到用戶端發送的命令,說明串連處問題了,需要關閉此連結。//正常情況下每隔HeartbeatInterval時間用戶端都會發送一個心跳回複。 client.SetReadDeadline(time.Now().Add(client.HeartbeatInterval * 2)) //nsq規定所有的命令以 “\n”結尾,命令與參數之間以空格分隔 line, err = client.Reader.ReadSlice('\n') //params[0]為命令的類型,params[1:]為命令參數params := bytes.Split(line, separatorBytes)//處理用戶端發送過來的命令response, err := p.Exec(client, params)if err != nil {sendErr := p.Send(client, frameTypeError, []byte(err.Error()))if _, ok := err.(*util.FatalClientErr); ok {break}continue} //將命令的處理結果發送給用戶端if response != nil {err = p.Send(client, frameTypeResponse, response)}} //串連出問題了,需要關閉串連conn.Close()close(client.ExitChan) //關閉client的ExitChan//client.Channel記錄的是該客訂閱的Channel,用戶端關閉的時候需要從Channel中移除這個訂閱者。if client.Channel != nil {client.Channel.RemoveClient(client.ID)}return err}func (p *protocolV2) Exec(client *clientV2, params [][]byte) ([]byte, error) {switch {case bytes.Equal(params[0], []byte("FIN")):return p.FIN(client, params)case bytes.Equal(params[0], []byte("RDY")):return p.RDY(client, params)case bytes.Equal(params[0], []byte("PUB")):return p.PUB(client, params)case bytes.Equal(params[0], []byte("NOP")):return p.NOP(client, params)case bytes.Equal(params[0], []byte("SUB")):return p.SUB(client, params)}return nil, util.NewFatalClientErr(nil, "E_INVALID", fmt.Sprintf("invalid command %s", params[0]))}```我們來看下NSQ中幾個比較重要的命令:> + **NOP** 心跳回複,沒有實際意義+ **PUB** 發布一個訊息到 話題(topic) ` PUB <topic_name>\n [ 四位元組訊息的大小 ][ 訊息的內容 ]`+ **SUB** 訂閱話題(topic) /通道(channel) `SUB <topic_name> <channel_name>\n`+ **RDY** 更新 RDY 狀態 (表示用戶端已經準備好接收N 訊息) `RDY <count>\n`+ **FIN** 完成一個訊息 (表示成功處理) `FIN <message_id>\n` 生產者產生訊息的過程比較簡單,就是一個PUB命令,先讀取四位元組的訊息大小,然後根據訊息大小讀取訊息內容,然後將內容寫到topic.MessageChan中。我們重點來看下消費者是如何從nsq中讀取訊息的。1. 消費者首先需要發送SUB命令,告訴nsqd它想訂閱哪個Channel,然後nsqd將該Client與Channel建立對應關係。2. 消費者發送RDY命令,告訴服務端它以準備好接受count個訊息,服務端則向消費者發送count個訊息,如果消費者想繼續接受訊息就需要不斷髮送RDY命令告訴服務端自己準備好接受訊息(類似TCP協議中滑動視窗的概念,消費者並不是按照順序一個個的消費訊息,NSQD最多可以同時count個訊息給消費者,每推送給消費者一個訊息count數目減一,當消費者處理完訊息回複FIN指令時count+1)。``` func (p *protocolV2) SUB(client *clientV2, params [][]byte) ([]byte, error) {topicName := string(params[1])channelName := string(params[2])topic := p.ctx.nsqd.GetTopic(topicName)channel := topic.GetChannel(channelName)//將Client與Channel建立關聯關係channel.AddClient(client.ID, client)client.Channel = channel// update message pumpclient.SubEventChan <- channelreturn okBytes, nil}func (p *protocolV2) messagePump(client *clientV2, startedChan chan bool) {subEventChan := client.SubEventChanheartbeatTicker := time.NewTicker(client.HeartbeatInterval)for { //IsReadyForMessages就是檢查Client的RDY命令所設定的ReadyCount,判斷是否可以繼續向Client發送訊息if subChannel == nil || !client.IsReadyForMessages() {//用戶端還未做好準備則將clientMsgChan設定為nilclientMsgChan = nil} else {//用戶端做好準備,則試圖從訂閱的Channel的clientMsgChan中讀取訊息clientMsgChan = subChannel.clientMsgChan}select {//接收到用戶端發送的RDY命令後,則會向ReadyStateChan中寫入訊息,下面的case條件則可滿足,重新進入for迴圈case <-client.ReadyStateChan://接收到用戶端發送的SUB命令後,會向subEventChan中寫入訊息,subEventChan則被置為nil,所以一個用戶端只能訂閱一次Channelcase subChannel = <-subEventChan:// you can't SUB anymoresubEventChan = nil//發送心跳訊息case <-heartbeatChan:err = p.Send(client, frameTypeResponse, heartbeatBytes)//會有N個消費者共同監聽channel.clientMsgChan,一條訊息只能被一個消費者搶到case msg, ok := <-clientMsgChan:if !ok {goto exit}//以訊息的發送時間排序,將訊息放在一個最小時間堆上,如果在規定時間內收到對該訊息的確認回複(FIN messageId)說明訊息以被消費者成功處理,會將該訊息從堆中刪除。//如果超過一定時間沒有接受 FIN messageId,會從堆中取出該訊息重新發送,所以nsq能確保一個訊息至少被一個i消費處理。subChannel.StartInFlightTimeout(msg, client.ID, msgTimeout)client.SendingMessage()//通過網路發送給消費者err = p.SendMessage(client, msg, &buf)case <-client.ExitChan:goto exit}}exit:heartbeatTicker.Stop()}``` ##參考文獻[NSQ 指南](http://wiki.jikexueyuan.com/project/nsq-guide/intro.html)[使用訊息佇列的 10 個理由](http://www.oschina.net/translate/top-10-uses-for-message-queue)[關於go同步和非同步模式的疑惑](https://groups.google.com/forum/?fromgroups#!topic/golang-china/q9qClpwk5RY)