Elasticsearch Preference + Slice 加速查询实战案例

文章摘要

本文深入探讨了在大型推送系统中使用Elasticsearch进行全量数据查询的性能优化方案。针对传统单次查询所有分片方式存在的查询时间长、协调节点压力大、内存占用高、无法并行处理等问题,提出了Preference + Slice组合优化方案

核心优化点:

  1. Preference参数 :通过_shards:语法指定查询特定分片,避免协调节点成为瓶颈
  2. Slice参数:将单个分片数据切分为多个slice,实现分片内并行查询
  3. 两层并行架构:结合Preference和Slice实现分片间和分片内双重并行

性能提升效果:

  • 查询时间从约10分钟降至2-3分钟
  • 内存占用显著降低
  • 支持高并发并行处理

关键技术:

  • Scroll API配合使用实现分页查询
  • 合理的sliceMax值确定方法
  • 配置管理和错误处理最佳实践

适用场景: 需要从Elasticsearch全量扫描大量数据(如千万级设备ID查询)的业务场景。

一、背景与问题

1.1 业务场景

在大型推送系统中,我们需要从 Elasticsearch 中查询所有设备ID进行消息推送。假设:

  • 索引包含 1200万 条设备数据
  • 索引有 12个分片(Shard)
  • 需要全量扫描所有数据

1.2 传统查询的问题

传统方式:单次查询所有分片

java 复制代码
// 传统查询方式
SearchResponse response = client.search(s -> s
    .index("user_push_info_index")
    .query(q -> q.matchAll(m -> m))
    .size(10000)
);

存在的问题:

  1. ⏱️ 查询时间长:需要等待所有12个分片返回结果
  2. 🔄 协调节点压力大:需要合并所有分片的结果
  3. 📊 内存占用高:一次性加载大量数据
  4. 🚫 无法并行处理 :单线程处理,效率低
    性能表现:
  • 查询时间:约 10分钟
  • 内存占用:高
  • 扩展性:差

二、核心概念

2.1 Preference 参数

preference 参数用于控制查询在哪个分片副本上执行

2.1.1 常用值
参数值 说明 使用场景
_primary 只在主分片执行 需要强一致性
_local 优先本地节点 减少网络开销
_shards:0,1 只查询指定分片 精确控制查询范围
custom_string 自定义字符串 保证查询一致性
2.1.2 _shards: 格式详解
java 复制代码
// 只查询分片 0
.preference("_shards:0")
// 只查询分片 0 和 1
.preference("_shards:0,1")
// 只查询分片 5
.preference("_shards:5")

工作原理:

  • ES 协调节点识别 _shards: 前缀
  • 只将查询请求发送到指定的分片
  • 其他分片被忽略,即使包含匹配数据
  • 减少网络传输和结果合并开销

2.2 Slice 参数

slice 参数用于将单个分片的数据进一步切分,实现更细粒度的并行。

2.2.1 基本语法
java 复制代码
.slice(slice -> slice
    .id("1")      // 切片ID(0 到 max-1)
    .max(4))      // 切片总数
2.2.2 数据分布原理

ES 根据文档的 _id 哈希值分配数据到不同切片:

复制代码
假设 slice.max = 4:
文档 _id 哈希值 % 4 = 0  → 分配给 slice 0
文档 _id 哈希值 % 4 = 1  → 分配给 slice 1
文档 _id 哈希值 % 4 = 2  → 分配给 slice 2
文档 _id 哈希值 % 4 = 3  → 分配给 slice 3

关键特性:

  • ✅ 数据均匀分布
  • ✅ 无重复、无遗漏
  • ✅ 所有切片覆盖完整数据

三、优化方案:Preference + Slice 组合

3.1 两层并行架构

复制代码
┌─────────────────────────────────────────────────────────┐
│              Elasticsearch 索引(全部数据)                │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ... ┌────────┐ │
│  │ Shard 0  │ │ Shard 1  │ │ Shard 2  │ ... │Shard 11│ │
│  │          │ │          │ │          │     │        │ │
│  │ ┌──────┐ │ │ ┌──────┐ │ │ ┌──────┐ │     │        │ │
│  │ │Slice0│ │ │ │Slice0│ │ │ │Slice0│ │     │        │ │
│  │ │Slice1│ │ │ │Slice1│ │ │ │Slice1│ │     │        │ │
│  │ │Slice2│ │ │ │Slice2│ │ │ │Slice2│ │     │        │ │
│  │ │Slice3│ │ │ │Slice3│ │ │ │Slice3│ │     │        │ │
│  │ └──────┘ │ │ └──────┘ │ │ └──────┘ │     │        │ │
│  └──────────┘ └──────────┘ └──────────┘     └────────┘ │
└─────────────────────────────────────────────────────────┘

