Golang-長連接-狀態推送

狀態推送

前言:掃碼登錄功能自微信提出後,越來越多的被應用於各個web與app。這兩天公司要做一個掃碼登錄功能,在leader的技術支持幫助下(基本都靠leader排坑),終於將服務搭建起來,並且支持上萬併發。

長連接選擇

決定做掃碼登錄功能之後,在網上查看了很多的相關資料。對於掃碼登錄的實現方式有很多,淘寶用的是輪詢,微信用長連接,QQ用輪詢……。方式雖多,但目前看來大體分爲兩種,1:輪詢,2:長連接。(兩種方式各有利弊吧,我研究不深,優缺點就不贅述了)
在和leader討論之後選擇了用長連接的方式。所以對長連接的實現方式調研了很多:
1.微信長連接:通過動態加載script的方式實現。
圖片描述

這種方式好在沒有跨域問題。
2.websocket長連接:在PC端與服務端搭起一條長連接後,服務端主動不斷地向PC端推送狀態。這應該是最完美的做法了。
3.我使用的長連接:PC端向服務端發送請求,服務端並不立即響應,而是hold住,等到用戶掃碼之後再響應這個請求,響應後連接斷開。
圖片描述

爲什麼不採用websocket呢?因爲當時比較急、而對於websocket的使用比較陌生,所以沒有使用。不過我現在這種做法在資源使用上比websocket低很多。

接口設計

(本來想把leader畫的一副架構圖放上來,但涉及到公司,不敢)
自己畫的一副流程圖
圖片描述
稍微解釋一下:
第一條連接:打開PC界面的時候向服務端發送請求並建立長連接(1)。當APP成功掃碼後(2),響應這次請求(3)。
第二條連接類似。

分析得出我們的服務只需要兩個接口即可
1.與PC建立長連接的接口
2.接收APP端數據並將數據發送給前端的接口

再細想可將這兩個接口抽象爲:
1.PC獲取狀態接口:get
2.APP設置狀態接口:set

具體實現

用GO寫的(不多嗶嗶)
長連接的根本原理:連接請求後,服務端利用channel阻塞住。等到channel中有value後,將value響應

Router
func Router(){
    http.HandleFunc("/status/get", Get)
    http.HandleFunc("/status/set", Set)
}
GET

每一條連接需要有一個KEY作標識,不然APP設置的狀態不知道該發給那臺PC。每一條連接即一個channel

var Status map[string](chan string) = make(map[string](chan string))

func Get(w http.ResponseWriter, r *http.Request){
    ...        //接收key的操作
    key = ...  //PC在請求接口時帶着的key
    Status[key] = make(chan string)    //不需要緩衝區
    value := <-Status[key]
    ResponseJson(w, 0, "success", value)    //自己封的響應JSON方法
}
SET

APP掃碼後可以得到二維碼中的KEY,同時將想給PC發送的VALUE一起發送給服務端

func Set(w http.ResponseWriter, r *http.Request){
    ...        
    key = ...
    value = ...    //向PC傳遞的值
    Status[key] <- value
}

這就是實現的最基本原理。
接下來我們一點點實現其他的功能。

1.超時

從網上找了很多資料,大部分都說這種方式

srv := &http.Server{  
    ReadTimeout: 5 * time.Second,
    WriteTimeout: 10 * time.Second,
}
log.Println(srv.ListenAndServe())

這種方式確實是設置讀超時與寫超時。但(親測)這種超時方式並不友善,假如現在WriteTimeout是10s,PC端請求過來之後,長連接建立。PC處於pending狀態,並且服務端被channel阻塞住。10s之後,由於超時連接失效(並沒有斷,我也不瞭解其中原理)。PC並不知道連接斷了,依然處於pending狀態,服務端的這個goroutine依然被阻塞在這裏。這個時候我調用set接口,第一次調用沒用反應,但第二次調用PC端就能成功接收value。
圖片描述
從圖可以看出,我設置的WriteTimeout爲10s,但這條長連接即使15s依然能收到成功響應。(ps:我調用了兩次set接口,第一次沒有反應)


研究後決定不使用這種方式設置超時,採用接口內部定時的方式實現超時返回

select {
    case <-`Timer`:
        utils.ResponseJson(w, -1, "timeout", nil)
    case value := <-statusChan:
        utils.ResponseJson(w, 0, "success", value)
    }

