参考推荐系统总结中的推荐算法业务流程。要设计一个简单的推荐系统,可以大概分为这几步:数据召回、(规则提取)、评分计算、业务调控,因此系统主要围绕这几个组件设计。系统设计的既定目标是能够实现不同业务的简单推荐。因此,系统框架将推荐中要用到的各个关键组件都暴露给各个业务,各个业务实现该组件,然后将其注入Spring供推荐系统获取。之后,各个业务通过传入Spring Bean的名称来调用自定义的推荐组件。下面给出评分系统的执行流程:
从上面的业务流程图可以看出,系统目前负责的工作十分简单,就是负责各个推荐组件的协调与扭转以及组件间数据传递。当然,目前也只需要一个简单的推荐流程规范而已;后续可以在推荐系统中引入池子的概念,池子的引入将使得各个组件之间的扭转变为异步,一定程度上提高推荐系统的推荐性能。
- 推荐实体设计
系统要支持各个业务的推荐,就要支持各个业务自己的推荐实体,因此系统定义了一个推荐实体父类,所有需要推荐的实体都必须去继承这个实体。一旦继承了这个实体就表明这个实体就拥有了被推荐的资格。
定义了推荐实体都具有的一些属性以及推荐实体拥有哪些能力。目前推荐实体拥有合并另外一个推荐实体分数的能力,主要是为了扩展多种推荐方式。
- 数据召回器
数据召回器提供了两个方法,一个是召回批量数据。一个是召回自己的数据。批量召回数据用于召回一批推荐样本,需要传入一些召回的参数,比如召回多少样本,召回阶段要排除的数据行等等。这里还提供了一个召回自己的推荐实体的方法,因为评分的对比标准是自己,所以需要自己的实体数据。如果不实现这个方法就会默认去调用recall方法,原因见注释。
- 评分计算器
该组件主要用于执行推荐实体的评分计算,需要传入规则,数据列表以及被推荐人自己的实体作为评分的一个依据。
- 业务调控组件
数据列表评分完成后需要进行额外的计算、转换等操作都可以通过这个组件实现。同样可以传入两个参数,一个是调控需要的参数,一个是数据列表,返回的结果仍然是数据列表。默认不进行业务调控,直接返回传入的数据列表。
- 推荐执行组件
该组件主要作用就是负责调度召回、计算、调控这几个组件,并且传递推荐这几个组件间的数据。组件对外暴露两个接口,一个接口是通过传入业务类型,被推荐人的ID,以及参数来调用实现的推荐组件。另外一个是通过直接传入自己实现的组件实例来完成推荐,本质上是一样的。
以下是一个完整推荐业务运行的时序图:
推荐系统实践
推荐目标关键点:
- 每天的推荐的数据量有限制,每次推荐的数据量也有限制,对于资料卡的喜欢的数量也有限制;
- 对于已经操作过的资料卡一定周期内不能推荐;
- 数据是否推荐的评判维度和玩家身上的个人信息挂钩,比如个人标签、距离、性别等都占有一定比重;
- 要有默认的规则,当用户没有设置推荐规则的时候,系统要有合适的默认规则;
- 推荐结果遵循三七原则,防止饿死:样本评分结束后不能全部返回高分样本,而是返回70%的高分样本和30%的低分样本;
- 推荐的性能要快,尽可能符合理论上的推荐结果(处于性能考虑不可能完全去基于整个数据库作为样本推荐,尽可能每次推荐结果都能一定程度反应理论上的结果)
- 容器设计
- 推荐计数器:由于每天推荐量和喜欢量都有限制。为每个人设计一个推荐计数器每次推荐结束后都要记录下推荐的条数,每次推荐前都要去检查这个数量;计数器的有效期是直到晚上24点;
- 资料卡喜欢计数器:同样的为每个设计一个喜欢计数器,每次喜欢操作之前都会检查这个计数器,有效期也是当天;
- 回收池设计:由于资料卡的推荐功能要求,对于推荐的资料卡必须要有操作,要么忽略,要么喜欢;如果出现推荐的资料卡没有被操作,那么必定是没有看完,我仍然认为下次可推;所以回收池的写入权就完全交给了资料卡操作这个接口,也就是说推荐结果我没有直接加入回收池,而是用户操作资料卡后才去加入回收池;
每次选取推荐样本的时候会排除掉回收池的资料卡,这样就能保证每次推荐都是足量的(限制了每批有多少条数据);
回收池也是挂在某个人的身上的,不能让你的回收影响到别人的推荐,它的有效期是一个可配置的周期;
- 规则存储设计:策划要求用户设定的推荐规则要持久,就算是退出游戏了也不要改变,所以就将规则存储到服务端,推荐过程对规则的要求就是自存自取;这样设计带来一个额外的好处,对于怎么推荐这件事,我可以完全自主决定,不需要客户端的任何参数,防止别人刷用户信息;
- 喜欢者容器设计:触发了一个喜欢某张资料卡的操作之后,就会把操作这个人的id放到被喜欢人的备胎列表中;设计这么一个容器的原因有两个,第一,我们操作资料卡选择喜欢的时候有个操作是当对方也喜欢了我,就会触发打招呼的操作,所以需要判断这个人是不是也喜欢了我。有了这个容器之后我们就可以直接在里面去取了,而不需要去遍历所有人的回收池,看回收池有没有我以及对我的操作是不是喜欢;第二,在评分的时候,如果某条数据喜欢了我,并且喜欢我的时间在规定时间之内,就会给这样资料卡增加权重;
所以这个容器的设计的目的是空间换时间,容器仍然是挂在某个用户身上,有效期同回收池;
自此,所有和推荐执行无关的限制条件基本都满足了;
- 数据召回器设计
数据召回器的作用是在数据库中召回一批数据作为评分样本,最后在这批打完分的样本中按照一定的规则返回一批数据给客户端;召回的数据如果过多会影响推荐的性能,过少又不能尽可能达到理论值;所以召回多少数据作为评分样本就值得考究了;这里我的召回规则如下:
为了尽可能做到推荐的随机性,我选择将整个数据库的数据量作为推荐池,前面提到:资料卡推荐限制了每天能推荐多少张,每批推荐多少张,那么我们就能知道每天能给用户推几批。于是我就对数据库分页,将其分为了 推荐张数 / 每批数量 这么多页,每天能推多少批,就分成了几页;这样每推一批其实就是在数据库的一页中操作,每批的数据必定没有交集,同时每批的推荐池也达到了最大化。
然后每批推荐,我就在这一页中去随机取一批数据作为评分的样本,这里样本的数量也是一个关键的地方,样本过少其实评分的作用就不大了;样本过大,在评分阶段消耗增加,影响推荐结果返回时间。这里我选择在这一页中随机选取要推荐的数量的3倍,后面会解释为什么会是3倍;
总之,我将数据库分成了固定的页数;每批推荐都在不同的页中随机选取样本,这个样本的数据量是通过业务要求计算出来的;最后返回的结果就兼具了隔离性:多批推荐之间不会出现重复数据、随机性:在一页中随机选取样本已经是最大的随机了、高效性:随机选取的样本数量经过计算是一个既能满足功能要求,又不会引起大性能问题的值;
关键代码:数据库每批推荐进入某一页选取随机样本
- 评分计算器设计
评分计算器并没有太多整体性能上的考虑,主要操作是一个数据遍历打分的过程。性能差异就落在了每个维度的打分上面,资料卡体现在标签打分和性格测试打分。最难的就是个人标签在矩阵中计算点数🤮🤮🤮。附上一段不太通用的思路:这里的矩阵匹配,其实就是看玩家身上的标签落在了矩阵的哪几列(也可以叫行,因为行列是相等的),我只要把自己落在哪行以及多少个标签落在了这一行统计出来就完成了评分的一半了。算了,只可意会,不能言传。剩下的就是两层遍历,直接上代码:
这只是一种数据结构层面上的解决办法,嵌套的循序多到不想去考虑复杂度。希望有算法大佬能提点矩阵相关算法解决。
- 业务调控组件设计
至此,已经计算出了要推荐条数 * 3的数据列表。业务调控主要就是对评分完成后的数据列表做一些调控操作,包括但不限于:结果计算、模型转换、数据过滤等等。资料卡中要做的是数据过滤以及结果计算。首先,这批样本的数量是要推荐数量的3倍,所以要过滤掉三分之二的数据;结果计算是由于我们给的某些数据客户端不能直接用,比如lbs数据,服务端只存储了经纬度,客户端要展示的是距离。
这里解释一下为什么样本选取要是推荐数量的3倍:推荐有一条规则是:重高分中选出70%、低分中选出30%的数据作为最后的推荐结果;既然已经提出了三七原则,那么就认为样本中前30%是高分,后70%是低分。那么问题就变成了我要在这个列表的前30%样本中选出最终结果数量的70%,在列表的后70%中选出最终结果数量的30%;假设列表长度为m ,要推荐的数量是 n, 那么可以得出:如果 0.7n <= 0.3m 且 0.3n <= 0.7m ,那么m/n >= 7/3;,也就是说,要满足这个需求,样本数据量至少是2点几倍。那么最接近这个值得整数就是3了。
这个过滤的过程是通过一个stream流一遍完成。完成过程近似随机,方案如下:
首先自定义一个收集器,收集器有一个累加器组件,这个累加器是组件时stream中每个元素都要执行的。我要实现高分中随机选一批数据。那么我就从这下手了。首先我知道高分元素有多少个,我也知道有多少个元素流过了。只要在我确保能选够高分要求的数据的情况下,我就可以生成随机数来决定经过的这个元素该不该被选中,这个选中的概率自然就是 高分要求返回的数量/ 高分的数量。这个过程就可以近似随机的去返回高分段的元素了,低分段同理。那么就可以实现一次stream操作完成数据过滤。
最后就会返回满足需求的推荐元素,客户端如果操作了这些资料卡,那么就会将其加入回收池,下次就不会推荐了。否则,下次还有可能会推荐这张资料卡。
扩展:服务端其实还支持补发的操作。当出现某些特殊情况,这批返回的数据没有达到规定的张数的时候,下一次推荐请求会自动计算是不是该补发上一批的数据,该补发多少张。然后将这些数据补发给客户端。这也是为什么服务器推荐设计成不相信客户端, 因为我完全能根据自己存储的数据给你做推荐,就没有必要去支持你选择性的请求推荐数据了,不能给你选择性拿我数据的机会。
但是目前来看,这种情况不会发生,每一批推荐都会是足量的,这是由于数据召回阶段对回收池进行排除导致的,回收池数据排除导致召回的数据不可能有推荐过的数据。那么就不可能有不足量的情况。
推荐扩展
扩展点一:
由于业务需要,对于推荐结构有多次访问的要求,因此设计了一个推荐结果池子,推荐结果会暂存到池子中,后续访问会先从池子里面获取数据,如果没有就再走推荐流程,否则直接从池子中取数据。推荐流程获取的数据也会放到池子中,供下次访问。设计如下:
// 池子 以及操作池子的动作
private ConcurrentHashMap<ERecommendType, List<T>> recommendedMap = new ConcurrentHashMap<>();
/**
* 暂存数据
* @param recommendType- 推荐业务类型
* @param dataList- 要暂存的数据
* @return 返回是否暂存成功
*/
public boolean Stage(ERecommendType recommendType, List<T> dataList) {
if(dataList == null || dataList.isEmpty()) {
return false;
}
recommendedMap.put(recommendType, dataList);
return true;
}
/**
* 当前业务是否暂存了推荐数据
* @param recommendType- 推荐业务类型
* @return 返回是否暂存
*/
public boolean isStaged(ERecommendType recommendType) {
return Objects.nonNull(recommendedMap.get(recommendType)) && !recommendedMap.get(recommendType).isEmpty();
}
/**
* 数据补发
* @param recommendType 推荐类型,区分不同的推荐业务;
* @return 返回补发的数据列表
*/
public List<T> reissue(ERecommendType recommendType) {
return recommendedMap.get(recommendType).stream().map(recommendEntity -> {
recommendEntity.setTag(RecommendTag.STORAGE);
return recommendEntity;
}).collect(Collectors.toList());
}
/**
* @param recommendType- 推荐业务类型
* @param delList- 要删除的数据ID列表
* @return 访问到的数据数量
*/
protected int decrementRecommendList(ERecommendType recommendType, List<String> delList) {
if(delList == null || delList.isEmpty()) {
return 0;
}
List<T> delResult = recommendedMap.compute(recommendType, (key, list) -> {
if (list != null) {
return list.stream().filter(recommendEntity -> !delList.contains(recommendEntity.getId())).collect(Collectors.toList());
}
return null;
});
int delNum = recommendedMap.get(recommendType).size() - delResult.size();
recommendedMap.put(recommendType,delResult);
return delNum;
}
扩展点二:
异步推荐:
经过测试发现,推荐的主要性能消耗就在数据召回阶段,因此将数据召回和后面的评分计算做异步,同样使用池子的思想,预先召回一批数据,当有推荐请求进来的时候直接取这批数据执行推荐,如果没有仍然使用实时召回兜底。并且每次推荐都要使用异步召回为下一次推荐做准备。设计如下:
private ConcurrentHashMap<ERecommendType, List<T>> recallMap = new ConcurrentHashMap<>();
/**
* 异步召回数据
* @param recommendType- 业务类型
* @param recall- 数据召回组件
* @param params- 数据召回需要的参数
* @return 预先召回数据
*/
protected void preRecallData(ERecommendType recommendType, IRecall<T> recall, Container<ERecommendKey> params) {
final List<T> callDataList = recall.recall(params);
if(callDataList != null && !callDataList.isEmpty()) {
recallMap.put(recommendType, callDataList);
}
}
/**
* 获取已经召回的数据
* @param recommendType- 业务类型
* @return 返回已经召回的数据
*/
protected List<T> preDataList(ERecommendType recommendType, IRecall<T> recall, Container<ERecommendKey> params) {
if(params.get(ERecommendKey.PreRecall)!= null && (boolean) params.get(ERecommendKey.PreRecall)) {
if (recallMap.get(recommendType) == null || recallMap.isEmpty()) {
flushRecallData(recommendType, recall, params);
return recall.recall(params);
}
List<T> result = recallMap.get(recommendType).stream().map(recommendEntity -> {
if(recommendEntity != null) {
recommendEntity.setTag(RecommendTag.PreRecall);
}
return recommendEntity;
}).collect(Collectors.toList());
flushRecallData(recommendType, recall, params);
return result;
} else {
return recall.recall(params);
}
}
/**
* 业务是否已经预召回数据
* @param recommendType- 业务类型
* @return 返回该业务当前时刻是否已经预召回了数据
*/
public boolean isPreRecallData(ERecommendType recommendType) {
return recallMap.get(recommendType) != null && !recallMap.get(recommendType).isEmpty();
}
/**
* 异步刷新内存中的数据
*/
private void flushRecallData(ERecommendType recommendType, IRecall<T> recall, Container<ERecommendKey> params) {
Object thread = params.get(ERecommendKey.GameLoop);
if(thread != null) {
((IGameLoop) thread).submit(() -> {
preRecallData(recommendType , recall, params);
});
} else {
preRecallData(recommendType, recall, params);
}
}