PAYATHON 2026

Elasticsearch 高流量延迟下如何合理 reshard indices?

数据小许

结论

拟议方案可能降低延迟,但不能仅凭“每个 shard 约 25 GB”来判断它是否有效。

其中更值得尝试的是减少几个小索引的 primary shard 数量,降低通过 billing-alias 搜索时的 fan-out。至于把 billing-index-v4.0 从 20 个 primary shards 增加到 40 个,则需要谨慎。这样会增加每次搜索需要协调的 shard 数量,不一定能缩短延迟,高峰期的尾延迟反而可能变差。

建议分两步实施:

  1. 先缩减明显过度分片的小索引,再重新压测。
  2. 只有在证据表明 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.02.25 GB22.5 GB
billing-index-v1.09.85 GB24.6 GB
billing-index-v2.070 MB700 MB
billing-index-v4.047.35 GB23.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_time
  • search thread 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 延迟和错误率作为判断依据。

备注:内容仅供参考。