Timer即爲定時器。剛開始Timer是這樣定義的

Timer := time.After(60 * time.Second)

60s後Timer會自動返回一個值,這時上面的通道就開了,響應timeout
但這樣做有一個弊端,這個定時器一旦創建就必須等待60s,並且我沒想到辦法提前將定時器關了。如果這個長連接剛建立後5s就被響應,那麼這個定時器就要多存在55s。這樣對資源是一種浪費,並不合理。
這裏選用了context作爲定時器

ctx, cancel := context.WithTimeout(context.Background(), time.Duration(Timeout)*time.Second)
defer cancel()
select {
    case <-ctx.Done():
        utils.ResponseJson(w, -1, "timeout", nil)
    case result := <-Status[key]:
        utils.ResponseJson(w, 0, "success", result)
}

ctx在初始化的時候就設置了超時時間time.Duration(Timeout)*time.Second
超時之後ctx.Done()返回完成,起到定時作用。如果沒有cancel()則會有一樣的問題。原因如下
圖片描述
具體參考如下:https://blog.csdn.net/liangzh...
context對比time包。提供了手動關閉定時器的方法cancel()
只要get請求結束,都會去關閉定時器,這樣可以避免資源浪費(一定程度避免內存泄漏)。
即使golang官方文檔中,也推薦defer cancel()這樣寫
圖片描述
官方文檔也寫到:即使ctx會在到期時關閉,但在任何場景手動調用cancel都是很好的做法。

這樣超時功能就實現了

2.多機支持

服務如果只部署在一臺機器上,萬一機器跪了,那就全跪了。
所以我們的服務必須同時部署在多個機器上工作。即使其中一臺掛了,也不影響服務使用。
這個圖不會畫,只能用leader的圖了
圖片描述
在項目初期討論的時候leader給出了兩種方案。1.如圖使用redis做多機調度。2.使用zookeeper將消息發送給多機
因爲現在是用redis做的,只講述下redis的實現。(但依賴redis並不是很好,多機的負載均衡還要依賴其他工具。zookeeper能夠解決這個問題,之後會將redis換成zookeeper)

首先我們要明確多機的難點在哪?
我們有兩個接口,get、set。get是給前端建立長連接用的。set是後端設置狀態用的。
假設有兩臺機器A、B。若前端的請求發送到A機器上,即A機器與前端連接,此時後端調用set接口,如果調用的是A機器的set接口,那是最好,長連接就能成功響應。但如果調用了B機器的set接口,B機器上又沒有這條連接,那麼這條連接就無法響應。
所以難點在於如何將同一個key的get、set分配到一臺機器。

做法有很多:
有人給我提過一個意見:在做負載均衡的時候,就將連接分配到指定機器。剛開始我覺的很有道理,但細細想,如果這樣做,在以後如果要加機器或減機器的時候會很麻煩。對橫向的增減機器不友善。
最後我還是採用了leader給出的方案:用redis綁定key與機器的關係
即前端請求到一臺機器上,以key做鍵,以機器IP做值放在redis裏面。後端請求set接口時先用key去redis裏面拿到機器IP,再將value發送到這臺機器上。
此時就多了一個接口,用於機器內部相互調用

ChanSet
func Router(){
    http.HandleFunc("/status/get", Get)
    http.HandleFunc("/status/set", Set)
    http.HandleFunc("/channel/set", ChanSet)
}

func ChanSet(w http.ResponseWriter, r *http.Request){
    ...
    key = ...
    value = ...
    Status[key] <- value
}
GET
func Get(w http.ResponseWriter, r *http.Request){
    ...        
    IP = getLocalIp()       //得到本機IP
    RedisSet(key, IP)       //以key做鍵,IP做值放入redis
    Status[key] <- value
    ...
}
SET
func Set(w http.ResponseWriter, r *http.Request){
    ...
    IP = RedisGet(key)    //用key去取對應機器的IP
    Post(IP, key, value) //將key與value都發送給這臺機器
}

這裏相當於用redis sentinel做多臺機器的通信。哨兵會幫我們將數據同步到所有機器上
這樣即可實現多機支持

3.跨域

剛部署到線上的時候,第一次嘗試就跪了。查看錯誤...(Access-Control-Allow-Origin)...
因爲前端是通過AJAX請求的長連接服務,所以存在跨域問題。
在服務端設置允許跨域

