分片数量,可以说是Elasticsearch索引的命门。它不像内存、CPU那样随时调整,一旦创建索引时定下主分片数量,后面就只能靠“搬家”来改。可偏偏数据是活的,今天刚够用,过两个月业务量一上来,原来的分片配置就成了“小马拉大车”或“大马拉小车”,要么查询慢得像老牛,要么写入时来回抖动。这篇文章就把整个动态调整的思路,拆开了讲清楚。
一、问题初现:分片多了还是少了?
先别急着谈怎么调,得先知道分片到底惹了什么祸。
1.1 分片过多为什么慢?
很多人以为分片越多,并行度越高,查询就越快。这话只对了一半。每个分片在底层其实是一个独立的Lucene索引,要占用文件句柄、内存和CPU。你设想一下,在厨房里切菜,你准备了五十个菜板,每个菜板上放几片菜叶子。每次要求你找到某一种菜,你得跑到每个菜板前去翻一遍,最后把所有菜板上的菜汇总起来。翻五十个菜板比翻五个菜板累多了,对吧?
Elasticsearch查询的时候也一样,一个搜索请求会被广播到所有主分片,然后汇总结果。分片太多,光是协调这些分片返回结果的开销就很大,再加上JVM堆内存被分片元数据吃掉一大块,查询自然越来越慢。很多运维同学发现明明加机器了,查询反而更慢,原因往往就是分片数量失控。
1.2 分片过少为什么写入抖动?
反过来,分片太少又会遇到单点瓶颈。一个分片的写入能力是有上限的,受限于磁盘IO、CPU以及Lucene本身的段合并机制。比如你的数据每天增长很快,但索引只有1个分片,写入请求一高,就像一条单车道的小路,所有车都往里挤,轻则响应时间飘忽不定,重则触发写入拒绝(es_rejected_execution_exception),业务侧就会看到大量写入超时。
还有更头疼的抖动:分片一旦达到某个阈值,Elasticsearch会触发段合并,合并时对磁盘IO和CPU的消耗非常大,如果全压在几个分片上,那别的查询也被拖累,整个集群的延迟曲线就会像过山车一样。
二、为什么不能一劳永逸?
有人会问:我一开始设置好分片数,不就行了吗?真不行。数据规模是动态的。今天是10GB,三个月后可能是100GB,一年后可能是1TB。业务方还会搞一些临时活动,数据量突然翻倍。分片配置就像买鞋,你买一双固定尺码的鞋,脚一直在长,那肯定不合适。
Elasticsearch官方建议单分片容量控制在20到40GB之间,但这不是死规则。如果你的访问模式是热数据多、查询频繁,那分片可以稍微小一点,比如20GB一个。如果你的数据写多读少,而且大都是冷数据,可以容忍单分片到50GB甚至更大。关键是:你得根据当前数据规模,推算出合理的分片数,并且随着数据量的变化,主动去调整。
三、动手之前:先看清当前状态
在调整之前,别慌,先做一个“身体检查”。我们要掌握三样东西:当前索引的分片分布、每个分片的大小、以及文档数量。
3.1 查看索引健康与分片分布
最简单的方式是用Elasticsearch的_cat接口。技术栈用Python,直接调用Elasticsearch客户端。
from elasticsearch import Elasticsearch
# 假设ES在本地9200端口
es = Elasticsearch(["http://localhost:9200"])
# 查看所有索引的健康状态、主分片数、副本数、文档数和存储大小
indices = es.cat.indices(index="my_index", v=True)
print(indices)
# 输出结果示例:
# health status index uuid pri rep docs.count docs.deleted store.size pri.store.size
# green open my_index XXXXXXXXXXXXXXXXXXX 5 1 2345678 0 112.3gb 55.4gb
这里的pri就是主分片数,rep是副本数。store.size是整个索引(含副本)占的空间,pri.store.size是主分片占的空间。
3.2 查看每个分片的具体大小
光看索引整体还不够,我们需要知道每个分片分布在哪台机器上,以及单分片有多大。
# 查看my_index的每个分片详情,包括分片编号、主分片还是副本、节点、分片大小
shard_info = es.cat.shards(index="my_index", v=True)
print(shard_info)
# 输出示例:
# index shard prirep state store ip node
# my_index 0 p STARTED 11.2gb 10.0.0.1 node-1
# my_index 0 r STARTED 11.2gb 10.0.0.2 node-2
# my_index 1 p STARTED 10.8gb 10.0.0.1 node-1
# ...
看到没,这就是分片的大致面貌。通过这些数据,我们就能判断当前分片数是否合理。
比如发现总数据量只有30GB,却分了30个主分片,平均每个分片才1GB,那就是严重“分片过剩”,查询肯定因为它而慢。反过来说明数据已经到了500GB,但只有一个主分片,写入肯定会抖。
四、完整动态调整方案核心思路
这里的“动态调整”并不是直接在旧索引上改分片数量——你别想了,Elasticsearch不允许修改主分片数。真正可行的方法是“重新建一个大小的新索引,然后迁移数据,最后切换”。整体思路分四步走:估算分片数、创建新索引、迁移数据、切换别名。
4.1 确定目标分片数
目标分片数怎么定?先看当前总主分片大小(pri.store.size),然后按单个分片目标大小去除。比如你当前主分片总大小是120GB,你希望单个分片在30GB左右,那主分片数就是120 / 30 = 4个。如果你有多个节点,还要考虑每个节点承担的分片数量均衡,不要出现一个节点上有8个分片,另一个只有1个的情况。
另外还要考虑未来一段时间的数据增长。比如现在是120GB,下个月大概会涨到180GB,那建议按180GB来算,分片数设为6个。你可以预留一点余量,但不要太夸张,否则又回到了分片过多的问题。
4.2 创建新索引
说好目标分片数,就动手创建新索引。注意映射和设置要尽量对齐旧索引,尤其是字段mapping,避免迁移后查询行为不一致。
from elasticsearch import Elasticsearch
es = Elasticsearch(["http://localhost:9200"])
new_index = "my_index_v2" # 新索引名
# 假设旧索引有5个分片,现在需要调整为4个主分片,副本数保持1
settings = {
"settings": {
"index": {
"number_of_shards": 4,
"number_of_replicas": 1
}
}
}
# 创建新索引,先不设置mapping也可以,reindex会把动态映射带过来
# 但最好先手动创建并指定mapping,这样能保留旧索引的字段属性
es.indices.create(index=new_index, body=settings)
print(f"新索引 {new_index} 已创建")
4.3 迁移数据(reindex)
reindex是Elasticsearch提供的数据复制接口,把旧索引里的所有数据“搬”到新索引。这个操作最好在业务低峰期做,因为它会跑大量查询和写入,对性能有压力。
from elasticsearch import Elasticsearch
from elasticsearch.helpers import reindex
es = Elasticsearch(["http://localhost:9200"])
old_index = "my_index"
new_index = "my_index_v2"
# 执行reindex,把old_index数据全部迁移到new_index
response = reindex(es, source_index=old_index, target_index=new_index,
query={"match_all": {}}) # 默认全量迁移,这里显式写查询
print(response)
# 响应示例:
# {'timed_out': False, 'total': 2345678, 'updated': 0, 'created': 2345678, 'deleted': 0, 'batches': 2346, 'version_conflicts': 0, 'noops': 0, 'retries': 0, 'throttled_millis': 0, 'requests_per_second': -1.0, 'throttled_until_millis': 0}
如果你觉得reindex太慢,还可以设置并行度:在reindex请求体里加"size"和"slices"参数。slices就是让reindex任务自动拆分成多个子任务并行执行。
# 在Kibana Dev Tools里也可以直接发送reindex请求,这里用JSON示例
# 使用slices=auto,让ES自己决定并行slice数
curl -X POST "localhost:9200/_reindex" -H 'Content-Type: application/json' -d'
{
"source": {
"index": "my_index",
"size": 5000
},
"dest": {
"index": "my_index_v2"
},
"slices": "auto"
}'
看到没,命令里直接写了auto,Elasticsearch会按物理分片或CPU核数自动拆分。这样迁移速度能快不少,但代价是源集群负载会升高,所以还是建议在低峰期跑。
4.4 切换别名与清理旧索引
数据迁移完成后,还不能直接删除旧索引。你的业务代码里写的索引名大概率是my_index,不能为了调整分片就去改代码。所以要用别名来“偷梁换柱”:先让别名指向新索引,再移除旧索引。
from elasticsearch import Elasticsearch
es = Elasticsearch(["http://localhost:9200"])
# 给新索引添加一个别名,别名叫my_index
es.indices.put_alias(index="my_index_v2", name="my_index")
print(f"别名 my_index 已指向 my_index_v2")
# 取消旧索引上的别名(如果旧索引原来有my_index这个别名的话)
# 先看看旧索引的别名情况
existing = es.indices.get_alias(index="my_index")
print(f"旧索引的别名情况:{existing}")
# 示例输出:
# {'my_index': {'aliases': {}}}
# 如果没有别名,那业务就直接用my_index这个索引名,现在要先把旧索引删掉,再重新命名新索引?
# 但更安全的做法是:先让新索引拥有my_index别名,然后再删除旧索引,
# 因为旧索引本身叫my_index,不是别名,直接删掉会导致短时间失去索引。
# 推荐流程:
# 1. 先将新索引别名设置为my_index
# 2. 然后删除旧索引
# 3. 再把旧索引名直接改名成别的?不行,ES不支持直接改名。
# 有个技巧:给旧索引先加上别名my_index_old,然后删除旧索引。此时新索引的别名仍然是my_index。
# 我们来演示一下这个安全步骤:
# 将旧索引添加到别名my_index_old,避免业务误连
es.indices.put_alias(index="my_index", name="my_index_old")
print("旧索引已打上临时别名my_index_old")
# 删除旧索引,释放名字my_index
es.indices.delete(index="my_index")
print("旧索引已删除")
# 此时,新索引有一个别名my_index,业务侧无需改动。
# 也可以把新索引名称改为my_index?不行,ES禁止更改索引名。
# 所以用别名是最优雅的方式。
这里需要注意一个坑:如果业务端直接用的索引名my_index(不带别名),那你不能把新索引直接重命名为my_index,因为ES不允许索引重命名。所以只能让新索引带头别名my_index,旧索引则通过删除来释放位置。如果你不想用别名,还有另一种方式:把旧索引删掉,然后立刻用新索引名来创建my_index,但这需要先保证旧索引数据已完整迁移到新索引,而且业务会有一小段时间不可用。为了平滑,还是推荐别名方案。
4.5 清理与健康度验证
删掉旧索引后,再检查一遍新索引的分片分布,确认主分片数和数据量正确,没有丢数据。
# 验证新索引分片数量
print(es.cat.shards(index="my_index_v2", v=True))
# 验证文档数
old_count = es.count(index="my_index_v2")['count']
print(f"新索引文档数: {old_count}")
这时候,分片调整算完成了。
五、结合示例跑一遍(完整版)
技术栈:Python 3.8 + elasticsearch-py 7.x
下面是一个比较完整的脚本,把“查询当前索引信息 -> 计算目标分片数 -> 创建新索引 -> 同步数据 -> 切换别名”全串起来。实际生产中建议把脚本拆成独立步骤,便于控制。
from elasticsearch import Elasticsearch
from elasticsearch.helpers import reindex, index
# 连接ES
es = Elasticsearch(["http://localhost:9200"])
# 要调整的索引名
old_index = "my_index"
new_index = f"{old_index}_v2"
# 1. 拿到主分片总大小(不含副本)
store_info = es.cat.shards(index=old_index, h=["shard", "store"], format="json")
# 只统计主分片(prirep字段?这里用shard和store,需要区分主分片)
# 简单点用stats接口拿总存储大小
stats = es.indices.stats(index=old_index)
primary_size_bytes = stats["indices"][old_index]["primaries"]["store"]["size_in_bytes"]
# 换算成GB,方便计算
primary_size_gb = primary_size_bytes / 1024**3 # 1GB=1024^3
print(f"当前主分片总大小: {primary_size_gb:.2f} GB")
# 2. 根据经验设定每个分片目标大小(比如30GB)
target_shard_size_gb = 30
# 3. 计算目标分片数,向上取整,且不小于1
import math
target_shards = math.ceil(primary_size_gb / target_shard_size_gb)
target_shards = max(1, target_shards)
print(f"计算得到目标主分片数: {target_shards}")
# 4. 创建新索引,设置分片数和副本数
settings = {
"settings": {
"index": {
"number_of_shards": target_shards,
"number_of_replicas": 1 # 生产环境副本为1,读多写少可以2
}
},
"mappings": es.indices.get_mapping(index=old_index)[old_index]["mappings"]
# 这里直接复制旧映射,避免手动写漏字段
}
es.indices.create(index=new_index, body=settings)
print(f"新索引 {new_index} 已创建,设置分片数为 {target_shards}")
# 5. 执行reindex迁移数据
# 为了看得清楚,我们不使用helpers,直接用原生的reindex接口,并打印进度
result = es.reindex(
body={
"source": {"index": old_index},
"dest": {"index": new_index}
},
wait_for_completion=False # 异步执行
)
task_id = result["task"]
print(f"reindex任务已启动,任务ID: {task_id}")
# 6. 循环查看任务状态(简单轮询,每3秒查一次)
import time
while True:
task_status = es.tasks.get(task_id=task_id)
status = task_status["task"]["status"]
if task_status["completed"]:
print("reindex任务完成!")
# 可以打印一些统计
if "created" in status:
print(f"总共创建文档数: {status['created']}")
break
else:
# 这里有total和created、updated等字段
total = status.get("total", 0)
created = status.get("created", 0)
print(f"迁移进度: {created}/{total}")
time.sleep(3)
# 7. 数据校验,确认新索引文档数等于旧索引(注意reindex期间如果有新写入,可能不一致)
old_count = es.count(index=old_index)["count"]
new_count = es.count(index=new_index)["count"]
print(f"旧索引文档数: {old_count}, 新索引文档数: {new_count}")
# 8. 切换别名(如果业务使用的是索引名my_index,就给新索引加别名my_index)
# 旧索引可能还没被删除,需要先给旧索引加一个临时别名,避免名字冲突
es.indices.put_alias(index=old_index, name=f"{old_index}_old")
# 删除旧索引,释放名字
es.indices.delete(index=old_index)
# 给新索引添加别名my_index
es.indices.put_alias(index=new_index, name=old_index)
print("切换完成,业务无感知")
上面的代码只是演示核心步骤,实际操作要加异常处理、日志打点、以及回滚机制。比如reindex失败时,新索引可以删掉重来,而旧索引没动,业务没有受影响。
六、注意事项与坑
6.1 分片不是越小越好,也不是越大越好
如果你把分片从5个改成4个,发现查询还是慢,那是别的原因,跟分片数无关。别把分片数当成万能药。单分片过小会导致无法利用并发,过大会导致读写放大。
6.2 reindex期间数据一致性
如果你的业务在迁移过程中还在不断写入旧索引,那reindex结束后,新旧索引文档数会不一致。有两种解决办法:一是通过业务侧在切换前暂停写入几分钟;二是使用“快照版本”的reindex机制,在reindex后再同步增量,但这复杂。最简单直观的就是在低峰期停写或者做只读。
6.3 副本分片会影响分片分布
调整分片数时,副本数量也会影响每个节点放的片数。比如你有3个节点,主分片6个,副本1个,那总片数是12个,平均每个节点4个。如果你只有2个节点,副本1个,那每个节点还是6个,负载可能不均。所以调整时要把节点数纳入考虑。
6.4 给未来留一点余地
设定分片数时可以根据半年后的预期数据量来算,但不要留太多。比如当前120GB,下个半年可能300GB,那就按300GB算分片数(300/30=10个),这样能撑半年。半年后再调一次。
6.5 使用生命周期管理(ILM)自动化
如果你用的是Elasticsearch 7.x以上,可以考虑ILM策略。它能自动根据文档数或存储大小滚动索引,自动切换冷热阶段,还能缩减分片数。比如设置rollover条件:当索引超过40GB时,自动创建新索引,并把旧索引收缩到1个分片。这比手动reindex省事得多。
技术栈:Python + ILM API示例
from elasticsearch import Elasticsearch
es = Elasticsearch(["http://localhost:9200"])
policy = {
"policy": {
"phases": {
"hot": {
"actions": {
"rollover": {
"max_size": "40GB",
"max_docs": 5000000
}
}
},
"warm": {
"min_age": "30d",
"actions": {
"shrink": {
"number_of_shards": 1
}
}
}
}
}
}
# 创建ILM策略
res = es.ilm.put_lifecycle(policy="my_policy", body=policy)
print(res)
不过ILM只能管那些带rollover别名的基础,对于普通索引还是要靠我们上面手动调整。
七、优缺点总结
7.1 分片过多和过少这两种“病态”的对比
| 场景 | 优点 | 缺点 |
|---|---|---|
| 分片过多 | 单个分片文件小,维护成本低,理论上可以分布到更多节点 | 队列开销大,查询汇总慢,元数据占用内存高 |
| 分片过少 | 单分片查询无需太多汇总,回存索引简单 | 单点瓶颈,写入易抖动,无法利用横向扩展 |
7.2 动态调整机制本身的优劣
优点:能精准匹配数据规模,性能可预期;操作可回滚;不改变业务逻辑。
缺点:reindex比较耗时耗资源,尤其是大数据量;需要维护额外的别名和索引;操作过程有窗口期风险,不适合高可用极严格的业务。
所以,动态调整分片数量不是无脑的,要结合业务特点和数据增长率,综合考虑。
八、总结
分片数量设置不合理,就像给一个赛车队配了错误的轮胎——要么太宽,要么太窄。好在Elasticsearch给了我们reindex这个“换胎工具”,我们只需要在合适的时间把它用起来。核心思路不复杂:观测现状、计算合理分片数、建新索引、迁数据、切别名。整个过程虽然有点繁琐,但好在可编程、可自动化。如果你能把这一套流程固化成一个运维脚本,那以后无论数据规模怎么变,你都能从容应对。记住,分片数不是配置一次就完事的静态值,它应当随着数据规模的变化,像调整呼吸一样自然。
评论
围绕“Elasticsearch索引分片数量设置不合理引发查询缓慢与写入抖动,如何依据数据规模变化进行完整动态调整的思路详解”参与讨论