RTMFP服务器的研发过程

SRS大家见到时,已经准备release 1.0了,其实从0到1.0的过程才是最重要的。
最近在弄RTMFP服务器,虽然我们公司不开源,但是记录我们做这个服务器的过程,应该是没有问题的。

简介
RTMFP有开源的服务器,譬如cumulus,我们也是用的cumulus。cumulus一个进程大约能支持5000并发,我们给新蓝网做直播前两期有近20万,第三期少一点也有4万,就需要8个cumulus进程。
cumulus进程多了会有问题问题?导致p2p之间不能互通,也就是一个cumulus就是一个社区,这些社区的所有人都可以知道。但是如果两个cumulus,是没有办法互通的。(据说cumulus也能弄集群,好像不太稳定)
所以提高cumulus的并发能力,我们就只好自己做rtmfp服务器。另外,cumulus已经2年不更新,明显是一个挂掉的势头,得自己掌握这个rtmfp协议才行。

分析
分析下系统的目标,很明显:超高并发。
分析资源:我们人员对于rtmfp协议都不算精通,对于cumulus也不算精通。 也就是说,我们不太了解如何达到目标。
现有系统:有cumulus,还有rtmplite。
做这种系统,最关键的就是要快,尽快上线,尽快跑通。因此可以参考rtmplite(它的代码少一些),rtmfp协议,以及网上的几篇博客。

过程:搭建快速原型系统
为了了解系统的目标(肯定不用实现rtmfp所有的协议内容,暂时只需要了解用到了哪些部分),需要搭建快速原型。
这个原型需要把整个系统做一个缩影,flash播放器做一个最小的,tracker做一个最简单的,rtmfp用cumulus/rtmplite有有可替换性。
这个原型参考:http://blog.csdn.net/win_lin/article/details/38440673 ( 博客文章后面有配图)
原型最重要的是,以最快的方式来发起请求,简化流程,这样服务器在调试时就很快。而不用真正的播放器,搭建复杂的环境等等。如果用真实的播放器,每次启动调试估计得1分钟;用快速原型,1秒钟就可以进入断点。
原型还可以验证服务器的可替换性,基本上只要原型通过,真实的播放器就同样可以通过。
原型实际上是真实系统的缩影,对于未知系统的研发,是一个不断了解的过程。
搭建原型,写原型播放器,前前后后用了11个工作日。

过程:服务器握手原型
参考rtmplite和协议以及博客,大约花了12工作日,把客户端和服务器握手部分实现了。
这一部分就是NetConnection.connect("rtmfp://server"),这个过程,包含rtmfp的四次握手,客户端发送connect和setPeerInfo消息,最后不断的ping消息保持心跳。
握手最难的部分是加密和丢包处理,不过在开发环境中,可以不用管丢包。加密过程是绕不过去的,这个着实痛苦了好些天,一个一个字节看才能发现问题所在。
另外,adobe的rtmfp协议其实没有描述全部,譬如checksum就没有描述,好在rtmplite和一些先行者已经搞清楚了。
原型播放器的快速调试起了极大的作用。

过程:客户端测试工具
做完服务器握手原型后,不是马上做peer-to-peer握手,而是要做客户端测试工具,即模拟客户端测试服务器并发。
原因有两个:
首先,系统的首要目标是做到比cumulus高N多的并发,必须要测试工具模拟才能找到瓶颈,方便调试,总不能去线上调试吧?
其次,了解丢包的处理过程,了解服务器在处理上的整体思路。有些处理过程还不是很了解,需要两天合起来一起看就知道了,譬如secret这个,其实就是共享密钥,客户端和服务器端是一样的。
客户端测试工具做了3天左右吧,有服务器的经验了,客户端的库就容易许多了。做客户端时大致了解了丢包处理,用ack确认机制。同时,对密钥的生成更加明了了。

过程:计划Refine代码
接下来是不是应该继续做peer-to-peer握手?不是。
客户端和服务器端代码总共9722行,其中有srs代码3576行(主要是amf0和一些通用类)。
也就是说,15天码了有6146行代码。这些代码基本上都是没有想清楚的。譬如有的地方叫做ssid,有的叫session_id;有的地方是叫sequence_number,有的是cumulate_ack;有的地方是secret,有的是shared_key。其实这些都是一回事,但是rtmplite的叫法和rtmfp协议的叫法是不一样的,导致这个问题。
因此,精简逻辑,简化结构,理顺流程,加强验证。也就是refine代码。refine和refactor是不一样的,refactor动作小很多,refine可以无所顾忌的改,随便改。
这时候客户端和服务器就可以互相验证,比原型播放器又快一些。 按照经验,6千行这么快速码出来的代码,可以精简一半左右。
人类理解事物的过程,难道不是往前走走,想想,再往前走走吗?如果一定要冠以敏捷方法,也未尝不可。

