前言:掃碼登陸功能自微信提出後,愈來愈多的被應用於各個web與app。這兩天公司要作一個掃碼登陸功能,在leader的技術支持幫助下(基本都靠leader排坑),終於將服務搭建起來,而且支持上萬併發。前端
決定作掃碼登陸功能以後,在網上查看了不少的相關資料。對於掃碼登陸的實現方式有不少,淘寶用的是輪詢,微信用長鏈接,QQ用輪詢……。方式雖多,但目前看來大致分爲兩種,1:輪詢,2:長鏈接。(兩種方式各有利弊吧,我研究不深,優缺點就不贅述了)
在和leader討論以後選擇了用長鏈接的方式。因此對長鏈接的實現方式調研了不少:
1.微信長鏈接:經過動態加載script的方式實現。
linux
這種方式好在沒有跨域問題。
2.websocket長鏈接:在PC端與服務端搭起一條長鏈接後,服務端主動不斷地向PC端推送狀態。這應該是最完美的作法了。
3.我使用的長鏈接:PC端向服務端發送請求,服務端並不當即響應,而是hold住,等到用戶掃碼以後再響應這個請求,響應後鏈接斷開。
golang
爲何不採用websocket呢?由於當時比較急、而對於websocket的使用比較陌生,因此沒有使用。不過我如今這種作法在資源使用上比websocket低不少。web
(原本想把leader畫的一副架構圖放上來,但涉及到公司,不敢)
本身畫的一副流程圖
稍微解釋一下:
第一條鏈接:打開PC界面的時候向服務端發送請求並創建長鏈接(1)。當APP成功掃碼後(2),響應此次請求(3)。
第二條鏈接相似。redis
分析得出咱們的服務只須要兩個接口便可
1.與PC創建長鏈接的接口
2.接收APP端數據並將數據發送給前端的接口後端
再細想可將這兩個接口抽象爲:
1.PC獲取狀態接口:get
2.APP設置狀態接口:set跨域
用GO寫的(很少嗶嗶)
長鏈接的根本原理:鏈接請求後,服務端利用channel阻塞住。等到channel中有value後,將value響應安全
func Router(){ http.HandleFunc("/status/get", Get) http.HandleFunc("/status/set", Set) }
每一條鏈接須要有一個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方法 }
APP掃碼後能夠獲得二維碼中的KEY,同時將想給PC發送的VALUE一塊兒發送給服務端websocket
func Set(w http.ResponseWriter, r *http.Request){ ... key = ... value = ... //向PC傳遞的值 Status[key] <- value }
這就是實現的最基本原理。
接下來咱們一點點實現其餘的功能。
從網上找了不少資料,大部分都說這種方式
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都是很好的作法。
這樣超時功能就實現了
服務若是隻部署在一臺機器上,萬一機器跪了,那就全跪了。
因此咱們的服務必須同時部署在多個機器上工做。即便其中一臺掛了,也不影響服務使用。
這個圖不會畫,只能用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發送到這臺機器上。
此時就多了一個接口,用於機器內部相互調用
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 }
func Get(w http.ResponseWriter, r *http.Request){ ... IP = getLocalIp() //獲得本機IP RedisSet(key, IP) //以key作鍵,IP作值放入redis Status[key] <- value ... }
func Set(w http.ResponseWriter, r *http.Request){ ... IP = RedisGet(key) //用key去取對應機器的IP Post(IP, key, value) //將key與value都發送給這臺機器 }
注
這裏至關於用redis sentinel作多臺機器的通訊。哨兵會幫咱們將數據同步到全部機器上
這樣便可實現多機支持
剛部署到線上的時候,第一次嘗試就跪了。查看錯誤...(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方式,則沒有跨域問題。
服務端直接容許跨域,可能會有安全問題,但我不是很瞭解,這裏爲了使用,就容許跨域了。
跨域問題解決以後,線上能夠正常使用了。緊接着請測試同窗壓測了一下。
預期單機併發10000以上,測試同窗直接壓了10000,服務掛了。
可能預期有點高,5000吧,因而壓了5000,服務掛了。
1000呢,服務掛了。
100,服務掛了。
……
這下豁然開朗,不多是機器問題,絕對是有BUG
看了下報錯
去看了下官方文檔
Map是不能併發的寫操做,但能夠併發的讀。
原來對Map操做是這樣寫的
func Get(w http.ResponseWriter, r *http.Request){ ... `Status[key] = make(chan string)` `defer close(Status[key])` ... 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() ... }
到如今,服務就能夠正常使用了,而且支持上萬併發。
服務正常使用以後,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,在過時時間內是沒法屢次使用的。不過這與業務要求並不衝突。
在給測試同窗測試以前,本身也壓測了一下。不過剛上來就瘋狂報錯,「%¥#@¥……%……%%..too many fail open...」
搜索結果是linux默認最大句柄數1024.
開了下本身的機器 ulimit -a
果真1024。修改(修改方法很少BB)
服務有兩個API,get是給前端使用的,對外開放。set是給後端使用的,內部接口。因此這兩個接口須要放在兩個端口上。
因爲http.ListenAndServe()
自己有阻塞,故第一個監聽須要一個goroutine
go http.ListenAndServe(":11000", FrontendMux) //對外開放的端口 http.ListenAndServe(":11001", BackendMux) //內部使用的端口