架构设计概述
架构设计是一个很大的话题,这里只讨论和推荐系统相关的部分。更具体地说,我们主要关注的是算法以及其他相关逻辑在时间和空间上的关系——这样一种逻辑上的架构关系。
在前面的章节中我们讲到了很多种算法,每种算法都是用来解决整个推荐系统流程中的某个问题的。我们的最终目标是将这些算法以合理的方式组合起来,形成一整套系统。在这个过程中,可用的组合方式有很多,每种方式都有舍有得,但每种组合方式都可被看作一种架构。这里要介绍的就是一些经过实践检验的架构层面的最佳实践,以及对这些最佳实践在不同应用场景下的分析。除此之外,还希望能够通过把各种推荐算法放在架构的视角和场景下重新审视,让读者对算法间的关系有更深入的理解,从全局的角度看待推荐系统,而不是只看到一个个孤立的算法。
架构设计的本质之一是平衡和妥协。一个推荐系统在不同的时期、不同的数据环境、不同的应用场景下会选择不同的架构,在选择时本质上是在平衡一些重要的点。下面介绍几个常用的平衡点。
1. 个性化 vs 复杂度
个性化是推荐系统作为一个智能信息过滤系统的安身立命之本,从最早的热榜,到后来的公式规则,再到著名的协同过滤算法,最后到今天的大量使用机器学习算法,其主线之一就是为用户提供个性化程度越来越高的体验,让每个人看到的东西都尽量差异化,并且符合个人的喜好。为了达到这一目的,系统的整体复杂度越来越高,具体表现为使用的算法越来越多、算法使用的数据量和数据维度越来越多、机器学习模型使用的特征越来越多,等等。同时,为了更好地支持这些高复杂度算法的开发、迭代和调试,又衍生出了一系列对应的配套系统,进一步增加了整个系统的复杂度。可以说整个推荐逻辑链条上的每一步都被不断地细化分析和优化,这些不同维度的优化横纵交织,构造出了一个整体复杂度非常高的系统。从机器学习理论的角度来类比,如果把推荐系统整体看作一个巨大的以区分用户为目标的机器学习模型,则可以认为复杂度的增加对应着模型中特征维度的增加,这使得模型的 VC 维不断升高,对应着可分的用户数不断增加,进而提高了整个空间中用户的个性化程度。这条通过不断提高系统复杂度来提升用户个性化体验的路线,也是近年来推荐系统发展的主线之一。
2. 时效性 vs 计算量
推荐系统中的时效性概念体现在实时服务的响应速度、实时数据的处理速度以及离线作业的运行速度等几个方面。这几个速度从时效性角度影响着推荐系统的效果,整体上讲,运行速度越快,耗时越少,得到的效果越好。这是因为响应速度越快,意味着对用户行为、物品信息变化的感知越快,感知后的处理速度越快,处理后结果的反馈就越快,最终体现到用户体验上,就是系统更懂用户,更快地对用户行为做出了反应,从而产生了更好的用户体验。但这些时效性的优化,带来的是更大的计算量,计算量又对应着复杂的实现逻辑和更多的计算资源。在设计得当的前提下,这样的付出通常是值得的。如同前面章节中介绍过的,时效性优化是推荐系统中非常重要的一类优化方法和优化思路,但由此带来的计算压力和系统设计的复杂度也是必须要面对的。
3. 时间 vs 空间
时间和空间之间的平衡关系可以说是计算机系统中最为本质的关系之一,在推荐系统中也不例外。时间和空间这一对矛盾关系在推荐系统中的典型表现,主要体现在对缓存的使用上。缓存通常用来存储一些计算代价较高以及相对静态变化较少的数据,例如用户的一些画像标签以及离线计算的相关性结果等。但是随着越来越多的实时计算的引入,缓存的使用也越来越广泛,常常在生产者和消费者之间起到缓冲的作用,使得二者可以解耦,各自异步进行。例如实时用户兴趣计算这一逻辑,如果没有将之前计算的兴趣缓存起来,那么在每次需要用户兴趣时都要实时计算一次,并要求在较短的时间内返回结果,这对计算性能提出了较高的要求。但如果中间有一层缓存作为缓冲,则需求方可以直接从缓存中取来结果使用。这在结果的实时性和新鲜度上虽然做了一定的妥协,但却能给性能提升带来极大的帮助。这样就将生产和消费隔离开来,生产者可以根据具体情况选择生产的方式和速度。当然,仍然可以努力提高生产速度,生产速度越快,缓存给时效性带来的损失就越小,消费者不做任何改动就可以享受到这一提升效果。所以说,这种利用缓存来解耦系统,带来性能上的提升以及开发的便利,也是在推荐系统架构设计中需要掌握的一种通用的思路。
上面介绍的一些基本性原则贯穿着推荐系统架构设计的方方面面,是一些具有较高通用性的思路,掌握这些思路,可以产生出很多具体的设计和方法;反过来,每一种设计技巧或方法,也都可以映射到一个或几个这样的高层次抽象原则上来。这种自顶向下的思维学习方法对于推荐系统的架构设计是非常重要的,并且可以推广到很多其他系统的设计中。
系统边界和外部依赖
架构设计的第一步是确定系统的边界。所谓边界,就是区分什么是这个系统要负责的,也就是边界内的部分,以及什么是这个模型要依赖的,也就是边界外的部分。划分清楚边界,意味着确定了功能的边界以及团队的边界,能够让后期的工作都专注于核心功能的设计和实现。反之,如果系统边界没有清晰的定义,可能会在开发过程中无意识地侵入其他系统中,形成冗余甚至矛盾,或者默认某些功能别人会开发而将其忽略掉。无论哪种情况,都会影响系统的开发乃至最终的运转。
系统边界的确定,简单来说,就是在输入方面确定需要别人给我提供什么,而在输出方面确定我要给别人提供什么。在输入方面,就是判断什么输入是需要别人提供给我的,要把握的主要原则包括:
-
这个数据或服务是否与我的业务强相关。在推荐业务中用到的每个东西,并不是都与推荐业务强相关,例如电商推荐系统中的商品信息,只有与推荐业务强相关的服务才应该被纳入推荐系统的边界中。
-
这个数据或服务除了我的业务在使用,是否还有其他业务也在使用。例如上面说到的商品信息服务,除了推荐系统在使用,其他子系统也在广泛使用,那么显然它应该是一个外部依赖。也有例外情况,例如推荐系统要用到一些其他系统都用不到的商品信息,这时候,虽然理论上应该升级商品信息服务来支持推荐系统,但由于其他地方都用不到这些信息,因此很多时候可能需要推荐系统的负责团队来实现这样一个定制化服务。
依照此原则,图 11-1 展示了推荐系统的主要外部依赖。
图 11-1 推荐系统的主要外部依赖
1. 数据依赖
推荐系统作为一个典型的数据算法系统,数据是其最重要的依赖。这里面主要包括用户行为数据和物品数据两大类,前面介绍的各种算法几乎都是以这两种数据作为输入进行计算的。这些数据除了为推荐系统所用,它们也是搜索、展示等其他重要系统的输入数据,所以作为通用的公共数据和服务,显然不应该在推荐系统的边界内部,而应该是外部依赖。需要特别指出的是,虽然有专门的团队负责行为数据的收集,但是收集到的数据是否符合推荐系统的期望却不是一件可以想当然的事情。例如,对于结果展示的定义,数据收集团队认为前端请求到了结果就是展示,但对于推荐系统来说,只有用户真正看见了才是真实的展示。其中的原因在于数据收集团队并不直接使用数据,那么他们就无法保证数据的正确性,这时就需要具体使用数据的业务方,在这里是推荐团队,来和他们一起确认数据收集的逻辑是正确的。如果数据收集的逻辑不正确,后面的算法逻辑就是在做无用功。花在确保数据正确上的精力和资源,几乎总是有收益的。
2. 平台工具依赖
推荐系统是一个计算密集型的系统,需要对各种形态的数据做各种计算处理,在此过程中,需要一整套计算平台工具的支持,典型的如机器学习平台、实时计算平台、离线计算平台、其他平台工具等。在一个较为理想的环境中,这些平台工具都是由专门的团队来构建和维护的。而在一些场景下,推荐系统可能是整个组织中最早使用这些技术的系统,推荐业务也还没有重要和庞大到需要老板专门配备一个平台团队为之服务的程度,在这种情况下,其中的一些平台工具就需要推荐系统的团队自己负责来构建和维护了。为了简化逻辑,下面我们假设这些平台工具都是独立于推荐系统存在的,属于推荐系统的外部依赖。
在对外输出方面,系统边界的划定会根据公司组织的不同有所差异。例如,在一些公司中,推荐团队负责的是与推荐相关的整个系统,在输出方面的体现就是从算法逻辑到结果展示,这时候系统的边界就要延伸到最终的结果展示。而在另外一些公司中,前端展示是由一个大团队统一负责的,这时候推荐系统只需要给出要展示的物品 ID 和相关展示信息即可,前端团队会负责统一展示这些物品信息。这两种模式没有绝对的好坏之分,重要的是要与整个技术团队的规划和架构相统一。在本书中,为了叙述简便,我们不讨论前端展示涉及的内容,只专注于推荐结果的生产逻辑。
推荐系统的效果和性能在一定程度上取决于这些依赖系统,所以在寻求推荐系统的优化目标时,目光不能只看到推荐系统本身,很多时候这些依赖系统也是重要的效果提升来源。例如,物品信息的变更如果能被更快地通知到推荐系统,那么推荐系统的时效性就会更好,给到用户的结果也就会更好;再如,用户行为数据收集的准确性能有所提高的话,对应的相关性算法的准确性也会随之提高。在有些情况下,外部系统升级会比优化算法有更大的效果提升。当然,推荐系统的问题也可能来自这些外部的依赖系统。例如,前端渲染展示速度的延迟会导致用户点击率的显著下降,因为这会让用户失去耐心。所以,当推荐系统指标出现下降时,不光要从内部找问题,也要把思路拓展到系统外部,从全局的角度去找问题。综合来讲,外部依赖的存在启发我们要从全链条、全系统的角度来看问题,找问题,以及设计优化方法。
离线层、在线层和近线层架构
架构设计有很多不同的切入方式,最简单也是最常用的一种方式就是先决定某个模块或逻辑是运行在离线层、在线层还是近线层。这三层的对比如表 11-1 所示。
任何使用非实时数据、提供非实时服务的逻辑模块,都可以被定义为离线模块。其典型代表是离线的协同过滤算法,以及一些离线的标签挖掘类算法。离线层通常用来进行大数据量的计算,由于计算是离线进行的,因此用到的数据也都是非实时数据,最终会产出一份非实时的离线数据,供下游进一步处理使用。与离线层相对的是在线层,也常被称为服务层,这一层的核心功能是对外提供服务,实时处理调用方的请求。这一层的典型代表是推荐系统的对外服务接口,接受实时调用并返回结果。在线层提供的服务是实时的,但用到的数据却不一定局限于实时数据,也可以使用离线计算好的各种数据,例如相关性数据或标签数据等,但前提是这些数据已经以对实时友好的形态被存储起来。
近线层则处于离线层和在线层的中间位置,是一个比较奇妙的层。这一层的典型特点就是:使用实时数据(也会使用非实时数据),但不提供实时服务,而是提供一种近实时的服务。所谓近实时指的是越快越好,但并不强求像在线层一样在几十毫秒内给出结果,因为通常在近线层计算的结果会写入缓存系统,供在线层读取,做了一层隔离,因此对时效性无强要求。其典型代表是我们前面讲过的实时协同过滤算法,该算法通过用户的实时行为计算最新的相关性结果,但这些计算结果并不是实时提供给用户的,而是要等到用户发起请求时才会把最新的结果提供给他使用。
下面详细介绍每一层的特点、案例和具体分析。
离线层架构
离线层是推荐系统中承担最大计算量的一个部分,很大一部分的相关性计算、标签挖掘以及用户画像挖掘工作都是在这一层进行的。这一层的任务具有的普遍特点是使用大量数据以及较为复杂的算法进行计算和挖掘。所谓大量数据,通常指的是可以使用较长时间段的用户行为数据和全量的物品数据;而在算法方面,可以使用较为复杂的模型或算法,对性能的压力相对较小。对应地,离线层的任务也有缺点,就是在时间上存在滞后性。由于离线任务通常是按天级别运行的,用户行为或物品信息的变更也要等一天甚至更久才能够被反映到计算结果中。在离线层虽然进行的是离线作业,但其生产出来的数据通常是被实时使用的,因此离线数据在生产出来之后还需要同步到方便在线层读取的地方,例如数据库、在线缓存等。
在具体实践中,经常放在离线层执行的任务主要包括:协同过滤等行为类相关性算法计算、用户标签挖掘、物品标签挖掘、用户长期兴趣挖掘、机器学习模型排序等。仔细分析这些任务,会发现它们都符合上面提到的特点。这些任务的具体流程各不相同,但大体上都遵循一个共同的逻辑流程,如图 11-2 所示。
图 11-2 离线层逻辑架构图
在这个逻辑架构图中,离线算法的数据来源主要有两大类:一类是 HDFS/Hive 这样的分布式文件系统,通常用来存储收集到的用户行为日志以及其他服务器日志;另一类是 RDBMS 这样的关系数据库,通常用来存储商品等物品信息。离线算法会从输入数据源获取原始数据并进行预处理,例如,协同过滤算法会先把数据处理成两个倒排表,LDA 算法会先对物品文本做分词处理,等等,我们将预处理后的数据统一称为训练数据(虽然有些离线算法并不是机器学习算法)。预处理这一步值得单独拿出来讲,这是因为很多算法用到的预处理是高度类似的,例如,文本标签类算法需要先对原始文本进行分词或词性标注,行为类相关性算法需要先将行为数据按用户聚合,点击率模型需要先将数据按照点击/展示进行聚合整理,等等。所以在设计离线挖掘的整体架构时,有必要有针对性地将数据预处理流程单独提炼出来,以方便后面的流程使用,做到更好的可扩展性和可复用性。下一步是各种推荐算法或机器学习模型基于各自的训练数据进行挖掘计算,得到挖掘结果。离线计算用到的工具通常包括 Hadoop、Spark 等,结果可能是一份协同过滤相关性数据,可能是物品的文本主题特征,也可能是结果排序模型。接下来,为了让挖掘结果能够被后面的流程所使用,需要将挖掘结果同步到不同的存储系统中。一般来说,如果挖掘结果要被用作下游离线流程的输入,是一份中间结果,那么通常它会被再次同步到 Hive 或 HDFS 这样的分布式文件系统中;如果挖掘结果要被最终的推荐服务在线实时使用,那么它就需要被同步到 Redis 或 RDBMS 这样对实时访问更为友好的存储系统中。至此,一个完整的离线挖掘流程就完成了。
上面讲到离线任务通常以天为单位来执行,但是在很多情况下,提高作业的运行频率以及对应的数据同步频率,例如从一天一次提升到一天多次,都会对推荐系统的效果有提升作用,因为这些都可以被理解为在做时效性方面的优化。一种极限的思想是,当我们把作业的运行频率提高到极致时,例如每分钟甚至每几秒钟运行一次作业,离线任务就变成了近线任务。当然,在这种情况下就需要对离线算法做相应的修改以适应近线计算的要求,例如前面介绍过的实时协同过滤算法就是对原始协同过滤算法的修改,以及将机器学习的模型训练过程从离线改为在线。
所以,虽然我们会把某些任务放到离线层来执行,但并不代表这些任务就只能是离线任务。我们要深入理解为什么将这些任务放在离线层来执行,在什么情况下可以提高其运行频率,甚至变为近线任务,以及这样做的好处和代价是什么。只有做到这一点,才能够做到融会贯通,不被当前的表象迷住眼睛。一种典型的情况是,当实时计算或流计算平台资源不足,或者开发人力资源不足时,我们倾向于把更多的任务放到离线层来执行,因为离线计算对时效性要求较低,出错之后影响也较小。综合来说,就是容错度较高,适合在整体资源受限的情况下优先选择。而随着平台的不断完善,以及人力资源的不断补充,就可以把一些对时效敏感的任务放到近线层来执行,以获得更好的收益。
近线层架构
有了上面的铺垫,近线层的存在理由和价值就比较明确了,从生产力发展的角度来看,可以认为它是实时计算平台工具发展到一定程度对离线计算的自然改造;而从推荐系统需求的角度来看,它是各种推荐算法追求实时化效果提升的一种自然选择。
近线层和离线层最大的差异在于,它可以获取到实时数据,并有能力对实时数据进行实时或近实时的计算。也正是由于这个特点,近线层适合用来执行对时效比较敏感的计算任务,例如实时的数据统计等,以及实时执行能够获得较大效果提升的任务,例如一些实时的相关性算法计算或标签提取算法计算。近线层在计算时可使用实时数据,也可使用离线生成的数据,在提供服务时,由于无须直接响应用户请求,因此也不用提供实时服务,而是通常会将数据写入对实时服务友好的在线缓存中,方便实时服务读取,同时也会同步到离线端做备份使用。
通常放在近线层执行的任务包括实时指标统计、用户的实时兴趣计算、实时相关性算法计算、物品的实时标签挖掘、推荐结果的去重、机器学习模型统计类特征的实时更新、机器学习模型的在线更新等,这些任务通常会以如下两种方式进行计算。
-
个体实时:所谓个体实时,指的是每个实时数据点到来时都会触发一次计算,做到真正意义上的实时。典型的工具代表是 Storm 和 Flink。
-
批量实时:很多时候并不需要到来一个实时数据点就计算一次,因为这会带来大量的计算和 I/O,而是可以将一定的时间窗口或一定数量的数据收集起来,以小批次为单位进行计算,这可以有效减少 I/O 量。这种妥协对于很多应用来说,只要时间窗口不太大,就不会带来效果的显著下降。典型的工具代表是 Spark Streaming。
图 11-3 展示了典型的近线层计算架构图。
图 11-3 典型的近线层计算架构图
从数据源接入的角度来看,近线层主要使用实时数据进行计算,这就引出了近线层和离线层的一个主要区别:近线层的计算通常是事件触发的,而离线层的计算通常是时间触发的。事件触发意味着对计算拥有更多的主动权和选择权,但时间触发则无法主动做出选择。事件触发意味着每个事件发生之后都会得到通知,但是否要计算以及计算什么是可以自己选择的。例如,可以选择只捕捉满足某种条件的事件,或者等事件累积到一定程度时再计算,等等。所以,当某个任务的触发条件是某个事件发生之后进行计算,那么这个任务就很适合放在近线层来执行。例如推荐结果的去重,需要在用户浏览过该物品之后将其加入一个去重集合中,这就是一个典型的事件触发的计算任务。此外,近线层的计算是可以使用离线数据的,但前提是需要提前将这些数据同步到对实时计算友好的存储系统中。
在近线层中执行的典型任务包括但不限于:
-
特征的实时更新。例如,根据用户的实时点击行为实时更新各维度的点击率特征。
-
用户实时兴趣的计算。根据用户实时的喜欢和不喜欢行为计算其当下实时兴趣的变化。
-
物品实时标签的计算。例如,在第 6 章用户画像系统中介绍过的实时提取标签的流程。
-
算法模型的在线更新。通过实时消息队列接收和拼接实时样本,采用 FTRL 等在线更新算法来更新模型,并将更新后的模型推送到线上。
-
推荐结果的去重。用户两次请求之间是有时间间隔的,所以无须在处理实时请求时进行去重,而是可以将这个信息通过消息队列发送给一个专门的服务,在近线层中处理。
-
实时相关性算法计算。典型的如实时协同过滤算法,按照其原理,也可以把随机游走等行为类算法改写为实时计算,放到近线层中执行。
总结起来,凡是可以和实时请求解耦,但需要实时或近实时计算结果的任务,都可以放到近线层中执行。
近线层的实时计算虽然没有响应时间的要求,但却存在数据堆积的压力。具体来说,近线层计算用到的数据大部分是通过 Kafka 这样的消息队列实时发送过来的,在接收到每一个消息或消息窗口之后,如果对消息或消息窗口的计算速度不够快,就会导致后面的消息堆积。这就像大家都在排队办理业务,如果一个业务办理得太慢,那么排的队就会越来越长,长到一定程度就会出问题。所以,近线层的计算逻辑不宜过于复杂,而且近线层读取的外部数据,例如离线同步好的 Redis 中的数据,也不宜过多,还有 I/O 次数不宜过多。这就要求近线层的计算逻辑和用到的数据结构都要经过精心的设计,共同保证近线层的计算效率,以免造成数据堆积。
除了纯数据统计类型的任务,以及结果去重这样的无数据产出的任务,近线层的大多数任务在离线层都有对应的部分,二者有着明显的优势和劣势,因此应该结合起来使用。典型的如实时协同过滤算法,由于引入了实时性,使得它在一些新物品和新用户上的效果比原始的协同过滤算法的效果好;但由于它只使用实时数据,所以在稀疏性和不稳定性方面的问题也是比较大的,要使用离线版本的协同过滤算法作为补充,才能形成更全面的覆盖。再比如在近线层执行的用户实时兴趣预测,能够捕捉到用户最新鲜的兴趣,准确率会比较高;但由于短期兴趣易受展示等各种因素影响发生较大的波动,如果完全根据短期兴趣来进行推荐的话,则很有可能会陷入局部的信息茧房,产生高度同质的结果,影响用户的整体体验。而如果将离线计算的长期兴趣和短期兴趣相结合,就可以有效避免这个问题,既能利用实时数据取得高相关性,又能利用长期数据取得稳定性和多样性。从这些例子可以看出,离线层和近线层之间并没有不可逾越的鸿沟,二者更多的是在效率、效果、稳定性、稀疏性等多个因素之间进行权衡得到的不同选择,一个优秀的工程师应该做到“码中有层,心中无层”,才算是对算法和架构做到了融会贯通。
上面讲到离线层的任务在一定条件下可以放到近线层来执行,那么类似地,近线层的任务是否可以放到在线层来执行呢?这个问题其实涉及离线层、近线层这两层作为整体和在线层的关系。如果把推荐系统比作一支打仗的军队,那么在线层就是在前方冲锋陷阵的士兵,直接面对敌人的攻击,而离线层和近线层就是提供支持的支援部门,离线层就像是生产粮食和军火的大后方,近线层就像是搭桥修路的前方支援部门,二者的本质都是让前线士兵能够最高效、最猛烈地打击敌人,但其业务本质导致它们无法到前线去杀敌。离线层和近线层是推荐系统的生产者,在线层是推荐系统的消费者(也会承担一定的生产责任),它们有着截然不同的分工和定位,是无法互换的。
在线层架构
在线层与离线层、近线层最大的差异在于,它是直接面对用户的,所有的用户请求都会发送到在线层,而在线层需要快速给出结果。如果抽离掉其他所有细节,这就是在线层最本质的东西。在线层最本质的东西并不是在线计算部分,因为在极端情况下,在接收到用户请求之后,在线层可以直接从缓存或数据库中取出结果,返回给用户,而不做任何额外计算。而事实上,早年还没有引入机器学习等复杂的算法技术时,绝大多数计算都是在离线层进行的,在线层就起到一个数据传递的作用,很多推荐系统基本都是这么做的,甚至时至今日,这种做法仍然是一种极端情况下的降级方案。
推荐系统发展到现在,尤其是各种机器学习算法的引入,使得我们可以使用的信息越来越多,可用的算法也越来越复杂,给用户的推荐结果通常是融合了多种召回策略,并且又加了重排序之后的结果,而融合和重排序现在通常是在在线层做的。那么问题来了:这些复杂计算一定要放到在线层做吗?为了回答这个问题,不妨假设:如果将所有计算都放在离线层做,在线层只负责按照用户 ID 查询返回结果,是否可行?如果将所有计算都放在离线层做,由于不知道明天会有哪些用户来访问系统,所以就需要为每个用户都计算出推荐结果,这要求我们计算出全平台所有用户的推荐结果,而对于那些明天没有来访问系统的用户,今天的计算就浪费掉了。但这仍然不够,因为明天还会有新来的用户,这些用户的信息在当前计算时是拿不到的,所以,即使今天离线计算出了所有当前用户的推荐结果,明天也还会有大量覆盖不到的用户。这就是将上面提到的复杂计算一定要放在在线层做的第一个主要原因:只有按需实时计算才能覆盖到所有用户,并且不会产生计算的浪费。从另一个角度来看,如果今天就把用户的推荐结果完全计算出来,若用户明天的实时行为表达出来的兴趣和今天的不相符,或者机器学习模型中一些关键特征的取值发生了变化,那么推荐结果就会不准确,并且无法及时调整。例如,用户昨天看的是手机,今天打算买衣服,但我们昨天计算出的推荐结果是以手机为主的,那么用户今天的需求是无法满足的。这就是需要在在线层做复杂计算的第二个主要原因:只有在线实时计算,才能够充分利用用户的实时信息,包括实时兴趣、实时特征以及其他近线层计算的结果等。除此以外,还有其他原因,比如实时处理可以快速应对实时发生的业务请求等。以上这些原因共同决定了在线层存在的意义。
从目前的趋势来看,在线层承担的工作越来越多,因为大家希望利用的信息越来越多地来自实时计算结果。如果说离线层和近线层是厨房里的小工,负责一切食材和配料的前期准备工作,那么在线层就是最后掌勺的大厨,它需要将大家准备好的材料进行组合装配,最终形成一盘菜。
在线层的典型形态是一个 RESTful API,对外提供服务。调用方传入的参数在不同公司的设计中差异较大,但基本都会包含访问用户的 ID 标识和推荐场景这两个核心信息,其他信息推荐系统都可以通过这两个信息从其他地方获取到。在线层接收到请求后会启动一套流程,将离线层和近线层生成的数据进行串联,在毫秒级响应时间内返回给调用方。这套流程的典型步骤包括:
-
AB 实验分流。根据用户 ID 或请求 ID,决定当前用户要执行的策略版本。
-
获取用户画像。根据传入的用户 ID 信息和场景信息,从 Redis 等缓存中获取用户的画像信息,用在后面的流程中。
-
相关性候选集召回。包括行为相关性、内容相关性、上下文相关性、冷启动物品等多维度候选集的召回。
-
候选集融合排序。将上面流程得到的候选集进行融合,再进一步进行机器学习模型排序,最后得到在算法上效果最优的结果列表。在当今推荐系统大量使用机器学习算法的背景下,这一部分的逻辑通常会比较复杂。而为了将机器学习模型预测这一越来越通用的逻辑和推荐主逻辑相剥离,通常也会为机器学习专门搭建一套在线系统,用来提供预测功能,包括对推荐结果的点击、转化预测。这样做的好处是机器学习模型的升级改造不会干扰到推荐系统本身,有利于模块化维护。
-
业务逻辑干预。在完成算法逻辑之前或之后,还需要加入一些业务逻辑,例如去除或减少某些类别的物品,或者出于业务考虑插入一些在算法上非最优的结果,等等。
-
拼接展示信息。在一些推荐系统中,推荐服务要负责将展示所需的所有信息集成到一起,这样调用方拿到结果后就可以直接展示了,而不需要再去获取其他内容。这看起来是一个负担,但从某些角度来看也是好事,因为我们可以做一些展示层面的个性化,典型的如根据不同的用户展示不同的图片或标题,要知道展示层对于用户是否对物品感兴趣是起着非常重要的作用的,毕竟这是一个处处看脸的时代。Netflix 就做过剧集封面个性化的尝试,相比给所有人展示同样的封面,个性化封面使得在用户点击方面获得了显著的提升。
在这套流程中,本书前面介绍过的相关性算法的结果、用户画像的结果、用户兴趣模型的结果等都会被串联起来。
这套流程对应的在线层服务架构图如图 11-4 所示。
图 11-4 在线层服务架构图
在图 11-4 中不仅呈现了在线服务层的流程架构,而且还把它所依赖的数据和服务也一并呈现出来,这样可以最直接地体现在线层“主厨”的串联作用。最上面一层在线服务层的流程体现了上面介绍的在线层的典型计算流程。下面所依赖的数据平台,包含了推荐服务用到的所有数据,如相关性数据、用户画像数据、用户兴趣数据,以及与机器学习相关的模型和特征数据等。这些数据又是通过下面的计算平台这一层生成的,包括离线层的计算平台和近线层的计算平台。这些计算平台所使用的数据构成了整个推荐系统的数据源,主要包括:物品数据源、行为数据源和外部数据源。
这个架构图从数据和计算的角度对推荐系统做了分割,跟之前讲的离线层和近线层的分割方法是两种不同的视角,相互正交。经常从不同的视角去抽象、剥离一个系统,有助于我们更全面、更深刻地认识系统。在复杂系统面前,我们的认识过程就像盲人摸象,需要不断地从新的视角去看待理解它,才能得到更全面的认识。
架构层级对比
在介绍完离线层、近线层和在线层的架构之后,我们通过表 11-2 对它们进行更全面的对比。
在表 11-2 中基本上列出了推荐系统的所有主要模块在架构中的位置,建议读者从架构的视角对其算法进行回顾,以加深对它们的理解。
系统和架构演进原则
上面介绍了推荐系统各个组成部分在架构中的合理位置,但就像罗马不是一天建成的,这个样子的推荐系统也不是一天构建起来的,一定是沿着一条路径逐渐演进而来的。每个企业、每个团队在系统演进方面都有不同的路径,但也有一些共同的特点,遵循一些共同的原则。
从简单到复杂
任何一个系统都是从简单到复杂不断演进的,这不仅对应着事物发展的一般规律,而且和系统背后的业务发展相契合。对于推荐系统来说,简单和复杂常常体现在如下一些维度上。
1. 产品形态的数量和复杂度
以电商网站为例,在一个较成熟的网站中,推荐产品会存在于转化流程的各个环节中,例如主页、商品详情页、购物车页、订单完成页等。但在系统开发初期,只需要在一两个最主要的场景下开发推荐模块就可以了。不仅因为这样更能集中资源快速开发,而且因为这个时候技术和业务层面的基础能力还不够全面、深入,盲目扩大地盘带来的损失可能要大于收益,会使整个团队陷入修复 bug 和满足各种不重要需求的低效率循环中。就像发布一个半成品到市场上,会收到大量的外部客户投诉和内部升级要求,还不如等产品相对成熟后再大面积推广。由于不同的推荐模块之间存在可复用部分,因此,当一两个推荐模块开发相对完整之后,就可以以较低的代价将其可复用部分应用到更多的模块中。
2. 推荐算法的数量和复杂度
大家知道,更丰富、更全面的推荐算法组合,能够更全面地覆盖相关性的维度,达到更好的推荐效果。但是在实际应用中,建议大家不要贪多,只考虑算法的数量,而是要把经典算法逐个吃透,让其在线上充分发挥作用。快速堆算法上去虽然能够在短期内起到一定的效果提升作用,但是却容易蒙蔽住工程师的眼睛,让其忽视了对算法内核本质的探求,以及对数据特点的探索和理解,把算法当作带有魔法的黑盒子来用,更加不利于对这些算法的持续优化。
3. 数据源的数量和质量
算法和数据是推荐系统的两条腿,与算法类似,在数据方面也存在由浅到深的演进过程。这中间不仅包括数据源的数量,也包括对数据的清洗和预处理程度。互联网上的数据,尤其是用户行为产生的数据,可以说没有百分之百干净的,多多少少总是混有杂质,或者说对算法造成干扰。典型的如网络爬虫访问产生的数据、作弊设备访问产生的数据,以及非作弊但是访问行为明显异于普通用户的所谓异常用户产生的行为数据,这些数据都会对算法效果产生不同程度的影响。在系统开发初期,注意力通常在算法和服务上,没有太多精力关注到这么细致层面的数据质量问题。但随着系统的向前发展,初期的算法红利被快速消化掉,这时就需要花更多的精力在数据质量上,通过不断细化数据质量来获得进一步的效果提升。数据质量的优化还有一个特点,就是它是一个需要持续进行的工作,甚至带有一些对抗性质。典型的如作弊数据,当你发现作弊规律并对作弊数据进行相应的处理之后,作弊者很快就会发现作弊数据被处理了,于是他就会想到用别的方法来生成作弊数据。例如,协同过滤算法比较容易受到用户行为的干扰,如果作弊者生成大量账号全部用来访问某个物品,那么这个物品就会和很多其他物品产生关联,出现在它们的协同过滤算法结果中,影响算法的公正性。当这样的作弊行为被发现后,作弊者可能又会找出其他方法来达到目的。像这样带有对抗性质的数据质量清洗和提升,也是由浅入深贯穿于推荐系统的发展历程的。在具体操作时,可能会是如下这样的演进路线。
(1)不对数据做任何清洗。大部分推荐算法,只要是在一个正常数据远多于异常数据的环境下,就可以发挥作用,所以,即使不对数据做任何清洗,一般结果也是有效的。
(2)清洗爬虫数据。爬虫数据是垃圾数据中量最大、相对最容易识别的一类数据。去掉大量的爬虫数据,不仅推荐效果会有所提升,而且计算量也会有所减小。
(3)清洗明显的作弊数据。所谓明显的作弊数据,指的是介于正常访问和爬虫访问中间的一类行为数据,其特点是访问量大、访问时间有规律、访问类目有规律等。和爬虫数据不同,这类作弊数据的目的通常可能是刷榜、刷单或者攻击推荐算法,以达到非正常提升某些物品流量的目的。
(4)建立对数据进行持续清洗和优化的机制。在完成上面几步的整体性清洗之后,需要建立一种日常持续的数据质量优化机制,在这种机制下,数据质量的优化通常和日常开发并行进行,或者融入日常开发过程中,例如周期性地处理促销期间产生的,与日常数据有着显著差异的数据。到了这个阶段,问题通常不会那么明显,这时就需要工程师对数据有高度的敏感性,并且要多花时间去观察数据,做到对数据以及数据背后用户的熟悉,才能够发现正常数据中的异常点。
4. 排序模型的复杂度
目前,基于机器学习的排序模型已经是推荐系统中必不可少的一个部分,各个团队在这方面的投入也在不断加大。和其他算法类技术一样,常用的简单模型能够解决 80%的问题,更加复杂的模型本质上是在解决 80%以上的问题。由于排序模型位于推荐算法流程的最后一个环节,因此其对推荐系统整体效果起到的作用是受前面环节制约的。所以,在模型应用选择方面,也应遵循循序渐进的原则,保持排序模型和召回算法同步前进,最大程度地减小上游对下游造成的瓶颈效应,让整个系统协调发展。
从离线到在线
前面讲过,推荐系统效果优化的一类重要方法就是把离线逻辑实时化,实现一系列实时的计算逻辑,例如实时的协同过滤算法、实时的用户兴趣模型、实时的排序模型更新等。从架构层面来看,这意味着把一大批任务从离线层迁移到近线层。毫无疑问,这样做是有好处的,但通常也会带来更大的维护成本。例如:
-
在线任务容错度低。如果离线任务一次执行失败,则可以重新执行,或者用上一个周期的数据替代,没有很强的时间要求。但在线任务一旦执行失败,数据就会产生堆积,需要尽快处理,对处理速度要求较高。如果在处理过程中出现问题,则会再次影响线上数据。因此,整体来讲,在线任务对错误的容忍度较低,需要更多的以及水平更高的工程师来确保其稳定性。
-
在线任务不易调试。当发生和预期不符的问题时,对于离线任务,可以很容易把数据现场固定下来,一步步缩小范围找到问题。而对在线的流式任务的处理则要困难一些,要想达到理想的调试效果,需要搭建配套的测试环境,这无疑增加了成本。
-
数据一致性难以保障。最常出现一致性问题的场景之一就是服务重启时,这时有的计算平台上可能会出现已经消费过的数据被重新处理的情况,造成数据不一致,进而影响推荐的效果。
-
监控机制更加复杂。对离线数据的监控统计可以通过一套离线作业来实现,实现逻辑较为直观,而实时作业则需要将离线监控作业改写为一套实时计算的监控作业,增加了统计的复杂度。
所以,在实际开发中,建议所有的算法都先实现其离线版本,拿到 80%的效果,并在此过程中深刻理解其运行原理,在此基础上,等有了充足的机器和人力资源时,再将其迁移改写为实时算法,以获取更高的收益。
从统一到拆分
从一个角度来看,推荐系统架构研发的进程,就是从统一到拆分的演进过程。在系统开发初期,一般所有的在线服务功能都在一个模块中,包括召回、排序、过滤和业务干预等。但随着业务和算法的发展,这个模块开始变得臃肿,难以扩展、维护,此时就需要按照功能做纵向拆分,把一些核心的通用功能拆分为独立的服务,以增强其独立性和可维护性。模块拆分增加了功能的复杂度和通用性,使得该模块以一种更加独立的形态存在,方便对其进行升级和维护。
系统拆分一定是有代价的,主要包括逻辑解耦的额外工作量、子系统之间数据通信的额外代价等。在整体逻辑不够复杂的情况下,如果从架构的角度出发,设计一个和当前业务需求不符的多模块架构,则会造成付出的代价大于收益,得不偿失。拆分重构,其背后的本质并不全是技术,还有业务的增长。甚至可以说,基于业务发展驱动的架构重构,或者至少有业务发展驱动成分在里面的架构重构,才是有生命力的重构,才能够站得住脚。因为在这种情况下可以明确地知道为什么要重构、重构的目标是什么、边界在哪里,更重要的是,知道收益是什么。其实在大多数技术工作中,如何解决问题并不是最难的,最难的是确定要不要解决某问题,或者说要解决什么问题。如果完全基于所谓的技术理想来驱动架构的拆分演进,则很有可能得不到明确的收益,重构的边界也无法确定,就像跳进一个火坑,最终以烂尾告终。
所以,推荐系统架构的研发,要本着以业务发展和技术进步为双主线的原则,根据确定性目标来进行架构的抽象和拆分,减少无用功。技术人员要追求技术,但也千万不能被技术蒙住了眼睛,把一切判断都建立在纯技术的基础上,置业务于不顾。
基于领域特定语言的架构设计
在实际的推荐系统开发中,常常会遇到这样的情况:不同位置的推荐模块具有不同的推荐逻辑,但底层调用的是同样的一批数据和算法逻辑。例如,在商品详情页和购物车页分别有一个推荐模块,其逻辑差异主要在于召回逻辑的不同和排序模型的不同。在召回逻辑方面,商品详情页的推荐模块会融合浏览的协同过滤和购买的协同过滤,而购物车页的推荐模块则主要使用了购买的协同过滤。在排序模型方面,两个推荐模块用同样的算法各自训练了一个模型,分别在线上调用。
在常规的做法中,会为两种推荐主逻辑分别写一套代码,一套代码描述商品详情页的推荐逻辑,包括它调用的召回数据以及调用的排序模型等;另一套代码用类似的方法描述购物车页对应的推荐逻辑。这样做在功能实现上是没有问题的,但会存在如下一些缺点:
-
出现大量冗余逻辑。不同推荐模块之间会存在较多类似的甚至相同的逻辑,例如业务过滤、候选集召回等,可能会出现两个推荐模块的代码大量是一样的,只有少量存在差异的情况。这无疑造成了代码的高冗余和低复用。
-
逻辑变更复杂。推荐系统是一个重算法的系统,经常需要对各种逻辑进行调整。如果将逻辑都用代码写死,则意味着每次调整逻辑都需要上线,拉长了实验周期,降低了实验效率。
由于存在以上一些缺点,越来越多的推荐系统开始采用领域特定语言来进行逻辑架构的构建描述。
领域特定语言(Domain Specific Language,DSL),指的是特定使用于某个领域的语言。所谓领域特定,指的是专门用来处理某一特定领域的问题,与 DSL 相对的是 Java、Python 等这些可用来解决各种通用问题的语言。大家最熟悉的 DSL 应该就是 SQL 了,它是特定用于处理关系数据或者说集合数据的一门语言,其核心的 select、project、join 操作都是针对关系数据库设计的,无法完成对关系数据以外的数据的操作。类似的还有 HTML,其专门用来描述网页结构和内容。
与传统的代码编写方式相比,基于 DSL 的开发方式增加了两个部分,一个是逻辑描述部分,一个是逻辑翻译部分。逻辑描述部分,也就是 DSL 外在的表现形式,用来描述想要实现的逻辑,例如可以指定使用哪些召回源、使用哪个排序模型、使用哪些过滤规则等;逻辑翻译部分,负责将逻辑描述的 DSL 代码翻译成通用的计算机语言,进而执行。与 SQL 相比,DSL 的逻辑描述部分就像工程师写的 SQL 语句,用来描述想要选择哪些列、条件是什么等,而逻辑翻译部分就像 SQL 的编译器,将 SQL 语句翻译成更底层的执行逻辑,最终执行的是翻译后的结果。
DSL 执行流程图如图 11-5 所示。首先用 DSL 来描述要执行的推荐逻辑,例如使用哪些召回源、使用哪个排序模型等。编写好逻辑描述代码之后,在服务启动时调用逻辑翻译代码,将逻辑描述代码中描述的逻辑翻译为最终可执行的逻辑执行代码。其中逻辑描述代码可以是自定义的 DSL 代码,有自己的语法;逻辑翻译代码和逻辑执行代码是平台的原生代码,例如 Java、Go 或 C++代码等。
图 11-5 DSL 执行流程图
下面看一个简单的例子。这是一段自定义的 DSL 代码,用来描述一个简单的推荐模块。
module_name = ShoppingCart
recallers = ItemCFRecaller + UserCFRecaller + RandomWalkRecaller
rank_model = LR
rank_model_host = 10.1.1.1:8888
filters = TopSaleFilter + BizCategoryFilter
复制代码
这段 DSL 代码描述的逻辑非常清晰,首先说明这个推荐模块的名字是 ShoppingCart,也就是购物车推荐,然后指定使用 ItemCF、UserCF 和 RandomWalk 三个召回源,接下来指定排序模型的类型和模型服务的调用地址,最后指定使用热销商品过滤器和某个业务指定类别的过滤器。
这段 DSL 代码随后会被一个 DSL 翻译解析程序所读取,构造成一段可执行代码。比如在选择 Java 语言作为 DSL 解析语言的情况下,可以在 DSL 代码中指定推荐主流程中所使用的召回器,然后用 Java 语言来解析这段 DSL 代码,就可以通过 Java 语言的反射机制将 DSL 代码中指定的召回器实例化为一个具体的召回器对象,如 ItemCFRecaller。推荐主流程按照上面的方法使用 DSL 代码完成初始化之后,就可以在接收到用户请求时,按照 DSL 代码中指定的逻辑执行了。
这种基于 DSL 的推荐系统架构设计,至少具有如下一些好处。
-
大量减少代码,减少冗余代码,提高代码的复用性。在这种模式下,如果不新增算法,只是新增推荐模块,那么只需要写一段描述逻辑的 DSL 代码即可,代码量远少于写一套 Java 代码的代码量。
-
逻辑表达与执行解耦,便于逻辑变更实验。由于执行程序是由解析程序解析 DSL 代码自动生成的,要更改逻辑,只需要更改 DSL 代码,然后让执行程序重新加载即可,秒级别实现逻辑线上生效。上线流程大大缩短,再配合 ABTest 模块,实验迭代效率得到大幅度提升。
-
表达清晰明了,减少 bug 数量。代码越少,逻辑越清晰,bug 就越少。在 DSL 代码中只保留了与推荐逻辑描述相关的核心代码,其他实现层面的细节全部隐去,使得开发者可以专注于推荐逻辑本身,代码的易读性得到显著提升。这对于组内新来人员熟悉工作以及工作交接都有好处。
-
算法实现和调用分离,减少开发成本。如果实现了一种新算法,例如新的召回算法,那么只需要按照接口规范实现算法并注册,然后在 DSL 代码中直接使用即可,减少了新算法上线的成本。
现在机器学习广泛使用的 TensorFlow 也可以被理解为一种 DSL,它提供了对机器学习业务更简单的表达方式。用户在写代码时,只需要关注和网络结构、特征处理等要解决的业务问题紧密相关的内容,而机器学习层面的技术细节被 DSL 所屏蔽,例如特征分桶是如何实现的、反向传播求导是如何实现的,等等。这使得机器学习的使用面被大幅度扩展,用户不再需要很深厚的机器学习基础就可以上手应用。
在实现推荐系统的 DSL 代码时,可以选择自己定义一套简单的语法逻辑,就像上面的例子中那样。后期如果要实现逻辑较复杂的 DSL 代码,则可以借助 Python、Groovy、Scala 等表达能力强的通用语言,网上也有这方面的资料,读者可以找来参考。
总结
本章我们从架构设计的角度回顾和讨论了推荐系统的一些核心算法模块,重点从离线层、近线层和在线层三个架构层面讨论了这些算法。本章虽然没有讲解一些具体推荐模块的架构设计,如时下热门的 feed 流推荐、商品详情页的推荐等,但无论什么推荐模块,其逻辑经过拆解后都可以映射到本章介绍的这套架构体系中,做到触类旁通,举一反三。
通过本章的讨论,希望工程师在设计和实现算法时,脑子里除了有算法和数据,还应多一个架构的维度,能够从架构工程的角度来考虑算法,做到心中有系统,而不只是一些零散推荐算法的实现,这样才能构建好一个推荐系统。
超详细:完整的推荐系统架构设计_架构_博文视点Broadview_InfoQ写作社区