Lua在直播特征平台中的一次实践

Source

本人今年年初开始换了一份工作,然后这一年来都在适应着工作节奏,比较累,放假的时候提不下心去学习。等今年工作告一段落后,明年就算工作节奏如此我也要逼迫自己把之前预定的东西给看完,这篇文章就是今年的唯一贡献了。

注意:这里只针对直播这种同时在线feed在比较少量的一个范围的场景,如果是短视频场景则本文提到的很多设计不适用。

1.什么是特征平台

在推荐系统的场景里大致可分为召回-粗排-精排-混排这4个阶段,这四个阶段我们都需要通过user和feed的特征去做不同的策略。其中在混排这个阶段对工程同学的负担尤为严重,混排需要决定哪些feed先出,还有最经典的两种操作:打散和强插。此时若工程同学一一去实现算法同学的策略是不现实的,所以在工程中期的时候会转变为配置化的形式,尽量把一些策略给平行化,算法可以通过修改配置去组装策略得到他们想要的结果。这样还不够,在工程后期的时候会发现每多一个策略就要实现一次代码,灵活度不够。最终我们采用的方案是通过给user和feed做一些特征标记,对策略再做一层抽象,比如刚刚说的打散和强插,最终可以按照feed的特征去做打散和强插。那么当功能足够强大的时候,工作量就会转移为给user和feed打特征标记的这个过程。

到了这个阶段,我们总结了一下我们给user和feed打标记的数据来源一共有3种:

  1. 业务方从请求包里带过来给我们。
  2. 我们主动从kv等存储里面获取。
  3. 我们主动从业务方提供的一些接口拿数据。

这几种方式会暴露出来几个问题:

  1. 耗时膨胀得很快,尤其是从请求包里带过来的这种,每个feed都会多带一个数据,而我们主动通过rpc调用去拿数据的这种方式可以在推荐的某一个步骤里统一去拿,这样通过协程并发可以控制耗时在一定的量级,但仍然会对链路耗时有所增加。
  2. 不够灵活,工程同学仍然需要从请求包里或者从远端数据里解析后加上一定的逻辑转换为特征。当某个特征不需要的时候删除也是一件麻烦事情。这里面还是有大量的沟通成本和开发成本在里面。

最后我们得出一个目标,搭建一个特征平台,推荐系统定时异步去拉取所有feed的特征,不增加耗时,而user的特征只需要请求来的时候一次rpc调用访问特征平台来一次性返回,而特征的控制则交给了特征平台去管理,最后问题就转换为如何让算法在特征平台里对特征做增删查改。这就是本文要提到的特征平台的来由。

2.如何设计特征平台

需要考虑以下几个问题:

  1. 如何尽可能地减轻提供数据的服务的压力。
  2. 如何让算法可以灵活配置,尽可能地减少工程同学的压力。
  3. 在平台功能未完善时,部分功能可能不能通过配置化来实现,平台需要考虑可以通过工程代码灵活扩展特征。

2.1减轻提供数据服务的压力

最开始想的是最简单的架构,feed的特征采用异步定时去拉所有的数据过来作运算然后写到内存里。如下图:

后来发现这样有个弊端,即便接入的服务也是采用异步拉取的形式访问特征平台,当接入的服务越来越多时,对特征平台的压力就越大,此时我们需要横向扩展,而远端的服务的压力是与特征平台的机器数是正相关的,此时容易把远端服务搞崩。所以后来又多加了一层。

 将所有feed的特征的计算全部交给featurememcache模块,由这个模块定时从远端获取数据再写到内存里,而对外提供的featureplatform模块则是异步拉取featurememcache服务的数据存到内存里,这样对远端服务的压力只和featurememcache的机器数正相关,经过这样的转换之后,正常情况下featurememcache只需极少量的机器就能对featureplatform提供服务。

而user的特征无法异步拉取,只能实时计算,对远端服务的压力与在用户在线的请求数正相关,可以接受。

2.2特征配置化

