微博推荐计算层解决方案:lab_common_so框架

jopen 9年前

1 背景

在微博推荐的体系架构中,计算层是一个中间层,主要承担排序的工作,是整体架构的重要的组成部分。计算层的核心是lab_common_so框架,它是一个C/C++语言编写的高效计算服务。(注:关于微博推荐整体的体系架构描述,可参见“ 微博推荐引擎的体系架构 ”及“微博推荐架构的演进”)

微博推荐在早期的发展过程中,承接了大量的业务,在这段时期内,根据需求不断总结和归纳,诞生了基于apache + mod_python的common_recom_framework(以下简称CRF),该框架具有如下特性:

  • 透明化的数据获取:使用统一的函数获取数据,而不用关心数据的存储格式;
  • 统一的接口形态:所有业务的对外接口,形式上保持一致;
  • 规范化的业务流程:业务只需要按照固定的模板编写即可,开发极为迅速;
  • 插件式的业务开发:业务的增加,修改都是热插拔式,互不影响。

CRF的诞生,使得开发人员,不必为多个业务维护多套代码,降低了维护成本。同时,开发人员只需要在框架之上做一些二次开发工作,极大地提高了开发效率。

随着微博推荐业务的高速发展,业务的访问量越来越大,业务逻辑变得越复杂,比如CTR预估模型的引入。我们发现CRF已经有点力不从心了,主要体现在以下三个方面:

  1. CTR预估模型会耗费较大的计算量,是CPU密集型的,而python在这方面的处理效率不那么让人满意,耗时很难降低;
  2. 预估模型整体的逻辑较为复杂,使得代码比较臃肿,业务迭代效率大大降低;
  3. apache是以阻塞式的多进程的方式来工作的,当套接字的I/O阻塞时,进程会挂起,一旦连接数增加,apache不得不生成更多的进程来响应请求。而更多的进程,意味着进程间切换的开销增加,也就是说,当业务访问量较大时,apache处理不过来。

为解决上述问题,我们决定把CPU密集型的复杂计算抽取出来,使用高效的C++服务来完成,这样就减轻了CRF的计算压力。同时,这种做法也可以让CRF更加专注于策略,代码看起来更加清爽,迭代速度会更快,能够解决问题(1)和问题(2)。于是,lab_common_so框架应运而生。

对于问题(3),经过一段时间的探索,我们演化出了recom_unite_front(以下简称RUF)框架。这是一个基于openresty开发的二次框架,它比CRF更为轻量,使用了异步非阻塞的事件模型,很好的解决了问题(3)。

下图 1展示了CRF框架的发展。可以看到,CRF向上轻量化为RUF,具有更快的迭代速度,更强的并行处理的能力。CRF向下稳定化为lab_common_so,具有更高的计算效率,用于处理复杂的业务逻辑。

图1 CRF框架的演进

注:关于RUF,之后会有相应的文章介绍,这里按下不表。

lab_common_so是CRF的发展和延续,因此它从一开始,就天然的具有CRF的几个特性,并在其基础之上,新增了如下特性:

  • 数据本地化:一些小规模的数据,可以加载到共享内存中,减少访问网络数据的开销;
  • 非中断式的资源更新:共享内存中的数据,可以实时更新,而不影响业务;
  • 可配置的算法支持:算法的调用,可通过配置文件来指定,避免修改代码;

基于以上特性,lab_common_so的可以用于但不限于如下场景:

  • 高效的实时在线计算服务
  • 并发式的离线计算服务
  • 数据存储代理算法模型训练

接下来的章节,将详细剖析lab_common_so的框架。

2 框架整体设计

本节介绍lab_common_so的整体设计以及框架处理流程。

2.1 框架整体构成

lab_common_so框架整体可以使用图 2来描述。

图2 lab_common_so框架整体构成

从上图可以看到, lab_common_so框架由四部分组成,分别是业务部分,算法部分,全局数据部分,和远程数据部分,每一部分都由一个配置文件指明该部分需要用到的属性。对于整个服务,则有一个配置文件说明服务整体的基本参数。

关于各部分的设计细节,由后续章节“3 框架类设计”叙述。

2.2 服务启动

