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

随声附和 提交于 2020-01-07 17:51:29

【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>>

1 悲剧直播推送功能



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


B站直播有个推送功能,就是这里,看到那个红色的数字没有,显示你关注的主播开播人数。

输入图片说明 然后每个进入B站的用户,不管是不是直播的观众、不管进入B站哪个页面、不管你要干啥,都要请求一次这个人数接口,直播服务表示:妈逼,就给老子几台土豆服务器,却要扛着跟主站一样PV,

输入图片说明

不仅仅是主站在使用这个功能,还有直播服务内部的各种推送心跳同样在使用这个功能,流量很大。

由于主站、直播对于UP主和主播关注是混在一起的,所以每次直播这边都要从一堆用户关注UP主中找到直播的主播,并且还要找到那个主播在直播,老的做法就是从缓存读各种数据,然后遍历,计算,然后输出,对缓存服务器、PHP服务器都造成了极大的压力,然后遇到大的活动,服务器分分钟都是:老子不想干了的节奏。然后大的活动每次都会把推送能关掉,来保证活动正常进行。

1.2 穷则思变的重构


你们以为大佬们一开始就同意我的Golang重构方案吗?你们啊

太年轻

我苦口婆心的跟大佬们诉说我的方案是多么适合这个业务,然后我Golang技术有多好(无耻笑)、多靠谱,加上在弹幕服务器部门做了一段时间Golang的兼职(没错,是我舔着脸要去的),做了些大流量的功能,他们终于同意了,呵呵,是时候展示真正的技术了(请脑补小黄毛EZ配音)。

1.3 使用Golang重构基本思路


  • 用Golang进程内存替换Memcache,减少网络io。
  • 让Golang计算数据,PHP通过RPC获取计算好数据,然后组装下房间标题,用户头像数据等。

没错,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这条路不通了,然后我开始研究其他的方式。


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

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!