一、为什么我的聚合查询像蜗牛爬?

每次看到Elasticsearch的聚合查询跑得比老奶奶过马路还慢,我就忍不住想掀桌子。明明数据量不大,硬件配置也不差,怎么就能卡成PPT呢?其实聚合查询慢通常逃不过这几个坑:

  1. 数据量暴增:就像超市结账,10个人排队和1000个人排队的区别
  2. 分片策略不当:相当于让所有顾客挤在同一个收银台
  3. 聚合类型选择错误:好比用电子秤来量身高
  4. 内存配置不足:就像给跑车加92号汽油

举个实际案例,我们有个电商平台用terms聚合统计商品销量TOP10,200万文档居然要8秒:

// Java示例:问题聚合查询
SearchRequest request = new SearchRequest("sales_data");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.aggregation(
    AggregationBuilders.terms("top_products")
        .field("product_id.keyword")
        .size(10)
);
request.source(sourceBuilder);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);

二、慢查询的七宗罪

1. 分片数设置不合理

分片太多就像把书柜分成100个小抽屉,找本书得翻遍所有抽屉。建议单个分片大小控制在30-50GB,过小的分片会导致合并开销激增。

2. 深度分页的死亡螺旋

当使用cardinality聚合计算UV时,如果字段基数很大:

// 高危操作:大基数去重
sourceBuilder.aggregation(
    AggregationBuilders.cardinality("uv")
        .field("user_id.keyword")  // 百万级用户
);

这相当于让服务器做全表扫描,内存直接爆炸。解决方案是用HyperLogLog++算法:

// 优化方案:使用HLL
sourceBuilder.aggregation(
    AggregationBuilders.cardinality("uv")
        .field("user_id.keyword")
        .precisionThreshold(10000)  // 设置精度阈值
);

3. 嵌套聚合的连环套

多层嵌套聚合就像俄罗斯套娃,每多一层复杂度就指数级上升:

// 三层嵌套聚合示例(危险!)
sourceBuilder.aggregation(
    AggregationBuilders.terms("by_region")
        .field("region_code")
        .subAggregation(
            AggregationBuilders.terms("by_city")
                .field("city_code")
                .subAggregation(
                    AggregationBuilders.avg("avg_price")
                        .field("price")
                )
        )
);

4. 未使用doc_values的惨案

对于text字段做聚合就像用菜刀砍柴:

// 错误示范:对text字段直接聚合
sourceBuilder.aggregation(
    AggregationBuilders.terms("product_names")
        .field("product_name")  // 这是text类型!
);

正确姿势是用keyword子字段:

// 正确做法:使用.keyword子字段
sourceBuilder.aggregation(
    AggregationBuilders.terms("product_names")
        .field("product_name.keyword")  // 使用keyword类型
);

三、性能调优的九阳神功

1. 预计算大法好

对于固定维度的统计,可以在写入时就计算好。比如使用pipeline聚合:

// 定义pipeline聚合
IndexRequest request = new IndexRequest("sales_stats");
Map<String, Object> doc = new HashMap<>();
doc.put("product_id", "P10086");
doc.put("daily_sales", 1500);  // 写入时预计算
doc.put("timestamp", new Date());
request.source(doc);
client.index(request, RequestOptions.DEFAULT);

2. 善用filter聚合

先过滤再聚合,就像先筛沙子再砌墙:

// 使用filter减少数据集
sourceBuilder.aggregation(
    AggregationBuilders.filter("recent_sales", 
        QueryBuilders.rangeQuery("sale_date")
            .gte("now-7d/d"))
    .subAggregation(
        AggregationBuilders.terms("top_products")
            .field("product_id.keyword")
    )
);

3. 调整refresh_interval

对于低频更新的索引,可以调大刷新间隔:

// 更新索引设置
UpdateSettingsRequest updateRequest = new UpdateSettingsRequest("logs");
Settings settings = Settings.builder()
    .put("index.refresh_interval", "30s")  // 默认1s
    .build();
