说明

本文描述问题及解决方法同样适用于 腾讯云 Elasticsearch Service(ES)。

前言

最近在回顾一些曾经在 ES 上踩过的坑,想起了这么一个问题:Elasticsearch 索引突然无法写入新文档了,日志里报错说文档数量超过了限制。这个问题在 7.x 版本的集群中尤其常见,因为从 7.x 开始,Elasticsearch 默认索引只有 1 个分片。今天就来回顾一下这个问题的来龙去脉。

问题现象

当索引的单个分片文档数达到上限时,会看到类似这样的错误:

Number of documents in the index can't exceed [2147483519]

这个数字看起来很眼熟对吧?没错,它非常接近 Java 的 Integer.MAX_VALUE(2147483647)。实际上,这个限制来自于 Lucene 的 IndexWriter.MAX_DOCS。

为什么会有这个限制?

这个限制其实是 Lucene 层面的硬性约束。我们来看看源码中的关键部分:

在 InternalEngine.java 中,有这样一段代码:

private Exception tryAcquireInFlightDocs(Operation operation, int addingDocs) {
    assert operation.origin() == Operation.Origin.PRIMARY : operation;
    assert operation.seqNo() == SequenceNumbers.UNASSIGNED_SEQ_NO : operation;
    assert addingDocs > 0 : addingDocs;
    final long totalDocs = indexWriter.getPendingNumDocs() + inFlightDocCount.addAndGet(addingDocs);
    if (totalDocs > maxDocs) {
        releaseInFlightDocs(addingDocs);
        return new IllegalArgumentException("Number of documents in the index can't exceed [" + maxDocs + "]");
    } else {
        return null;
    }
}

这里的 maxDocs 默认值就是 IndexWriter.MAX_DOCS,也就是 2147483519(约 21.47 亿)。

为什么 7.x 版本更容易遇到?

在 Elasticsearch 7.x 之前,默认索引会创建 5 个主分片。但从 7.x 开始,为了优化资源使用和提升性能,默认改为只创建 1 个主分片。

这意味着:

  • 6.x 及之前:21.47 亿 × 5 = 约 107 亿文档才会遇到问题
  • 7.x 及之后:21.47 亿文档就会遇到问题

对于日志类、时序类数据,如果没有合理规划索引策略,很容易就会触碰到这个天花板。

代码流程分析

让我们通过一个流程图来理解文档写入时的检查机制:

关键代码解析

1. 文档数量检查(InternalEngine.java)

private final AtomicLong inFlightDocCount = new AtomicLong();
private final int maxDocs;

public InternalEngine(EngineConfig engineConfig) {
    this(engineConfig, IndexWriter.MAX_DOCS, LocalCheckpointTracker::new);
}

引擎初始化时,maxDocs 被设置为 IndexWriter.MAX_DOCS。在每次写入操作前,都会检查:

final long totalDocs = indexWriter.getPendingNumDocs() + inFlightDocCount.addAndGet(addingDocs);
if (totalDocs > maxDocs) {
    // 超限,拒绝写入
}

这里有两个计数器:

  • indexWriter.getPendingNumDocs():IndexWriter 中待提交的文档数
  • inFlightDocCount:正在处理中但还未提交到 IndexWriter 的文档数

2. Shrink 操作的检查(TransportResizeAction.java)

在执行索引收缩(shrink)操作时,也会检查合并后的分片是否会超过限制:

for (int i = 0; i < numShards; i++) {
    if (resizeRequest.getResizeType() == ResizeType.SHRINK) {
        Set<ShardId> shardIds = IndexMetadata.selectShrinkShards(i, sourceMetadata, numShards);
        long count = 0;
        for (ShardId id : shardIds) {
            DocsStats docsStats = perShardDocStats.apply(id.id());
            if (docsStats != null) {
                count += docsStats.getCount();
            }
            if (count > IndexWriter.MAX_DOCS) {
                throw new IllegalStateException("Can't merge index with more than [" + IndexWriter.MAX_DOCS
                    + "] docs - too many documents in shards " + shardIds);
            }
        }
    }
}

这确保了在 shrink 操作时,不会将多个分片合并成一个超过限制的大分片。

解决方案

方案一:增加分片数(推荐用于新索引)

对于新创建的索引,可以在索引模板中设置合适的分片数:

PUT _index_template/my_template
{
  "index_patterns": ["logs-*"],
  "template": {
    "settings": {
      "number_of_shards": 3,
      "number_of_replicas": 1
    }
  }
}

注意:这个设置只对新创建的索引生效,已存在的索引不会受影响。

方案二:使用 Split API(用于已存在的索引)

对于已经达到限制的索引,可以使用 Split API 来增加分片数:

# 1. 先将索引设置为只读
PUT /my_index/_settings
{
  "settings": {
    "index.blocks.write": true
  }
}

# 2. 执行 split 操作
POST /my_index/_split/my_index_new
{
  "settings": {
    "index.number_of_shards": 3
  }
}

# 3. 等待 split 完成后,可以删除旧索引
DELETE /my_index

重要提示:

  • Split 操作要求新分片数必须是原分片数的倍数
  • Split 过程会消耗大量磁盘空间和 I/O 资源
  • 建议在业务低峰期执行

方案三:使用索引生命周期管理(ILM)

对于日志类、时序类数据,最佳实践是使用 ILM 自动管理索引:

PUT _ilm/policy/logs_policy
{
  "policy": {
    "phases": {
      "hot": {
        "actions": {
          "rollover": {
            "max_docs": 2000000000,
            "max_age": "7d",
            "max_size": "50gb"
          }
        }
      },
      "delete": {
        "min_age": "30d",
        "actions": {
          "delete": {}
        }
      }
    }
  }
}

这样可以在文档数接近限制前自动滚动到新索引,避免触碰天花板。

最佳实践建议

  1. 合理规划分片数:根据数据量和增长速度,预估合适的分片数。一般建议单个分片文档数控制在 10 亿以内。
  2. 使用索引模板:为不同类型的数据设置不同的索引模板,避免使用默认的 1 个分片。
  3. 监控文档数量:定期监控各分片的文档数,在接近限制前采取措施。可以使用以下 API:
GET /_cat/shards?v&h=index,shard,docs&s=docs:desc
  1. 时序数据使用 ILM:对于日志、监控等时序数据,强烈建议使用 ILM 自动管理索引生命周期。
  2. 避免单分片过大:即使没有达到文档数限制,单个分片过大(如超过 50GB)也会影响性能和恢复速度。

总结

Elasticsearch 的单分片文档数限制是 Lucene 层面的硬性约束,这个限制在 7.x 版本中因为默认分片数的改变而变得更容易触碰。理解这个限制的原理和检查机制,可以帮助我们更好地规划索引策略,避免生产环境中出现意外。

记住几个关键点:

  • 单分片最大文档数:2147483519(约 21.47 亿)
  • 7.x 默认只有 1 个分片,更容易达到限制
  • 提前规划比事后补救更重要
  • 对于时序数据,ILM 是最佳选择

希望这篇文章能帮助你更好地理解和应对这个问题。如果你的集群中有大量数据,不妨现在就检查一下各个分片的文档数吧!