推送架构的演进

    我是来自 TalkingData 推送团队的工程师许建辉,在 2015 年初加入 Push 项目以来,经历了设备量、并发数的高速提升。在此过程中,我们对系统架构做了一些细微调整,并让系统的性能表现上一个台阶。本文将阐述 Push 的系统架构,碰到的问题和应对的办法。

架构是为了更好的为业务提供更好的服务。架构最终会以产品的方式提供给客户使用。因此,在我们开始讨论这套系统架构的演进之前,请由我对我们系统做一个简单的介绍:

TD-Push,产品代号魔推。如上图所示,TD-Push是一款为移动APP提供的一套推送营销组件。我们的SDK拥有体积小、耗电少的特点,同时支持公有、私有云的部署。对于推送内容的报文,在传输过程中是全程密文,并且每个终端的秘钥都不相同。它使用了Go语言编写,拥有部署简单,成本低廉的特点。同时它的功能丰富,能够对每次推送的效果进行跟踪。

首先通过一个图来对我们的系统进行初步的了解:

如上图所示,客户的APP通过集成PushSDK,既可完成集成的工作。通过系统提供的Portal,即可完成推送和运营等相关的工作。好的开始是成功的一半。系统从一开始,就采用分布式的架构,这套架构可以支持横向扩展低廉的硬件,来支撑更大的数据和更高的并发。从数据库,到每个系统组件都是分布式的,支持横向扩容。

先对系统的架构组件进行一个系统的了解,如图所示:

从这个系统架构图上,可以清晰的看业务到系统的业务组件之间,都是通过Rest API进行调度的。系统组件分为SDKConnectorAPNSA3PNSWPNSGatewayController 。在GatewayController里面,包含了Collector, Dispatcher, DataService, TaskContainer, Portal。企业的员工,可以通过Portal执行相关的营销推送。企业的应用可以通过调用API,对业务进行更多更灵活的处理。

随着业务的发展、需求的演进、数据量级的增加,百万、千万、数亿、数十亿,架构和组件并未做太多调整。但也确实碰到了一些棘手的问题。我把这些棘手的问题,称之为甜蜜的负担,也可以叫做“成长的烦扰”。我们把问题列举如下:

1> 数据库不堪重负:  究竟是程序编写的问题?还是数据库选型需要调整?在传统应用系统编程的那些非常实用方法、技巧、规律,在数据量级提升,并发数的提升下。表现不尽人意。

2> 系统突如其来的请求高峰: 系统会存在大量密集的请求。峰值的请求,通常都会是普通情况下的好几倍甚至更多。如果把系统的容量,根据峰值进行评估,那会是平时的硬件的好几倍。虽然这样可以解决问题,但这对于客户和我们自己的云平台来说,都是一种极大的浪费。

3> 系统出现大量的Time Wait: 系统的业务组件之间,通过Rest API进行调度。业务组件和数据库之间,通过Socket进行操作。当系统业务量很高的时候,业务组件和业务组件之间,业务组件和数据库之间,会存在大量的网络操作。此时,从系统运行的日志里面,会发现一些奇怪的日志。比如 Request time out , Aerospike 出现 EOF,使用Netstat进行查看,发现有大量的Time wait

4> 临界区锁保护,性能提升不明显:  敏感资源,我们称为临界区。我们使用锁、读写锁 进行保护。但当并发量越来越大的时候,发现锁其实也很耗费资源。

5> 内存宝贵,如何榨取更多硬件资源: 在我们系统的组件里面,使用了MapCache,使用了队列等数据结构。这些结构的数据,能带来性能的提升,但通常随着数据量的增大,内存耗费也会增大。对于CPU来说,用满了,也不会产生明显的硬伤。但作对内存来说,一旦你用满了,程序就会出现OOM。但是我们需要更高的性能,更大的容量,更少的网络请求。

这都是我们遇到的一些问题。面对这些问题,我们都一一的,在日常的工作中,进行了处理:

1> 我们把一些可以拆分的并发逻辑处理单元,把它们拆分成CSP的结构。

2> 从普通的Sync Map到多元化的Cache