过程:Refine代码第一阶段

这段时间一直在Refine代码:
  1. 持续改进代码一周时间, 
  2. 提交了18次较大修改, 
  3. 代码增加了1600行, 
  4. 从只支持一个客户端,变成支持N个客户端 ,
  5. 命名和rtmfp协议保持一致,没有模糊的变量名了,  
  6. 流程上有所改进,没有交错在一起的流程
看起来没有什么变化,实际上已经有了翻天覆地的变化,直观的就是代码可读了。实际上代码行数还没有达到之前的要求,没有减少却增加了,当然是没有做完Refine的缘故,更重要的是:又不是演电影,哪有那么传奇。

电影就是现实极其简单的事情,要弄得特别复杂。现实特别复杂的事情,要弄得非常简单。 譬如非诚勿扰中,葛优忽悠了300万,这个就是后者。 Refine代码,看起来多么优雅、优美、高尚的事情,实际上这个过程和清理花园的杂草一样很不优雅、优美、高尚。

一周的Refine,总体代码行数其实没有什么变化:
FILE                 DEV          REFINE0
rtmfp.hpp       368           443
rtmfp.cpp       814           1017
server.hpp      456           569
server.cpp      2191          2405
client.hpp       132          215
client.cpp       1134          1994
stdinc.hpp       110          46
stdinc.cpp       118          46

过程:Refine代码第二阶段

一周之后(6工作日),代码Refine结束,并且改进了超时和重试机制,改进了收包/发包机制,队列的超时机制。总之,属于基本结构完善的服务器,而不是仅仅一个跑通的服务器。

代码状况从refine之前的5323行,增加到了6735,最后是6627行。 总体代码行数的变化:
FILE                 DEV          REFINE0      REFINE1
rtmfp.hpp       368           443              723
rtmfp.cpp       814           1017            2005
server.hpp      456           569              508
server.cpp      2191          2405            1934
client.hpp       132          215               111
client.cpp       1134          1994            1180
stdinc.hpp       110          46                 120
stdinc.cpp       118          46                  46

可以明显的看到,代码在向rtmfp这个公共模块聚集,server和client的代码都在减少。

过程:实现P2P握手

实现了Peer-to-Server握手,进行了Refine,代码已经适合继续开发功能了,开始实现Peer-to-Peer握手,即P2P握手。

一个周六的下午的时间,实现了peer-to-peer握手。新增解析两个包,一个是FIHello,一个是Redirect,还解析了Address。这个步骤确实比预计的要快很多,原因在于之前的Refine已经将基本结构都实现,新增功能就变得极其容易。

譬如,一个Address的解析实现如下:

RtmfpAddress::RtmfpAddress()
{
    ipv6 = false;
    origin = RtmfpAddressOriginUnknown;
    port = 0;
}

RtmfpAddress::~RtmfpAddress()
{
}

int RtmfpAddress::decode(SrsStream* stream)
{
    int ret = ERROR_SUCCESS;
    
    // u_int8_t flag
    if ((ret = rtmfp_buffer_require(stream, 1)) != ERROR_SUCCESS) {
        return ret;
    }
    u_int8_t flag = stream->read_1bytes();
    
    origin = (RtmfpAddressOrigin)(flag & 0x03);
    ipv6 = (flag >> 7) & 0x01;
    
    if (ipv6) {
        if ((ret = rtmfp_buffer_require(stream, 16)) != ERROR_SUCCESS) {
            return ret;
        }
        in6_addr addr;
        stream->read_bytes((char*)addr.s6_addr, 16);
        
        // @see man inet_ntop
        // @see man 7 ipv6
        // in network-order
        char buf[INET6_ADDRSTRLEN];
        if (inet_ntop(AF_INET6, &addr, buf, INET6_ADDRSTRLEN) == NULL) {
            ret = ERROR_RTMFP_ADDR_ERROR;
            warn("invalid ipv6 errno=%d, ret=%d", errno, ret);
            return ret;
        }
        
        ip_address = buf;
    } else {
        if ((ret = rtmfp_buffer_require(stream, 4)) != ERROR_SUCCESS) {
            return ret;
        }
        u_int32_t addr_h = stream->read_4bytes();
        
        in_addr addr;
        addr.s_addr = htonl(addr_h);
        
        // @see man inet_ntop
        // @see man 7 ip
        // in network-order
        char buf[INET_ADDRSTRLEN];
        if (inet_ntop(AF_INET, &addr, buf, INET_ADDRSTRLEN) == NULL) {
            ret = ERROR_RTMFP_ADDR_ERROR;
            warn("invalid ip errno=%d, ret=%d", errno, ret);
            return ret;
        }
        
        ip_address = buf;
    }
    
    // uint16_t port;
    if ((ret = rtmfp_buffer_require(stream, 2)) != ERROR_SUCCESS) {
        return ret;
    }
    // in host-order
    port = stream->read_2bytes();
    
    return ret;
}

