B站直播:使用Golang重構,流量最大的推送功能

1 悲劇直播推送功能



1.1 B站直播推送功能的困境


B站直播有個推送功能,就是這裏,看到那個紅色的數字沒有,顯示你關注的主播開播人數。php

輸入圖片說明 而後每一個進入B站的用戶,不論是不是直播的觀衆、無論進入B站哪一個頁面、無論你要幹啥,都要請求一次這我的數接口,直播服務表示:媽逼,就給老子幾臺土豆服務器,卻要扛着跟主站同樣PV,html

輸入圖片說明

不只僅是主站在使用這個功能,還有直播服務內部的各類推送心跳一樣在使用這個功能,流量很大。node

因爲主站、直播對於UP主和主播關注是混在一塊兒的,因此每次直播這邊都要從一堆用戶關注UP主中找到直播的主播,而且還要找到那個主播在直播,老的作法就是從緩存讀各類數據,而後遍歷,計算,而後輸出,對緩存服務器、PHP服務器都形成了極大的壓力,而後遇到大的活動,服務器分分鐘都是:老子不想幹了的節奏。而後大的活動每次都會把推送能關掉,來保證活動正常進行。linux

1.2 窮則思變的重構


大家覺得大佬們一開始就贊成個人Golang重構方案嗎?大家啊git

太年輕

我苦口婆心的跟大佬們訴說個人方案是多麼適合這個業務,而後我Golang技術有多好(無恥笑)、多靠譜,加上在彈幕服務器部門作了一段時間Golang的兼職(沒錯,是我舔着臉要去的),作了些大流量的功能,他們終於贊成了,呵呵,是時候展現真正的技術了(請腦補小黃毛EZ配音)。github

1.3 使用Golang重構基本思路


  • 用Golang進程內存替換Memcache,減小網絡io。
  • 讓Golang計算數據,PHP經過RPC獲取計算好數據,而後組裝下房間標題,用戶頭像數據等。

沒錯,Golang作的就是個數據中間件。golang



2 重構踩坑路



2.1 解決PHP和Golang的通訊問題


2.1.1 PHP的Yar RPC


最開始想要使用鳥哥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


2.1.2 JSON-RPC


經過閱讀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, &params)
}

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鏈接便可,在請求結束後,釋放這個鏈接。


2.2 數據結構的選擇


2.2.1 Golang map

將主播的直播關播數據和用戶關注數據用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不容許這麼作了……日了狗了……


2.2.2 syncmap


又要面向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))]
}


3 數據存儲的選擇



3.1 MySQL和LevelDB

進程啓動的時候,從MySQL全量讀取主播數據放到內存,而後並異步存到Go版本的LevelDB,用戶關注數據在用戶初次訪問的時候,從主站API獲取並緩存到LevelDB,而後在程序重啓更新的時候,能夠作到快速重啓(由於僅讀本地數據),對用戶的影響時間能夠降到最小。

LevelDB的主要做用就是數據冷備,在進程重啓的時候使用,減小對數據庫的壓力。

可是LevelDB能作的不只僅如此,LevelDB和Go能輕鬆實現一個相似於Redis(有人已經實現將LevelDB整合到Redis裏)的服務,還有待挖掘。



4 容災備份



進程啓動的時候會註冊到zookeeper的Ephemeral類型的node,在程序重啓、宕機的時候,自動將新的配置發送到PHP服務器,作到無縫切換。



5 重構後的效果



5.1 Golang 的性能

重構完成後,我對這個中間層服務作了個壓測(客戶端??本身寫呀),1000併發,1000請求,i5 CPU,8G內存debain linux pc機,達到了14W多的QPS,每一個核的使用率穩定在70%左右,線上服務器24核服務器請自動心算*X就能夠大體估算出來,並考慮服務器CPU比普通的PC的CPU高到不知道哪裏去了,呵呵,不當心又續……。

5.2 PHP接口耗時

具體監控數據不方便貼出來,我簡單說下:推送接口耗時減小了2/3還多,並且穩定性也提升了很多,並且這些接口的日訪問量是以億爲單位的。



佈道一波



Golang 如今已經擁有完善的社區環境,不少東西都能面向Github編程,內置包功能完善,學習成本很低,簡直就是編譯強類型語言中的PHP。

更多架構、PHP、GO相關踩坑實踐技巧請關注個人公衆號:PHP架構師

相關文章
相關標籤/搜索