文章摘要
本文深入探讨了在大型推送系统中使用Elasticsearch进行全量数据查询的性能优化方案。针对传统单次查询所有分片方式存在的查询时间长、协调节点压力大、内存占用高、无法并行处理等问题,提出了Preference + Slice组合优化方案。
核心优化点:
- Preference参数 :通过
_shards:语法指定查询特定分片,避免协调节点成为瓶颈 - Slice参数:将单个分片数据切分为多个slice,实现分片内并行查询
- 两层并行架构:结合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)
);
存在的问题:
- ⏱️ 查询时间长:需要等待所有12个分片返回结果
- 🔄 协调节点压力大:需要合并所有分片的结果
- 📊 内存占用高:一次性加载大量数据
- 🚫 无法并行处理 :单线程处理,效率低
性能表现:
- 查询时间:约 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);
}
}
关键参数解析:
.preference("_shards:0")- 只查询分片 0
- 忽略其他 11 个分片
- 减少协调节点开销
.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 核心优势
- 性能提升显著:通过两层并行,查询速度提升 48 倍
- 资源利用高效:减少协调节点压力,降低内存占用
- 扩展性强:通过 MQ 分发任务,支持大规模并行
- 灵活可控:通过配置调整并行度,适应不同场景
9.2 适用场景
✅ 适合:
- 全量数据扫描
- 大数据量查询
- 需要并行处理的场景
- 批量数据导出
❌ 不适合: - 小数据量查询(增加复杂度)
- 需要精确排序的场景(切片可能影响顺序)
- 实时查询场景(Scroll 有延迟)
9.3 关键要点
- Preference:精确控制查询的分片,减少协调开销
- Slice:进一步切分数据,提升并行度
- Scroll API:处理大数据量,支持分页获取
- 配置管理:灵活调整并行度,适应不同场景