u_int16_t RtmfpAddress::total_size()
{
    u_int16_t size = 1+2; // flag+port
    
    if (ipv6) {
        size += 16; 
    } else {
        size += 4;
    }
    
    return size;
}

int RtmfpAddress::encode(SrsStream* stream)
{
    int ret = ERROR_SUCCESS;
    
    u_int8_t flag = origin;
    if (ipv6) {
        flag |= 0x80;
    }
    
    // u_int8_t flag
    if ((ret = rtmfp_buffer_require(stream, 1)) != ERROR_SUCCESS) {
        return ret;
    }
    stream->write_1bytes(flag);
    
    if (ipv6) {
        if ((ret = rtmfp_buffer_require(stream, 16)) != ERROR_SUCCESS) {
            return ret;
        }
        // @see man inet_pton
        // @see man 7 ipv6
        // in network-order
        in6_addr addr;
        int code = inet_pton(AF_INET6, ip_address.c_str(), &addr);
        if (code <= 0) {
            ret = ERROR_RTMFP_ADDR_ERROR;
            warn("invalid ipv6 addr=%s, code=%d, ret=%d", ip_address.c_str(), code, ret);
            return ret;
        }
        
        stream->write_bytes((char*)addr.s6_addr, 16);
    } else {
        if ((ret = rtmfp_buffer_require(stream, 4)) != ERROR_SUCCESS) {
            return ret;
        }
        // @see man inet_pton
        // @see man 7 ip
        // in network-order
        in_addr addr;
        int code = inet_pton(AF_INET, ip_address.c_str(), &addr);
        if (code <= 0) {
            ret = ERROR_RTMFP_ADDR_ERROR;
            warn("invalid ip addr=%s, code=%d, ret=%d", ip_address.c_str(), code, ret);
            return ret;
        }
        // in host-order
        u_int32_t addr_h = ntohl(addr.s_addr);
        stream->write_4bytes(addr_h);
    }
    
    // uint16_t port;
    if ((ret = rtmfp_buffer_require(stream, 2)) != ERROR_SUCCESS) {
        return ret;
    }
    // in host-order
    stream->write_2bytes(port);
    
    return ret;
}

解析和封装包已经不用要面向bytes层次,vlu也不用计算几个字节,直接使用现有代码实现即可。需要处理的只是根据rtmfp协议定义的包格式直接解析。

这样,一个负责握手和打洞的rtmfp服务器基本功能就实现了。接下来有几件事情:
  1.  完善协议定义的功能,譬如ipv6是否需要支持,譬如peer-to-peer打洞时的地址选择。
  2.  压力测试,完善客户端进行压力测试。需要提供接口获取其他peer的id,也可以用tracker完成。
  3. 给出性能瓶颈,系统的指标,一个服务器支持多少用户握手和打洞。
  4. Refine代码,继续Refine代码。只要有时间,就Refine代码。 
目前的代码情况:
FILE                 DEV          REFINE0      REFINE1    P2P
rtmfp.hpp       368           443              723            823
rtmfp.cpp       814           1017            2005          2321
server.hpp      456           569              508             529
server.cpp      2191          2405            1934          2081
client.hpp       132          215               111            111
client.cpp       1134          1994            1180          1180
stdinc.hpp       110          46                 120            121
stdinc.cpp       118          46                  46              46

增加这个主要功能,只用了585行(总共7212行)。某种程度上来讲,OCP中讲的新增功能开放,从系统层面讲完全是由于现有代码的质量良好,才有可能在新增和维护时容易。

过程:性能分析和测试

进行性能测试,发现rtmplite在500并发,cumulus在5k-10k并发,我们没有调优的服务器在20k+没有问题。基本上达到了目标。
数据如下:



性能必须要经过分析和测试,不能猜测。用gprof分析性能,参考: https://github.com/winlinvip/simple-rtmp-server/wiki/GPROF
性能分析数据,在12k客户端时,CPU80%,发现都是dh(openssl)函数在消耗CPU:
              %   cumulative   self              self     total           
              time   seconds   seconds    calls  ms/call  ms/call  name    
              69.89     45.44    45.44                             bn_sqr4x_mont
              15.86     55.75    10.31                             bn_mul4x_mont_gather5
