
在海量数据检索场景中,Elasticsearch凭借近实时的查询能力、灵活的分布式扩展特性,成为了日志分析、商品搜索、舆情监控等业务的核心基础设施。很多开发者能熟练使用ES的CRUD操作,却在面对查询延迟高、写入吞吐低、集群不稳定等问题时无从下手。本质原因是没有吃透ES的三大核心支柱:倒排索引是检索效率的底层基石,分片机制是分布式架构的核心灵魂,全链路精细化调优是生产环境稳定运行的关键保障。
检索的本质是从海量数据中快速找到符合条件的目标内容,而索引的设计直接决定了检索的效率。要理解倒排索引,首先要明确正排索引与倒排索引的本质区别。
正排索引的核心逻辑是文档ID映射到文档内容,就像图书的目录,通过页码找到对应的页面内容。我们常用的MySQL B+树索引就是典型的正排索引,它适合基于主键或固定字段的精准查询,但面对全文检索场景时,会出现性能急剧下降的问题。比如要从1000万条商品数据中找到包含“华为手机”关键词的商品,正排索引需要逐行扫描所有文档,判断是否包含目标关键词,时间复杂度为O(n),完全无法满足海量数据的检索需求。
倒排索引的核心逻辑是内容关键词映射到包含该关键词的文档ID列表,就像图书最后的关键词索引,通过关键词直接找到对应的所有页码。它将全文检索的流程从“遍历文档找关键词”变成了“通过关键词直接定位文档”,时间复杂度直接降低到O(1)级别,这也是ES能实现亿级数据毫秒级查询的核心原因。
ES的倒排索引不是单一的结构,而是由三层核心结构协同组成,兼顾了查询效率与存储效率,完整结构如下:

