文章目录
- 一、优化小项
-
- 1、索引类型必须确定
- 2、无关字段关闭索引
- 3、数值:用scaled_float
- [4、filter 优于 must](#4、filter 优于 must)
- 二、深分页优化
-
- 1、基础分页:from/size
- [2、Scroll 分页(滚动分页)(不推荐、复杂)](#2、Scroll 分页(滚动分页)(不推荐、复杂))
- [3、Search After 分页(游标分页)(稍微推荐)](#3、Search After 分页(游标分页)(稍微推荐))
- [4、自定义排序字段条件查询(类似search after)](#4、自定义排序字段条件查询(类似search after))
- [5、 From+Size + 创建时间 实现深分页(完美实现分页功能)](#5、 From+Size + 创建时间 实现深分页(完美实现分页功能))
一、优化小项
1、索引类型必须确定
(1)简介
上线前务必把核心索引写死。
明确:字段类型、是否索引、所需 fields 及分析器。不让 ES 自己猜。
当你不手动定义索引映射(Mapping)时,ES 会根据插入的第一条数据 "猜" 字段类型:
数字可能被识别为text(文本),导致排序 / 聚合时需要额外转换,性能下降;
手机号 / 身份证号被识别为long,但超出数值范围会报错;
所有字段默认开启索引,即使是不需要检索的字段(如备注、日志详情),浪费内存和磁盘;
文本字段默认使用standard分析器(拆分中文为单字),不符合业务检索需求。
(2)反例
js
//直接插入数据而不定义 Mapping, 插入第一条订单数据,ES自动生成映射
PUT /order_auto/_doc/1
{
"order_id": "20260318001", // ES会猜成text类型
"user_id": 10086, // ES会猜成long类型
"order_amount": 99.9, // ES会猜成double类型
"create_time": "2026-03-18 10:00:00", // ES会猜成text类型
"address": "北京市朝阳区XX小区", // 默认为text+keyword,且开启索引
"order_note": "用户要求下午配送" // 默认为text,开启索引
}
// 此时 ES 自动生成的 Mapping(部分):
{
"order_auto": {
"mappings": {
"properties": {
"order_id": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 }}},
"user_id": { "type": "long" },
"order_amount": { "type": "double" },
"create_time": { "type": "text" }, // 时间被识别为文本,无法按时间范围查询
"address": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 }}},
"order_note": { "type": "text" } // 备注无需检索,却开启了索引
}
}
}
}
(3)正例
js
// 正确做法: 创建订单索引,手动定义Mapping(上线前写死)
PUT /order_core
{
"settings": {
"number_of_shards": 3, // 分片数(根据数据量定,避免过多/过少)
"number_of_replicas": 1, // 副本数(兼顾高可用和性能)
"analysis": { // 自定义分析器(解决中文检索问题)
"analyzer": {
"my_ik_analyzer": { // 自定义IK中文分词器(需提前安装IK插件)
"type": "custom",
"tokenizer": "ik_max_word", // 细粒度分词
"filter": ["lowercase"]
}
}
}
},
"mappings": {
"properties": {
"order_id": { // 订单编号:仅需精确匹配,设为keyword
"type": "keyword",
"index": true, // 需检索,开启索引
"doc_values": true // 支持排序/聚合
},
"user_id": { // 用户ID:数字类型,设为integer(比long更节省内存)
"type": "integer",
"index": true,
"doc_values": true
},
"order_amount": { // 订单金额:设为scaled_float(比double更节省内存,精度可控)
"type": "scaled_float",
"scaling_factor": 100, // 精度到分(99.9 → 9990)
"index": true,
"doc_values": true
},
"create_time": { // 创建时间:设为date类型,指定格式
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis",
"index": true,
"doc_values": true
},
"address": { // 收货地址:需中文分词检索,自定义分析器
"type": "text",
"index": true,
"analyzer": "my_ik_analyzer", // 使用自定义IK分词器
"fields": { // 保留keyword子字段,支持精确匹配(如按完整地址聚合)
"keyword": {
"type": "keyword",
"ignore_above": 512 // 超过512字符的地址不索引
}
}
},
"order_note": { // 订单备注:无需检索,关闭索引
"type": "text",
"index": false, // 关键:关闭索引,节省磁盘/内存
"doc_values": false // 无需排序/聚合,关闭doc_values
},
"pay_status": { // 支付状态:枚举值,设为keyword
"type": "keyword",
"index": true,
"doc_values": true
}
}
}
}
(4)可选:禁用动态映射
核心索引需关闭动态映射,防止 ES 自动新增字段:
js
PUT /order_core/_mapping
{
"dynamic": "strict" // 严格模式:插入未知字段时直接报错,避免脏数据
}
(5)新增字段时确定索引类型
js
# 给order_core索引新增logistics_no(物流单号)字段
PUT /order_core/_mapping
{
"properties": {
"logistics_no": { // 新增字段:物流单号
"type": "keyword", // 明确类型为keyword(精确匹配)
"index": true, // 开启索引(支持检索)
"doc_values": true, // 支持排序/聚合
"ignore_above": 128 // 超过128字符的物流单号不索引
}
}
}
2、无关字段关闭索引
对于只做存储展示的原始报文或长文本,务必设置 "index": false。能大幅缩减体积,降低写入与合并开销。
3、数值:用scaled_float
处理价格或比例,用 scaled_float + scaling_factor(如 100)代替普通浮点,避开精度丢失坑,兼顾聚合排序性能。
4、filter 优于 must
只过滤,不打分: 状态、时间范围等条件丢进 bool.filter。它不参与算分且完美利用缓存,极速省算力。
需打分:真需要全文相关性排序的再放进 must。
二、深分页优化
1、基础分页:from/size
深分页性能差:ES 是分布式系统,from=10000&size=10 时,每个分片需要先查询出 10010 条数据,汇总到协调节点后排序,再截取第 10001-10010 条,数据量越大,内存和 CPU 消耗越高。
有默认限制:ES 默认设置 index.max_result_window=10000,超过会报错(可修改但不推荐)。
js
GET /index_name/_search
{
"from": 10, // 跳过前10条
"size": 20, // 每页20条
"query": {
"match_all": {} // 匹配所有文档
}
}
2、Scroll 分页(滚动分页)(不推荐、复杂)
3、Search After 分页(游标分页)(稍微推荐)
适用场景:深分页、需要实时数据、支持顺序翻页(上一页 / 下一页),不支持跳页。
必须指定唯一排序字段(如 _id 或业务唯一键),避免分页重复 / 遗漏。
(1)实现步骤
第一步:查询第一页数据,获取最后一条文档的排序值
js
GET /index_name/_search
{
"size": 20, // 每页20条
"query": {
"match_all": {}
},
"sort": [
{"id": "asc"}, // 业务唯一键升序
{"_id": "asc"} // ES内置唯一ID,防止排序值重复
]
}
返回结果中,最后一条文档的 sort 数组(如 [100, "123456"])就是下一页的游标。
第二步:通过 search_after 查询下一页
js
GET /index_name/_search
{
"size": 20,
"query": {
"match_all": {}
},
"sort": [
{"id": "asc"},
{"_id": "asc"}
],
"search_after": [100, "123456"] // 上一页最后一条的sort值
}
Java代码示例:
java
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.common.unit.TimeValue;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.elasticsearch.search.sort.SortBuilders;
import org.elasticsearch.search.sort.SortOrder;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class SearchAfterPagination {
public static void main(String[] args) throws IOException {
RestHighLevelClient client = new RestHighLevelClient();
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
// 每页20条
sourceBuilder.size(20);
// 查询条件
sourceBuilder.query(QueryBuilders.matchAllQuery());
// 排序:业务唯一键+_id,保证排序唯一
sourceBuilder.sort(SortBuilders.fieldSort("id").order(SortOrder.ASC));
sourceBuilder.sort(SortBuilders.fieldSort("_id").order(SortOrder.ASC));
// 存储上一页最后一条的排序值
List<Object> lastSortValues = null;
// 模拟分页查询3页
for (int i = 0; i < 3; i++) {
SearchRequest searchRequest = new SearchRequest("index_name");
// 设置search_after游标(第一页为null)
if (lastSortValues != null) {
sourceBuilder.searchAfter(lastSortValues.toArray());
}
searchRequest.source(sourceBuilder);
SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
SearchHit[] hits = response.getHits().getHits();
if (hits.length == 0) {
break; // 无更多数据
}
// 处理当前页数据
System.out.println("第" + (i+1) + "页命中数:" + hits.length);
// 获取最后一条文档的排序值,作为下一页的游标
lastSortValues = new ArrayList<>();
for (Object sortValue : hits[hits.length - 1].getSortValues()) {
lastSortValues.add(sortValue);
}
}
client.close();
}
}
(2)实现上一页
Search After 本身不支持直接 "上一页",但可以靠前端缓存轻松实现上一页 / 下一页。
每次查询之后,需要前端维护一个游标栈 / 游标列表:
js
pageMap: {
1: { firstSort: [1,"xxx1"], lastSort: [10,"xxx10"] }
}
后端需要前端传入searchAfter字段:
java
// 上一页/下一页通用查询
public List<Doc> searchAfter(List<Object> sortValues, int size) {
SearchSourceBuilder builder = new SearchSourceBuilder()
.size(size)
.sort("id", ASC)
.sort("_id", ASC);
if (sortValues != null) {
builder.searchAfter(sortValues.toArray());
}
// 执行查询...
}
4、自定义排序字段条件查询(类似search after)
用from+size+自增字段 作为查询条件:
前提是排序规则是根据某个字段来排序的。
基本实现思路与search after类似,增加一个自增字段的查询条件
js
GET /order_index/_search
{
"from": 0, // 始终为0,避免深分页
"size": 20, // 每页条数
"query": {
"bool": {
"must": [
{"match_all": {}} // 业务查询条件(如订单状态、时间等)
],
"filter": [
{"range": {"order_id": {"gt": 9980}}} // 核心:用ID限定范围
]
}
},
"sort": [{"order_id": "asc"}] // 按ID排序,保证顺序
}
5、 From+Size + 创建时间 实现深分页(完美实现分页功能)
浅分页(≤8000 条)用原生 From/Size,深分页(>8000 条)结合创建时间缩小查询范围,既保留了跳页能力,又规避了深分页性能问题。
核心思路:
阈值判断:当 from + size ≤ 8000 时,直接使用 from+size 分页(普通分页);
深分页优化:当 from + size > 8000 时,分两步查询:
第一步:查询 0~8000 条数据的最后一条创建时间(作为时间边界);
第二步:以该创建时间为条件(createTime < 最后创建时间),并将 from 调整为 from - 8000,再执行分页查询;
多段扩展:若数据量远超 8000(如 16000+),则重复上述逻辑,每次以当前段的最后创建时间作为下一段的时间边界。
前提是:需要有固定的排序字段。
(1)实现方案
java
import com.fasterxml.jackson.databind.ObjectMapper;
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.common.unit.TimeValue;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
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 java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Service
public class DeepPaginationService {
// 深分页阈值(可根据 ES 性能调整,建议小于 10000)
private static final int PAGINATION_THRESHOLD = 8000;
// 索引名(替换为你的实际索引)
private static final String INDEX_NAME = "business_data";
// 创建时间字段名(替换为你的实际字段)
private static final String CREATE_TIME_FIELD = "createTime";
@Autowired
private RestHighLevelClient restHighLevelClient;
@Autowired
private ObjectMapper objectMapper;
/**
* 深分页查询入口方法
* @param from 起始位置(从 0 开始)
* @param size 每页条数
* @return 分页结果列表
* @throws IOException ES 查询异常
*/
public List<Map<String, Object>> queryByDeepPagination(int from, int size) throws IOException {
int endPos = from + size;
List<Map<String, Object>> result = new ArrayList<>();
// 1. 未超过阈值:直接 from+size 查询
if (endPos <= PAGINATION_THRESHOLD) {
result = normalFromSizeQuery(from, size, null);
}
// 2. 超过阈值:分段查询
else {
// 第一步:获取前 8000 条的最后一条数据的创建时间
List<Map<String, Object>> lastOfThreshold = normalFromSizeQuery(PAGINATION_THRESHOLD - 1, 1, null);
if (lastOfThreshold.isEmpty()) {
return result; // 前 8000 条无数据,直接返回空
}
String lastCreateTime = (String) lastOfThreshold.get(0).get(CREATE_TIME_FIELD);
if (lastCreateTime == null) {
throw new RuntimeException("创建时间字段为空,无法执行深分页");
}
// 第二步:计算剩余查询量,重置 from=0 + 时间过滤
int remainingSize = endPos - PAGINATION_THRESHOLD;
result = normalFromSizeQuery(0, remainingSize, lastCreateTime);
// 扩展:如需查询 16000+ 数据,取消以下注释实现循环分段
/*
int totalNeed = endPos;
String currentLastTime = null;
while (totalNeed > 0) {
int currentSize = Math.min(PAGINATION_THRESHOLD, totalNeed);
List<Map<String, Object>> segmentData = normalFromSizeQuery(0, currentSize, currentLastTime);
if (segmentData.isEmpty()) break;
result.addAll(segmentData);
totalNeed -= currentSize;
currentLastTime = (String) segmentData.get(segmentData.size()-1).get(CREATE_TIME_FIELD);
}
*/
}
return result;
}
/**
* 基础 from+size 查询(支持创建时间过滤)
* @param from 起始位置
* @param size 条数
* @param maxCreateTime 最大创建时间(小于该值),null 则不过滤
* @return 查询结果
* @throws IOException ES 查询异常
*/
private List<Map<String, Object>> normalFromSizeQuery(int from, int size, String maxCreateTime) throws IOException {
// 1. 构建查询请求
SearchRequest searchRequest = new SearchRequest(INDEX_NAME);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
// 2. 构建查询条件(Bool 组合)
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 添加创建时间过滤(核心:分段查询的条件)
if (maxCreateTime != null) {
boolQuery.filter(QueryBuilders.rangeQuery(CREATE_TIME_FIELD).lt(maxCreateTime));
}
// 可添加其他业务条件(示例:查询状态为有效的数据)
// boolQuery.must(QueryBuilders.termQuery("status", "VALID"));
sourceBuilder.query(boolQuery);
// 3. 分页参数
sourceBuilder.from(from);
sourceBuilder.size(size);
// 4. 核心:必须按创建时间排序(保证分页一致性)
sourceBuilder.sort(CREATE_TIME_FIELD, SortOrder.DESC); // 降序(最新在前)
// 5. 超时设置
sourceBuilder.timeout(new TimeValue(60, TimeUnit.SECONDS));
// 6. 执行查询
searchRequest.source(sourceBuilder);
SearchResponse response = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
// 7. 解析结果
List<Map<String, Object>> result = new ArrayList<>();
for (SearchHit hit : response.getHits().getHits()) {
result.add(hit.getSourceAsMap());
}
return result;
}
// 测试方法:查询第 8001-8010 条数据(超过 8000 阈值)
public void testDeepPagination() {
try {
List<Map<String, Object>> data = queryByDeepPagination(8000, 10);
System.out.println("查询到数据条数:" + data.size());
data.forEach(item -> System.out.println("数据:" + item));
} catch (IOException e) {
e.printStackTrace();
}
}
}