菜鸟笔记
提升您的技术认知

魅族 C++ 微服务框架技术

阅读 : 1629

kiev,是魅族科技推送平台目前使用的C++后台开发框架。2012年,魅族的推送业务刚刚有一点从传统架构向微服务架构转型的意识萌芽,为了在拆分系统的同时提高开发效率,决定做一个C++开发框架,这就是最早期Kiev的由来。在不断的演变中,框架也经过了多次调整升级,在此一一进行讲述和揭秘。框架是为架构在做服务,所以整篇内容会在架构演进和框架演进两条线之间交错展开。

第一版:没有开发框架

首个版本的架构非常简单粗暴,首先开一个WEB接口,接入PUSH,再开一个TCP长连接的接口,让手机连上。这么做的目的就是为了能快速上线。不过快是快了,问题也很严重。这个版本没有开发框架,完全从socket写起,不仅难写,而且不能水平扩展,承载能力也非常有限。

第二版:框架首次出现

随着魅族用户量级的快速提升,很快迭代了第二个版本。第二版首次出现了开发框架,命名为“Kiev”。这个版本对手机连接的部分进行了拆分,拆出接入层和路由层,业务层支持水平扩展,这样重构以后抗住了百万级的用户量。不过同样存在不少问题,因为还是在用普通的HASH算法在做均衡负载,扩容非常不平滑,容易影响用户体验。而且随着用户量的增长,日志量变的非常多,甚至都要把磁盘刷爆。此外,由于使用的文本协议很臃肿,当某一天中午12点推送高峰期的时候,整个公司的机房带宽都被吃完,其他业务受到了不同程度的干扰。

这个版本的框架如下,左上角是Kiev协议,左下角是使用到的一些开源的第三方库 ,包括谷歌开源的Protobuf、用于加解密的Openssl、 用于支持HTTP的Curl、优化内存分配的Tcmalloc等。右上角是Kiev框架的功能组件,包括提供HTTP接口的FastCGI、一些常用的算法和数据结构、日志模块、编码常用的定时器以及一个自研的单链接能达到10W+QPS的Redis Client。

第三版:增加限速、业务流程优化、日志切割和压缩

考虑到前面说到的带宽撑爆问题,第三版增加了限速模块。此外还做了一个业务流程上的优化,使用redis存储离线消息,用户上线时再推送出去。负载均衡上,改用一致性HASH算法,这样做的好处是每次扩容受到影响的只有迁移的那一部分用户,另一部分用户则不会受任何影响,扩容变得平滑了很多。针对日志刷爆磁盘的问题,做了一个每天定时切割和压缩日志的脚本。

看看这个版本在框架上做的一些修改,图中深色部分为新增的东西:

第四版:全面重构

为了彻底解决第二版的一些问题,花了半年多的时间对框架进行全面重构。重构主要针对以下几点:

一是将限速、接入层、路由层、逻辑层等都做成了无状态服务,这样的话在整个扩容的过程中可以做到完全平滑;

二是对协议进行优化,将原本臃肿的文本协议改为二进制协议,协议头从700字节降到6个字节,大幅度降低了流量;

三是流程上的优化,这个还是趋于流量的考量。大家都知道移动互联网有个很显著的特点,就是手机网络特别不稳定,可能这一秒在线,下一秒走进电梯就失去信号,这个时候如果直接进行消息推送的话,既浪费机房带宽,又没效果,而且还可能会出现重复推送的问题。所以针对这种情况,魅族的做法是每次先推一个很小的只有几个字节的消息过去,如果手机端的网络稳定,它会回复一个同样很小的消息,这时候再真正进行消息推送,这样可以有效利用带宽资源。而且给每一条消息打上唯一的序号,当手机端每次收到消息时,会将序号储存起来,下次拉取消息的时候再带上来,比如某用户已收到1、2、3的消息,拉取的时候把3带上来,服务端就知道1、2、3都已经推过了,直接推送4之后的消息即可,避免消息重复。

这个版本的框架改进比较小,在上个版本的基础上引入MongoDBClient,对序号进行索引。

业务越做越大,发现新问题1

随着业务越做越大,业务流程也变得越来越复杂。举个栗子,魅族有一个业务流程中,请求过来时,会先和Redis来回交互几次,然后才访问MongoDB,最后还要和Redis交互几次才能返回结果。

这种时候如果按早期的异步模式去写代码,会很难看。可以看到整个业务流程被切割的支离破碎,写代码的和看代码的人都会觉得这种方式很不舒服,也容易出错。

针对这种复杂的问题,魅族引入了“协程”,用仿造Golang的方式自己做了一套协程框架Libgo。重构后的代码变成如下图左侧的方式,整个业务流程是顺序编写的,不仅没有损失运行的效率,同时还提高了开发的效率。