这真的是一件很头疼的事情,实际上开始想通过protobuf的反射机制去支持一套完美的配置,比如算法只需要在一个JSON配置(这里假设是JSON,或者你可以用YAML都可以)里填写需要从哪里读数据出来,经过怎样的运算机制得出最终的特征值。后来我们讨论了一下,最终还是放弃了这个方案。主要有以下几个问题:

  1. 实现比较复杂,我们希望能够尽快地搭建这个平台去使用,如果采用这个方式,针对现在特征计算的复杂程度来看,短时间内不可能完成,并且容易出bug,调试起来也相当麻烦。
  2. 算法需要深刻理解我们的配置,配置做得越抽象,往往伴随着理解难度的几何级上升,可能到时候算法就需要频繁地问开发同学为什么没有生效,到底哪里出问题。而开发同学可能也需要花费巨大的精力去看这个问题,甚至严重的情况下开发同学也看不出问题,可能是代码出了错误,这不是我们愿意看到的事情。

针对这种情况,我们最终决定把特征运算这个过程单独交给Lua来操作,而JSON配置只负责填写需要计算的feature_name,feature_type和需要的数据源,可以举个简单的例子。

{
  "features": {
    "name": "abc",
    "type": "uint",
    "fkv_ids": [1, 2]
  }
}
function abc_cal(params)
  local fkv_1 = PB.decode("Fkv1Message", params["fkv_1"])
  return 1
end

简单描述就是我们需要计算一个名叫abc的feature,他的返回值是uint类型,需要读取fkv1和fkv2的数据,而我们会把数据源都以字符串的形式带给lua脚本,而lua需要把这些字符串反序列化回对应的结构,再进行操作。这样不管逻辑有多么复杂,都是可以通过脚本去处理得到你想要的结果,灵活且配置很容易理解,清理feature的时候只需要修改JSON配置就可以了,成本就是算法需要稍微学一下Lua脚本的语法,套用同事的一句话就是工作量从来都不会消失,只会转移。

C++和Lua是如何交互的,Lua提供了一些C API操作,Lua会维护一个栈状态,C++通过API操作这个栈来和Lua进行交互,你需要做的所有操作都需要先把数据放到栈里然后再告诉Lua你需要用这些数据做什么。比如下面是一个函数调用的例子。

lua_getglobal(L,"add");               
lua_pushinteger(L,a) ;  
lua_pushinteger(L,b) ;  
lua_pcall(L,2,1,0) ;  
printf("sum:%d + %d = %ld\n",a,b,lua_tointeger(L,-1)) ; 

这里就是一个调用add函数的例子,L是lua的状态机,首先getglobal会将一个全局的对象放到栈顶,然后pushinterger则是把数字类型放进栈,最后调用lua_pcall是告诉Lua我现在要调用函数,参数2个返回值1个,最后一个参数是错误处理函数在栈的位置(这里没用到,我们忽略)。此时Lua状态机会把栈顶三个参数弹出来,得到function_name+a+b去执行lua脚本,最终得到得返回值会放到lua状态机的栈顶,此时再调用lua_tointerger(L, -1)会把栈顶的数据转成c++的int类型,这样就完成了一次交互。

其他的可以自行查阅下资料扩展,比如怎么把C++的函数注册到Lua里使用,上面代码里用到的PB库实际上就是从C++代码里把lua-protobuf库给注册到脚本里面。

2.3整体设计

整体设计大致可以用下图来概括。

 

这个架构的核心在于特征管理层,特征管理层知道所有的特征,以及特征对应的更新和获取方式。

注册中心的作用在于当你暂时无法用Lua脚本去实现这个特征的时候可以通过在注册中心代码添加的方式向特征管理层注册特征,提供可扩展性。

对于可异步计算的特征则会在异步线程里访问特征管理层得到特征的更新方式进行更新,最终会更新到特征数据存储这部分。

同步请求过来时,也会去访问特征管理层得到特征的获取方式去获取。如果是可异步计算的特征则会从特征数据存储这里拿到数据返回,如果是需要实时计算的,那么这一步获取的操作就会做实时计算进行返回。

特征管理层会根据特征配置解析的结果去构造特征的更新和获取的逻辑。

如果是可异步计算的特征,则会在特征的更新这里先访问dao层获取需要的数据,调用Lua工具库运算后写到特征数据存储,而特征的获取则会直接返回特征数据存储里的数据。

实时计算的特征则不存在特征的更新,直接在特征的获取这一步走一遍计算逻辑直接返回。

3.其他的一些思考

目前特征的膨胀速度很快,后续还需要对特征做更细致的划分,对不同的服务按需返回特征而不是直接全量返回。