3> 数据库程序的优化,从Open Session In View,到基于代理的数据库连接。

4> 针对Dispatch组件进行升级,负载和调度的算法进行升级。

5> 使用http2的协议,针对组件之间的调度进行网络I/O的优化。

 

CSP模型介绍

CSPCommunicating Sequential Processes的缩写,它的意思是顺序通信进程。也就是通过通信的方式,来代替函数的调用。

如图所示:图中的Channel 就是通信的管道,Worker就是处理单元。  Channel可以打个简单的比方,它和系统常见的队列看起来很相似。WorkerJava的线程相似。当然这里说的是相似,也就是说它们之间存在差异。大家可以先按照Channel是队列,Work是线程的这种方式去理解。这套模型虽然我们是在Go语言中是使用,同时这套模型在其它的语言理也同样适用,大家可以基于自己的理解,进行灵活的应用。模型中,Worker之间不直接彼此联系,而是通过不同Channel进行消息发布和侦听。消息的发送者和接收者之间通过Channel松耦合,发送者不知道自己消息被哪个接收者消费了,接收者也不知道是哪个发送者发送的消息。

函数

GolangCSP模型里,它的Worker,就是一个普通的函数。在函数的前面放上一个Go 进行执行,这个函数便是一个并发的携程。在Golang里面,函数是一等公民。函数可以作为变量、参数、返回值。如果您擅长函数式编程的话,Golang的函数也能支持您基于函数式编程。刚才我们提到了,函数可以作为变量、参数、返回值。函数作为返回值,可以提高函数的抽象层级、并且可以减少全局变量的暴露。

 

GoRoutine

模型中的每个Worker,都是一个GoRoutine 协程(Coroutine)这个概念最早是Melvin Conway1963年提出的,是并发运算中的概念。构建一个协程默认只会产生4K的内存,构建一个线程的时候,会产生4M左右的内存。线程的调度依赖于操作系统进行调度。协程的调度是轻量级的,它是在进程里进行调度的。如图所示:

Go的调度器内部有三个重要的结构:MPS

M:代表真正的内核OS线程,和POSIX里的Thread差不多,真正干活的人

G:代表一个Goroutine,它有自己的栈,Instruction Pointer和其它信息(正在等待的Channel等等),用于调度。

P:代表调度的上下文,可以把它看做一个局部的调度器,使Go代码在一个线程上跑,它是实现从N:1N:M映射的关键。

 

Channel

CSP模型中,Channel是一个数据传输的纽带。在申明Channel的时候,可以指定Channel的只读、只写、读写权限和Buffer大小。在对Channel进行操作的时候,可以使用阻塞和非阻塞的方式。这里列举一些Channel的应用:

       基于Channel返回大量的数据:
基于Channel返回大量的数据。可以在函数中返回一个Channel 。然后一边往Channel里面写,一边往Channel里面读。一方面节省了内存,另一方面提高了运算。

       使用Channel实现超时、心跳等:
使用定时和超时的Channel来驱动处理函数,来实现超时、心跳等多项自定义业务。

       基于Channel实现Latch,控制并发数:

如上图所示,通过一个固定长度的Channel,实现并发数的控制。获取并发权限的时候,往Channel里面写入一个对象。释放并发权限的时候,往Channel里面读取一个对象。Channel的长度,就是并发数。

       使用Channel实现Recycling memory buffers

左侧是Give Channel , 右侧是Get Channel .中间是 Recyling Buffer.左侧的Give Channel负责,收回已经借出去的内存.中间的Recycling Buffer负责在缺少内存时产生Buffer,并根据过期规则过期多余的buffer.右侧的Get Channel负责,把Buffer借出去。

使用了CSP模型之后,给系统带来了稳定的收益。这些收益来自于:

无锁: 使用通讯代替共享内存的方式,减少锁竞争带来的性能下降。

Channel: 使用了Channel,把大量处理的任务以Worker的方式执行,在负载过高时,系统依旧平稳。

GoRoutine:为系统带来了计算和并发性能提升