图 3展示了lab_common_so的启动流程。

程序首先读入一个配置文件,确定服务运行所需要的基本参数,包括日志级别,线程数目,套接字的相关属性等。

然后程序根据读入的基本参数,开始执行初始化。这个过程主要是将一些本地数据,加载到内存中。

第三步,创建指定数目的线程,并对每个线程进行初始化。线程的初始化工作包括db_company和work_company。

最后,绑定指定的服务端口,开始监听。服务的端口有两个:一个是查询端口,服务通过监听这个端口,来响应各业务请求。一个是控制端口,服务通过监听这个端口,来响应命令类的请求,比如更新共享数据,更新业务等。

图3 lab_common_so框架启动流程

2.3 查询服务处理

当服务端接收到查询请求之后,开始处理。图 4展示了查询服务的处理流程。

第一步:服务端接受到请求。请求使用的是woo协议,这是我们内部研发的一个协议,见“3.1 woo协议”。

第二步:服务的某一个空闲线程,对接受到的请求进行处理。线程使用work_company对象的work_core函数,解析出请求中的参数。

第三步:根据上个步骤提取的参数,使用work_interface_factory对象,获取业务处理类,然后调用业务处理类的work_core函数进行处理。

第四步:在业务处理类中,通过db_interface获取远程数据,通过global_db_interface获取本地数据,通过algorithm_core函数执行算法。

第五步:将执行完的结果,拼装成接口指定格式(一般使用json)返回。

图片4.png

图 4 查询服务处理流程

2.4 控制服务处理

类似地,当服务端接收到控制请求后,开始处理。

图 5以更新全局数据为例,展示了控制服务的处理流程。

第一步:服务端接受woo协议的请求。

第二步:服务端解析请求,以确定该如何响应。这里响应的是更新全局数据的命令。

第三步:服务端会新建一个global_db_company,并将新的数据加载入内存。

第四步:对于每个线程,使用新的global_db_company去初始化。

第五步:当所有线程都更新完毕后,交换新旧指针。

第六步,睡眠一段时间,以确保旧的资源无人使用。然后释放旧指针指向的内存。

图 5 控制服务处理流程

3 框架类设计

这一节将详细讲述lab_common_so框架的类设计。框架的UML类图,如图 6所示。

对于每个线程,包含一个WorkCompany类对象的指针和一个DbCompany类对象的指针,前者用于管理业务资源,后者用于管理数据资源。

因此,我们将可以简单的将所有的类分为两种类别:一种是业务类,包括“2.1 框架整体构成”中的业务部分和算法部分;一种是数据类,包括“2.1 框架整体构成”中的全局数据部分和远程数据部分。

接下来,将对业务和算法这两个类别,分述各类的设计。

图片6.png

图6 lab_common_so的UML类图

3.1 业务类设计

业务类主要包含四个类,分别是WorkCompany,WorkInterfaceFactory,WorkInterface和AlgorithmInterface。分述如下:

3.1.1 WorkCompany

WorkCompany类中包含了WorkIntefaceFactory类对象的指针和work_core函数。这个类是业务类的最高层。

当服务的某个线程接受到请求以后,首先调用WorkCompany类的work_core函数,对传入的请求参数进行解析,然后调用WorkCompany类的WorkIntefaceFactory类对象的指针,获取指定的WorkInteface类对象进行业务处理。

3.1.2 WorkInterfaceFactory

顾名思义,这是一个工厂类,用于对多个WorkInterface进行管理。

该类包含的map,记录了业务名称与业务处理类的对应关系,这样,我们就可以通过传入参数中的指定字段,调用get_interface函数,获取对应的业务处理类了。

业务名称与业务处理类的对应关系,是通过配置文件work_config.ini指定的。当需要新增业务时,我们无需更新框架代码,而是更新配置文件的即可。

3.1.3 WorkInterface

这是每个业务处理类的抽象基类。所有的业务处理类,都需要继承该类,并实现该类的work_core函数。work_core函数是所有业务处理类的入口函数。

此外,在WorkInterface中,还维护了一个VEC_PAIR_MAP_ALG结构,记录了每个算法处理类与算法名称的对应关系,这一对应关系,也是通过配置文件指定的。在业务处理类中,可以根据这一对应关系,调用指定的算法处理函数。

