Elasticsearch 高流量延迟下如何合理 reshard indices?
结论
拟议方案可能降低延迟,但不能仅凭“每个 shard 约 25 GB”来判断它是否有效。
其中更值得尝试的是减少几个小索引的 primary shard 数量,降低通过 billing-alias 搜索时的 fan-out。至于把 billing-index-v4.0 从 20 个 primary shards 增加到 40 个,则需要谨慎。这样会增加每次搜索需要协调的 shard 数量,不一定能缩短延迟,高峰期的尾延迟反而可能变差。
建议分两步实施:
- 先缩减明显过度分片的小索引,再重新压测。
- 只有在证据表明
billing-index-v4.0的单 shard 查询、恢复或写入已经成为瓶颈时,才考虑增加 shard 数量。
当前问题更像是搜索放大,而不是 CPU 或 heap 不足
通过 alias 搜索时,请求会发送到 alias 覆盖的每个索引和每个 primary shard group。每个 shard group 会从 primary 或某个 replica 中选择一个副本执行查询,并不是 primary 和 replica 同时执行同一次查询。
如果 alias 当前包含 6 个索引,每个索引有 20 个 primary shards,那么一次搜索最多需要访问:
6 indices × 20 primary shard groups = 120 shard-level searches
即使应用层每秒只有 5 次搜索,也可能放大为大约:
5 × 120 = 600 shard-level searches/s
这与各索引的 shard search rate 明显高于应用请求率相符。不过,实际数字还会受到并发、重试、分页和监控统计口径的影响,不能只用这个公式精确反推。
当前 shard 的平均大小约为:
| Index | 当前平均 primary shard 大小 | 拟议平均大小 |
|---|---|---|
billing-index-v0.0 | 2.25 GB | 22.5 GB |
billing-index-v1.0 | 9.85 GB | 24.6 GB |
billing-index-v2.0 | 70 MB | 700 MB |
billing-index-v4.0 | 47.35 GB | 23.7 GB |
前三个索引都有不同程度的过度分片,尤其是 billing-index-v2.0,总容量只有 1.4 GB,却配置了 20 个 primary shards。相比之下,47 GB 左右的 shard 不能仅凭大小就认定为“过大”。是否需要拆分,还要看查询时间、segment 数量、恢复时间、磁盘延迟以及 search thread pool 的排队情况。
如何调整 shard 数量
可以先采用以下方案:
billing-index-v0.1: 2 primary shards
billing-index-v1.1: 8 primary shards
billing-index-v2.1: 1 或 2 primary shards
billing-index-v4.1: 暂时保持 20 primary shards
billing-index-v2.1 只有 1.4 GB,从容量上看,1 个 primary shard 已经足够。配置 1 个 replica 后仍有两个数据副本,但该索引只能分布在两个节点上。如果选择 2 个 primary shards,副本更容易分散到四个节点,代价是搜索 fan-out 会增加。具体选 1 个还是 2 个,应根据节点分布和吞吐测试决定,不必追求固定的 shard 大小。
如果 v3 和 v5 仍保留 20 个 primary shards,按上述方案调整后,alias 覆盖的 primary shard group 数量会从 120 个降至:
2 + 8 + 1/2 + 20 + 20 + 20 = 71/72
这比当前减少约 40%。对于这类广播查询,这种做法通常比单纯把大索引拆成更多 shard 更可能改善性能。
不要把 25 GB 当成硬性标准
每个 shard 25 GB 可以作为初始规划值,但这不是 Elasticsearch 的性能保证。合理的 shard 大小取决于:
- 单个 shard 的文档数量和 segment 数量
- 查询复杂度和字段映射
- 磁盘读取延迟,而不只是 IOPS
- 节点故障后的恢复时间
- merge、refresh 和 update 压力
- page cache 命中情况
- 每次请求需要访问的 shard 数量
- 节点间网络和 coordinating node 开销
CPU 长期低于 20%、heap 没有明显压力,也不能说明系统不存在瓶颈。搜索线程可能正在等待磁盘读取,少量请求也可能被慢 shard、缓存未命中、segment merge、网络或协调阶段拖慢。排查时应重点观察 P95 和 P99,而不只是平均值。
先定位延迟发生在哪个阶段
建议分别采集高峰期和低峰期的以下指标,并进行对比:
search.query_time与search.fetch_timesearchthread pool 的active、queue、rejected- 各 shard 的查询耗时,而不只是索引级平均值
- 磁盘读取延迟、吞吐量和队列深度
- segment 数量、merge 时间和 refresh 时间
- page cache、文件系统缓存命中情况
- coordinating node 的网络流量和请求延迟
- 是否存在热点 shard 或不均衡的 shard 分配
可以用 slow log 捕获高峰期的慢查询。阈值应按照正常延迟目标设置,例如:
PUT billing-index-*/_settings
{
"index.search.slowlog.threshold.query.warn": "1s",
"index.search.slowlog.threshold.query.info": "500ms",
"index.search.slowlog.threshold.fetch.warn": "500ms",
"index.search.slowlog.level": "info"
}
profile API 可以进一步分解查询开销,但它本身会带来明显开销,只适合诊断少量有代表性的请求,不应直接用于生产流量。
GET billing-alias/_search
{
"profile": true,
"size": 20,
"query": {
"...": "原查询"
}
}
这个查询还有进一步优化空间
查询中最需要关注的是跨 10 个 .autocomplete 字段执行的 multi_match。autocomplete 字段通常会产生较多 term,查询成本可能远高于普通 keyword 字段或标准全文字段。
可以从以下几个方面检查:
1. 不需要精确总数时关闭统计
如果接口只返回前 20 条结果,不需要精确的总命中数,可以加入:
{
"size": 20,
"track_total_hits": false,
"query": {
"...": "原查询"
},
"sort": [
{
"modifiedDate": {
"order": "desc"
}
}
]
}
对于显式排序且只取少量结果的查询,这项设置可能有帮助,实际收益仍需通过压测确认。
2. 检查 autocomplete mapping
确认 .autocomplete 字段没有使用过于激进的 ngram 配置,尤其要检查很小的 min_gram。同时确认这十个字段是否都需要参与每次搜索。
如果业务允许,可以给不同字段设置不同权重:
"multi_match": {
"query": "Kelly",
"fields": [
"firstName.autocomplete^3",
"lastName.autocomplete^3",
"fullName.autocomplete^2",
"emailId.autocomplete^2",
"phone.autocomplete",
"companyName.autocomplete",
"country.autocomplete",
"state.autocomplete",
"city.autocomplete",
"address.autocomplete"
]
}
字段权重本身不一定能降低查询成本,但在梳理字段时,往往可以顺便删除价值较低的搜索字段。
3. 避免深分页
如果接口支持翻页,不要使用不断增大的 from。应改用 search_after,并增加一个稳定的第二排序字段:
"sort": [
{
"modifiedDate": "desc"
},
{
"_id": "asc"
}
],
"search_after": [
"2026-08-31T12:00:00Z",
"last_document_id"
]
4. 评估按 accountId routing
所有示例查询都包含精确的 accountId 条件。如果绝大多数读写都限定在单个账户内,可以考虑将 accountId 用作 routing。搜索时传入相同的 routing,通常只需访问每个索引中的一个 shard,而不是全部 shards:
GET billing-alias/_search?routing=9812374287423
{
"query": {
"bool": {
"filter": [
{
"term": {
"accountId": "9812374287423"
}
}
],
"must": [
{
"multi_match": {
"query": "Kelly",
"fields": [
"firstName.autocomplete",
"lastName.autocomplete",
"fullName.autocomplete"
]
}
}
]
}
}
}
这种优化可能比调整 shard 大小更有效,但实施前必须满足以下条件:
- 写入、读取、更新和删除必须使用完全一致的 routing。
- 现有数据需要通过 reindex 按 routing 重新写入。
- routing 不能遗漏,否则更新或删除时可能找不到文档。
- 如果某些账户的数据量特别大,可能产生热点 shard。
- 跨账户查询仍需访问所有 shards。
因此,应先分析 accountId 的数据分布,确认没有少量超大租户。
Reshard 的实施方式
Elasticsearch 不能直接将已有索引任意修改为目标 primary shard 数量。
例如,20 个 primary shards 无法通过 shrink 直接变成 8 个,因为 shrink 的目标数量必须是原数量的因数。20 变成 2 可以使用 shrink,20 变成 40 可以使用 split,但这些操作都需要满足只读、路由分配和磁盘空间等前置条件。如果迁移同时涉及 shard 缩减、增加和 mapping 优化,统一创建新索引后再 reindex,通常更容易控制。
创建新索引示例:
PUT billing-index-v1.1
{
"settings": {
"number_of_shards": 8,
"number_of_replicas": 1
},
"mappings": {
"...": "复制并复核现有 mappings"
}
}
然后限速执行 reindex:
POST _reindex?wait_for_completion=false&requests_per_second=200
{
"source": {
"index": "billing-index-v1.0"
},
"dest": {
"index": "billing-index-v1.1"
}
}
requests_per_second 没有适用于所有集群的固定值。应从较低值开始,再根据查询延迟、磁盘吞吐、merge 和 thread pool 的情况逐步调整。
验证文档数量、业务抽样查询和更新一致性后,再原子切换 alias:
POST _aliases
{
"actions": [
{
"remove": {
"index": "billing-index-v1.0",
"alias": "billing-alias"
}
},
{
"add": {
"index": "billing-index-v1.1",
"alias": "billing-alias"
}
}
]
}
由于这些历史索引仍允许 update,迁移期间必须处理并发更新。可以短暂停写、让应用双写,或者记录并重放迁移期间的变更。如果只执行一次 _reindex 就切换 alias,可能丢失 reindex 开始后发生的更新。
迁移期间的主要风险
- Reindex 会与线上查询争用磁盘、page cache、CPU 和 merge 资源。
- 新旧索引并存需要占用大量额外磁盘空间。
- 对于 947 GB 的索引,如果同时保留新旧索引及各自的 replica,空间占用可能非常大。
- 新索引刚建立时缓存是冷的,alias 切换后可能短暂变慢。
- 减少 shards 可以降低搜索 fan-out,但也会减少可并行处理查询的 shard 副本数量。
- 增加 shards 会提高 shard 管理、segment、文件句柄和协调开销。
- 如果只修改历史索引,而 write index 仍固定创建 20 个 shards,问题会周期性重现。
最后一点需要纳入长期配置。应通过 index template 或索引创建流程,为后续版本设置经过压测的 shard 数量,避免每一代索引都沿用 20 个 primary shards。
总体而言,应先减少小索引的 shard 数,检查 alias 的搜索范围,并评估使用 accountId routing。对于 billing-index-v4.0,在没有证据表明存在慢 shard、恢复时间过长或单 shard 吞吐瓶颈之前,建议保留 20 个 primary shards。最终配置应使用与生产查询结构一致的并发压测来验证,并以 P50、P95、P99 延迟和错误率作为判断依据。
备注:内容仅供参考。