CSP模型:可以让代码的结构清晰,易于维护。从而增加了软件的可维护性和可扩展性。

多元化的Cache

    在最起初的时候,数据量很小,我们使用一个普通的Map,作为Cache,放在内存中。对于部分业务,甚至没有Cache,直接查数据库。随着数据量和并发量的提升。这种做法已经不能应对当前的情况了。因此,我们在演进的过程中Cache实现了多元化。如图所示:

 

 

首先我们在Map的基础上,引入了Heap 。在Heap里面存放了基于Score排序好的Key。这些Key可以是最后一次使用时间或者过期时间或者使用频次等。由此我们就有了Timer Cache, LRU Cache.

其次在数据过期的时候,我们想处理更多的逻辑,因此我们引入了回调函数。回调函数作为闭包,在构造的时候,传入。过期时Cache会自动执行回调函数

随着数据量变得更大之后,我们发现内存十分宝贵。我们需要向硬盘索取更大的容量。我们采用了LevelDB存储引擎,把较大的Value存放在磁盘上。

在分布式环境中,当一台服务器更新了Cache数据,而其它主机没有同步刷新此条Cache的时候,会出现一些数据不一致的问题。因此我们系统引入了哨兵,来保证Cache数据的一致性。

至此,我们在Cache的优化的道路上,一直努力着。比如:前段时间我们发现,即便你有各种过期算法,但也不能明确和量化Cache具体使用的内存大小。因此目前我们在Cache的容量上限也做了一定的控制。

 

数据层的优化

我们架构的这套系统是一套业务系统,数据库的性能,直接影响程序的性能。数据层的优化如下图所示:

系统在早期的雏形中,使用了MongoDBRedis. MongoDB负责所有业务的数据存储。Redis支撑起了离线消息存储。在系统的程序里,我们使用依赖注入和Open Session In View的模式。

随着数据量的上升。SDK会周期性的上报自己的基础信息。系统的写比例远高于读取的比例,MongoDB写入出现瓶颈。此时引入了分布式内存数据库Aerospike。让Aerospike提供无中心的、高效的读写性能。

就这样系统平稳了好一段时间。突然有一天,系统发现Mongo连接池不够用。一开始我们怀疑是否自己程序有问题?驱动有使用不恰当的地方?最后经过我们发现,其实是我们的Mongo连接资源占用的时间较长导致的。再此情况下,我们编写了一个数据源代理程序,用于减少敏感的数据库连接的占用时间。就这样,连接数降低一个数量级。而且也稳定在这个数量级。

如此往复系统又平稳了好一段时间。突然发现系统的吞吐量下降了。QPSTPS都下降了。为何之前一直运行平稳的系统,性能出现抖动?加入直方图,监控一些性能影响因素,我们发现了特定代码的在特定情况下的表现出现了2ms的抖动,导致了这一现象的发生。直方图对问题的定位起到了很好的作用。

现在我们针对Mongo客户端的使用,进行一个总结。如下图所示:

图的左侧是Open Session In View的方式。也是我们系统起初使用的方式。它能够确保每个http请求,都有数据处理的能力。同时又能保证每个数据库连接都能够被关闭。基于类型注入,对变量的作用域,也有一个很好的管理。这种方式,在并发量不大的时候,还是不错的。随着并发量的增大,它的缺点也暴露出来了:

图中小方块,每一格,代表一个单位的时间。Open Session In View的方式占用的Session的时间太长,以至于出现了等待、连接池不够用的现象。

为了应对这一现象,我们引入了连接代理。图片的右侧没有等待连接的现象

而且每个数据库操作的时间非常短

同时它也保证了之前Open Session In View里拥有的优点,代码改动也很小。

那对于新的这种方式,我们做了以下工作:

       沿用了OSIV

       使用了代理连接,也就是说:每次Open Session的时候,Open的是一个代理类,并未产生真正的数据库连接

       代理连接支持自动打开和重复关闭

       在真正操作数据库的时候,建立连接。

 


Aerospike的使用过程中,我们积极的向社区反馈问题。比如驱动抛出的错误日志、数据库在特定场景下出现的一些问题等等。

