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