背景
最近项目上有个日志采集,我作为接收端接收udp发送过来的报文数据缓存到es上,然后查询es上的数据分页展示。但是之后我发现es对分页支持很不友好,它分为深分页与浅分页,浅分页就是MySQL里的limit,但是他最大展示长度只能到10000,也就是说当每页100条数据的话,只能翻100页,超过会报错。 所以你要么做限制,尽可能的把数据控制在10000条以内,要么对前端翻页进行限制。
下面我们针对es提供的search after
深分页来完成小幅跳页的操作, 所谓的小幅跳页就是虽然我不能直接从第一页到最后一页,但是我也可以通过缓存游标的方式实现几页几页的跳,search after
深分页的方式只能一直往后翻,scroll
我不太了解,但是应该原理差不多。
环境
jdk8, es7.6.1, maven3.3.9, springboot2.3.2
代码
添加依赖
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
配置
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestHighLevelClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author 朝花不迟暮
* @version 1.0
* @date 2020/9/26 9:08
*/
@Configuration
public class ElasticSearchClientConfig
{
@Bean
public RestHighLevelClient restHighLevelClient()
{
RestHighLevelClient client = new RestHighLevelClient(
RestClient.builder(new HttpHost("119.29.10.76", 9200, "http"))
);
return client;
}
}
创建实体
索引类
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class Document {
/**
* es中的唯一id
*/
private Long id;
/**
* 文档标题
*/
private String title;
/**
* 文档内容
*/
private String content;
/**
* 创建时间
*/
private Date createTime;
/**
* 当前时间
*/
private Long currentTime;
}
传输层,当然有冗余设计,各位取其精华去其糟粕吧
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class DocumentDTO {
private Integer pageNum = 1;
private Integer pageSize = 10;
/**
* es中的唯一id
*/
private Long id;
/**
* 文档标题
*/
private String title;
/**
* 文档内容
*/
private String content;
/**
* 创建时间
*/
private Date createTime;
/**
* 当前时间
*/
private Long currentTime;
/**
* 开始时间
*/
private String startTime;
/**
* 结束时间
*/
private String endTime;
/**
* 最后一页的游标页码
*/
private Object[] lastPageSort;
}
返回对象,可根据需求自定义
import com.study.sample.entity.Document;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class DocumentVO {
private Integer pageNum;
private Integer pageSize;
private long total;
private List<Document> data;
}
服务层
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil;
import com.study.sample.entity.Document;
import com.study.sample.entity.dto.DocumentDTO;
import com.study.sample.entity.vo.DocumentVO;
import com.study.sample.service.DocumentService;
import com.study.sample.utils.DateParseUtil;
import com.study.sample.utils.EsClientUtil;
import lombok.extern.slf4j.Slf4j;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.SearchHits;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.elasticsearch.search.sort.SortOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.*;
@Service
@Slf4j
public class DocumentServiceImpl implements DocumentService {
@Autowired
private RestHighLevelClient restHighLevelClient;
//存储游标的集合
private static final Map<String, Map<Integer, Object[]>> sortMap = new HashMap<>(256);
@Override
public DocumentVO deepSearchPage(DocumentDTO documentDTO, HttpServletRequest req) {
String id = req.getSession().getId();
//条件构造器
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
//返回的数据
List<Document> documents = new ArrayList<>();
//当前页
int currentPageNum;
//总数
long total = 0;
DocumentVO documentVO = new DocumentVO();
if (StrUtil.isEmpty(id)) throw new RuntimeException("id不能为空");
//页码和游标对应的集合
Map<Integer, Object[]> pageMap = sortMap.get(id);
//---------------设置查询条件start--------------
//范围查询
if (documentDTO.getStartTime() != null) {
Date startDate = DateParseUtil.parseString2Date(documentDTO.getStartTime());
boolQueryBuilder.filter(QueryBuilders.rangeQuery("createTime").gte(startDate));
}
if (documentDTO.getEndTime() != null) {
Date endDate = DateParseUtil.parseString2Date(documentDTO.getEndTime());
boolQueryBuilder.filter(QueryBuilders.rangeQuery("createTime").lte(endDate));
}
// 模糊查询
if (documentDTO.getContent() != null) {
// 同一字段在多个field里查询
// boolQueryBuilder.filter(QueryBuilders.multiMatchQuery(documentDTO.getContent(), fields));
boolQueryBuilder.must((QueryBuilders.wildcardQuery("content", documentDTO.getContent())));
}
if (documentDTO.getTitle() != null) {
boolQueryBuilder.should((QueryBuilders.wildcardQuery("title", documentDTO.getTitle())));
}
//---------------设置查询条件end----------------
/*首先不能是第一页,其次页码集合不能是空的,对应的页码也得在这个集合里,最后是当前页要小于此集合。
* 我觉得重点在于最后一条,为什么一定要小于呢?因为当前页数=集合的容量,可以视为已经翻到了最后一页,那么我们要继续向后查询5页
* 索引所以我们把这个边界处理放到了最后一层,本层只处理缓存有的游标,存在就放search after里查*/
if (documentDTO.getPageNum() != 1 && MapUtil.isNotEmpty(pageMap)
&& pageMap.containsKey(documentDTO.getPageNum())
&& pageMap.size() > documentDTO.getPageNum()) {
try {
//构造查询条件
searchSourceBuilder.query(boolQueryBuilder)
.sort("_id", SortOrder.DESC) //拿什么排序,游标就是什么
.size(documentDTO.getPageSize());
//从缓存里拿到了当前页的游标---> 存放的时候就已经做了对应处理!!
searchSourceBuilder.searchAfter(pageMap.get(documentDTO.getPageNum()));
SearchRequest searchRequest2 = new SearchRequest("document")
.source(searchSourceBuilder);
SearchResponse searchResponse2 = restHighLevelClient.search(searchRequest2, RequestOptions.DEFAULT);
SearchHits searchHits = searchResponse2.getHits();
if (searchHits.getTotalHits().value > 0) {
SearchHit[] hits = searchHits.getHits();
EsClientUtil.convertResult(documents, Document.class, hits);
total = searchHits.getTotalHits().value;
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/*当pageNum=1的时候我就默认他刚接在或者已经刷新当然也有可能是从第2页回去之类的情况,但这里均不予考虑,只要是1就
* 重新构造页标和游标的对应关系*/
else if (documentDTO.getPageNum() == 1) {
// 先移除
sortMap.remove(id);
// 上面被移除,pageMap更不可能获取到,这里必须自己初始化
pageMap = new HashMap<>();
//游标
Object[] sortValues;
//当前页
currentPageNum = 1;
//下一页
int nextPageNum = currentPageNum + 1;
try {
searchSourceBuilder.query(boolQueryBuilder)
.sort("_id", SortOrder.DESC)
.from(0) //必须是0,不熟悉的朋友可能会觉得这里就可以循环,from从1开始就可以拿第二页,其实不行
//这样拿到的数据会有一点点错位,而easy-es这个框架是直接不允许深分页查询from > 0的
.size(documentDTO.getPageSize());
SearchRequest searchRequest = new SearchRequest("document").source(searchSourceBuilder);
SearchResponse searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
SearchHit[] hits = searchResponse.getHits().getHits();
if (hits.length != 0) {
//查询最后一个数据
SearchHit result = hits[hits.length - 1];
sortValues = result.getSortValues();
pageMap.put(1, new Object[]{
}); // 第一页没有游标
pageMap.put(2, sortValues); //第一页的游标是去拿第二页的数据的,所以是2
EsClientUtil.convertResult(documents, Document.class, hits);
total = searchResponse.getHits().getTotalHits().value;
}
//向后获取5页的游标数据 所以你要品nextPageNum和currentPageNum的作用,就是处理游标和页码的对应关系的
for (int i = nextPageNum; i < nextPageNum + 5; i++) {
//取出上一页的游标
searchSourceBuilder.searchAfter(pageMap.get(i));
SearchRequest searchRequest2 = new SearchRequest("document")
.source(searchSourceBuilder);
SearchResponse searchResponse2 = restHighLevelClient.search(searchRequest2, RequestOptions.DEFAULT);
SearchHits searchHits = searchResponse2.getHits();
if (searchHits.getTotalHits().value > 0) {
SearchHit[] nextHits = searchHits.getHits();
//当数据量不大的情况下且每页pageSize很大的话,他可能都没有5页,所以每次循环要判断,一旦
//不足就要终止,因为总数据已经不足分页了,在遍历就越界了
if (nextHits.length < documentDTO.getPageSize()) break;
SearchHit nextHit = nextHits[nextHits.length - 1];
sortValues = nextHit.getSortValues();
//从3开始 3/4/5/6/7
pageMap.put(i + 1, sortValues);
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/*这里是边界,也就是当前端页面显示当前展示的最大页数到第7页了,而你的页码正好是7,那么就该继续向后拿后面的游标并且要和页码对应*/
else if (pageMap.containsKey(documentDTO.getPageNum()) && pageMap.size() == documentDTO.getPageNum()) {
searchSourceBuilder.query(boolQueryBuilder)
.sort("_id", SortOrder.DESC)
.size(documentDTO.getPageSize());
currentPageNum = documentDTO.getPageNum();
try {
for (int i = currentPageNum; i < currentPageNum + 5; i++) {
//这里要知道当前页的游标在上面的集合里已经有了
searchSourceBuilder.searchAfter(pageMap.get(i));
SearchRequest searchRequest2 = new SearchRequest("document")
.source(searchSourceBuilder);
SearchResponse searchResponse2 = restHighLevelClient.search(searchRequest2, RequestOptions.DEFAULT);
SearchHits searchHits = searchResponse2.getHits();
total = searchHits.getTotalHits().value;
if (searchHits.getTotalHits().value > 0) {
SearchHit[] hits = searchHits.getHits();
//这里是数据边界的终止,上面已说
if (hits.length < documentDTO.getPageSize()) {
EsClientUtil.convertResult(documents, Document.class, hits);
break;
}
SearchHit result = hits[hits.length - 1];
Object[] sortValues = result.getSortValues();
//存放游标
pageMap.put(i + 1, sortValues);
//这里是拿出当前页的数据
if (i == documentDTO.getPageNum()) {
EsClientUtil.convertResult(documents, Document.class, hits);
}
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
documentVO.setPageNum(documentDTO.getPageNum());
documentVO.setPageSize(documentDTO.getPageSize());
documentVO.setTotal(total);
documentVO.setData(documents);
sortMap.put(id, pageMap);
return documentVO;
}
}
思路简述
其实从上面的代码加注释,我觉得你应该就可以理解了,思路只有一个那就是缓存游标,这里我有个三个判断,第一个判断是判断当前页数是不是已经在缓存里了,进了第一个说明是有的,就直接拿出游标查询并返给前端。
第二个判断是判断是不是初次加载,如果是就清掉之前缓存的游标集合,因为你要考虑数据增量的情况,如果你没有数据增量的情况甚至都不用按标记分,直接建立个游标缓存,什么时候有增量数据(比如那种一天一增),就什么时候删缓存。然后还要获取后五页的游标数据。
第三个判断是边界判断,主要任务有三个,第一个任务是获取后5页的游标,第二个任务是判断总数据是不是没得分了,第三个任务是拿到当前边界的数据
后续
首先是关于这个缓存的维护,比如session已经不再有效,怎么移除,其实我的项目里是还有个map的,他就是来实时更新这个session的最后查询时间的,可以通过定时任务,一旦超过一个时间点,就从sortMap
移除。
其次关于时间的建议,我个人建议你在存时间的时候字段设置成Long型,就算不方便你也要一个Date类型一个Long型,Es读取出来的那个时间你不好转Date,所以建议用Long比较,建议你采纳我的建议!!
第三是谈浅分页,其实我们一开始也不是用深分这个方案的,而是通过限制数据的首次加载条数,我们后台逻辑处理好,尽量避免超出那个1w的限制。跟前端也说好,比如我每页50条数据,那么前端那边翻页的总页数就不能大于200,也不要展示总页数,也不要让前端弄那个尾页最大页的那个按钮,就让用户5页5页往后跳。如果你不能的客户不允许这样,那我这边建议你放弃es拥抱MySQL,两难自解!
第四条如果你可以随意选型的话,且你对传统的api不熟悉的话,建议你考虑easy-es,让你操作es如同操作关型数据库,且封装好了深分页查询。
第五是关于上面的代码逻辑,可能还会有漏洞,就是关于跳页的问题,我也许还没有处理的太成熟,但是前端那边老老实实jump的话应该不会出什么问题!
有问题可以联系707409741
转载:https://blog.csdn.net/Curtisjia/article/details/127658239