可见,若使用加密解密集群,服务器可以支持的并发会更高。我们可以去掉加密解密部分测试下看12k时多少CPU。

通过测试,发现没有加解密(以及计算那些key)时,cpu没有降低,但是丢包为0也没有断开的session:


性能数据如下:
                   %   cumulative   self              self     total           
                  time   seconds   seconds    calls   s/call   s/call  name    
                  14.20      2.44     2.44                             heap_delete
                   7.76      3.77     1.33 16640538     0.00     0.00  std::less<unsigned int>::operator()() const
                   5.86      4.77     1.01                             heap_insert
可见频繁释放内存有提升的空间,map的比较less已经最优了。可以试试tcmallo,或者自己用内存池。

如果加解密集群放在本机,rtmfpd服务器在没有找到加解密集群时,使用openssl直接加解密,如果找到了就用加密解密集群。这个方案扩展性也非常好,没有额外带宽浪费。

初步估计,系统在没有加解密集群的支持下,能支持20k并发;有加解密集群时,支持50k应该问题不大。至此,完整的服务器方案就O了,剩下的就是时间来实现了。

结论就是几点:
1. 加解密集群那块是大头,占80%性能瓶颈。这个可以和rtmfp集群互补。
2. 频繁释放内存是小头,占10%。tcmalloc试了作用不大,可能需要自己管理内存分配,需要用缓存。
3. 可以用rtmfp集群,master管理共享数据,worker去查询。可以和加解密集群配合。
客户端模拟工具最重要,不然无法度量改进效果。

过程:性能度量

通过和cumulus的比较,在没有任何优化的情况下(P2P握手做了快速索引,P2P握手时客户端会指定要握手的peer的nearid,nearid是32字节的hmac摘要,所以服务器需要在所有session中查找nearid匹配的session,我们做了快速索引,即将前8字节作为u_int64_t作为map的key,这样就能快速查找,复杂度为O(log(n)),如果找不到再一个一个查找),我们性能是cumulus的两倍。
我们可选的性能优化包括:
  • aes-ni:加密解密的硬件加速。 
  • ssl-cluster:加解密集群。
  • rtmfp-cluster:rtmfp集群。 
性能优化最重要的是找到基准后进行度量,现在我们的基准就是cumulus的两倍性能,度量为我们写的模拟客户端,数据是:12000并发时有55%左右的P2P握手请求。
接下来的性能优化都是围绕这个性能基准,即12000并发时55%的P2P握手。进行逐项优化,评估每个优化带来的影响。

过程:二次调度和负载均衡

单个进程的性能我们比cumulus高100%,即是cumulus的两倍。peer查找由于使用快速索引算法,所以比cumulus高千倍(O(n)和O(log(n))的区别)。加下来还可以通过aes-ni硬件加速,共享session的集群,以及加解密集群等优化。但是在这个之前,redirect/reject更值得优先完成。
打个比方,服务器就像是公交,但是承载的人数超过负载限度就会导致公交拒绝服务(譬如爆胎了就走不了),这个服务器上的用户就会一下子涌向其他的公交,导致其他的公交一起爆胎,结果就是整个服务器组都崩溃掉,很像多米诺骨牌,或者雪崩效应,一个挂掉其他都一起挂。
其实从服务器的角度来看,就是负载均衡,避免单台服务器的负载过高。RTMFP协议在设计时就考虑了这个因素,所以会有Redirect包。我们实现了Redirect,即一个公交超载时,会把新来的用户直接转向到其他公交,其他公交还可以再转向其他,这样就保证了:
  • 每个服务器的人数都不多不少,足够多就能互相分享(当然还可以用共享sesssion实现)
  •  每个服务器都不会超载,当超过阈值就重定向,重定向的速度非常快。  
  • 当某个服务器崩溃时,上面的用户涌向其他的服务器时,也不会导致其他的服务器崩溃(过载的服务器会直接将用户转向其他的服务器)  
  • 所有的重定向行为客户端没有影响,播放器甚至不知道被重定向了,底层协议处理隐藏了这个细节。
这个其实就是用户的二次调度和负载均衡,通过dns或者api调度到服务器之后,应用协议若支持类似HTTP302跳转,就可以再次根据服务器负载重新定向。 这个就是自动均衡的服务器集群,实现了这个之后,运维就可以放心的让服务器自己调度,根据自己的负载。并且服务器有reload,能够实时调整。
自动负载均衡的集群要满足三个条件:协议支持Redirect,服务器支持Reload,客户端屏蔽了定向的细节。

<<END>>

你可能感兴趣的:(RTMFP服务器的研发过程)