func Get(w http.ResponseWriter, r *http.Request){
    ...
    w.Header().Set("Access-Control-Allow-Origin", "*")
    w.Header().Add("Access-Control-Allow-Headers", "Content-Type")
    ...
}

若是像微信的做法,動態的加載script方式,則沒有跨域問題。
服務端直接允許跨域,可能會有安全問題,但我不是很瞭解,這裏爲了使用,就允許跨域了。

4.Map併發讀寫問題

跨域問題解決之後,線上可以正常使用了。緊接着請測試同學壓測了一下。
預期單機併發10000以上,測試同學直接壓了10000,服務掛了。
可能預期有點高,5000吧,於是壓了5000,服務掛了。
1000呢,服務掛了。
100,服務掛了。
……
這下豁然開朗,不可能是機器問題,絕對是有BUG
看了下報錯
圖片描述
去看了下官方文檔
圖片描述
Map是不能併發的寫操作,但可以併發的讀。
原來對Map操作是這樣寫的

func Get(w http.ResponseWriter, r *http.Request){
    ...
    `Status[key] = make(chan string)`
    ...
    select {
    case <-ctx.Done():
        utils.ResponseJson(w, -1, "timeout", nil)
    case `result := <-Status[key]`:
        utils.ResponseJson(w, 0, "success", result)
    }
    ...
}

func ChanSet(w http.ResponseWriter, r *http.Request){
    ...
    `Status[key] <- value`
    ...
}

Status[key] = make(chan string)在Status(map)裏面初始化一個通道,是map的寫操作
result := <-Status[key]從Status[key]通道中讀取一個值,由於是通道,這個值取出來後,通道內就沒有了,所以這一步也是對map的寫操作
Status[key] <- value向Status[key]內放入一個值,map的寫操作
由於這三處操作的是一個map,所以要加同一把鎖

var Mutex sync.Mutex
func Get(w http.ResponseWriter, r *http.Request){
    ...
    //這裏是同組大佬教我的寫法,通道之間的拷貝傳遞的是指針,即statusChan與Status[key]指向的是同一個通道
    statusChan := make(chan string)
    Mutex.Lock()
    Status[key] = statusChan
    Mutex.Unlock()
    
    //在連接結束後將這些資源都釋放
    defer func(){
        Mutex.Lock()
        delete(Status, key)
        Mutex.Unlock()
        close(statusChan)
        RedisDel(key)
    }()
    
    select {
        case <-ctx.Done():
            utils.ResponseJson(w, -1, "timeout", nil)
        case result := <-statusChan:
            utils.ResponseJson(w, 0, "success", result)
    }
    ...
}

func ChanSet(w http.ResponseWriter, r *http.Request){
    ...
    Mutex.Lock()
    Status[key] <- value
    Mutex.Unlock()
    ...
}

到現在,服務就可以正常使用了,並且支持上萬併發。

5.Redis過期時間

服務正常使用之後,leader review代碼,提出redis的數據爲什麼不設置過期時間,反而要自己手動刪除。我一想,對啊。
於是設置了過期時間並且將RedisDel(key)刪了。
設置完之後不出意外的服務跪了。
究其原因
我用一個key=1請求get,會在redis內存儲一條數據記錄(1 => Ip).如果我set了這條連接,按之前的邏輯會將redis裏的這條數據刪掉,而現在是等待它過期。若是在過期時間內,再次以這個key=1,調用set接口。set接口依然會從redis中拿到IP,Post數據到ChanSet接口。而ChanSet中Status[key] <- value由於Status[key]是關閉的,會阻塞在這裏,阻塞不要緊,但之前這裏加了鎖,導致整個程序都阻塞在這裏。
這裏和leader討論過,仍使用redis過期時間但需要修復這個Bug

func ChanSet(w http.ResponseWriter, r *http.Request){
    Mutex.Lock()
    ch := Status[key]
    Mutex.Unlock()

    if ch != nil {
        ch <- value
    }
}

不過這樣有一個問題,就是同一個key,在過期時間內是無法多次使用的。不過這與業務要求並不衝突。

6.Linux文件最大句柄數

在給測試同學測試之前,自己也壓測了一下。不過剛上來就瘋狂報錯,“%¥#@¥……%……%%..too many fail open...”
搜索結果是linux默認最大句柄數1024.
開了下自己的機器 ulimit -a 果然1024。修改(修改方法不多BB)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章