3.1.4 AlgorithmInterface

这是每个算法处理类的抽象基类。所有的算法处理类,都需要继承该类,并实现该类的algorithm_core函数。其设计和WorkInterface相似。

3.2 数据类设计

在lab_common_so中,db_company管理了两类数据分。一类是静态全局数据,这类数据被加载在共享内存中,所有的线程共享,其相关类包括GlobalDbCompany,GlobalDbInterfaceFactory和GlobalDbInterface。另一类是远程数据,支持多种数据库,比如redis,memcache等,其相关类包括DbInterfaceFactory和DbInterface。

3.2.1 db_company

每个db_company的对象,包含了一个global_db_company,同时还记录了db_interface映射关系。

其中global_db_company管理了全局数据资源,db_interface映射关系记录了db_id以及db_name和db_interface的对应关系,在访问数据时,根据配置文件中的DB_ID字段或者DB_NAME字段,就可以获取对应的db_interface类对象的指针。

3.2.2 GlobalDbCompany

该类包含了一个map,记录了全局数据库名称和全局数据库的对应关系,这个对应关系,同样是通过配置文件指定的。load_config函数的作用即是读取配置文件,生成对应关系。给定指定的数据库名称,就可通过get_global_db_interface获取全局数据库对象。

update_global_db_interface函数则是用于响应全局数据更新命令。

3.2.3 GlobalDbInterfaceFactory

该类只包含了一个函数,get_global_db_interface函数。这个函数和GlobalDbCompany中的同名函数的不同之处在于,该函数是根据指定的数据库类型,构建全局数据库对象。因此,该函数仅在GlobalDbCompany中的load_config函数中被调用。

3.2.4 GlobalDbInterface

该类是每个全局数据类的抽象基类。在此基础上,可以派生出多种数据类型。

is_exist函数用于确定指定数据是否在数据库中,load_db_config函数用于将读取静态数据,载入到内存中。

3.2.5 DbInterfaceFactory

该类的设计和GlobalDbInterfaceFactory一样。

3.2.6 DbInterface

该类是远程数据的抽象基类。在此基础上,可以派生出多种数据存储格式。

很多远程数据库如redis、memcache等,都支持批量获取多个key的value值,因此我们将mget函数设计成为纯虚函数,必须实现。

3.3 总结

可以看到,无论是业务类还是算法类,无论是全局数据类还是远程数据类,我们的设计思路都是一致,都采用了工厂模式。

我们通过继承的方式提供了统一的接口,这样在使用之时,对数据的访问是透明化的。

我们通过工厂模式,隐藏了类的创建细节,从而使得程序具有更高的扩展性。

4 框架基础

本节将描述在lab_common_so框架中的一些基础的定义。

4.1 woo协议

woo是一个轻型通讯框架,其通讯协议及日志系统比较完善。

woo的服务端由C/C++语言编写,客户端则提供了基于C/C++,PHP以及Python等多种语言版本。

woo协议采用了基于epoll的I/O通信模型,具有较好的I/O处理能力。还提供了对多线程的支持。

由于协议结构简单,通信轻量化,在微博推荐内部,使用的较为广泛。

4.2 GlobalDb类型

该类型用于支持全局静态数据的载入及获取。数据多以文件形式挂在本地磁盘,在框架启动时加载入内存,全局唯一,所有线程共享。

GlobalDbInterface是所有全局静态数据读取类的基类,提供了一个is_exist通用函数接口用于查询指定key是否在数据库中。该类的某些派生类还提供了get_value和mget_value等特定函数接口。

目前支持如下类型:

  • __gnu_cxx::hash_set<uint64_t>
  • __gnu_cxx::hash_map<uint64_t, uint32_t>
  • MapDb(自研)

4.3 Db类型

该类型用于支持远程数据的获取。

DbInterface是所有远程数据读取类的基类,目前派生出了四种类型:

  • redis:提供对远程redis数据库的访问
  • woo:提供对基于woo协议服务的访问
  • openAPI:提供对http服务的访问
  • MC:提供对远程memcache数据库的访问

