B站直播有個推送功能,就是這裏,看到那個紅色的數字沒有,顯示你關注的主播開播人數。php
而後每一個進入B站的用戶,不論是不是直播的觀衆、無論進入B站哪一個頁面、無論你要幹啥,都要請求一次這我的數接口,直播服務表示:媽逼,就給老子幾臺土豆服務器,卻要扛着跟主站同樣PV,html
不只僅是主站在使用這個功能,還有直播服務內部的各類推送心跳一樣在使用這個功能,流量很大。node
因爲主站、直播對於UP主和主播關注是混在一塊兒的,因此每次直播這邊都要從一堆用戶關注UP主中找到直播的主播,而且還要找到那個主播在直播,老的作法就是從緩存讀各類數據,而後遍歷,計算,而後輸出,對緩存服務器、PHP服務器都形成了極大的壓力,而後遇到大的活動,服務器分分鐘都是:老子不想幹了的節奏。而後大的活動每次都會把推送能關掉,來保證活動正常進行。linux
大家覺得大佬們一開始就贊成個人Golang重構方案嗎?大家啊git
我苦口婆心的跟大佬們訴說個人方案是多麼適合這個業務,而後我Golang技術有多好(無恥笑)、多靠譜,加上在彈幕服務器部門作了一段時間Golang的兼職(沒錯,是我舔着臉要去的),作了些大流量的功能,他們終於贊成了,呵呵,是時候展現真正的技術了(請腦補小黃毛EZ配音)。github
沒錯,Golang作的就是個數據中間件。golang
最開始想要使用鳥哥PHP的Yar RPC擴展,雖然Yar在php手冊裏並無說明Yar支持tcp協議的通訊方式,可是我經過閱讀Yar的源碼發現,其實它是支持tcp協議的通訊方式。 yar_client.c數據庫
PHP_METHOD(yar_client, __construct) { zend_string *url; zval *options = NULL; if (zend_parse_parameters_throw(ZEND_NUM_ARGS(), "S|a!", &url, &options) == FAILURE) { return; } zend_update_property_str(yar_client_ce, getThis(), ZEND_STRL("_uri"), url); if (strncasecmp(ZSTR_VAL(url), "http://", sizeof("http://") - 1) == 0 || strncasecmp(ZSTR_VAL(url), "https://", sizeof("https://") - 1) == 0) { } else if (strncasecmp(ZSTR_VAL(url), "tcp://", sizeof("tcp://") - 1) == 0) { zend_update_property_long(yar_client_ce, getThis(), ZEND_STRL("_protocol"), YAR_CLIENT_PROTOCOL_TCP); } else if (strncasecmp(ZSTR_VAL(url), "unix://", sizeof("unix://") - 1) == 0) { zend_update_property_long(yar_client_ce, getThis(), ZEND_STRL("_protocol"), YAR_CLIENT_PROTOCOL_UNIX); } else { php_yar_client_trigger_error(1, YAR_ERR_PROTOCOL, "unsupported protocol address %s", ZSTR_VAL(url)); return; } if (options) { zend_update_property(yar_client_ce, getThis(), ZEND_STRL("_options"), options); } }
客戶端OK,那麼開始找服務端,面向Github編程的時候到了,果然已經有人實現Golang的Yar服務端 goyar,嘿嘿,把做者寫的demo跑一下,發現沒什麼問題,而後我習慣性的用wireshark抓包看看,Yar client和server之間的通訊,愕然發現,Yar client不復用任何tcp鏈接,即便是同一個Yar client對象,每次請求都是不復用tcp鏈接的(大寫的懵逼臉),雖然不太清楚鳥哥這麼實現真實意圖,我的猜想多是爲了Yar client異步併發請求,防止數據錯誤,才這麼設計的。代碼就不貼了,有興趣的同窗能夠去goyar,本身跑下demo驗證。編程
Yar RPC這條路不通了,而後我開始研究其餘的方式。json
經過閱讀Golang jsonrpc包源碼、文檔、JSON-RPC協議文檔、Golang實現jsonrpc的server和client通訊抓包,我發現JSON-RPC協議,僅僅是經過固定格式的json字符串來通訊的,並且沒有什麼包頭、包長、結束符之類的設置,真是簡單粗暴(微笑臉),貼一段Go jsonrpc server 簡單看下。
// 這裏就是請求的結構體 type serverRequest struct { Method string `json:"method"` Params *json.RawMessage `json:"params"` Id *json.RawMessage `json:"id"` } // 這裏就是返回的結構體 type serverResponse struct { Id *json.RawMessage `json:"id"` Result interface{} `json:"result"` Error interface{} `json:"error"` } func (c *serverCodec) ReadRequestHeader(r *rpc.Request) error { c.req.reset() if err := c.dec.Decode(&c.req); err != nil { return err } r.ServiceMethod = c.req.Method // JSON request id can be any JSON value; // RPC package expects uint64. Translate to // internal uint64 and save JSON on the side. c.mutex.Lock() c.seq++ c.pending[c.seq] = c.req.Id c.req.Id = nil r.Seq = c.seq c.mutex.Unlock() return nil } func (c *serverCodec) ReadRequestBody(x interface{}) error { if x == nil { return nil } if c.req.Params == nil { return errMissingParams } // JSON params is array value. // RPC params is struct. // Unmarshal into array containing struct for now. // Should think about making RPC more general. var params [1]interface{} params[0] = x return json.Unmarshal(*c.req.Params, ¶ms) } var null = json.RawMessage([]byte("null")) func (c *serverCodec) WriteResponse(r *rpc.Response, x interface{}) error { c.mutex.Lock() b, ok := c.pending[r.Seq] if !ok { c.mutex.Unlock() return errors.New("invalid sequence number in response") } delete(c.pending, r.Seq) c.mutex.Unlock() if b == nil { // Invalid request so no id. Use JSON null. b = &null } resp := serverResponse{Id: b} if r.Error == "" { resp.Result = x } else { resp.Error = r.Error } return c.enc.Encode(resp) }
這裏有實現代碼PHP和Golang經過JSON- RPC通訊,順便說一下,PHP socket擴展的性能仍是很不錯的,i5 CPU、8G內存的macOS能夠單鏈接達到2-3w QPS,Golang服務的QPS後面再說。
至於鏈接複用,只須要簡單是用下單例模式,保證用戶一次http請求到結束,用的是一個tcp鏈接便可,在請求結束後,釋放這個鏈接。
將主播的直播關播數據和用戶關注數據用key value的形式分別放到map裏,寫了第一個版本,而後用Golang寫個一個壓測工具,1000併發、每一個鏈接請求1000次,每次測試程序都會crash,錯誤
fatal error: concurrent map writes fatal error: concurrent map read and map write
當時我是懵逼的,我寫map只有一個goroutine在寫,其餘都在讀啊,怎麼會這樣,OK,面向stackoverflow編程的時候到了,看到有人說Go 1.5的時候,map是能夠髒讀的(推送服務並不要求100%的準備,容許有髒數據),可是Go 1.6不容許這麼作了……日了狗了……
又要面向Github編程,有人實現併發map,syncmap,原理很簡單,就是使用Go sync包的讀寫鎖功能,來實現併發安全,並且還實現了數據分片,看的代碼,寫的不錯,作了下測試,性能不錯,可是這個syncmap key只能用string,我作了簡單的修改key支持int。
func (m *SyncMap) locate(key interface{}) *syncMap { ik, ok := key.(int) if ok { return m.shards[uint32(ik) & uint32((m.shardCount - 1))] } sk := key.(string) return m.shards[bkdrHash(sk) & uint32((m.shardCount - 1))] }
進程啓動的時候,從MySQL全量讀取主播數據放到內存,而後並異步存到Go版本的LevelDB,用戶關注數據在用戶初次訪問的時候,從主站API獲取並緩存到LevelDB,而後在程序重啓更新的時候,能夠作到快速重啓(由於僅讀本地數據),對用戶的影響時間能夠降到最小。
LevelDB的主要做用就是數據冷備,在進程重啓的時候使用,減小對數據庫的壓力。
可是LevelDB能作的不只僅如此,LevelDB和Go能輕鬆實現一個相似於Redis(有人已經實現將LevelDB整合到Redis裏)的服務,還有待挖掘。
進程啓動的時候會註冊到zookeeper的Ephemeral類型的node,在程序重啓、宕機的時候,自動將新的配置發送到PHP服務器,作到無縫切換。
重構完成後,我對這個中間層服務作了個壓測(客戶端??本身寫呀),1000併發,1000請求,i5 CPU,8G內存debain linux pc機,達到了14W多的QPS,每一個核的使用率穩定在70%左右,線上服務器24核服務器請自動心算*X就能夠大體估算出來,並考慮服務器CPU比普通的PC的CPU高到不知道哪裏去了,呵呵,不當心又續……。
具體監控數據不方便貼出來,我簡單說下:推送接口耗時減小了2/3還多,並且穩定性也提升了很多,並且這些接口的日訪問量是以億爲單位的。
Golang 如今已經擁有完善的社區環境,不少東西都能面向Github編程,內置包功能完善,學習成本很低,簡直就是編譯強類型語言中的PHP。
更多架構、PHP、GO相關踩坑實踐技巧請關注個人公衆號:PHP架構師