并行策略:

  • 第一层:分片级并行(12个分片同时查询)
  • 第二层:切片级并行(每个分片4个切片同时查询)
  • 总并行度 :12 × 4 = 48个任务

3.2 数据分布示例

假设有 1200 万条设备数据:

复制代码
索引总数据:12,000,000 条
分片分布(12个分片):
├─ Shard 0: 1,000,000 条
├─ Shard 1: 1,000,000 条
├─ Shard 2: 1,000,000 条
...
└─ Shard 11: 1,000,000 条
切片分布(每个分片4个切片,以 Shard 0 为例):
├─ Shard 0 - Slice 0: 250,000 条
├─ Shard 0 - Slice 1: 250,000 条
├─ Shard 0 - Slice 2: 250,000 条
└─ Shard 0 - Slice 3: 250,000 条

四、实战案例代码分析

4.1 任务生成(生产者)

java 复制代码
@Service
public class MqServiceImpl {
    @Autowired
    private ApplicationConfig applicationConfig;  // 配置:esShard=12, maxSlice=4
    public void sendMqMessage(String messageId) {
        // 双重循环生成所有分片+切片的组合
        for (int i = 0; i < applicationConfig.getEsShard(); i++) {      // 12个分片
            for (int j = 0; j < applicationConfig.getMaxSlice(); j++) { // 4个切片
                PushRegIdShardInfoMsg msg = new PushRegIdShardInfoMsg();
                msg.setShard(i);           // 分片ID: 0-11
                msg.setSlice(j);           // 切片ID: 0-3
                msg.setSliceMax(applicationConfig.getMaxSlice()); // sliceMax: 4
                msg.setMessageId(messageId);
                // 发送到MQ,实现任务分发
                rocketMQTemplate.asyncSend(topic, msg);
            }
        }
    }
}

生成的任务数量:

  • 12 个分片 × 4 个切片 = 48 个任务
  • 每个任务独立处理一个分片的一个切片

4.2 查询执行(消费者)

java 复制代码
@Service
public class ElasticsearchService {
    public ElasticDeviceResponseDTO queryDeviceIdsByCityInShard(
            ElasticDeviceQueryRequestDTO requestDTO) throws IOException {
        SearchResponse<Void> response = elasticsearchClient.search(b0 -> b0
            .index("user_push_info_index")
            .query(b1 -> b1.matchAll(b2 -> b2))  // 查询所有数据
            // 关键1: 使用 preference 指定查询的分片
            .preference("_shards:" + requestDTO.getShard())  // 例如: "_shards:0"
            // 关键2: 使用 slice 指定查询的切片
            .slice(slice -> slice
                .id(String.valueOf(requestDTO.getSlice()))  // 例如: "1"
                .max(requestDTO.getSliceMax()))              // 例如: 4
            // 性能优化:不返回文档内容,只返回 _id
            .source(source -> source.fetch(false))
            // 性能优化:使用 _doc 排序,避免计算相关性分数
            .sort(sort -> sort.field(fs -> fs.field("_doc")))
            // 使用 Scroll API 处理大数据量
            .scroll(time -> time.time("1m"))
            .size(1000), Void.class);
        return dealElasticResponse(response);
    }
}

关键参数解析:

  1. .preference("_shards:0")
    • 只查询分片 0
    • 忽略其他 11 个分片
    • 减少协调节点开销
  2. .slice().id("1").max(4)
    • 在分片 0 中,只查询切片 1
    • 总共 4 个切片,当前查询第 2 个
    • 数据量约为分片 0 的 25%

4.3 完整处理流程

