做大数据相关的后端开发,Elasticsearch 几乎是绕不开的一个组件。我这边在维护一个订单数据统计分析模块,数据从 MySQL 同步到 ES 之后,需要用 Java 实现对索引的增删查改,以及按业务维度做各种聚合统计。项目本身不复杂,但涉及的知识点很碎,尤其是聚合这部分,API 容易记混、参数容易配错。这篇文章就把这套实践整理一下,包括客户端选型、基础 CRUD 的写法和聚合分析的具体玩法,也会聊一聊我在实际排障中踩过的坑。适合刚接触 ES 的 Java 开发者,也适合已经上手但 API 老是记不全的朋友。
1. 项目背景与方案选型
1.1 这个项目的核心需求是什么
先交代一下项目背景。某数据平台里有一个订单统计模块,原始数据在 MySQL 里,单表数据量涨到千万级以后,多条作查询和统计的速度明显变慢,尤其是带时间范围、多条件组合的 GROUP BY 查询,经常要好几秒。后面引入 Elasticsearch 做查询和聚合层,业务数据通过同步任务写入 ES,Java 服务负责对外提供接口。
标题里说的“增删查改聚合”,拆开看其实就是五个能力:
- 新增文档(Index)
- 查询文档(Search / Get)
- 更新文档(Update)
- 删除文档(Delete)
- 聚合统计(Aggregation)
这些能力听起来基础,但真正写起来有不少细节。比如新增要考虑幂等,查询要分清楚词项查询和全文匹配的区别,更新要考虑局部更新和脚本更新,聚合要考虑字段类型对结果的影响。整个项目最核心的部分其实是聚合,因为业务方需要的不只是查明细,还要在百万甚至千万级数据上做秒级的分组统计。
1.2 三种客户端方案怎么选
用 Java 操作 Elasticsearch,目前市面上主要见过三种方式,我用一个表格来对比一下:
| 客户端方案 | 通信方式 | 当前状态 | 适用版本 |
|---|---|---|---|
| TransportClient | TCP(9300端口) | 已废弃,8.x彻底移除 | 2.x~7.x |
| RestHighLevelClient | HTTP(9200端口) | 7.x主流推荐,8.x标记废弃 | 6.x~8.x |
| Elasticsearch Java API Client | HTTP(9200端口) | 8.x官方新客户端 | 8.x以后 |
很多人刚开始学习时会在网上查到各种 TransportClient 的示例代码,但注意,这个客户端从 7.0 开始就已经标记废弃,到了 8.0 直接移除了。它走的是 TCP 二进制协议,客户端和服务端必须保持版本完全一致,升级成本很高。
我实际项目里用的是 RestHighLevelClient,也就是高级 REST 客户端。它在 7.x 这个阶段是最稳的选择:API 封装得比较友好,社区资料多,踩坑容易找到解决方案。虽然 8.x 出了新的 Java API Client,但目前大部分存量项目都还在 7.x 版本上,RestHighLevelClient 短时间内不会淘汰。
1.3 为什么不建议直接上最新客户端
有朋友可能会问,既然 8.x 出了新的 Java API Client,为什么不直接用新的?主要原因是项目里的 ES 集群版本还是 7.x。客户端和服务端的版本匹配是一个很现实的问题,如果强行用 8.x 客户端去连 7.x 服务端,大概率会报版本不兼容的错误。
我个人的建议是:客户端版本尽量和服务端保持一致,退一万步也不能差一个大版本。如果项目是从零开始,ES 集群版本是 8.x,那就直接学 Java API Client;如果线上集群还是 7.x,就用 RestHighLevelClient,不要折腾。
2. 环境准备与依赖配置
2.1 Maven 依赖和版本匹配原则
我用的是 Maven 管理依赖,核心只需要引入一个包:
<dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> <version>7.15.2</version> </dependency>注意,这个依赖会传递引入一些核心依赖,比如 elasticsearch-core、elasticsearch-x-content 等。如果你本地已经引入了其他版本的 ES 相关包,需要检查冲突。最典型的问题是:项目里有一个旧版本的 elasticsearch 依赖,传递依赖版本被覆盖,启动时报各种 NoClassDefFoundError。
一个比较省心的排查办法是执行mvn dependency:tree看一下 es 相关包的依赖树,确认所有 elasticsearch 相关包的版本都对齐到 7.15.2。
2.2 初始化 RestHighLevelClient 的完整姿势
客户端的初始化是整个项目的地基,配置得好不好直接关系到后续服务的稳定性和性能。我贴一段实际在用的初始化代码:
import org.apache.http.HttpHost; import org.apache.http.impl.nio.client.HttpAsyncClientBuilder; import org.elasticsearch.client.RestClient; import org.elasticsearch.client.RestClientBuilder; import org.elasticsearch.client.RestHighLevelClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class ElasticsearchConfig { @Bean public RestHighLevelClient restHighLevelClient() { // 如果有多个节点,可以在这里继续添加 HttpHost HttpHost node1 = new HttpHost("192.168.10.10", 9200, "http"); HttpHost node2 = new HttpHost("192.168.10.11", 9200, "http"); RestClientBuilder builder = RestClient.builder(node1, node2); // 请求相关配置 builder.setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder .setConnectTimeout(5000) // 连接超时 .setSocketTimeout(60000) // 读取超时,聚合查询耗时较长,放宽一点 .setConnectionRequestTimeout(1000)); // 连接池相关配置 builder.setHttpClientConfigCallback(new RestClientBuilder.HttpClientConfigCallback() { @Override public HttpAsyncClientBuilder customizeHttpClient(HttpAsyncClientBuilder httpClientBuilder) { httpClientBuilder.setMaxConnTotal(200); httpClientBuilder.setMaxConnPerRoute(100); return httpClientBuilder; } }); return new RestHighLevelClient(builder); } }这里有几个点要特别注意:
- socketTimeout 不要设置太短。我一开始设置成 10 秒,结果业务方做了个大范围的时间聚合查询,直接报 SocketTimeoutException。聚合查询本身比普通查询要重,建议至少留 60 秒。
- 连接池大小要根据服务的 QPS 来调整。如果并发高但连接池小,HTTP 层会出现连接排队等待的情况,表现为接口 RT 很高但不报错。我遇到过 maxConnTotal 设置太小,压测时大量请求堆积,一度怀疑是慢查询,实际上就是连接不够用。
- 生产环境建议将 client 做成 Spring Bean 单例,不要每次请求都 new 一个。因为 RestHighLevelClient 底层持有连接池和线程池,频繁创建会导致连接泄漏甚至 OOM。
2.3 索引设计与 Mapping 预定义
很多初学者刚开始用 ES 时,图省事直接靠动态映射写入数据,不手动定义 Mapping。这在开发环境没问题,但上了生产环境很容易踩坑。最典型的问题是:用户 ID 被映射成 text 类型,导致无法做 term 精确匹配;价格字段因为混合类型无法做统计聚合。
我这边在设计订单索引时,预定义了 Mapping,关键字段设置如下:
{ "mappings": { "properties": { "orderNo": { "type": "keyword" }, "orderDate": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss" }, "userId": { "type": "keyword" }, "productName": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "productType": { "type": "keyword" }, "price": { "type": "double" }, "orderStatus": { "type": "integer" }, "remark": { "type": "text", "index": false } } } }简单解释一下几个设计决策:
- orderNo、userId、productType 这些字段在业务里只需要做精确匹配和分组聚合,设置为 keyword。keyword 类型的字段聚合性能比 text 快很多。
- productName 需要支持全文搜索,所以用 text,但同时加了一个 keyword 子字段,方便在某些场景下做排序或者精确分组。
- remark 这个字段业务方只是存进来,几乎不会去搜它,直接设置 index:false 不建索引,可以省不少磁盘空间。
注意:索引设计应该在导入数据之前就想清楚。一旦线上索引已经建好了,再想改字段类型基本只能重建索引,数据迁移的成本远高于事前设计。
3. 增删查改核心操作详解
3.1 文档新增:单条写入与注意事项
新增文档最基础的方式是 IndexRequest。这个 API 既可以新增,也可以做全量覆盖更新,由文档 ID 是否存在决定。示例代码:
public void indexDocument(String indexName, String id, Map<String, Object> doc) throws IOException { IndexRequest request = new IndexRequest(indexName); // 指定文档ID,不指定的话ES会自动生成 request.id(id); // 数据源,也可以是JSON字符串、XContentBuilder、Map等 request.source(doc, XContentType.JSON); // 可选:设置路由,路由相同的数据会落在同一分片,对聚合和查询有一定优化 // request.routing(userId); IndexResponse response = client.index(request, RequestOptions.DEFAULT); System.out.println("result status: " + response.getResult()); }调用方式看起来很简单,但实际生产环境有几点需要想清楚:
- 文档 ID 的生成策略。如果业务数据本身有唯一业务主键(比如订单号),最好显式指定 ID。好处是:重复写入同一订单数据时,ES 会直接覆盖,不会在索引里产生脏数据。如果不指定 ID,每次写入都会生成一个随机 UUID,数据同步任务重复执行时会插入重复文档。
- refresh 策略。默认情况下,写入的文档大约 1 秒后才会变成可见状态(取决于索引 refresh_interval),因为 ES 是近实时搜索引擎。如果业务方要求写入后立刻能查到,可以手动调用
request.setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE),但这么做会显著降低写入性能,一般不建议高频操作。 - 批量场景不要用单条 for 循环写入,要用 BulkRequest,后面会细说。
3.2 查询操作:精确查询、全文查询和组合查询
查询是日常开发中使用最频繁的部分。ES 的搜索 API 非常灵活,最常用的是 SearchRequest + SearchSourceBuilder,通过 BoolQueryBuilder 组合多个条件。
先看一个带有筛选条件的查询示例:
public void searchDocuments() throws IOException { SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); // 组合查询 BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); // must:必须满足,影响相关度分数 boolQuery.must(QueryBuilders.termQuery("orderStatus", 1)); // filter:必须满足,不影响分数,可缓存,性能更好 boolQuery.filter(QueryBuilders.rangeQuery("price").gte(100).lte(5000)); // should:满足任意一个即可,配合 minimumShouldMatch 使用 boolQuery.should(QueryBuilders.matchQuery("productName", "手机 配件")); sourceBuilder.query(boolQuery); // 分页参数 sourceBuilder.from(0); sourceBuilder.size(10); // 只需要某些字段 sourceBuilder.fetchSource(new String[]{"orderNo", "productName", "price", "orderDate"}, null); // 按时间倒序 sourceBuilder.sort("orderDate", SortOrder.DESC); SearchRequest searchRequest = new SearchRequest("product_order"); searchRequest.source(sourceBuilder); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); SearchHits hits = response.getHits(); for (SearchHit hit : hits) { String sourceAsString = hit.getSourceAsString(); // 这里可以反序列化成自己的业务对象 } }这里有必要区分几个容易搞混的查询类型:
- termQuery:词项查询,不做分词,适用于 keyword 字段或 text 字段的精确词查询。查询条件是“完整且精确”匹配某个词。
- matchQuery:全文查询,会对查询关键字做分词处理,适用于 text 字段的模糊搜索。比如查询“手机配件”,ES 会把它拆成“手机”“配件”两个词去匹配。
- wildcardQuery:通配符查询,性能不好,数据量大时要慎用,比如前导通配符 “*手机” 会扫描大量词项。
- rangeQuery:范围查询,适用于数字和日期字段。
关键字就是:keyword 字段用 term,text 字段用 match。
3.3 更新操作:局部更新与脚本更新
更新文档有两种常见方式:局部更新(Partial Update)和脚本更新(Script Update)。
局部更新适合字段级别的修改场景。比如订单状态从待支付改成已支付:
public void updatePartial(String indexName, String id) throws IOException { UpdateRequest request = new UpdateRequest(indexName, id); Map<String, Object> doc = new HashMap<>(); doc.put("orderStatus", 2); doc.put("payTime", "2024-01-01 12:30:00"); request.doc(doc); UpdateResponse response = client.update(request, RequestOptions.DEFAULT); System.out.println(response.getResult()); }要注意的一点是:局部更新本质上是一个“读取—合并—写入”的过程,底层并不是原地修改,而是把旧文档和更新字段合并成一个新文档再写入。频繁更新对写入放大有一定影响,适合小字段低频场景。
脚本更新的能力更强,适合在旧值基础上计算新值的场景。比如给价格统一打九折:
public void updateByScript(String indexName, String id) throws IOException { UpdateRequest request = new UpdateRequest(indexName, id); // 使用painless脚本:从source字段中读取price并更新 request.script(new Script("ctx._source.price = ctx._source.price * 0.9")); UpdateResponse response = client.update(request, RequestOptions.DEFAULT); System.out.println(response.getResult()); }使用脚本更新时,还有一个实用功能叫 Upsert:文档不存在时走 Index 操作,存在时走 Update 操作。这个在同步场景下非常香:
IndexRequest indexRequest = new IndexRequest(indexName).id(id).source(doc, XContentType.JSON); UpdateRequest updateRequest = new UpdateRequest(indexName, id) .doc(doc) .upsert(indexRequest);简单说,你用 Update 接口写了一段“如果存在就局部更新”的逻辑,又用 Upsert 保证了数据不存在时能创建,这样同步逻辑里就不用先查库判断是否存在了。这个技巧在数据处理任务中能省不少代码。
3.4 删除操作和批量处理
删除单条文档很简单:
DeleteRequest request = new DeleteRequest("product_order", "20240101001"); DeleteResponse response = client.delete(request, RequestOptions.DEFAULT);批量删除和批量写入都是通过 BulkRequest 实现的。BulkRequest 可以同时塞入 Index、Update、Delete 多种操作,一次请求批量提交,能有效减少网络开销。项目里做数据初始化时,我就是用 Bulk 分批导入了百万级别的数据,单批 1000 条左右,整体写入速度比循环单条快了几十倍。
public void bulkOperate() throws IOException { BulkRequest bulkRequest = new BulkRequest(); for (int i = 0; i < 1000; i++) { String id = "order_" + i; Map<String, Object> doc = new HashMap<>(); doc.put("orderNo", "NO" + i); doc.put("productName", "商品" + i); doc.put("price", 100 + i * 0.5); bulkRequest.add(new IndexRequest("product_order").id(id).source(doc, XContentType.JSON)); } BulkResponse response = client.bulk(bulkRequest, RequestOptions.DEFAULT); // 一定要检查是否全部成功,批量接口允许部分失败 if (response.hasFailures()) { System.out.println("部分文档写入失败: " + response.buildFailureMessage()); } }关于 Bulk 有一个很容易被忽略的点:批量请求里有一个操作失败不会阻断其他操作,所以必须检查 hasFailures(),然后针对失败项做重试或记录日志。不要想当然认为 bulk 返回成功就代表所有数据都写进去了。
3.5 高亮、排序与分页的实用组合
在业务平台里,搜索关键词高亮是一个高频需求。比如商品搜索接口,需要把命中的关键词用红色标注。用 ES 实现很简单,代码示例如下:
HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.field("productName"); highlightBuilder.preTags("<span style='color:red'>"); highlightBuilder.postTags("</span>"); sourceBuilder.highlighter(highlightBuilder);查询后从 Hit 里取高亮片段:
Map<String, HighlightField> highlightFields = hit.getHighlightFields(); HighlightField highlight = highlightFields.get("productName"); if (highlight != null) { Text[] fragments = highlight.fragments(); String highlightedText = fragments[0].string(); }分页方面,最基础的 from + size 在数据量小的时候没问题。但一旦页码很大(超过 10000 条),ES 默认会拒绝查询,因为深分页的性能开销极大。后面常见问题章节我会详细讲深分页的替代方案。
4. 聚合分析实战与数据洞察
4.1 聚合到底能解决什么问题
聚合是 ES 对比传统关系型数据库的一个大亮点。以前用 MySQL 做统计查询,SQL 写起来复杂不说,数据量大了以后跑得很慢。ES 的聚合框架(Aggregation)可以在海量数据上实现秒级甚至毫秒级的分组统计。
打个比方:如果把 ES 想象成一个大型仓库,普通查询就是在仓库里找某个东西,聚合则是在盘点整个仓库里有哪些东西、各自有多少。两者面对的数据量维度不一样,因此性能和实现方式也不同。
聚合整体上分为两大类:
- 指标聚合(Metric Aggregation):计算指标,比如求和、平均值、最大值、最小值、统计数量、去重计数。
- 桶聚合(Bucket Aggregation):把数据按条件分组,形成一个个“桶”,相当于 SQL 里的 GROUP BY,也可以在桶内继续嵌套子聚合。
实际业务里,两者经常组合使用:先分桶分组,再在每个桶内做指标计算,也就是嵌套聚合。
4.2 指标聚合:总额、均值、最值和统计信息
先看一个最简单的场景:统计全部订单的金额总额、平均金额、最高金额、最低金额。用 ES 的 Stats 聚合一把就能搞定:
public void metricAggregation() throws IOException { SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); // 聚合不需要返回文档明细,设置size=0只返回聚合结果,提升性能 sourceBuilder.size(0); // 统计指标:sum、min、max、avg、count 一次返回 StatsAggregationBuilder stats = AggregationBuilders.stats("price_stats") .field("price"); sourceBuilder.aggregation(stats); SearchRequest searchRequest = new SearchRequest("product_order"); searchRequest.source(sourceBuilder); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); ParsedStats statsResult = response.getAggregations().get("price_stats"); System.out.println("总金额: " + statsResult.getSum()); System.out.println("平均金额: " + statsResult.getAvg()); System.out.println("最大金额: " + statsResult.getMax()); System.out.println("最小金额: " + statsResult.getMin()); System.out.println("文档数量: " + statsResult.getCount()); }如果只想单独取某一个指标,可以用 SumAggregationBuilder、AvgAggregationBuilder、MaxAggregationBuilder 等:
SumAggregationBuilder sumPrice = AggregationBuilders.sum("total_sales").field("price"); AvgAggregationBuilder avgPrice = AggregationBuilders.avg("avg_price").field("price"); MaxAggregationBuilder maxPrice = AggregationBuilders.max("max_price").field("price"); CardinalityAggregationBuilder uniqueUser = AggregationBuilders.cardinality("unique_users").field("userId");其中 Cardinality 聚合对应 SQL 里的 COUNT(DISTINCT user_id),非常实用。比如统计一个时间段内的下单用户数,就可以用它。但它也有一个特点:默认情况下这是一个近似统计,基于 HyperLogLog 算法,误差大概在 0.5% 左右,对于大部分统计场景完全可以接受。如果业务要求精确去重,需要设置 precisionThreshold 参数,或者改用较高精度的方案,但性能和内存开销会增大。
4.3 桶聚合:分组统计与多维度下钻
桶聚合最常见的应用场景是按某个字段分组,然后在每个组内做统计。我举个例子:按商品类型分组,统计每个分组的订单量、销售总额、平均单价、最高单价。用 Java 写就是这样:
public void bucketAggregation() throws IOException { SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.size(0); // 按商品类型分组,返回前10个分组,默认按文档数降序 TermsAggregationBuilder byProductType = AggregationBuilders.terms("by_product_type") .field("productType") .size(10); // 在分组内嵌套指标聚合 SumAggregationBuilder sumSales = AggregationBuilders.sum("total_sales").field("price"); AvgAggregationBuilder avgPrice = AggregationBuilders.avg("avg_price").field("price"); MaxAggregationBuilder maxPrice = AggregationBuilders.max("max_price").field("price"); byProductType.subAggregation(sumSales); byProductType.subAggregation(avgPrice); byProductType.subAggregation(maxPrice); sourceBuilder.aggregation(byProductType); SearchRequest searchRequest = new SearchRequest("product_order"); searchRequest.source(sourceBuilder); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); ParsedStringTerms termsResult = response.getAggregations().get("by_product_type"); for (Terms.Bucket bucket : termsResult.getBuckets()) { String productType = bucket.getKeyAsString(); long docCount = bucket.getDocCount(); // 从bucket中获取嵌套聚合结果 ParsedSum sales = bucket.getAggregations().get("total_sales"); ParsedAvg avg = bucket.getAggregations().get("avg_price"); ParsedMax max = bucket.getAggregations().get("max_price"); System.out.println("商品类型: " + productType + ", 订单量: " + docCount + ", 销售额: " + sales.getValue() + ", 平均价: " + avg.getValue() + ", 最高价: " + max.getValue()); } }这个查询出来之后,后端可以直接把结果序列化成 JSON 返回给前端图表,前端就能画一个多维度的柱状图或饼图,整个过程后端不需要写任何 SQL,也不用手工循环统计数据。
再举一个多字段下钻的场景:按商品类型分组后,再在每种类型内部按照订单状态二次分组,查看各类型订单的支付情况。这种嵌套分桶写法就是在原有 by_product_type 桶聚合上继续嵌套一个 terms 子聚合,逻辑完全一样,只是层级加深了一层。这样的多级下钻在报表分析系统中非常常见。
4.4 日期直方图:按时间维度分析趋势
除了分组统计,业务里另一个高频需求是看时间段内的趋势,比如每天、每月、每小时的订单量变化。日期直方图(Date Histogram)聚合正好干这个事:
public void dateHistogramAggregation() throws IOException { SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.size(0); // 时间范围过滤,只看最近90天 sourceBuilder.query(QueryBuilders.rangeQuery("orderDate") .gte("now-90d") .lte("now")); // 按天分桶 DateHistogramAggregationBuilder perDay = AggregationBuilders.dateHistogram("orders_per_day") .field("orderDate") .calendarInterval(DateHistogramInterval.DAY) .format("yyyy-MM-dd"); // 每个桶内统计订单数和销售总额 perDay.subAggregation(AggregationBuilders.sum("daily_sales").field("price")); sourceBuilder.aggregation(perDay); SearchRequest searchRequest = new SearchRequest("product_order"); searchRequest.source(sourceBuilder); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); ParsedDateHistogram histogram = response.getAggregations().get("orders_per_day"); for (Histogram.Bucket bucket : histogram.getBuckets()) { String date = bucket.getKeyAsString(); long docCount = bucket.getDocCount(); ParsedSum dailySales = bucket.getAggregations().get("daily_sales"); System.out.println(date + " 订单数: " + docCount + ", 销售额: " + dailySales.getValue()); } }日期直方图有几个参数在官方文档里写得很细,但实际使用容易出错,我提几个注意点:
- calendarInterval 和 fixedInterval 的区别。日历间隔会按自然日/月/年对齐,比如按周分桶时,桶的边界是按周一或者周日算;固定间隔则是纯时间间隔,比如每 7 天一个桶。业务报表通常用日历间隔更符合直觉。
- 日期格式要用 format 指定,否则返回的 key 是 epoch 毫秒值,可读性很差。我这里指定了 yyyy-MM-dd,前端直接就能用。
- 时间字段类型必须是 date,而且 Mapping 里定义的 format 要和你写入的数据格式一致,否则读取会报错或者结果为空。
5. 常见问题排查与性能优化建议
5.1 版本不匹配与连接失败
这条几乎是我在所有 ES 项目里见到过最多的启动报错。场景通常是:项目本地依赖了 7.x 的 RestHighLevelClient,但测试环境连的是一个 5.x 或者 6.x 的 ES 集群,启动时报类似下面的错误:
Caused by: java.lang.IllegalStateException: Unsupported version: 5.6.3或者连接时出现:
org.elasticsearch.client.ResponseException: method [GET], host [http://xxx:9200], URI [/_cluster/health], status line [HTTP/1.1 406 Not Acceptable]这种问题没有太多技巧,就是保证客户端版本和服务端版本大版本一致。如果服务端有大版本升级计划,建议在升级窗口内同步升级客户端,并在测试环境完整回归一遍写入和查询。
对于连接失败的情况,排查思路可以按顺序走:
- ES 进程是否存活:
ps -ef | grep elasticsearch或者查看服务状态 - 9200 端口是否能通:
curl http://ip:9200 - 如果网络不通,检查防火墙和 HTTPS 配置,有没有开启 TLS
- 如果项目里有多个 ES 环境配置文件,检查是不是连接配置指向错了环境
5.2 聚合不准:字段类型和 doc_values 的坑
我见过一个很典型的案例:同事做 terms 聚合时发现,分组的数量明显大于预期,而且结果里出现了一个奇怪的空字符串分组。排查下来发现是字段被动态映射成 text 类型,text 类型的字段默认不支持聚合,ES 会基于分词后的词项做分桶,所以统计结果完全不对。
解决办法有两个方向:
- 如果字段后续需要做分桶聚合,设计 Mapping 时直接用类型 keyword。
- 如果字段是 text 且带着 keyword 子字段,那么在聚合时要用
字段名.keyword的方式引用:AggregationBuilders.terms("by_product").field("productName.keyword")。
另一个潜在性能问题是 doc_values。默认情况下,ES 为 keyword、date、数值类型开启 doc_values,聚合就是基于 doc_values 数据结构来计算的。如果某个字段被设置了"doc_values": false,那它就不能用于聚合和排序了,查询时也会报错。在索引设计时,对不需要聚合的字段(如大段文本)可以考虑关闭 doc_values 来节省磁盘,但要确保业务上没有对该字段做聚合的需求。
5.3 深分页与大数据量查询方案
from + size 是最常见的分页方式,但有两个坑:
- 默认只能分到第 10000 条,超过就报错。这个限制可以通过
index.max_result_window参数调整,但不建议调得太大,因为每页请求都要遍历该深度之前的所有文档。 - 深分页会导致内存和 CPU 开销剧增,并发场景下很容易拖垮集群。
如果业务场景是需要翻页到底部(比如后台管理系统),有两个替代方案:
- Scroll:适合数据导出,比如一次性拉取百万条数据做离线分析。它的原理是生成一个快照,在这个快照上持续遍历。
- Search After:适合有交互式深度翻页的场景。它利用上一页的最后一条数据的排序值作为游标,继续向后取下一页,性能和稳定性远优于深分页。
我给一个 Search After 的简单示例思路:
// 第一页查询,sort里指定一个唯一值字段,确保排序稳定 sourceBuilder.sort("orderDate", SortOrder.DESC); sourceBuilder.sort("_id", SortOrder.ASC); sourceBuilder.size(20); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); // 从上一页最后一条结果中取出排序值数组 Object[] sortValues = hits.getAt(hits.getHits().length - 1).getSortValues(); // 下一页查询,设置searchAfter sourceBuilder.searchAfter(new Object[]{sortValues[0], sortValues[1]});注意 Search After 依赖排序的稳定性,所以尽量使用日期加 ID 这种复合排序,避免只有单个日期字段时出现排序相等导致漏数据或重复数据的情况。
5.4 写入性能与 refresh 策略
项目中做过一次大规模数据同步,最初按单条 IndexRequest 写入,每秒只能写入几百条,后来改成 Bulk 批量写入,单批 2000 条左右,并行 4 个线程,每秒能稳定写入上万条。
影响写入性能的因素有多个维度:
- 副本数:写入时主分片和副本分片都要写。如果业务允许短暂的数据丢失风险,可以在导入期间把副本调整为 0,导入完成后再调回 1。
- refresh_interval:默认是 1s,意味着每秒写一次内存段。导入期间可以临时调大到 30s 甚至 -1(不自动刷),大幅降低磁盘 IO。
- 分片数:数据量大但分片少,单分片压力会很大。分片过多又会增加协调节点的聚合成本。一般单个分片数据量控制在 30GB~50GB 比较合适。
我实际调整参数的经历是这样的:批量导入前,临时修改索引配置:
{ "index": { "refresh_interval": "30s", "number_of_replicas": 0 } }导入完成后,再改成正常配置:
{ "index": { "refresh_interval": "1s", "number_of_replicas": 1 } }这套操作下来,导入速度提升非常明显。但要注意,副本为 0 期间如果主分片所在节点宕机,数据就有丢失风险,所以这种做法只适合可重放的数据导入场景,比如从 MySQL 全量同步、离线文件导入等。
5.5 常见问题排查速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 启动报 Unsupported version | 客户端版本与服务端版本不一致 | 统一客户端版本,使用 6.8.x 对应服务端 6.x,7.15.x 对应 7.x |
| 查询报 400 parse_exception | 查询语法错误,字段名或类型不对 | 检查查询 JSON 结构,用 Kibana 的 Dev Tools 先验证一遍 |
| 聚合结果为空或分组异常 | text 字段直接做 terms 聚合 | 改用.keyword子字段,或修改 Mapping 为 keyword |
| 分页超过 10000 条报错 | 深分页限制 | 用 search_after 或 scroll 替代 |
| 写入后立刻查询查不到 | refresh 间隔未到 | 业务要求强一致时设置 RefreshPolicy.IMMEDIATE,但注意性能损耗 |
| bulk 部分失败但不报错 | 批量接口允许部分失败 | 检查 hasFailures(),提取失败项重试或进入日志队列 |
| 查询慢但集群无高负载 | 查询条件未走索引字段 | 用 explain 查看执行计划,避免通配符查询和对 text 字段做 term 查询 |
| 文档更新时版本冲突 | 并发更新同一文档 | 用 retryOnConflict 重试,或者业务层面做好乐观锁 |
6. 个人实操经验补充
文章写到这里,核心内容基本讲完了。最后再分享几个自己踩过的坑和比较实用的经验。
第一,ES 的“集群”不等于“高可用”。我之前的项目里只有两个 ES 节点,看起来有副本就有冗余,但实际上如果节点宕机,另一个节点可能因为网络分区变成集群脑裂状态,导致整个集群变红。生产环境建议至少三个节点,或者配置好 discovery 的参选主节点数量。这事儿和数据本身关系不大,但影响非常大,提前折腾好能省很多事。
第二,聚合结果反序列化不要硬编码。我见过不少同事写聚合查询时,把 bucket 的 key 硬编码到业务逻辑里,比如判断if ("手机".equals(bucket.getKeyAsString()))。这非常脆弱,商品分类一变,代码逻辑就错了。更好的做法是把聚合结果直接转换成通用的 Map 结构,交给前端自己渲染,后端不关心具体分类内容。
第三,加日志比什么都管用。ES 查询的问题排查,很多时候是看底层请求长什么样。RestHighLevelClient 支持把请求日志打到 logger 里,我建议在开发环境开启 tracer 级别的日志,看看真实发出的查询语句是什么,再对照官方文档去核对参数。这个做法比盲猜效率高很多。
第四,用 Kibana 的 Dev Tools 验证查询。我写 Java 代码前,习惯先在 Kibana 里把查询 DSL 调通,确认返回结果符合预期,再翻译成 Java 代码。这样能极大地减少代码返工时间。尤其是聚合这种层级嵌套结构,在工具里可视化地看结果,比跑一遍 Java 代码再调试输出要直观得多。
整个项目做下来,最大的感悟是:ES 的上手门槛其实不高,CRUD API 对着文档敲一遍就会了,难点在于理解底层的倒排索引、分片路由、数据结构这些基础原理。像聚合不准这种问题,如果不了解字段类型和 doc_values 的机制,光靠查代码是查不出来的。希望这篇文章能帮你少走一些弯路,尤其是聚合分析那部分,自己动手跑一遍才能真正掌握。