首先来说 elasticsearch(文章里面我们简称 “ es ”) 我们在这一期项目中应用的比较简单,本来是要整合各种东西,最后由于时间原因,还只是做了一部最简单的推荐系统。
只需要技术点: elasticsearch + redis
在这里说一下,说道理谁都会,总有些人会夸夸而谈,但是你在一个真正的实践过的人眼前,感觉就是那种特别low,这我深有体会。 没做之前,我也感觉非常简单,几天就搞定,但是真的在做的过程中,遇到的各种坑,再加上我们的业务复杂,导致,在项目上线前一天,还在各种bug频繁的出现。
先说一下主要 实现架构,算不上是大数据分析,说白了 就是用了 more_like的模糊搜索
实现原理:根据用户搜索、点击视频播放的时候存下用户记录,去给推荐栏目里面 推荐用户喜好的文章或者视频,新闻有banner页,所以说 一边要推荐用户喜好的文章,还要推荐banner页,还有一点就是用户每次刷新一次,都要去给他更新数据,先用redis 记录上次更新时间,与当前时间进行搜索,如果说不满十条或者要求推荐的数,那就去取五天前的,如果说五天前的数据没有的话,那就有几天就推送几条。注意 :这里有一个去重问题,就是当前时间段,或者五天前的数据 会跟我们现在redis里面取出来的数据重复(我们这里不是直接从es里面取数据,而是先从es里面取出来,放到redis里面,这个时候redis充当了一个推荐池,可能有很多人有疑问,为什么不直接从es里面取,后面我会一一的介绍)去重问题我们也会在分解代码的时候提到,有一点是栏目下面,也要推荐,实现原理是跟推荐里面一样的,只不过多加了一个栏目id而已。
搭建基础设置(es 现在主要有两种请求方式 Rest Client 和 Transport client)这里我们用的是 Rest Client ,据说Transport client已经摒弃。
依赖包:
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId>
<version>6.3.1</version>
</dependency>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client-sniffer</artifactId>
<version>6.3.1</version>
</dependency>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>6.3.1</version>
</dependency>
配置文件:(spring cloud)
elasticsearch.host=192.168.1.33
elasticsearch.port=9200
elasticsearch.schema=http
elasticsearch.cluster.isCluster=true
elasticsearch.cluster.name=ELK-cluster
elasticsearch.connectionTimeOut=1000
elasticsearch.socketTimeOut=30000
elasticsearch.connectionRequestTimeOut=500
elasticsearch.maxConnectNum=100
elasticsearch.maxConnectPerRoute=100
elasticsearch.uniqueConnectTimeConfig=false
elasticsearch.uniqueConnectNumConfig=true
贴上es工具类代码,根据我们的业务场景自己封装的。(注意:千万不要盲目复制哦。里面有我们的业务代码,要复制的话把我们的业务代码去掉即可)
1、搜索代码,搜索直接从es里面取出直接展示出来,没有经过redis
/**
* 搜索条件 more_like 搜索 分页
*/
public NewsOrVideos searchOrRecommendPage(SearchAllDto searchDto){
//设置索引
SearchRequest searchRequest = new SearchRequest(searchDto.getEsIndex());
//设置类型
searchRequest.types(searchDto.getEsType());
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
MoreLikeThisQueryBuilder moreLikeThisQueryBuilder = null;
//如果说 length 等于0 说明需要把所有的数据都查询出来
if (searchDto.getTitle() != null && searchDto.getTitle().length!=0){
moreLikeThisQueryBuilder = QueryBuilders.moreLikeThisQuery(searchDto.getSourceFields(), searchDto.getTitle(), null);
moreLikeThisQueryBuilder.minTermFreq(0);
moreLikeThisQueryBuilder.minDocFreq(0);
}
TermQueryBuilder termQueryBuilder = null;
if (StringUtils.isNotBlank(searchDto.getColumnId())){
termQueryBuilder = QueryBuilders.termQuery(searchDto.getSourceCom(), searchDto.getColumnId());
}
if (StringUtils.isNotBlank(searchDto.getIsTop()) && !searchDto.getIsTop().equals("isTop")){
termQueryBuilder = QueryBuilders.termQuery("isTop", searchDto.getIsTop());
}
if (searchDto.getStartTime()!=null && searchDto.getEndTime()!=null){
boolQueryBuilder.filter(QueryBuilders.rangeQuery("onlineTime").gte(searchDto.getStartTime().getTime()).lte(searchDto.getEndTime().getTime()));
searchSourceBuilder.query(boolQueryBuilder).sort("onlineTime",SortOrder.ASC);
}
//分页
int page = searchDto.getPage();
int size = searchDto.getSize();
if(page<=0){page= 1;}
int start = (page-1)*size;
searchSourceBuilder.from(start);
searchSourceBuilder.size(size);
//请求搜索
if (moreLikeThisQueryBuilder!=null){
boolQueryBuilder.must(moreLikeThisQueryBuilder);
}
if (termQueryBuilder!=null){
boolQueryBuilder.must(termQueryBuilder);
}
searchSourceBuilder.query(boolQueryBuilder);
//
searchRequest.source(searchSourceBuilder);
SearchResponse searchResponse = null;
try {
searchResponse = restHighLevelClient.search(searchRequest);
} catch (IOException e) {
e.printStackTrace();
}catch (Exception e){
}
//结果集处理
SearchHits hits = searchResponse.getHits();
SearchHit[] searchHits = hits.getHits();
//记录总数
long totalHits = hits.getTotalHits();
//数据列表
NewsOrVideos newsOrVideos = new NewsOrVideos();
//不管当前还是视频 统一用这个返回 调用一下方法的时候 添加对应字段
if (StringUtils.isNotBlank(searchDto.getContentType()) && searchDto.getContentType().equals(Constant.ContentType.VIDEO)){
//查询视频
List<VideoListVo> list = getVideosList(searchHits);
if (list.size()>0){
newsOrVideos.setVideoEditVoList(list);
}
}else{
//新闻
List<NewsIndexPageVo> newsList = getNewsList(searchHits);
if (newsList.size()>0) {
newsOrVideos.setNewsList(newsList);
}
}
//总记录数
newsOrVideos.setTotalCount(totalHits);
return newsOrVideos;
}
搜索比较简单,直接调用这个上面的方法即可,封装好指定的VO ,直接传给前端,但是需要注意一些细节,在传入参数的时候,尽量直接传入一个数组,封装到一个dto里面。
这里的搜索使用的more_like 注意的一点事
moreLikeThisQueryBuilder.minTermFreq(0);
moreLikeThisQueryBuilder.minDocFreq(0);
这段代码必须要有!必须有!
min_term_freq:一篇文档中一个词语至少出现次数,小于这个值的词将被忽略,默认是2
min_doc_freq:一个词语最少在多少篇文档中出现,小于这个值的词会将被忽略,默认是无限制
这两个缺一个都会导致查不出来!
moreLikeThisQueryBuilder = QueryBuilders.moreLikeThisQuery(searchDto.getSourceFields(), searchDto.getTitle(), null);
第一个参数是 要搜索的字段,第二个参数是 要搜索的内容数组,第三个设置为null 即可
分页的话 大家应该都知道吧,
searchSourceBuilder.from(start);//这里的start 代表的是 从第几条开始查 ps:不是指从第几页!是从第几条
searchSourceBuilder.size(size); //这里size 值得是 查询多少条
searchResponse = restHighLevelClient.search(searchRequest);
这段代码 是请求搜索。可能很多人 在写restHighLevelClient 这个类的时候有问题 其实是要先注入的
@Autowired
private RestHighLevelClient restHighLevelClient;
给大家看一下 我封装的dto 也就是 searchDto
@Data
public class SearchAllDto {
private String esIndex;//索引名称
private String esType; //索引类型
private int page;//当天第几条
private int size;//分页条数
private String[] content;//分页内容
private String contentType;// 1、新闻 4、视频
private String[] title;//标题
private String[] sourceFields;
private String columnId;//栏目id
private String tagLable;//标签id
private String sourceCom;//栏目字段
private String isTop;//是否置顶
private String columnTopFlag; //栏目置顶
private String orderNum; //置顶顺序
@JSONField(format="yyyy-MM-dd HH:mm:ss")
private Date startTime; //开始时间
@JSONField(format="yyyy-MM-dd HH:mm:ss")
private Date endTime;//结束时间
private String id;//id
private String currPageTrue;//判断是不是要更新时间 等于0的时候不更新时间
}
搜索总结:其实es 的搜索里面有一个搜索机制,它搜索的机制是根据搜索出来的结果打分,分数高的 就会排在最前面。
2、每次刷新更新指定条数的代码 这里我们是每次最多更新十条
/**
* 推荐十条
*/
public NewsOrVideos searchOrRecommendFromTime(SearchAllDto searchDto){
//设置索引
SearchRequest searchRequest = new SearchRequest(searchDto.getEsIndex());
//设置类型
searchRequest.types(searchDto.getEsType());
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
// MoreLikeThisQueryBuilder moreLikeThisQueryBuilder = null;
//如果说 length 等于0 说明需要把所有的数据都查询出来
// if (searchDto.getTitle() != null && searchDto.getTitle().length!=0){
// moreLikeThisQueryBuilder = QueryBuilders.moreLikeThisQuery(searchDto.getSourceFields(), searchDto.getTitle(), null);
// moreLikeThisQueryBuilder.minTermFreq(0);
// moreLikeThisQueryBuilder.minDocFreq(0);
// }
TermQueryBuilder termQueryBuilder = null;
TermQueryBuilder termQueryBuilder1 = null;
if (searchDto.getContentType().equals("4")){
//当前为视频
if (StringUtils.isNotBlank(searchDto.getColumnId())){
termQueryBuilder = QueryBuilders.termQuery(searchDto.getSourceCom(), searchDto.getColumnId());
}
}else{
if (StringUtils.isNotBlank(searchDto.getColumnId())){
termQueryBuilder = QueryBuilders.termQuery(searchDto.getSourceCom(), searchDto.getColumnId());
if (StringUtils.isNotBlank(searchDto.getColumnTopFlag()) && !searchDto.getColumnTopFlag().equals("columnTopFlag")) {
termQueryBuilder1 = QueryBuilders.termQuery("columnTopFlag", searchDto.getColumnTopFlag());
}
} else {
if (StringUtils.isNotBlank(searchDto.getIsTop()) && !searchDto.getIsTop().equals("isTop")){
termQueryBuilder = QueryBuilders.termQuery("isTop", searchDto.getIsTop());
}
}
}
if (searchDto.getStartTime()!=null && searchDto.getEndTime()!=null){
boolQueryBuilder.filter(QueryBuilders.rangeQuery("onlineTime").gte(searchDto.getStartTime().getTime()).lte(searchDto.getEndTime().getTime()));
searchSourceBuilder.query(boolQueryBuilder).sort("onlineTime",SortOrder.ASC);
//分页
searchSourceBuilder.from(0);
searchSourceBuilder.size(10);
}
//请求搜索
// if (moreLikeThisQueryBuilder!=null){
// boolQueryBuilder.must(moreLikeThisQueryBuilder);
// }
if (termQueryBuilder!=null){
boolQueryBuilder.must(termQueryBuilder);
}
if (termQueryBuilder1 != null) {
boolQueryBuilder.must(termQueryBuilder1);
}
if (StringUtils.isNotBlank(searchDto.getColumnId())){
if (StringUtils.isNotBlank(searchDto.getColumnTopFlag()) && searchDto.getColumnTopFlag().equals("columnTopFlag")) {
// searchSourceBuilder.sort("columnTopFlag",SortOrder.DESC);
}
} else {
if (StringUtils.isNotBlank(searchDto.getIsTop()) && searchDto.getIsTop().equals("isTop")){
// searchSourceBuilder.sort("isTop",SortOrder.DESC);
}
}
//
searchRequest.source(searchSourceBuilder);
SearchResponse searchResponse = null;
try {
searchResponse = restHighLevelClient.search(searchRequest);
} catch (IOException e) {
e.printStackTrace();
}catch (Exception e){
}
//结果集处理
SearchHits hits = searchResponse.getHits();
SearchHit[] searchHits = hits.getHits();
//记录总数
long totalHits = hits.getTotalHits();
//数据列表
NewsOrVideos newsOrVideos = new NewsOrVideos();
//不管当前还是视频 统一用这个返回 调用一下方法的时候 添加对应字段
if (StringUtils.isNotBlank(searchDto.getContentType()) && searchDto.getContentType().equals(Constant.ContentType.VIDEO)){
//查询视频
List<VideoListVo> list = getVideosList(searchHits);
if (list.size()>0){
newsOrVideos.setVideoEditVoList(list);
String formatStart = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(list.get(list.size()-1).getOnlineTime());
// if (!searchDto.getCurrPageTrue().equals("0")){
// //这个时候可以更新
// if (list.size()>10){
// newsOrVideos.setNewTime(formatStart);
// }
// }else{
newsOrVideos.setNewTime(formatStart);
// }
}
}else{
//新闻
List<NewsIndexPageVo> newsList = getNewsList(searchHits);
if (newsList.size()>0) {
newsOrVideos.setNewsList(newsList);
}
}
//总记录数
newsOrVideos.setTotalCount(totalHits);
return newsOrVideos;
}
推荐是相对来说比较麻烦的,为什么这么说呢,因为他还要整合redis,还要去重,而且每个用户对应着一组redis,
每个用户userID 对应的redis如下:
key1: 推荐id+ userId
key2: 栏目id+userId
key3: 时间标识id+ userId (这个键 就是存放推荐的上次刷新时间)
key4: 栏目标识id+userId (这个键是存放 栏目下的刷新时间)
key5: 用户搜索标识+userId
大家感觉是不是感觉比较乱,但是我的设计如此,如果有更好的方法欢迎留言,我会虚心接受,谢谢。
我前面说了,推荐要放到redis 里面 ,大致的意思就是,一个新用户进来以后,我默认的从es 里面按照时间降序取200条放到推荐池里面,注意这200条数据并不是我前面所说的,每次刷新十条从这里面取,对,他跟着200条没有关系,只不过是保证用户进来不刷新的情况下有数据,能分页(我这里是用户下拉刷新 才会有推荐的数据,前提基于用户的喜好),这是用户下拉刷洗以后就会推荐十条。
这十条数据会先去查询用户是不是有搜索记录,如果有直接到es里面使用模糊查询(more_like),若是没有,直接用当前的时间到上次刷新的时间去取这个时间段里面的数据,这里可能会有人产生疑问,那么用户第一次进入的时候我们要怎么存这个时间,我这里是用了 redis的set方法,存了一个时间,第一次进来 默认给他一个当前时间 new Date()。
随后去es里面查询,这里在啰嗦一下,尽量将es里面的date类型存成 时间戳类型 到时候 时间都转换成时间戳进行比较即可,用到的方法如下:
boolQueryBuilder.filter(QueryBuilders.rangeQuery("onlineTime").gte(searchDto.getStartTime().getTime()).lte(searchDto.getEndTime().getTime()));
getTime() 这个方法就是获取时间戳。
最重要的一点来了,必须要啰嗦:
如果说 我现在redis里面存的是10点,那么我现在去下拉刷新,当前new date()为12点,但恰好这个时间段内有200条数据,那我们200条数据只取十条未免是不是有点浪费,用户昨天登录APP,过了好几天又登录了一次,难道这几天的数据对用户的下拉推荐,再多也是十条? 浪费不?
这个问题 解决方案如下:我前面已经说过 这个可以分页了对吧 所以说取十条没问题,假如说现在有200条数据,那么正序排列,是不是就能取到前十条,为了避免浪费数据,就取第十条的时间,当做该次刷新时间,之前是把当前的时间为刷新时间,这里不是了,注意哦!
es排序正序方法:
searchSourceBuilder.query(boolQueryBuilder).sort("onlineTime",SortOrder.ASC); //正序排列
如果当前这个时间段没有数据,就去取五天前的,获取五天前的时间代码:
/**
* 获取过去几天时间
* @param past
* @return
*/
public static Date getPastDate(int past) throws ParseException {
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.DAY_OF_YEAR, calendar.get(Calendar.DAY_OF_YEAR) - past);
Date today = calendar.getTime();
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String result = format.format(today);
Date parse = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(result);
return parse;
}
获取某一个时间段内的数据,就会出现一个去重的问题,那么redis如何去重呢?
这里我们用的是redis的list类型,去重会比较麻烦,但是分页比较简单,速度快,这里的解决办法是,在添加一个list类型的时候,在同时加一个redis 的 hash类型,id作为hashKey 在每一次从es里面查出一个时间段里面的数据以后,要先获取这hashkey是否存在,如果存在就不要添加了,为null的时候就添加。 这就达到了去重的效果。
这两个方法里面都有一个 取出list的方法 其实就是直接将map强转为对象
/**
* 取出videos_list
*/
public List<VideoListVo> getVideosList(SearchHit[] searchHits){
List<VideoListVo> list = new ArrayList<>();
for (SearchHit hit : searchHits) {
VideoListVo videoEditVo = new VideoListVo();
//取出source
Map<String, Object> sourceAsMap = hit.getSourceAsMap();
try {
BeanUtils.populate(videoEditVo, sourceAsMap);
}catch (Exception e){
}
//添加到list
list.add(videoEditVo);
}
return list;
}
其实这都不算是这个项目的难点,最后遇到的难点,就是同步问题,这里可能对很多人来说 会存在一个误区,因为修改某一条数据,或者删除的时候,不仅仅要同步es,最主要的是同步redis,同步redis的 是每一个用户都会对应一个redis。
解决办法: 建一个 专门同步es的redis库,这样每次就不用都修改用户对应的redis了,因为用户可能上千万,速度也跟不上啊 是不是,这就是很好的解决办法,个人的redis里面只需要存放 id就可以,每次去查询,要知道,redis的查询比mysql快很多。
这种场景是很多的,比如说是用户评论了,点赞了,都是要记录的。用这个不错吧?当然 有人人一开始技术选型就很好,不需要这么麻烦了。
说道这里基本上已经结束了!我想大多数人 都会有一个疑问,而且很大的疑问,是问,为什么不直接从es里面取,其实这也是我一开始的疑问,这里要知道我们面向的不是一个用户,我们不可能去给每一个人搭建一套es,而且,每次的推送也是一个很大的问题,最明显的一点就是,这样只能去推送最新的数据,相当于从头拿,不是说没有解决办法,有!我找到了一种,很麻烦,我想在下一篇博客里面主要的分析这个问题。
技术问题 请加 技术群 :808249297