updateRequest.settings(settings);
client.indices().putSettings(updateRequest, RequestOptions.DEFAULT);

4. 启用自适应副本选择

在elasticsearch.yml中配置:

# 开启自适应副本选择
cluster.routing.use_adaptive_replica_selection: true

四、实战中的独孤九剑

案例1:电商大促的秒级统计

我们通过以下组合拳将聚合时间从12秒降到800ms:

  1. 使用runtime_mappings替代复杂script
// 运行时字段替代script
Map<String, Object> params = new HashMap<>();
params.put("discount_rate", 0.8);
sourceBuilder.runtimeMappings(
    Collections.singletonMap("final_price", 
        Collections.singletonMap("type", "double")
            .put("script", 
                Collections.singletonMap("source", "doc['price'].value * params.discount_rate")
                    .put("params", params)
            )
    )
);
  1. 对热数据使用分片请求缓存
// 启用请求缓存
sourceBuilder.requestCache(true); 

案例2:日志分析系统的涅槃重生

某日志平台原来每小时聚合超时,我们通过三板斧解决:

  1. 冷热数据分离
// 创建热节点索引模板
PutIndexTemplateRequest request = new PutIndexTemplateRequest("hot_logs");
request.patterns(Collections.singletonList("logs-*"));
Settings settings = Settings.builder()
    .put("index.routing.allocation.require.temperature", "hot")
    .build();
request.settings(settings);
client.indices().putTemplate(request, RequestOptions.DEFAULT);
  1. 使用date_histogram的固定间隔
// 优化时间直方图
sourceBuilder.aggregation(
    AggregationBuilders.dateHistogram("by_hour")
        .field("@timestamp")
        .fixedInterval(DateHistogramInterval.HOUR)  // 比calendar_interval更高效
        .minDocCount(0)
);
  1. 启用docvalue_fields替代_source
// 只获取必要字段
sourceBuilder.docValueField("log_level.keyword", null);
sourceBuilder.docValueField("response_time", null);

五、避坑指南与最佳实践

  1. 监控三件套

    • 使用Nodes Stats API监控内存
    NodesStatsRequest nodesStatsRequest = new NodesStatsRequest();
    nodesStatsRequest.addMetrics(NodesStatsRequest.Metric.JVM.metricName());
    NodesStatsResponse response = client.nodes().stats(nodesStatsRequest, RequestOptions.DEFAULT);
    
  2. 熔断机制: 在elasticsearch.yml配置断路器:

    indices.breaker.total.limit: 70%
    indices.breaker.fielddata.limit: 60% 
    
  3. 查询优化口诀

    • 能filter不query
    • 能keyword不text
    • 能terms不script
    • 能top_hits不size
  4. 硬件选择黄金法则

    • 每GB堆内存对应20-25GB磁盘空间
    • SSD比HDD快5-10倍
    • 每个节点不超过32核CPU

六、未来演进方向

  1. 时序数据处理:考虑使用Time Series索引模式
// 创建时序索引
CreateIndexRequest request = new CreateIndexRequest("metrics-2023");
request.settings(Settings.builder()
    .put("index.mode", "time_series")
    .put("index.routing_path", "service_name,metric_name")
    .build());
  1. 混合查询方案:对超大规模数据使用Elasticsearch + ClickHouse组合

  2. 机器学习预聚合:用Rollup Jobs定期预计算

// 创建rollup任务
PutRollupJobRequest request = new PutRollupJobRequest(
    new RollupJobConfig("daily_sales", "sales_raw", "sales_rollup", "*/30 * * * * ?",
        new GroupConfig(
            new TermsGroupConfig("product_id"),
            new DateHistogramGroupConfig("sale_date", "1d")
        ),
        Collections.singletonList(new MetricConfig("price", Arrays.asList("min", "max", "sum")))
    )
);
client.rollup().putRollupJob(request, RequestOptions.DEFAULT);

记住,没有银弹,只有最适合的方案。建议每次调优后都用Profile API验证:

// 启用查询分析
sourceBuilder.profile(true);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
String profile = response.getProfileResults().toString();