为了提升搜索推荐系统的整体工程效率和服务质量,搜索推荐工程团队对系统架构进行了调整。将原本的单一服务架构拆分为多个专门化的独立模块,分别为中控服务、召回服务和排序服务。
在新的架构中,中控服务负责统筹协调请求的分发和流量控制,召回服务用于搜索意图和用户行为分析并计算返回搜索推荐候选商品集合,而排序服务则进一步对这些候选商品进行精排序,以达到更好的最终展示结果的相关性、点击率、转化率等目标。
在推荐系统接入新的排序服务过程中,发现与原有逻辑相比,微详情页场景的响应时间显著增加了大约10毫秒,主要问题出现在接口请求和响应的环节上。
同样,搜索系统在接入排序服务时也遇到了类似的情况。与原有逻辑相比,响应时间增加了近20毫秒,导致无法达到上线的性能标准。
例如,搜索排序服务在本地执行时的时间为60毫秒,但通过远程调用时,执行时间却增加到接近80毫秒,两者之间的差异接近20毫秒。
为了解决这些性能问题,需要对这些延迟的原因进行深入分析和优化。
问题现象是调用方等待耗时和服务方执行耗时相差较大,所以问题主要出现在远程调用过程,接下来就是分析这个过程
远程调用可以理解为一种实现远程代码与本地接口调用相一致体验的开发模式
远程调用一般通过动态代理实现,通过调用动态方法将调用方法标识和调用参数序列化为字节码,再通过通信协议请求服务端
服务端解析方法标识和反序列化参数字节码到实体参数对象,再反射的方式调用方法标识对应的方法
该方法返回结果(即响应对象)序列化,并返回给调用方,调用房完成反序列化响应对象,返回给代理调用者
整个过程较为耗时的部分为序列化/反序列化、网络IO、本地调用,一般为ms级别。
调用动态方法和反射耗时相对不高,一般为us级别。如下图所示。
图片
调用动态方法耗时相比于其他过程一般可忽略不计,服务端本地调用逻辑与原服务已经对齐,也不在本次考虑之内。
接下来就是要分析序列化和网络IO请求开销在各环节的占比。选择skynet来定位序列化和网络环节的耗时
skynet是公司架构组提供的分布式链路追踪工具,通过链路中携带的上下文,可以记录经过的服务接口的日志信息
skynet的基本概念是一次完整请求链路称为trace,一次远程调用过程称为span
以推荐微详情页某次请求为例,查询单次请求的调用过程,可以看到下图所示,排序服务调用过程中的远程耗时与本地耗时差值约为 4ms
图片
通过span携带的日志,可以看到以下数据
指标 | 耗时 | 说明 |
scf.request.serialize.cost | 0.242ms | 请求序列化耗时 |
scf.request.deserialize.cost | 0.261ms | 请求反序列化耗时 |
scf.response.deserialize.cost | 0.624ms | 响应反序列化耗时 |
scf.response.serialize.cost | 约0.624ms | 响应序列化耗时,skynet未提供,可以认为与反序列化时间差异不大 |
可见,耗时问题主要集中在响应过程阶段。如果要计算远程耗时与本地耗时的差异为20毫秒的开销情况,可以结合上述日志数据进行线性计算和整理,得出各个过程的近似耗时及其占比,如下图所示。
搜索与推荐的区别在于,搜索的请求阶段不传输特征,因此响应过程的耗时占比更高。因此,需要优先考虑减少响应过程的耗时。
图片
在整个响应过程中,序列化和网络 I/O 的耗时各占一半。影响 I/O 耗时的因素之一是网络环境和机器配置,另一个因素是序列化后对象的长度。
通过与运维团队沟通,我们了解到部分机器使用的是千兆网卡,而我们传输的对象长度通常都在 MB 级别,这对耗时有一定的影响。
网络问题可以统一整理后提交给运维团队调整机器配置来解决,而我们的主要精力应放在优化序列化过程上。
接下来是对响应对象的序列化过程分析
首先需要了解响应对象数据结构,响应对象是一个泛型类,以支持不同类型的ID,如下所示,包括:
主要存储约500长度的RankResultItem列表,每个Item对象需要返回商品ID,并以Map的形式返回模型日志、模型打分结果及部分特征
请求携带的其他信息,如A/B测试实际命中分组名的集合等等
如以下代码所示
class RankResponse<T> { int status; RankResult<T> result; String errorMsg;} class RankResult<T> { List<RankResultItem<T>> items; Map<String, Object> others;} class RankResultItem<T> { T id; Map<String, Object> features; // 回传特征 Map<String, String> metric; // 模型日志 Map<String, Double> results; // 模型结果}
优化前,搜索排序服务采用了架构组提供的 SCFV4 序列化方法。
SCFV4 是一种最终输出字节码的序列化方式,它会对所有被 @SCFSerializable 注解标注的业务数据传输对象预编译序列化和反序列化方法。对于 Java 的基础类(如 List、Map 等)和基本类型(如 Integer 等),SCFV4 也预设了相应的序列化和反序列化方法。
在序列化执行时,SCFV4 根据对象的数据结构层次逐层遍历,调用每个子成员对象的序列化方法,最终输出字节码。反序列化的过程与之类似。
SCFV4 序列化的特点如下:
下面回到排序响应对象,分析响应对象的序列化过程,将过程梳理到下图:
图片
可以看到,一次序列化过程可能需要对多达 500 次的商品日志 Map、商品得分 Map 和商品 ID 进行序列化。
由于日志对象的数据规模远大于其他类型的对象,因此我们可以假设,序列化的主要开销来自于序列化特征日志 Map 的耗时。
在相关的 MapSerializer 类中可以发现,序列化 Map 时不仅需要解析 Key-Value 的数据类型,还大量调用了 String.getBytes() 方法。
假设特征日志的总长度约为 1MB,在本地测试中,getBytes 方法的耗时大约为 10 毫秒,这与我们的预期一致。因此,我们的优化思路应重点放在优化特征日志的序列化过程中。
首先想到的优化方案是通过不传输日志来完全节省日志的序列化时间。针对这一思路,有两种具体的实现方式:
如果直接打印日志,每个请求最多需要打印 1000 条日志。经过与数据团队的讨论,我们得出了以下结论:
经过分析,我们认为改用直接打印日志的方案并不是最优选择,因此没有实施。
如果将日志异步写入Redis,并在重排序时从Redis中读取,就能有效地优化性能。通过设置日志缓存的过期时间为 1 秒,可以满足排序到重排序之间的时间间隔要求。
在这种情况下,Redis的预估使用量为:每请求日志大小 × QPS × 过期时间 = 2MB × 500 × 1秒 = 1GB。
由于成本相对可控,因此我们决定尝试这一方法。
图片
如上图所示,本次方案的目标是将红色部分的输入特征日志处理逻辑提前到模型输入特征处理之后,并引入 Redis 缓存逻辑,同时实现异步执行。
然而,这里遇到了一个挑战:输入特征集合是由预测框架生成的,其生成时间点只有框架内部知道。因此,日志处理过程必须在预测框架内部执行。
在深入讨论之前,先介绍一下预测框架和排序框架之间的关系。预测框架的主要职责是管理和执行算法模型,它生成模型所需的输入特征,并基于这些特征进行预测。排序框架则利用预测框架的输出,对商品或内容进行排序,以优化最终展示给用户的结果。
虽然预测框架和排序框架在功能上是相互独立的,但它们之间密切合作。排序框架依赖预测框架提供的预测结果,而预测框架则处理来自排序框架的输入特征。然而,预测框架的主要任务是执行算法模型,与具体的业务逻辑无关。因此,在预测框架中引入排序框架的业务逻辑会导致相互依赖,这违背了各自的设计初衷,也不利于系统的可维护性和扩展性。
因此,我们需要一种方式来解耦两者,实现日志处理逻辑的同时,不破坏架构设计。
如果在预测框架内部执行日志处理,就需要将排序商品列表、排序上下文、日志处理插件、线程池、Redis 客户端对象、过期时间配置等所有组件都传递到预测框架中。这将导致排序框架与预测框架的相互依赖,而这种双向依赖是不合理的,因为预测框架不仅服务于排序框架,还用于通用推荐和定价框架。
为了解决这个问题,我们可以通过传递 Consumer 对象来实现解耦。预测框架作为模型输入特征的生产者,排序框架作为消费者。具体来说,排序框架可以实现一个指定日志处理逻辑的 Consumer 接口。当预测框架生成输入特征集合后,调用 accept 方法来完成日志处理。
为了便于异步处理,我们在排序框架中定义了一个 IFutureConsumer 接口,该接口支持获取 Future 方法,用于在排序框架中等待日志处理完成。同时,预测框架接收到的仍然是一个标准的 Consumer 接口对象。
这种方法确保了预测框架和排序框架之间的解耦,明确了各自的职责,避免了双向依赖,使系统更加灵活和可扩展。
interface IFutureConsumer<T> extend Consumer<T> { // accpet(T t) Future<?> getFuture();}
预测框架生成输入特征并传递给排序框架。
// 预测框架生成输入特征T inputFeatures = ...;// 调用排序框架的日志处理逻辑IFutureConsumer<T> consumer = ...; // 由排序框架提供consumer.accept(inputFeatures);
排序框架处理日志。
public class HandleLogConsumer<T> implements IFutureConsumer<T> { private Future<?> future; @Override public void accept(T t) { // 异步处理日志逻辑 this.future = executorService.submit(() -> { // 处理日志逻辑 }); } @Override public Future<?> getFuture() { return this.future; }}
整个过程如下图所示:
另外本方案使用了Redis的哈希(hash)数据结构进行存储,原因是在搜索服务中,每次请求都需要刷新结果缓存,这也意味着需要同时刷新相关的日志缓存。然而,搜索服务并不知道具体有哪些 infoid 已经被存储,如果使用字符串(string)结构来存储这些日志数据,很难做到全量刷新,因为无法有效地管理和定位所有存储的键值对。
相比之下,使用哈希(hash)结构存储日志信息有明显的优势。我们可以使用 ctr、cvr、info 等作为哈希表的关键字前缀,并以请求的 MD5 值作为后缀。这种方式只需要刷新 2~3 个哈希键(key),就可以覆盖所有相关的日志数据。这种方法不仅简化了缓存刷新操作,而且更高效,因为只需操作少量的哈希键即可完成全量刷新,适合搜索服务的需求。
推荐侧在找靓机微详情页场景先行接入了此方案,额外耗时由14ms优化至4ms
但随后发现了两个问题:
随着首页推荐等主要场景的接入,后处理过程中获取日志过程耗时达到了7ms以上,原因是写qps较高(单redis-server节点近7w qps),日志数据又属于bigkey(1k以上),redis-server极易发生阻塞,扩容后仍在3ms左右水平搜索测试耗时并未明显下降,读取和刷缓存过期时间也带来了额外的成本。
总结
在本地打印日志和缓存日志的方案不可行后,我们只能重新考虑通过响应对象将日志数据传回调用方的方案。以下是几种可行的思路:
经过评估,前两种方案虽然具有一定的可行性,但需要调用方进行较多的开发支持,实施周期较长。此外,这些方案还需考虑更复杂的容灾处理设计,例如应对因重启或超时导致的日志丢失,以及缓存引起的 GC 问题。为了规避这些风险,我们决定尝试第三种方案。
为了能实现仅在需要时,即取商品列表topN并打印后端日志时,才将日志从bytes转回String,在响应RankResultItem增加了LazyMetric数据类型,利用延迟加载机制,减少了不必要的数据处理和传输开销,数据结构如下:
class RankResultItem { Map<String, LazyMetric> lazyMetricMap;} class LazyMetric { byte[] data; // 编码后的字符串数据 byte compressMethodCode; // 压缩类型 LazyMetric(String str){ // string2bytes } String toString() { // bytes2string }}
日志生产及获取过程调整为:
图片
可以看出,String 转 bytes 的编码过程耗时已经在模型执行时并行处理中被优化掉了。在从模型预测模块到 topN 节点的整个执行过程中,系统始终携带的是 bytes 类型的数据。只有在 topN 节点完成了所有搜索推荐流程需要准备返回商品时,才会主动调用 toString 方法将 bytes 转回 String,而其他商品的日志数据则会被直接丢弃。这样一来,decode 的次数从 500 次减少到了 10 次(假设 N 一般为 10)。整个过程对业务侧的集成并不复杂,开启功能后,排序框架就会自动将日志数据转存到 LazyMetricMap 中。中控服务随后可以从每个 Item 的 LazyMetricMap 中取出 LazyMetric 对象,并在合适的时机调用 toString 方法,提升搜索推荐业务整体开发效率。压缩过程选择了java自带的gzip和zlib两种方法进行测试,测试结果如下:
方法 | 序列化时间 | 反序列化时间 | 总时间 | 数据大小 (bytes) | 压缩比率 |
【SCFV4】原方法 | 1.70ms | 1.28ms | 2.98ms | 1,192,564 | 0.8336 |
【SCFV4】metric日志直接转bytes传输 | 1.46ms | 0.74ms | 2.20ms | 1,216,565 | 0.8504 |
【SCFV4】metric日志zlib转bytes传输 | 1.15ms | 0.73ms | 1.88ms | 472,065 | 0.3300 |
【SCFV4】metric日志gzip转bytes传输 | 1.30ms | 0.77ms | 2.07ms | 490,065 | 0.3425 |
【Hessian】原方法 | 2.33ms | 3.45ms | 5.78ms | 1,165,830 | 0.8149 |
【Hessian】metric日志直接转bytes传输 | 1.00ms | 3.80ms | 4.80ms | 1,168,143 | 0.8165 |
【Hessian】metric日志zlib转bytes传输 | 0.61ms | 1.46ms | 2.07ms | 422,513 | 0.2953 |
【Hessian】metric日志gzip转bytes传输 | 0.59ms | 1.44ms | 2.03ms | 440,509 | 0.3079 |
可以看到,在不同的序列化方法下,序列化耗时都有所减少,性能最高提升至原来的 35%,序列化后的数据量也减少到原来的 36%,这也预示着网络 I/O 的开销会有所下降。
接入情况:
总结:
根据对 V2 方案的总结,V3 方案的设计原则是:放弃使用 SCF 的通用对象序列化,RPC 层仅通过字节数组进行交互,而排序框架采用自定义的序列化方法。
思路一:继续尝试接入现有的开源序列化框架,并在此基础上对排序响应对象进行定制化开发。常见的开源项目包括 protobuf、Kryo、Hessian 等。
思路二:自行开发专门适用于排序响应对象的序列化方法。
思路一的优势在于安全性、通用性和高性能方面都表现良好,部分框架也提供一定的定制化能力。然而,这类框架通常为了适应多种业务场景,会包含大量通用代码和复杂逻辑。以 Kryo 为例,其项目代码行数超过 2 万行,这使得短期内很难掌握所有细节,一旦出现问题可能会阻碍开发进度,并且不一定能按期解决序列化问题。不过,开源框架技术成熟,适合作为长期方案。
思路二的优势在于既可以借鉴其他框架的优化策略,又可以低成本地针对特定对象进行定制优化,从而实现更高的序列化效率。虽然在安全性方面,需要通过单元测试来保障,但开发一个针对特定应用场景的序列化方法相对简单。考虑到排序框架接口的参数对象不经常更改,这种方法可以做到一次开发、长期受益。因此,我们倾向于选择思路二。
整理思路后,序列化开发可以按照以下步骤进行:
定义字节数组的序列化数据结构
定义序列化接口并实现具体的序列化类:
定义序列化过程的数据缓冲类:
实现各对象的具体序列化方法:
序列化结果最终要存储在字节数组(byte[])中,因此定义如何存储是我们的首要任务。
一个排序对象包含许多内容。为了简化存储过程并便于编写代码,我们采用了一种类似树状的存储结构,与其他序列化方式大致相同。这种结构将排序对象的整体作为根节点,然后按照对象的层次结构逐级展开存储。
与其他序列化方式不同的是,我们考虑到排序过程中对所有商品都会执行相同的操作,因此商品类的特征 Map、结果 Map 和日志 Map 的存储键集合在实际应用中是保持一致的。由于这些键是可以复用的,我们将其提取出来并统一存储在 items_common 中。这样一来,Map 的值可以按照固定的顺序进行链式存储,这种方法不仅节省了空间,还提升了存储效率。
为了进一步降低代码复杂度,还需要定义统一的接口,再将各个成员序列化过程分解到多个具体实现类中
自定义序列化方法接口定义如下:
public interface IRankObjSerializer<T> { int estimateUsage(T obj, RankObjSerializeContext context); void serialize(T obj, RankObjSerializeContext context) throws Exception; T deserialize(RankObjDeserializeContext context) throws Exception;}
方法的含义如下:
estimateUsage:快速评估序列化对象的长度。
serialize:用于序列化对象。
deserialize:用于反序列化对象。
通过这些方法,可以更有效地管理对象的序列化和反序列化过程,提升整体性能和资源利用率。
根据响应对象的数据层次,序列化过程需要针对不同的类型进行拆解,并为每种具体类型设计相应的序列化类。以下是各类序列化器的设计:
GeneralObjSerializer:
GeneralMapSerializer(用于基本 Map 类型,按顺序存储键值对):
GeneralListSerializer(
GeneralSetSerializer:
RankResultSerializer:
RankResultItemSerializer:
RankResponseSerializer:
序列化过程中依次将写入到一段足够长的byte数组里,序列化完成时再一次性读出所有写入数据,定义Output类作为序列化过程中的数据缓冲(同样有Input类作用于反序列化,实现类似)
class Output { byte[] data; int offset; Output(int estimateUsage) { data = new byte[estimateUsage]; offset = 0; } void writeInt(int); void writeLong(long); void writeFloat(float); void writeBytes(byte[]); ...}
data:作为序列化数据的缓冲区,为了写入效率最高,缓冲区是连续且足够长的byte数组,足够长由入参estimateUsage来保证
offset:是下一个要写入数据的位置,如果offset >= 数组长度,则需要扩容,扩容每次按两倍扩容
estimateUsage的准确性影响了扩容次数,进而影响序列化效率,经测试从以32为起始容量初始化并逐渐扩容到所需容量与直接使用estimateUsage初始化,序列化耗时相差20%左右
writeInt、writeLong:整型和长整型的写入是可变长的,虽然int和long分别使用了32bit和64bit的空间,但如1、2、8、64等较小的数字只是用了前8bit的空间,一般可变长序列化采取的做法是将每8bit为一组,低7位存储真实数据,高位存储标识符,表明更高位是否仍存在更多数据,可变长编码下整型需要1~5byte,长整型则需要1~10byte,存储数字值越小时,可变长的压缩效果越好。读取时再从低位依次向高位读取,直到标识符表明数据读取完毕,当缓冲区剩余长度不足可变长的最大长度时,需要调用readInt_slow或readLong_slow方法,逐个byte读取并判断是否越界
writeFloat、writeDouble:这两种类型不能直接写入,需要调用Float.floatToRawIntBits和Double.doubleToRawLongBits转为Integer型和Long型。我们的特征由于特征默认值等原因存在大量0.0、-1.0、1.0等数值,但在可变长存储下,转int后实际占用位数很长,优化方式是转换前先判断了它是否为整型数字,如是整型就取整后直接存为整型,可将原本需要5~10位的存储空间节省到1位,一个较为快速的判断方式为:
void checkDoubleIsIntegerValue(double d) { return ((long)d == d);}
多数序列化实现按待序列化的各个成员类型依次调用对应序列化方法即可
Item间的共享数据处理,是本次序列化优化最核心的优化点,对序列化效率提升有决定性影响,如特征/结果/日志Map的keySet的存储复用,具体做法是
读取第一个Item的所有keySet并保存在序列化上下文中,作为基准数据,后续每个Item都与第一个的keySet判断,完全相同就按第一个item的相同顺序将values依次取出,按队列存储,快速的判断方式如下:
private static boolean isNotEqualSet(Set<String> set1, Set<String> set2) { return set1 == null || set2 == null || (set1.size() != set2.size()) || !set1.containsAll(set2); }
当任意商品不满足keySet一致性的要求时,Item序列化方法会向上抛出异常,排序框架会捕获到该异常,并将返回的压缩响应对象(CompressedRankResponse)退化为普通响应对象(RankResponse)
异常行为会根据用户的选择上报给监控平台,或需要排查问题时选择打印到本地文件
上游服务无需关心排序服务返回了哪种响应对象,这是因为普通响应对象和序列化后的压缩响应对象实现了同一接口
即原RankResponse对象和新CompressedRankResponse对象实现了IRankResponse接口,CompressedRankResponse是RankResponse的装饰器对象
CompressedRankResponse对象在用户调用任意方法,且当内置RankResponse对象为空时完成反序列化,如下段代码中的getStatus方式所示
后续再调用其他方法在使用体验上是与未压缩对象一致的,这种与直接返回byte数组相比,业务使用更友好,异常时可以快速降级,也没有太多带来额外成本
IRankResponse rank(RankRequest request); class RankResponse implements IRankResponse; class CompressedRankResponse implements IRankResponse { byte[] bytes; // 排序服务返回的数据 RankResponse response = null; // 调用任意方法后反序列化生成的数据 public int getStatus() { if(response == null) { // 执行反序列化 response = this.doDeserilize(); } return response.getStatus(); }}
模拟搜索500个商品,测试2000次,序列化前大小1430640
第一次测试:实验组为V3优化,对照组为无优化
方法 | 序列化时间 | 反序列化时间 | 总时间 | 数据大小 (bytes) | 压缩比率 |
SCFV4 序列化原方法 | 1.86ms | 1.19ms | 3.05ms | 1,188,961 | 0.8310 |
框架自定义序列化方法 | 0.32ms | 0.17ms | 0.49ms | 392,127 | 0.2741 |
优化降低 | 82.80% | 85.71% | 83.93% | 67.02% | 67.02% |
第二次测试:实验组为V3优化,对照组为V2优化
方法 | 序列化时间 | 反序列化时间 | 总时间 | 数据大小 (bytes) | 压缩比率 |
框架自定义序列化方法 | 0.41ms | 0.20ms | 0.61ms | 393,001 | 0.274 |
带日志 byte 压缩 + SCF 序列化方法 | 3.10ms | 1.00ms | 4.10ms | 503,961 | 0.35 |
优化降低 | - | - | 85.12% | 21.7% | 21.7% |
可见V3对序列化过程的执行效率提升明显
以下是业务接入情况
搜索侧接入:测试接入排序服务耗时于未服务化时持平,满足上线要求
推荐测接入:序列化过程在2ms左右完成,场景接入后耗时均有明显下降,符合预期
场景 | 优化前 | 优化后 | 提升时间 | 百分比 |
找靓机微详情页推荐 | 69ms | 64ms | 5ms | 7.24% |
转转 B2C 详情页 | 97ms | 94ms | 3ms | 3.09% |
转转 C2C 详情页 | 92ms | 87ms | 5ms | 5.43% |
转转首页推荐 3C 页 | 112ms | 106ms | 6ms | 5.36% |
转转首页推荐默认 | 130ms | 128ms | 2ms | 1.54% |
本项目旨在解决搜索推荐服务化过程中因日志传输引起的序列化额外耗时问题。经过三次版本迭代和测试,最终方案成功落地。
结论
本地测试:
思考
从问题发现到解决上线,项目历时近一个月。虽然问题定位较为迅速,但在确定最终方案和落地时经历了较长的周期。方案设计过程中有两点需要注意:
方案评估要更细致:
方案设计要更具全局性:
后续工作
废弃遗留代码:
召回框架的序列化优化:
本文链接:http://www.28at.com/showinfo-26-112793-0.html转转搜推排序服务的响应对象序列化优化
声明:本网页内容旨在传播知识,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。邮件:2376512515@qq.com
上一篇: 我尝试重现 React 的 useState() Hook 并失去了工作机会
下一篇: 微服务为什么要容器化?