Libgo的简介和开源地址如下:

  • 提供CSP模型的协程功能
  • Hook阻塞的系统调用,IO等待时自动切换协程
  • 无缝集成使用同步网络模型的第三方库 (mysqlclient/CURL)
  • 完善的功能体系:Channel / 协程锁 / 定时器 / 线程池等等

业务越做越大,发现新问题2

在这个时期,在运营过程中有遇到一个问题,每天早上9点钟,手机端会向服务端发一个小小的订阅请求,这个请求一旦超时会再来一遍,不断重试。当某天用户量增长到1300万左右的时候,服务器雪崩了!

雪崩的原因是因为过载产生的,通过分析发现过载是在流程中的两个服务器间产生的。服务器A出现了大量的请求超时的log,服务器B出现接收队列已满的log,此时会将新请求进行丢弃。此时发现,在服务器B的接收队列中积压了大量请求,而这些请求又都是已经超时的请求,手机端已经在重试第二次,所以当服务器拿起之前这些请求来处理,也是在做无用功,正因为服务器一直在做无用功,手机端就会一直重试,因此在外部看来整个服务是处于不可用状态,也就形成了雪崩效应。

当时的紧急处理方式是先对接收队列的容量进行缩小,提供有损服务。所谓的有损服务就是当服务器收到1000个请求但只能处理200个请求时,就会直接丢弃剩下的800个请求,而不是让他们排队等待,这样就能避免大量超时请求的问题。

那紧急处理后,要怎么样根治这个问题呢?首先对这个过载问题产生的过程进行分析,发现是在接收队列堵塞,所以对接收点进行改造,从原来的单队列变为多队列,按优先级进行划分。核心级业务会赋予最高级的优先处理队列,当高优先级的请求处理完后才会处理低优先级的请求。这样做的就能保证核心业务不会因为过载问题而受到影响。

还有一点是使用固定数量的工作协程处理请求,这样做的好处是可以控制整个系统的并发量,防止请求积压过多,拖慢系统响应速度。

业务越做越大,发现新问题3

在最早的时候,这一块是没有灰度发布机制的,所有发布都是直接发全网,一直到机器量涨到上百台时依然是用这种方式,如果没问题当然皆大欢喜,有问题则所有一起死。这种方式肯定是无法长远进行,需要灰度和分组。但由于服务是基于TCP长连接的,在业内目前没有成熟的解决方案,所以只能自己摸索。

当时的第一个想法是进行分组,分为组1和组2,所有的请求过来前都加上中间层。这样做的好处是可以分流用户,当某一组出现故障时,不会影响到全部,也可以导到另外一组去,而且在发布的时候也可以只发其中一组。

那中间层这一块要怎么做呢?在参考了很多业界的成熟方案,但大多是基于HTTP协议的,很少有基于TCP长连接的方案,最终决定做一个反向代理。它的灵感是来源于Nginx反向代理,Nginx反向代理大家知道是针对HTTP协议,而这个是要针对框架的Kiev协议,恰好魅族在使用ProtoBuf在做协议解析,具有动态解析的功能,因此基于这样一个功能做了Kiev反向代理的组件。这个组件在启动时会向后端查询提供哪些服务、每个服务有哪些接口、每个接口要什么样的请求、回复什么样的数据等等。将这些请求存储在反向代理组件中,组成一张路由表。接收到前端的请求时,对请求的数据进行动态解析,在路由表中找到可以处理的后端服务并转发过去。

第五版:针对问题,解决问题

有了上述这些规则后,第五版也就是目前使用的版本部署如下图。对逻辑层进行了分组,分流用户。在实际使用过程中精准调控用户分流规则,慢慢进行迁移,一旦发现有问题,立即往回倒。此外,还精简了存储层,把性价比不高的MongoDB砍掉,降低了70%的存储成本。

很多项目特别是互联网项目,在刚刚上线的时候都有个美好的开始,美好之处在于最初所有服务的协议版本号都是一样的。就比如说A服务、B服务、C服务刚开始的时候全都是1.0,完全不用去考虑兼容性问题。当有一天,你需要升级了,要把这三个服务都变成2.0的时候,如果想平滑的去升级就只能一个一个来。而在这个升级的过程中,会出现低版本调用高版本,也会出现高版本调用低版本的情况,特别蛋疼,这就要求选择的通讯协议支持双向兼容,这也是魅族使用Protobuf的原因。

最终,完整的框架生态如下。虚线框内为后续将加入的服务。

魅族消息推送服务的现状

该服务在过去的4年多来一直只是默默的为魅族的100多个项目提供,前段时间,正式向社区所有的开发者开放了这种推送能力,目前有3000万的长连接用户,为100多个项目提供服务。集群中有20多个微服务和数百个服务进程,有100多台服务器,每天的推送量在2亿左右。