正文
2.2 数据压缩
海量的离线特征加载到线上系统并在系统间流转,对内存、网络带宽等资源都是不小的开销。数据压缩是典型的以时间换空间的例子,往往能够成倍减少空间占用,对于线上珍贵的内存、带宽资源来说是莫大的福音。数据压缩本质思想是减少信息冗余,针对特征系统这个应用场景,我们积累了一些实践经验与大家分享。
2.2.1 存储格式
特征数据简单来说即特征名与特征值。以用户画像为例,一个用户有年龄、性别、爱好等特征。存储这样的特征数据通常来说有下面几种方式:
-
JSON格式,完整保留特征名-特征值对,以JSON字符串的形式表示。
-
元数据抽取,如Hive一样,特征名(元数据)单独保存,特征数据以String格式的特征值列表表示。
-
元数据固化,同样将元数据单独保存,但是采用强类型定义每个特征,如Integer、Double等而非统一的String类型。
三种格式各有优劣:
-
JSON格式的优点在特征数量可以是变长的。以用户画像为例,A用户可能有年龄、性别标签。B用户可以有籍贯、爱好标签。不同用户标签种类可以差别很大,都能便捷的存储。但缺点是每组特征都要存储特征名,当特征种类同构性很高时,会包含大量冗余信息。
-
元数据抽取的特点与JSON格式相反,它只保留特征值本身,特征名作为元数据单独存放,这样减少了冗余特征名的存储,但缺点是数据格式必须是同构的,而且如果需要增删特征,需要更改元数据后刷新整个数据集。
-
元数据固化的优点与元数据抽取相同,而且更加节省空间。然而其存取过程需要实现专有序列化,实现难度和读写速度都有成本。
特征系统中,一批特征数据通常来说是完全同构的,同时为了应对高并发下的批量请求,我们在实践中采用了元数据抽取作为存储方案,相比JSON格式,有2~10倍的空间节约(具体比例取决于特征名的长度、特征个数以及特征值的类型)。
2.2.2 字节压缩
提到数据压缩,很容易就会想到利用无损字节压缩算法。无损压缩的主要思路是将频繁出现的
模式
(Pattern)用较短的字节码表示。考虑到在线特征系统的读写模式是一次全量写入,多次逐条读取,因此压缩需要针对单条数据,而非全局压缩。目前主流的Java实现的短文本压缩算法有Gzip、Snappy、Deflate、LZ4等,我们做了两组实验,主要从单条平均压缩速度、单条平均解压速度、压缩率三个指标来对比以上各个算法。
数据集
:我们选取了2份线上真实的特征数据集,分别取10万条特征记录。记录为纯文本格式,平均长度为300~400字符(600~800字节)。
压缩算法
:Deflate算法有1~9个压缩级别,级别越高,压缩比越大,操作所需要的时间也越长。而LZ4算法有两个压缩级别,我们用0,1表示。除此之外,LZ4有不同的实现版本:JNI、Java Unsafe、Java Safe,详细区别参考
https://github.com/lz4/lz4-java
,这里不做过多解释。
实验结果图中的毫秒时间为单条记录的压缩或解压缩时间。压缩比的计算方式为压缩前字节码长度/压缩后字节码长度。可以看出,所有压缩算法的压缩/解压时间都会随着压缩比的上升而整体呈上升趋势。其中LZ4的Java Unsafe、Java Safe版由于考虑平台兼容性问题,出现了明显的速度异常。
从使用场景(一次全量写入,多次逐条读取)出发,特征系统主要的服务指标是特征高并发下的响应时间与特征数据存储效率。因此特征压缩关注的指标其实是:快速的解压速度与较高的压缩比,而对压缩速度其实要求不高。因此综合上述实验中各个算法的表现,Snappy是较为合适我们的需求。
2.2.3 字典压缩
压缩的本质是利用共性,在不影响信息量的情况下进行重新编码,以缩减空间占用。上节中的字节压缩是单行压缩,因此只能运用到同一条记录中的共性,而无法顾及全局共性。举个例子:假设某个用户维度特征所有用户的特征值是完全一样的,字节压缩逐条压缩不能节省任何的存储空间,而我们却知道实际上只有一个重复的值在反复出现。即便是单条记录内部,由于压缩算法窗口大小的限制,长Pattern也很难被顾及到。因此,对全局的特征值做一次字典统计,自动或人工的将频繁Pattern加入到字典并重新编码,能够解决短文本字节压缩的局限性。
2.3 数据同步
当每次请求,策略计算需要大量的特征数据时(比如一次请求上千条的广告商特征),我们需要非常强悍的在线数据获取能力。而在存储特征的不同方法中,访问本地内存毫无疑问是性能最佳的解决方式。想要在本地内存中访问到特征数据,通常我们有两种有效手段:
内存副本
和
客户端缓存
。
2.3.1 内存副本技术
当数据总量不大时,策略使用方可以在本地完全镜像一份特征数据,这份镜像叫内存副本。使用内存副本和使用本地的数据完全一致,使用者无需关心远端数据源的存在。内存副本需要和数据源通过某些协议进行同步更新,这类同步技术称为内存副本技术。在线特征系统的场景中,数据源可以抽象为一个KV类型的数据集,内存副本技术需要把这样一个数据集完整的同步到内存副本中。
推拉结合——时效性和一致性
一般来说,数据同步为两种类型:
推
(Push)和
拉
(Pull)。Push的技术比较简单,依赖目前常见的消息队列中间件,可以根据需求做到将一个数据变化传送到一个内存副本中。但是,即使实现了不重不漏的高可靠性消息队列通知(通常代价很大),也还面临着初始化启动时批量数据同步的问题——所以,Push只能作为一种提高内存副本时效性的手段,本质上内存副本同步还得依赖Pull协议。Pull类的同步协议有一个非常好的特性就是幂等,一次失败或成功的同步不会影响下一次进行新的同步。
Pull协议有非常多的选择,最简单的每次将所有数据全量拉走就是一种基础协议。但是在业务需求中需要追求数据同步效率,所以用一些比较高效的Pull协议就很重要。为了缩减拉取数据量,这些协议本质上来说都是希望高效的计算出尽量精确的数据差异(Diff),然后同步这些必要的数据变动。这里介绍两种我们曾经在工程实践中应用过的Pull型数据同步协议。
基于版本号同步——回放日志(RedoLog)和退化算法
在数据源更新时,对于每一次数据变化,基于版本号的同步算法会为这次变化分配一个唯一的递增版本号,并使用一个更新队列记录所有版本号对应的数据变化。
内存副本发起同步请求时,会携带该副本上一次完成同步时的最大版本号,这意味着所有该版本号之后的数据变化都需要被拉取过来。数据源方收到请求后,从更新队列中找到大于该版本号的所有数据变化,并将数据变化汇总,得到最终需要更新的Diff,返回给发起方。此时内存副本只需要更新这些Diff数据即可。
对于大多数的业务场景,特征数据的生成会收口到一个统一的更新服务中,所以递增版本号可以串行的生成。如果在分布式的数据更新环境中,则需要利用分布式id生成器来获取递增版本号。