凡事预则立不预则废。对于硬件扩容需要有一个规划。容量和内存评估,为硬件规划提供很好的理论基础。

运维监控:可以通过运维,观察到系统的数据总量、以及增长速度。并且为系统存在风险进行预警。

在客户端的读写参数调整:我们根据不同的数据等级,采用了不同的写入策略。针对不同查询数量级,使用不同的查询策略。

在项目的初期,我们把Aerospike放在云的虚拟机上。即便是分布式内存数据库,面对高峰时的成吨的并发,也会有不尽人意的地方。我们在客户端,将密集的写请求改成CSP的模式。让性能稳定在一个最佳值。与此同时优化读写策略。内存如此宝贵,把Aerospike从全内存模式,变成SSD的模式。同等内存条件下,存储容量得到了1个数量级的提升。通过容量评估,为硬件规划提供很好的理论基础。在客户端的数据访问层我们做了一些优化工作:

1> 引入Cache,减少读取压力

2> 将并发密集的写,改成CSP模式

3> 引入直方图监控,量化性能指标,实现针对性的优化。

 

任务分发与调度的演进

任务分发和调度演进,大致可以通过下图进行一个概括:

在之前的调度流程中,我们包含Dispatch/任务分发/健康检查的工作内容。

l  Dispatch: 当一个终端连接上来的时候,需要根据Connector当前的负载和权值,进行连接地址的指定。

l  任务分发:推送消息下发。由于下发任务的服务器,并不清楚,哪个连接归属于哪台服务器,因此需要做二次发送,第一次在在线发送,并且记录发送失败的设备。在第二次发送的时候,Shuffle打乱在进行离线发送。

l  健康检查:是定期检查Connector的负载,并记录。

但其实这么发送,是存在一定的资源浪费的。针对这些资源的让费,我们使用了一致性哈希算法,优化了这些流程。我们先来看看基于权值和监控检查的路由和分发是如何工作又存在什么问题。这个流程大致如下图方式进行工作:

图片的左上角是Dispatch流程,中间是健康检查流程,最右边是任务下发流程。随着结点数的增加,(健康检查、任务下发)的网络请求会成倍增加。随着Dispatch数量级的增加,并发也相应增加。又因为健康检查存在时间窗口,导致误差会随着设备数量级的增加而增加。

下发性能会随着下发的数量的增加而成倍增加,导致任务下发出现瓶颈。面临这个现状,我们使用了一致性哈希算法,调整后的流程如下图:

首先,我们砍掉了健康检查这个流程,无须了解各个Connector的负载

其次,在Dispatch,直接根据哈希算法进行指定Connector

然后,在任务下发的时候,具有很强的针对性。一步到位,减少了往复的流程。

 

网络I/O的优化

我们在网络I/O上确实碰到了一些偶发性的问题。即便Aerospike服务器和客户端都做了很多优化的工作。但日志里面仍然会出现EOF的问题。在我们系统的组件之间,存在着频繁、密集的交互。这些交互都是Http1的协议。当系统之间频繁交互(如:获取推送业务指标和状态、密集的单推和广播等推送业务)。系统会在极端的情况下出现Time WaitClose Wait等现象。针对上述现象。我们使用了Http2的协议,在代码小幅度调整的情况下,实现了请求的连接的多路复用。起初我们思考,为什么非得使用HTTP而不用RPC呢?Rest API,既能用于 前端页面的展现,又便于其它语言进行集成。Http相对于RPC更轻量级。使用Passive Feed Back的方式,把系统里面可以合并的请求进行合并。将请求从被动的Post,变成主动的Get。使用Redis Pipe Line 的方式,合并Redis的操作,将多个操作打包成一个Pipe Line进行发送。节省了socket成本。

 

 

在这篇博文中,涵盖了CSP并发编程模型、数据库访问层的优化、Dispatch流程的优化,多元化的Cache以及网络I/O相关的话题。如果大家对Push和相关的技术感兴趣,可以通过TalkingData联系到我,进行相关的探讨。

 

 

猜你喜欢

转载自xxiongdi.iteye.com/blog/2335126