大规模机器实学习中实时样本特征回溯实践

2,608 阅读7分钟

背景

笔者从事内容理解服务端开发,主要是对文本、视频、音频进行加工,通过nlp、cv技术生产出对应特征,提供给推荐、搜索、广告业务,用于对应场景下召回和排序的模型的训练。

术语

  • 实时特征:即随着时间不断改变的特征,比如说用户浏览短视频过去五分钟的曝光序列、直播当前的图像特征,这类特征一般使用flink等流式计算框架对其进行实时更新,实时特征直接存储在kv存储中,通过flink实时动态更新。
  • 离线特征:即不会实时随着时间改变的特征,比如用户的年龄、性别,短视频的封面图embedding,这类特征一般生成一次,或者每天更新一次。对于年龄、性别这类人口统计学特征,一般会周期性的重新计算,计算好的结果存储在hdfs中,然后通过spark或者mapreduce写到kv中;而短视频封面图embedding这类特征,一旦生成之后,会直接写到kv中,同时也会在离线数仓存储一份副本。
  • 实时样本:为了在线serving时候满足性能要求,离线特征与实时特征都会存储到kv(比如redis、hbase)中,用户每一次请求预估服务的时候,会把当时的所有请求kv得到特征落下来,作为后续模型训练的RawSample,这份样本再与前端的曝光、点击、转化日志join,生成对应的label,最终得到模型训练所需的实时样本。

需求

  • 内容理解侧的新的特征交付给推荐、搜索、广告后,由于实时样本的缘故,都需要在线上进行特征积累,一般要七天以上,积累完成之后,进行模型训练,离线效果评估达标之后,再进行线上ABTest,整个周期相当长,影响了内容理解业务的迭代速度,因此需要回溯历史的特征数据,然后join到实时样本之中,更快进入离线模型训练,验证特征离线效果,提高整体的实验速度。

基本逻辑

数据结构

  • 离线特征/OfflineFeature
int64 featureId=1;
Feature feature=2;
  • 实时特征/RealtimeFeature
int64 featureId=1;
Feature feature=2;
int64 timestamp=3;
  • 注意: featureId可以不是int64类型,可能会是字符串类型,这时候使用MurmurHash64进行对字符串进行Hash,转成int64,提高join时的效率;当然在对实时样本进行处理时,也需要进行同样的操作。
  • 实时样本/RawSample
int64 timestamp=1;
repeated features=2;

实时样本通过便利features,可以得到与实时特征、离线特征对应的featureId对应的特征值,这是实时样本与特征实现join的基础。

拼接逻辑

实时特征进行处理可得到

int64 timestamp=1;
repeated features=2;
int64 joinFeatureId=3;
  • 当需要实现实时样本join离线特征时,可以直接通过featureId进行join。
  • 当需要实现实时样本join实时特征的时候,需要对实时特征进行按照特征id,在一定时间区间聚合成一个序列,然后把时间对齐到时间区间的开始或结束(比如整分或者整小时),然后使用这个时间与featureId联合作为key。同样也对实时样本时间对齐,使用时间与joinFeatureId联合作为key,在进行join的时候,再根据实时样本的timestamp与实时特征序列中的timestamp对比,选择未存在穿越的时间戳最大的特征写入实时样本。

挑战与解决方案

样本规模巨大

  • 在某个推荐场景下,实时样本的每小时的占用离线hdfs存储450G,接近350w条,单条大小样本超过100k,整个join过程中如果存在shuffle,有稍微的数据倾斜,spark任务就很可能crash。存储方式为protobuf的数组使用base64编码成字符串后,再存储为snappy压缩格式,整个序列化与反序列化的成本也相当高。

第一个坑

  • 在解码的同时把rdd转为PairRdd,这时的shuffle和序列化的开销都很大,executor出现oom,spark任务crash
val rawRdd = sc.textFile(rawSamplePath) //读取实时实时样本
val pairRdd = rawRdd.map(row=>{
    //base64解码成二进制数组
    //讲二进制数组反序列化为pb对象
    //从pb对象中获取用于join的featureId
    (featureId,pb对象)
}

这个过程中最大的问题就是读取hdfs上的实时样本文件后,rdd的分区数等于hdfs文件数目,这时候是80,在解码、反序列化以及生成pairRdd同时进行的时候,80个分区,很容易就出现数据倾斜,最终导致spark任务crash,同时也无法充分利用spark申请的executor,整体的速度也会很慢。 解决方式:

val rawRdd = sc.textFile(rawSamplePath).repartition(1000) //读取实时实时样本
val pairRdd = rawRdd.map(row=>{
    //base64解码成二进制数组
    //讲二进制数组反序列化为pb对象
    //从pb对象中获取用于join的featureId
    (featureId,pb对象)
}

在读取完hdfs文件后,进行repartition,并行度更大,数据倾斜的比例会小很多,同时单个executor的shuffle、解码、反序列化的压力会下降很多,几乎不会出现executor oom。

join的难题

在实时特征与特征生成对应的PairRdd之后,如果直接进行join,势必会因为实时特征规模过大,对应的featureId分布不均出现数据倾斜,导致整个spark任务crash。

第一种解决方案

  • 对实时样本的按照featureId聚合,按不同featureId对应的实时样本数量进行排序,把头部的前N个featureId对应的实时特征进行broadcast join,中尾部的featureId对应的实时特征与实时样本使用spark 常规的PairRdd join,中尾部的数据倾斜现象要少很多。

第一种解决方案的问题

  • 对于不同的featureId,其在实时样本中的分布差别很大,有的最多不到100个,但是spark任务还是出现了crash,这时候,很难确认对头部多少个featureId 使用broadCast join。

第二种解决方案

  • 既然实时样本的对象很大,使用featureId进行join,稍微有数据倾斜就会出现导致spark任务crash,那么我们就对每个实时样本对象打上一个唯一id,生成一个uniqId,使用PairRdd(featureId,uniqId)与实时特征进行join,join完成之后得到PairRdd(uniqId,Feature> 在于实时样本进行PairRdd(uniqId,rawSample) join,这样就绝对不存在数据倾斜。

实现过程中遇到的坑

  • 在生成uniqId的过程中,最开始选择了uuid随机的方式,到join的时候发现,最后两个key为uniqId的pairRdd的join结果为空,这是因为对于实时样本生成rdd过大,无法cache,会重新进行计算,自然uniqId就不一样。
  • 然后尝试对rdd进行persist,由于rdd太大,选择StorageLevel.MEMORY_AND_DISK_SER,但是spark还是crash掉了,应该是rdd太大,写到硬盘和读取过程中,io和序列化、反序列化开销很大,导致了oom。
  • 最终从RawSample获取一个reqId,对reqId 使用MurmurHash64生成64位 uiqId,表示RawSample的唯一Id。

总结

受限于笔者的spark开发水平,目前的解决方案还很粗糙,特别是对于存储和序列化这两块,还有很多需要深挖的地方,还需要后续在对spark的存储与序列化机制以及protobuf的序列化有更深入学习之后,进行相应的优化。