ElasticSearch:优化案例实战解析(持续更新)

文章目录

一、优化小项

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();
        }
    }
}
相关推荐
一叶落4382 小时前
LeetCode 54. 螺旋矩阵(C语言详解)——模拟 + 四边界收缩
java·c语言·数据结构·算法·leetcode·矩阵
最初的↘那颗心2 小时前
Prompt 工程实战:五要素框架与 Spring AI 模板化落地
java·大模型·prompt工程·spring ai·ai应用开发
墨狂之逸才3 小时前
React Native 移动项目目录导致的 Android 编译失败问题及解决方案
android·react native
东离与糖宝3 小时前
Java 21 虚拟线程与 AI 推理结合的最新实践
java·人工智能
feng一样的男子3 小时前
住在手机里的“小龙虾” (OpenClaw):接入本地模型,解决记忆“装死”顽疾
android·ai·智能手机·openclaw
hongtianzai3 小时前
MySQL中between and的基本用法
android·数据库·mysql
菜鸟小九4 小时前
hot100(71-80)
java·数据结构·算法
大傻^4 小时前
LangChain4j 1.4.0 快速入门:JDK 11+ 基线迁移与首个 AI Service 构建
java·开发语言·人工智能