Term Dictionary是倒排索引的核心,存储了所有文档分词后生成的不重复关键词(Term),以及每个Term对应的Posting List的物理地址指针。为了提升查询效率,Term Dictionary会按照Term的字典序进行排序,支持二分查找,时间复杂度为O(log n)。
当Term数量达到千万级甚至亿级时,Term Dictionary本身的体积会变得非常大,无法全部加载到内存中,二分查找依然需要多次磁盘IO,影响查询性能。Term Index就是为了解决这个问题而生的,它基于FST(有限状态自动机)数据结构,为Term Dictionary构建了一层前缀索引。
FST的核心优势有两个:一是极致的压缩率,它将相同前缀的Term进行合并存储,内存占用仅为Term Dictionary的几十分之一,可以完全加载到内存中;二是极快的查询速度,通过FST可以在微秒级定位到Term在Term Dictionary中的偏移位置,无需遍历整个词典,只需要一次磁盘IO就能找到目标Term。
Posting List存储了包含对应Term的所有文档ID,以及词频、位置、偏移量等信息,是倒排索引的最终落脚点。ES的联合查询、相关性打分、高亮等能力,都依赖于Posting List中的数据。如果Posting List直接存储原始的文档ID,当文档数量达到亿级时,存储开销会非常大,因此ES采用了两种高性能的压缩算法,对Posting List进行压缩存储。
FOR算法的核心逻辑是分块增量编码,它将Posting List中的有序文档ID分成固定大小的块,每个块内只存储文档ID的增量值,而不是原始值。比如文档ID列表为[73, 300, 302, 303, 310],增量值为[73, 227, 2, 1, 7],原始值需要32位整型存储,而增量值的最大值为227,只需要8位就能存储,存储空间直接减少75%。
FOR算法适合存储连续的文档ID列表,压缩率极高,同时解压速度极快,几乎没有CPU开销,是ES默认的Posting List压缩算法。
RBM算法的核心逻辑是分桶位图存储,它将32位的文档ID分成高16位和低16位,高16位作为桶的编号,低16位存储在对应桶的位图中。比如文档ID为100000,转换为十六进制是0x186A0,高16位是0x0001,低16位是0x86A0,就会被存储到编号为1的桶的位图中。
RBM算法兼顾了稀疏数据和密集数据的压缩效率,当桶内的文档ID数量小于4096时,采用短数组存储;当数量大于等于4096时,采用位图存储。相比传统的Bitmap,RBM的存储空间减少了近90%,同时支持极快的位运算,多条件联合查询时,直接对多个Posting List的位图进行与运算,就能快速得到符合所有条件的文档ID列表,这也是ES多条件查询性能极高的核心原因。
ES的倒排索引构建基于Lucene实现,核心特性是段的不可变性,完整的构建流程如下:
这里需要明确一个核心知识点:ES的更新和删除操作不是实时修改原有段,因为段是不可变的。删除操作只是在.del文件中标记文档为已删除,查询时会过滤掉已标记的文档;更新操作是先标记旧文档为已删除,再写入一条新的文档,段合并时才会真正物理删除已标记的文档。
我们可以通过ES的原生API,直观地看到倒排索引的构建结果,以下操作均基于ES 8.x版本。
PUT /product_index
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"refresh_interval": "1s"
},
"mappings": {
"properties": {
"product_id": {
"type": "keyword"
},
"product_name": {
"type": "text",
"analyzer": "ik_max_word",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"category_id": {
"type": "long"
},
"price": {
"type": "double"
},
"stock": {
"type": "integer"
},
"create_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||epoch_millis"
}
}
}
}
PUT /product_index/_doc/1
{
"product_id": "10001",
"product_name": "华为Mate60 Pro 5G手机",
"category_id": 1001,
"price": 5999.00,
"stock": 1000,
"create_time": "2024-01-01 10:00:00"
}
通过_analyze API可以查看文档的分词结果,也就是Term Dictionary中的Term列表:
POST /product_index/_analyze
{
"analyzer": "ik_max_word",
"text": "华为Mate60 Pro 5G手机"
}
返回结果中会包含拆分后的所有Term,比如“华为”“Mate60”“Pro”“5G”“手机”等,这些就是倒排索引中的核心关键词。
通过_termvectors API可以查看指定文档的倒排索引详情,包括Term对应的文档ID、词频、位置等信息:
GET /product_index/_doc/1/_termvectors?fields=product_name
返回结果中可以清晰看到每个Term对应的Posting List信息,直观验证倒排索引的构建结果。
ES的分布式能力,本质上是通过分片机制实现的。它将一个大的索引拆分成多个独立的分片,分布在不同的节点上,实现了数据的水平扩展与高可用。如果说倒排索引决定了ES的查询上限,那么分片机制就决定了ES的集群扩展上限与稳定性。
很多开发者会混淆ES索引与分片的关系,这里明确核心定义:一个ES索引由一个或多个分片组成,每个分片本质上是一个完整独立的Lucene索引。
Lucene是ES的底层检索引擎,所有的文档存储、倒排索引构建、查询检索,最终都是由Lucene实现的。每个分片都有自己独立的段文件、Term Dictionary、Posting List,不依赖其他分片,可以独立完成查询和写入操作。ES的分布式架构,就是将这些独立的分片,调度到集群的不同节点上,协同完成整个索引的读写操作。
ES的分片分为两种类型:主分片(Primary Shard)和副本分片(Replica Shard),两者的职责与协同机制完全不同。
文档写入的完整流程,是主分片与副本分片协同的核心,完整流程如下:

这里需要注意:只有当所有副本分片都写入成功后,ES才会向客户端返回写入成功的响应,保证了主分片与副本分片的数据一致性。
ES在处理读写请求时,必须精准定位到文档所在的分片,这个定位过程就是通过路由算法实现的。ES的路由算法是固定的,公式如下:
shard = hash(routing) % number_of_primary_shards
routing:路由值,默认是文档的_id,也可以自定义指定。hash():哈希函数,默认使用MurmurHash3算法,将路由值转换为一个32位的整型数值。number_of_primary_shards:索引的主分片数量。这个算法的核心特性是:相同的routing值,一定会被计算到同一个分片上。基于这个特性,我们可以自定义routing值,将同一类数据写入到同一个分片上,查询时指定相同的routing值,只需要查询一个分片,不需要扫描所有分片,查询性能可以提升数倍甚至数十倍。
这里也解释了一个核心问题:为什么索引的主分片数量创建后不能修改? 因为主分片数量是路由算法的分母,一旦修改,之前写入的文档的路由计算结果就会全部改变,ES就无法找到文档所在的分片,导致数据丢失。如果需要调整主分片数量,只能通过reindex API重建索引。
ES的分片分配机制,是集群稳定性的核心保障,核心分配规则如下:
一个标准的3节点集群,3主分片1副本分片的架构如下:

这个架构中,每个节点都有1个主分片和1个副本分片,任何一个节点宕机,都不会丢失数据,剩余的2个节点依然有完整的3个主分片,服务可以正常运行,实现了高可用。
分片设计是ES集群性能与稳定性的核心,很多生产环境的问题,都是因为分片设计不合理导致的。以下是分片设计的核心原则:
ES的高性能不是靠单一参数的调优就能实现的,而是需要从索引设计、写入、查询、分片、JVM、操作系统等多个维度,形成全链路的精细化调优闭环。以下是各个维度的核心调优方案,所有参数均基于ES 8.x版本。
索引设计是ES性能的根基,不合理的索引设计,后续无论怎么调优,都无法达到理想的性能。核心调优方案如下:
index: false,ES不会为该字段构建倒排索引,减少索引体积与写入开销。doc_values: false,ES不会为该字段构建正排索引,减少存储空间与写入开销。注意:keyword类型默认开启doc_values,text类型默认关闭。dynamic: strict,避免写入错误的字段类型,产生大量无用的垃圾字段,导致索引体积膨胀,性能下降。product_name字段,主字段为text类型用于全文检索,子字段product_name.keyword为keyword类型用于排序聚合,兼顾两种场景的需求。写入性能调优的核心目标,是减少磁盘IO与段合并的开销,提升批量写入的吞吐。核心调优方案如下:
index.translog.durability: async,index.translog.sync_interval: 30s,index.translog.flush_threshold_size: 1024mb,让translog异步刷盘,减少磁盘IO开销,提升写入吞吐。index.merge.scheduler.max_thread_count: 1,机械硬盘的随机IO性能极差,过多的合并线程会导致磁盘IO占满,写入性能急剧下降。indices.memory.index_buffer_size,默认是JVM堆内存的10%,用于存储写入内存缓冲区的文档数据。对于写入量大的集群,可以调大到20%,给写入操作提供更多的内存,减少频繁的refresh操作。查询性能调优的核心目标,是减少扫描的文档数量、降低磁盘IO、减少协调节点的合并开销。核心调优方案如下:
filter与query的精准使用
路由查询的合理使用
深度分页的优化方案
from+size不超过10000。比如from=10000&size=10,ES需要在每个分片都取10010条数据,协调节点合并所有分片的数据,再排序取对应的10条,开销极大,延迟极高。通配符查询的避坑与替代
*xxx,这种查询会扫描整个Term Dictionary,性能极差,甚至会导致集群OOM。字段数据的优化
慢查询的定位与优化
PUT /_cluster/settings
{
"persistent": {
"search.slowlog.threshold.query.warn": "500ms",
"search.slowlog.threshold.fetch.warn": "200ms"
}
}
分片策略调优的核心目标,是让分片均匀分布在集群中,充分利用每个节点的资源,避免数据倾斜与负载不均。核心调优方案如下:
主分片数 = 索引预估总数据量 / 单分片最佳大小(30GB),向上取整,同时不要超过集群的节点数。比如索引预估总数据量是100GB,主分片数设置为4;200GB设置为7,以此类推。ES是Java开发的应用,JVM的配置与操作系统的优化,直接决定了ES的底层性能上限。核心调优方案如下:
-XX:+UseZGC -XX:+ZGenerational,同时堆内存不要低于8GB,否则ZGC的优势无法发挥。swapoff -a,同时修改/etc/fstab文件,永久关闭swap。elasticsearch - nofile 65535。elasticsearch - nproc 4096。vm.max_map_count=262144,执行sysctl -p生效。以下实战代码基于JDK17、Spring Boot 3.x、ES官方最新的Java Client 8.x开发。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
<relativePath/>
</parent>
<groupId>com.jam.demo</groupId>
<artifactId>es-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>es-demo</name>
<properties>
<java.version>17</java.version>
<elasticsearch.version>8.15.0</elasticsearch.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<fastjson2.version>2.0.52</fastjson2.version>
<lombok.version>1.18.30</lombok.version>
<springdoc.version>2.5.0</springdoc.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>${elasticsearch.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.0.0-jre</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
spring:
application:
name: es-demo
datasource:
url: jdbc:mysql://localhost:3306/test_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
elasticsearch:
host: localhost
port: 9200
username: elastic
password: elastic123
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
type-aliases-package: com.jam.demo.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
springdoc:
swagger-ui:
path: /swagger-ui.html
enabled: true
api-docs:
enabled: true
path: /v3/api-docs
CREATE TABLE `t_product` (
`product_id` varchar(64) NOT NULL COMMENT '商品ID',
`product_name` varchar(255) NOT NULL COMMENT '商品名称',
`category_id` bigint NOT NULL COMMENT '分类ID',
`price` decimal(10,2) NOT NULL COMMENT '商品价格',
`stock` int NOT NULL DEFAULT '0' COMMENT '库存数量',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`product_id`),
KEY `idx_category_id` (`category_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品表';
package com.jam.demo.config;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.ElasticsearchTransport;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.elasticsearch.client.RestClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Elasticsearch客户端配置类
*
* @author ken
*/
@Configuration
public class ElasticsearchConfig {
@Value("${elasticsearch.host}")
private String host;
@Value("${elasticsearch.port}")
private int port;
@Value("${elasticsearch.username}")
private String username;
@Value("${elasticsearch.password}")
private String password;
/**
* 构建ElasticsearchClient实例
*
* @return ElasticsearchClient客户端实例
*/
@Bean
public ElasticsearchClient elasticsearchClient() {
CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY,
new UsernamePasswordCredentials(username, password));
RestClient restClient = RestClient.builder(new HttpHost(host, port))
.setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder
.setDefaultCredentialsProvider(credentialsProvider))
.build();
ElasticsearchTransport transport = new RestClientTransport(
restClient, new JacksonJsonpMapper());
return new ElasticsearchClient(transport);
}
}
package com.jam.demo.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 商品实体类
*
* @author ken
*/
@Data
@TableName("t_product")
@Schema(description = "商品实体")
public class Product implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "商品ID", example = "10001")
private String productId;
@Schema(description = "商品名称", example = "华为Mate60 Pro 5G手机")
private String productName;
@Schema(description = "分类ID", example = "1001")
private Long categoryId;
@Schema(description = "商品价格", example = "5999.00")
private BigDecimal price;
@Schema(description = "库存数量", example = "1000")
private Integer stock;
@Schema(description = "创建时间", example = "2024-01-01 00:00:00")
private LocalDateTime createTime;
}
package com.jam.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.entity.Product;
import org.apache.ibatis.annotations.Mapper;
/**
* 商品Mapper接口
*
* @author ken
*/
@Mapper
public interface ProductMapper extends BaseMapper<Product> {
}
package com.jam.demo.service;
import com.jam.demo.entity.Product;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 商品ES服务接口
*
* @author ken
*/
public interface ProductEsService {
void batchSaveProduct(List<Product> productList);
List<Product> queryProductByFilter(Long categoryId, BigDecimal minPrice, BigDecimal maxPrice);
List<Product> queryProductByRouting(Long categoryId, String keyword);
List<Product> queryProductBySearchAfter(Long categoryId, Integer pageSize, LocalDateTime lastCreateTime, String lastProductId);
}
package com.jam.demo.service.impl;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.BulkRequest;
import co.elastic.clients.elasticsearch.core.BulkResponse;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.bulk.BulkOperation;
import co.elastic.clients.elasticsearch.core.search.Hit;
import com.google.common.collect.Lists;
import com.jam.demo.entity.Product;
import com.jam.demo.service.ProductEsService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* 商品ES服务实现类
*
* @author ken
*/
@Slf4j
@Service
public class ProductEsServiceImpl implements ProductEsService {
private static final String INDEX_NAME = "product_index";
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final ElasticsearchClient elasticsearchClient;
public ProductEsServiceImpl(ElasticsearchClient elasticsearchClient) {
this.elasticsearchClient = elasticsearchClient;
}
@Override
public void batchSaveProduct(List<Product> productList) {
if (CollectionUtils.isEmpty(productList)) {
return;
}
List<BulkOperation> bulkOperations = Lists.newArrayListWithCapacity(productList.size());
for (Product product : productList) {
if (ObjectUtils.isEmpty(product) || !StringUtils.hasText(product.getProductId())) {
continue;
}
bulkOperations.add(BulkOperation.of(op -> op
.index(idx -> idx
.index(INDEX_NAME)
.id(product.getProductId())
.routing(product.getCategoryId().toString())
.document(product)
)
));
}
BulkRequest bulkRequest = BulkRequest.of(req -> req.operations(bulkOperations));
try {
BulkResponse bulkResponse = elasticsearchClient.bulk(bulkRequest);
if (bulkResponse.errors()) {
log.error("ES批量写入数据存在错误,错误信息:{}", bulkResponse.items().stream()
.filter(item -> !ObjectUtils.isEmpty(item.error()))
.map(item -> item.error().reason())
.toList()
);
}
} catch (IOException e) {
log.error("ES批量写入数据异常", e);
throw new RuntimeException("ES批量写入数据异常", e);
}
}
@Override
public List<Product> queryProductByFilter(Long categoryId, BigDecimal minPrice, BigDecimal maxPrice) {
if (ObjectUtils.isEmpty(categoryId)) {
return Lists.newArrayList();
}
try {
SearchResponse<Product> searchResponse = elasticsearchClient.search(req -> req
.index(INDEX_NAME)
.query(query -> query
.bool(bool -> bool
.filter(filter -> filter
.term(term -> term
.field("category_id")
.value(categoryId)
)
)
.filter(filter -> {
if (ObjectUtils.isEmpty(minPrice) && ObjectUtils.isEmpty(maxPrice)) {
return filter.matchAll(m -> m);
}
return filter.range(range -> range
.field("price")
.gte(!ObjectUtils.isEmpty(minPrice) ? minPrice : null)
.lte(!ObjectUtils.isEmpty(maxPrice) ? maxPrice : null)
);
})
)
),
Product.class
);
return parseSearchResponse(searchResponse);
} catch (IOException e) {
log.error("ES过滤查询数据异常", e);
throw new RuntimeException("ES过滤查询数据异常", e);
}
}
@Override
public List<Product> queryProductByRouting(Long categoryId, String keyword) {
if (ObjectUtils.isEmpty(categoryId) || !StringUtils.hasText(keyword)) {
return Lists.newArrayList();
}
try {
SearchResponse<Product> searchResponse = elasticsearchClient.search(req -> req
.index(INDEX_NAME)
.routing(categoryId.toString())
.query(query -> query
.match(match -> match
.field("product_name")
.query(keyword)
)
),
Product.class
);
return parseSearchResponse(searchResponse);
} catch (IOException e) {
log.error("ES路由查询数据异常", e);
throw new RuntimeException("ES路由查询数据异常", e);
}
}
@Override
public List<Product> queryProductBySearchAfter(Long categoryId, Integer pageSize, LocalDateTime lastCreateTime, String lastProductId) {
if (ObjectUtils.isEmpty(categoryId) || ObjectUtils.isEmpty(pageSize) || pageSize <= 0) {
return Lists.newArrayList();
}
try {
co.elastic.clients.elasticsearch.core.SearchRequest.Builder requestBuilder = new co.elastic.clients.elasticsearch.core.SearchRequest.Builder()
.index(INDEX_NAME)
.size(pageSize)
.sort(sort -> sort.field(f -> f.field("create_time").order(co.elastic.clients.elasticsearch.core.sort.SortOrder.Desc)))
.sort(sort -> sort.field(f -> f.field("product_id").order(co.elastic.clients.elasticsearch.core.sort.SortOrder.Asc)))
.query(query -> query
.term(term -> term
.field("category_id")
.value(categoryId)
)
);
if (!ObjectUtils.isEmpty(lastCreateTime) && StringUtils.hasText(lastProductId)) {
requestBuilder.searchAfter(
lastCreateTime.format(DATE_TIME_FORMATTER),
lastProductId
);
}
SearchResponse<Product> searchResponse = elasticsearchClient.search(requestBuilder.build(), Product.class);
return parseSearchResponse(searchResponse);
} catch (IOException e) {
log.error("ES分页查询数据异常", e);
throw new RuntimeException("ES分页查询数据异常", e);
}
}
/**
* 解析ES查询响应结果
*
* @param searchResponse ES查询响应
* @return 商品列表
*/
private List<Product> parseSearchResponse(SearchResponse<Product> searchResponse) {
List<Hit<Product>> hitList = searchResponse.hits().hits();
if (CollectionUtils.isEmpty(hitList)) {
return Lists.newArrayList();
}
List<Product> productList = Lists.newArrayListWithCapacity(hitList.size());
for (Hit<Product> hit : hitList) {
Product product = hit.source();
if (!ObjectUtils.isEmpty(product)) {
productList.add(product);
}
}
return productList;
}
}
package com.jam.demo.controller;
import com.jam.demo.entity.Product;
import com.jam.demo.mapper.ProductMapper;
import com.jam.demo.service.ProductEsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 商品ES操作Controller
*
* @author ken
*/
@RestController
@RequestMapping("/product/es")
@Tag(name = "商品ES操作接口", description = "商品ES数据写入与查询相关接口")
public class ProductEsController {
private final ProductEsService productEsService;
private final ProductMapper productMapper;
public ProductEsController(ProductEsService productEsService, ProductMapper productMapper) {
this.productEsService = productEsService;
this.productMapper = productMapper;
}
@PostMapping("/batch/sync")
@Operation(summary = "全量同步商品数据到ES", description = "从MySQL查询全量商品数据,批量写入到ES")
public String syncAllProductToEs() {
List<Product> productList = productMapper.selectList(null);
if (CollectionUtils.isEmpty(productList)) {
return "没有需要同步的商品数据";
}
productEsService.batchSaveProduct(productList);
return "商品数据同步完成,同步数量:" + productList.size();
}
@GetMapping("/filter/query")
@Operation(summary = "过滤条件查询商品", description = "基于分类ID和价格区间过滤查询商品")
public List<Product> queryProductByFilter(
@Parameter(description = "分类ID", required = true) @RequestParam Long categoryId,
@Parameter(description = "最低价格") @RequestParam(required = false) BigDecimal minPrice,
@Parameter(description = "最高价格") @RequestParam(required = false) BigDecimal maxPrice
) {
return productEsService.queryProductByFilter(categoryId, minPrice, maxPrice);
}
@GetMapping("/routing/query")
@Operation(summary = "路由查询商品", description = "基于分类ID作为路由,精准查询对应分片的商品数据")
public List<Product> queryProductByRouting(
@Parameter(description = "分类ID", required = true) @RequestParam Long categoryId,
@Parameter(description = "搜索关键词", required = true) @RequestParam String keyword
) {
return productEsService.queryProductByRouting(categoryId, keyword);
}
@GetMapping("/page/searchAfter")
@Operation(summary = "search_after分页查询", description = "基于search_after实现高性能深度分页查询")
public List<Product> queryProductBySearchAfter(
@Parameter(description = "分类ID", required = true) @RequestParam Long categoryId,
@Parameter(description = "每页条数", required = true) @RequestParam Integer pageSize,
@Parameter(description = "上一页最后一条数据的创建时间") @RequestParam(required = false) LocalDateTime lastCreateTime,
@Parameter(description = "上一页最后一条数据的商品ID") @RequestParam(required = false) String lastProductId
) {
return productEsService.queryProductBySearchAfter(categoryId, pageSize, lastCreateTime, lastProductId);
}
}
Elasticsearch的高性能,从来都不是靠单一的参数调优实现的,而是需要从底层原理出发,形成完整的认知闭环。倒排索引是ES的检索基石,只有吃透它的三层结构与压缩算法,才能设计出合理的索引与字段,从源头提升性能;分片机制是ES分布式架构的灵魂,只有掌握了分片的路由规则与设计原则,才能搭建出稳定、可扩展的集群;而全链路的精细化调优,是将ES的性能发挥到极致的关键,需要从索引设计、写入、查询、JVM、操作系统等多个维度,针对性优化。