java 复制代码
@Service
public class MqMessageProcessingServiceImpl {
    @Override
    public void processDeviceInfoMessage(PushRegIdShardInfoMsg msg) {
        // 1. 转换消息为查询请求
        ElasticDeviceQueryRequestDTO queryDTO = BeanUtil.toBean(msg,
            ElasticDeviceQueryRequestDTO.class);
        // 2. 首次查询:获取第一批数据和 scrollId
        ElasticDeviceResponseDTO response = elasticsearchService
            .queryDeviceIdsByCityInShard(queryDTO);
        // 3. 处理第一批数据
        pushService.broadcast(msg.getMessageId(), response.getRegIds());
        // 4. 使用 Scroll API 继续获取剩余数据
        boolean hasMore = response.isHasMore();
        String scrollId = response.getScrollId();
        while (hasMore) {
            ElasticDeviceResponseDTO scrollResponse = elasticsearchService
                .queryDeviceIdsByScrollId(scrollId);
            pushService.broadcast(msg.getMessageId(), scrollResponse.getRegIds());
            hasMore = scrollResponse.isHasMore();
            scrollId = scrollResponse.getScrollId();
        }
        // 5. 清理资源
        elasticsearchService.clearScrollId(scrollId);
    }
}

五、性能优化效果

5.1 性能对比

方案 查询时间 并行度 协调节点压力 内存占用
传统方式 ~10分钟 1
Preference + Slice ~12.5秒 48
性能提升:约 48 倍

5.2 优化原理

5.2.1 减少协调节点开销

传统方式:

复制代码
查询请求 → 协调节点 → 所有12个分片 → 合并结果 → 返回
         ↑
    需要等待所有分片,然后合并

优化方式:

复制代码
任务1: 查询 → 分片0 → 直接返回(无合并)
任务2: 查询 → 分片1 → 直接返回(无合并)
...
任务12: 查询 → 分片11 → 直接返回(无合并)
5.2.2 提升并行度
  • 分片级并行:12 个分片可以同时查询
  • 切片级并行:每个分片内 4 个切片可以同时查询
  • 总并行度:12 × 4 = 48 个任务
5.2.3 降低单次查询数据量
  • 单个任务只处理一个分片的一个切片
  • 数据量从 1200 万降低到 25 万(1/48)
  • 查询速度显著提升

六、关键技术点

6.1 Scroll API 配合使用

java 复制代码
// 首次查询:开启 Scroll
//"1m" 不是整个 Scroll 会话的总时长,而是每次 Scroll 请求的有效期
.scroll(time -> time.time("1m"))
// 后续查询:使用 scrollId
ScrollResponse response = client.scroll(s -> s
    .scrollId(scrollId)
    .scroll(time -> time.time("1m"))
);

作用:

  • 处理大数据量,避免一次性加载
  • 保持查询上下文,支持分页获取
  • 必须手动清理 scrollId,释放 ES 资源

6.2 性能优化技巧

java 复制代码
// 1. 不返回文档内容,只返回 _id
.source(source -> source.fetch(false))
// 2. 使用 _doc 排序,避免计算相关性分数
.sort(sort -> sort.field(fs -> fs.field("_doc")))
// 3. 使用 matchAll + filter,避免评分计算
.query(b1 -> b1.matchAll(b2 -> b2))

6.3 配置管理

java 复制代码
@Configuration
public class ApplicationConfig {
    // 分片数量(对应索引的分片数)
    private Integer esShard;  // 12
    // 每个分片的切片数量(可调整)
    private Integer maxSlice;  // 4
}

配置原则:

  • esShard:必须等于索引的分片数
  • maxSlice:根据数据量和资源情况调整
    • 数据量大 → 增大 maxSlice
    • 资源有限 → 减小 maxSlice

七、最佳实践

7.1 如何确定 sliceMax 值?

计算公式:

复制代码
sliceMax = 每个分片数据量 / 期望每个切片数据量

示例:

  • 每个分片:100 万条数据
  • 期望每个切片:25 万条
  • sliceMax = 1,000,000 / 250,000 = 4

7.2 注意事项

✅ 必须保持一致

所有查询任务必须使用相同的 sliceMax,否则:

  • 数据可能重复
  • 数据可能遗漏
  • 切片分布不一致
✅ 必须传递 sliceMax

ES 需要 slice.max 来计算数据分布:

java 复制代码
// 正确:传递 sliceMax
.slice(slice -> slice
    .id("1")
    .max(4))  // 必须传递
// 错误:只设置 slice.id
.slice(slice -> slice.id("1"))  // 缺少 max,ES 无法计算分布
✅ 及时清理 Scroll
java 复制代码
// 查询完成后必须清理
elasticsearchService.clearScrollId(scrollId);

