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架构师

相关文章
相关标签/搜索