目前提供了四个通用函数接口:

  • get(uint64_t n_key):根据整型key访问数据库
  • s_get(char* p_str_key):根据字符串key访问数据库
  • mget(uint64_t n_keys[]):根据多个整型key访问数据库
  • s_mget(char* p_str_keys[]):根据多个字符串key访问数据库

此外,还提供了一个get_multi_db函数,可对多种数据请求,并行访问不同数据库。

4.4 Work类型

该类型是业务类,提供给用户进行二次开发。

框架对业务执行了so化,使得业务可以快速上线迭代,并能让服务支持多个业务。

我们定义了统一的接口格式,在基类work_interface中提供了统一的接口函数work_core。

对于输入的请求串,必须是json格式的,例如{"api":"example", "cmd":"query", "body":"welcome!"}。其中api是必需的,以此来获取指定的业务,并将请求中的其它参数传递给业务。对于输出格式,没有进行限制。

4.5 Algorithm类型

该类型是算法类,提供给用户进行二次开发。

框架对算法执行了so化,使得算法库可以迅速上线,并能让多个业务使用多个算法库。

我们定义了统一的接口格式,在基类algorithm_interface中提供了统一的接口函数algorithm_core。

4.6 服务类型

框架可编译成两个可执行文件,分别为lab_common_main和lab_common_svr。

lab_common_main程序是离线处理程序,执行完请求之后会退出。

lab_common_svr程序是在线服务程序,持续监听端口,接收请求并处理。

目前在线程序提供两种服务,一种是查询服务,一种是控制服务。

5 lab_common_so框架的演进

早期的计算层框架,名为lab_common,是一个具有实验性质的通用框架,它是lab_common_so框架的雏形。我们首先使用这个框架开发了几个业务,性能超出预期。

在lab_common框架的使用过程中,我们发现这个框架有一个很大的缺点:业务逻辑的每一次变动,都需要重新编译代码,生成新的服务程序。发布到线上则需要重启服务,期间所有的业务都会暂停。也就是说,lab_common框架并没有将业务做到热插拔,实际还是损失了CRF的特性。尽管计算层的业务趋于稳定,但是每一次改动都会导致服务中断,这是不可接受的。

于是,我们对框架进行了升级。我们将每一个业务代码,都编译成so文件,框架通过读取配置文件,打开指定业务的so文件。当我们修改一个业务,或者新增一个业务时,只需要单独编译业务代码,将生成的新的so文件,发送到线上,然后发送更新配置文件的命令,即可实现动态加载。在整个更新过程中,服务无需终止,其它业务不受影响,可以继续对外提供服务。这样,我们就成功的使框架具备了热插拔的特性。因为我们将业务so化了,所以,我们将框架的名称修改为lab_common_so。

之后,lab_common_so框架由于其易用性与稳定性,开始在团队内部被广泛使用。随着越来越多的业务使用该框架开发,我们逐渐发现了一些功能及细节上的问题。对此,我们一一做了扩充支持与修复,比如支持多线程获取数据,支持多个算法调用,支持多种格式的数据获取等等。

如今,已有十多个使用lab_common_so框架开发的业务,在线上稳定运行,每天流量数以亿计。

从最早的lab_common框架诞生,到当前lab_common_so框架在线上稳定运行,已经过去了一年半的时间。微博推荐团队的各个成员,都参与到了这个框架的设计与演进中。因此,当前的lab_common_so框架,是大家群策群力的结果。

当然,框架本身还有不足,比如:

(1)db_interface类可以写的更抽象一些;

(2)HTTP访问目前仅支持GET方式;

(3)db_interface类也可以像work_interface类一样so化

(4)多线程获取数据有一定的局限性

这些都是以后框架演进过程中需要解决的问题。

6 总结

本篇文章简单地介绍了lab_common_so框架,主要从框架的起源,发展,设计方面进行描述,力图让读者对本框架整体有一个初步的了解。

lab_common_so框架已经开源,具体的实现细节,可以从 https://github.com/wbrecom/lab_common_so 上获取。开源项目中也包含了一份较为详细的框架的使用说明文档。

更多文章,请持续关注wbrecom博客: http://www.wbrecom.com/

来自: http://www.wbrecom.com/?p=634