原因:

  • Scroll 上下文会占用 ES 资源
  • 不清理会导致资源泄漏
  • 影响集群性能

7.3 错误处理

java 复制代码
try {
    // 查询逻辑
    ElasticDeviceResponseDTO response = elasticsearchService
        .queryDeviceIdsByCityInShard(queryDTO);
    // 处理数据
} catch (Exception e) {
    log.error("查询失败: {}", e.getMessage());
    // 清理资源
    if (scrollId != null) {
        elasticsearchService.clearScrollId(scrollId);
    }
}

八、完整示例代码

8.1 查询请求对象

java 复制代码
@Data
public class ElasticDeviceQueryRequestDTO {
    private Integer shard;      // 分片ID: 0-11
    private Integer slice;      // 切片ID: 0-3
    private Integer sliceMax;   // 切片总数: 4
}

8.2 查询响应对象

java 复制代码
@Data
public class ElasticDeviceResponseDTO {
    private String scrollId;           // Scroll ID,用于后续查询
    private List<String> regIds;       // 设备ID列表
    private boolean hasMore;           // 是否还有更多数据
    private Long took;                 // 查询耗时(毫秒)
}

8.3 完整查询方法

java 复制代码
public ElasticDeviceResponseDTO queryDeviceIdsByCityInShard(
        ElasticDeviceQueryRequestDTO requestDTO) throws IOException {
    SearchResponse<Void> response = elasticsearchClient.search(b0 -> b0
        .index("user_push_info_index")
        .query(b1 -> b1.matchAll(b2 -> b2))
        .size(1000)
        .source(source -> source.fetch(false))
        .slice(slice -> slice
            .id(String.valueOf(requestDTO.getSlice()))
            .max(requestDTO.getSliceMax()))
        .preference("_shards:" + requestDTO.getShard())
        .sort(sort -> sort.field(fs -> fs.field("_doc")))
        .scroll(time -> time.time("1m")), Void.class);
    return dealElasticResponse(response);
}

九、总结

9.1 核心优势

  1. 性能提升显著:通过两层并行,查询速度提升 48 倍
  2. 资源利用高效:减少协调节点压力,降低内存占用
  3. 扩展性强:通过 MQ 分发任务,支持大规模并行
  4. 灵活可控:通过配置调整并行度,适应不同场景

9.2 适用场景

适合:

  • 全量数据扫描
  • 大数据量查询
  • 需要并行处理的场景
  • 批量数据导出
    不适合:
  • 小数据量查询(增加复杂度)
  • 需要精确排序的场景(切片可能影响顺序)
  • 实时查询场景(Scroll 有延迟)

9.3 关键要点

  1. Preference:精确控制查询的分片,减少协调开销
  2. Slice:进一步切分数据,提升并行度
  3. Scroll API:处理大数据量,支持分页获取
  4. 配置管理:灵活调整并行度,适应不同场景

十、参考资料


相关推荐
金融支付架构实战指南12 小时前
支付系统 ES 实战案例:从索引创建到真实业务查询
大数据·elasticsearch·搜索引擎·支付
Elastic 中国社区官方博客17 小时前
13.7万人,零人工决策:使用 Elasticsearch 实现智能体驱动的灾害响应系统
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
可乐ea18 小时前
【知识获取与分享社区项目 | 项目日记第 19 天】基于 Elasticsearch 实现关键词检索与业务权重排序
java·大数据·spring boot·mysql·elasticsearch·搜索引擎·全文检索
查拉图斯特拉面条20 小时前
Git操作指南:克隆、提交、推送与避坑大全
大数据·git·elasticsearch
Zhu7581 天前
在k8s环境部署elasticsearch+kibana
elasticsearch·kubernetes·jenkins
为爱停留1 天前
让智能体「记住」对话:Checkpoint 功能、持久化数据接口与 thread_id 详解
java·数据库·elasticsearch
可乐ea1 天前
【知识获取与分享社区项目 | 项目日记第 23 天】项目梳理下篇:高并发与最终一致性复盘:Redis、Kafka、Outbox、ES 与 RAG 如何协同
java·redis·mysql·elasticsearch·缓存·ai·kafka
chushiyunen1 天前
elasticsearch查询相关
大数据·elasticsearch·搜索引擎
jiayong231 天前
Claude Code 快速参考卡片
大数据·elasticsearch·搜索引擎·ai·claude·claude code