深入解析 Elasticsearch 搜索优化中的自定义路由(routing)规划
以下内容将从以下几个方面深入解析 Elasticsearch 搜索优化中的自定义路由(routing)规划,包括原理、配置方法、典型应用场景、最佳实践以及常见注意事项,并辅以代码示例和图解,帮助你理解如何通过 routing 将查询流量精准地发送到目标分片,从而提升搜索性能与资源利用率。
一、Elasticsearch 分片路由概述
1.1 为什么需要路由(Routing)
在默认情况下,Elasticsearch 会将每个索引拆分成若干个主分片(primary shard)和相应的副本分片(replica shard),并自动将文档按照 _id
进行哈希计算,决定落在哪个分片上:
shard_index = hash(_id) % number_of_primary_shards
同理,当你执行一次全局搜索(不带 routing),Elasticsearch 会将请求广播到该索引所有主分片或者所在节点的全部副本中,然后在各分片上并行执行过滤并归并结果。
缺点:
- 对于大数据量索引,全量广播搜索会触及大量分片,产生较多的网络通信与 IO 压力,导致延迟、吞吐不佳。
- 如果某些文档天然存在“分组”或“业务域”概念(比如“用户 ID”、“公司 ID” 等),我们其实只需要在对应分组分片上查询,而不需要触达整个集群。
自定义路由(custom routing)正是为了解决“只查目标分片,跳过无关分片”的场景:
- 索引文档时,指定一个 routing 值(如
userID
、tenantID
),使它与_id
一起共同参与分片定位。 - 查询该文档或该分组的所有文档时,将相同的 routing 值传入查询,Elasticsearch 就只会将请求发送到对应的那一个(或多个)分片,而无需全量广播。
1.2 路由对分片定位的影响
默认 Behavior(无 routing)
索引时:
PUT my_index/_doc/“doc1” { "name": "Alice" }
Elasticsearch 会根据内部哈希(仅
_id
)将“doc1”
定位到某个主分片,比如 Shard 2。查询时:
GET my_index/_search { "query": { "match_all": {} } }
系统会将这一请求广播到所有主分片(若主分片挂掉则广播到可用副本),各分片各自执行查询并汇总结果。
指定 routing
在索引时,显式指定
routing
:PUT my_index/_doc/doc1?routing=user_123 { "name": "Alice", "user_id": "user_123" }
这时 Elasticsearch 会根据
hash("doc1")
与routing="user_123"
混合哈希定位:shard_index = hash("user_123") % number_of_primary_shards
假设结果落在 Shard 0,那么该文档就存储在 Shard 0(以及其副本)之中。
在查询时,若你只想查
user_123
下的所有文档:GET my_index/_search?routing=user_123 { "query": { "term": { "user_id": "user_123" } } }
Elasticsearch 会只将该查询请求发送到 Shard 0,避免访问其他 Shard,从而减少无谓的网络和 IO 开销,提升查询速度。
二、自定义路由的原理与流程
2.1 路由值与分片计算公式
Elasticsearch 内部将 routing 值先进行 MurmurHash3 哈希,再对主分片数量取模,以计算目标主分片编号:
target_shard = murmur3_hash(routing_value) & 0x7fffffff) % number_of_primary_shards
如果不显式指定
routing
,则默认为_id
的哈希:target_shard = murmur3_hash(_id) % number_of_primary_shards
- 如果同时指定
routing
和_id
,则以 routing 为准;l即哈希仅基于 routing,将完全忽略_id
对分片的影响。
示例: 假设一个索引有 5 个主分片(shard 0\~4)。
用户
user_123
索引文档doc1
:routing_value = "user_123" murmur3("user_123") % 5 = 2
所以
doc1
被存到主分片 2(以及其副本)。用户
user_456
索引文档doc2
:murmur3("user_456") % 5 = 4
所以
doc2
被存到主分片 4(以及其副本)。

图示说明:
- 每一个 routing 值经过 MurmurHash3 算法后生成一个 32 位整数。
- 取低 31 位(去 sign bit 后)再对主分片数取模。
- 得到的余数就是目标主分片编号。
2.2 索引与查询流程
以下是具体的索引与查询流程示意:
┌────────────────────┐
│ 用户发起索引请求 │
│ PUT /my_idx/_doc/1?routing=user_123 │
│ { “name”: “Alice” }│
└────────────────────┘
│
▼
┌───────────────────────────────────────────────────┐
│ 1. ES 计算 routing_hash = murmur3(“user_123”) % 5 │
│ → target_shard = 2 │
└───────────────────────────────────────────────────┘
│
▼
┌────────────────────────┐
│ 2. 将文档写入主分片 2 │
│ (并复制到其 副本分片)│
└────────────────────────┘
│
▼
┌────────────────────────┐
│ 3. 返回索引成功响应 │
└────────────────────────┘
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
┌────────────────────┐ ┌───────────────────────┐
│ 用户发起查询 │ │ ES 路由节点 │
│ GET /my_idx/_search?routing=user_123 │ │ │
│ { "query": { "term": { "user_id": "user_123" } } } │
└────────────────────┘ └───────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ 1. ES 计算 routing_hash = murmur3("user_123") % 5 = 2 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 2. 只将查询请求发送到主分片 2 及其可用副本 │
│ (跳过分片 0、1、3、4) │
└─────────────────────────────────────────────┘
│
▼
┌──────────────────────┐ ┌──────────────────────┐
│ 3. 分片 2 处理 查询 │◀──────▶│ 3. Composer 节点(协调) │
└──────────────────────┘ └──────────────────────┘
│
▼
┌──────────────────────┐
│ 4. 聚合搜索结果并返回 │
└──────────────────────┘
三、自定义路由配置方法
3.1 针对某个索引开启 Routing 约束
在创建索引时,可指定 routing.required
(仅对删除或更新操作影响)和 routing_path
(动态映射字段到 routing)等参数:
PUT /my_index
{
"settings": {
"index.number_of_shards": 5,
"index.number_of_replicas": 1
},
"mappings": {
"properties": {
"user_id": {
"type": "keyword"
},
"message": {
"type": "text"
}
},
"routing": {
"required": false, ← 默认为 false,表示更新/删除时可以不带 routing
"path": "user_id" ← 如果更新/删除时不传 routing,则默认使用文档的 user_id 作为 routing
}
}
}
routing.required: false
: 当执行DELETE
或UPDATE
时,如果不显示传入 routing,Elasticsearch 会尝试从文档字段user_id
中读取 routing。routing.path: "user_id"
: 指定映射层次中哪个字段作为 routing 值。若不指定,删除/更新时就必须显式传入 routing。
3.2 索引文档时指定 routing
如果索引时未指定 routing.path
,则必须在请求 URL 上手动传入 routing。
# 有 routing.path 时(自动从 user_id 获取 routing)
PUT /my_index/_doc/1
{
"user_id": "user_123",
"message": "Hello, Elasticsearch!"
}
# 没有 routing.path 时(或者想覆盖默认 routing)
PUT /my_index/_doc/2?routing=user_456
{
"user_id": "user_456",
"message": "Another message"
}
备注:
- 如果同时指定 URL 上的
?routing=
参数 与文档中的user_id
字段,则以 URL 参数为准,二者不一致时以显式 routing 值生效。- 若 mapping 中已声明
routing.path
,在删除、更新或取回某个文档时可以省略?routing=
,ES 将自动从源文档获取。
3.3 查询时指定 routing
执行搜索或 GET _doc
时,如果想只访问特定分片,应在 URL 中传入 routing
参数:
GET /my_index/_search?routing=user_123
{
"query": {
"match": {
"message": "Hello"
}
}
}
如果你忘记传入 routing,ES 会做全量广播,自己去所有分片比对——失去了 routing 的性能优势。
四、路由优化的典型应用场景
4.1 多租户(Multi-tenant)场景
假设你在一套 Elasticsearch 集群上为多个租户存储日志或指标数据。每个租户的数据量可能会非常大,但不同租户之间几乎没有交集。此时如果采用默认分片策略,每次查询都会穿透所有分片,且不同租户的数据完全混合在同一个索引中,难以做热/冷数据分离。
解决方案:
- 在索引映射中声明
routing.path: "tenant_id"
,或者每次索引时传入?routing=tenantA
、?routing=tenantB
。 - 查询时
GET /logs/_search?routing=tenantA
,仅查询租户 A 的数据所落分片,大幅减少 IO 开销。
PUT /logs
{
"settings": {
"index.number_of_shards": 5,
"index.number_of_replicas": 1
},
"mappings": {
"properties": {
"tenant_id": { "type": "keyword" },
"timestamp": { "type": "date" },
"level": { "type": "keyword" },
"message": { "type": "text" }
},
"routing": {
"required": false,
"path": "tenant_id"
}
}
}
# 租户 A 索引一条日志
PUT /logs/_doc/1001
{
"tenant_id": "tenantA",
"timestamp": "2025-05-28T10:00:00Z",
"level": "info",
"message": "User logged in"
}
# 查询租户 A 的所有 ERROR 日志
GET /logs/_search?routing=tenantA
{
"query": {
"bool": {
"must": [
{ "term": { "tenant_id": "tenantA" } },
{ "term": { "level": "error" } }
]
}
}
}
效果对比:
- 默认:查询会打到 5 个主分片;
- 自定义 routing (
tenantA
):只打到 1 个主分片(即 MurmurHash("tenantA") % 5 所映射的分片),理论上可提升约 5 倍的查询速度,同时减少 CPU 与网络带宽消耗。
┌──────────────────────────┐
│ 集群共 5 个主分片 │
│ shard0, shard1, shard2, │
│ shard3, shard4 │
└──────────────────────────┘
│
│ GET /logs/_search?routing=tenantA
▼
┌───────────────────────────────────────────┐
│目标分片计算: │
│ shard_index = murmur3("tenantA") % 5 = 3 │
└───────────────────────────────────────────┘
│
▼
┌───────────────────────────┐
│ 只查询 shard 3(主分片 + 副本) │
└───────────────────────────┘
4.2 某些业务需要热点数据隔离(Hot Data Separation)
如果某个字段(如 customer_id
)查询量极高,希望将该类“热点”用户的数据尽可能聚集到同一个或少数几个分片,以减少分片间的交叉查询压力。
思路:
- 将所有“VIP”或“活跃高”的
customer_id
分配到一组固定的 routing 值范围内,比如vip_1~vip_10
对应 shard0,vip_11~vip_20
对应 shard1。 - 在查询时,mall 这些 “VIP” 用户时传递相应 routing,确保只访问热点分片,不干扰其他分片的 IO。
这种方式需要在业务层维护一个 customer_id → routing_value
的映射表,并在索引和查询时沿用同样的 routing 逻辑。
五、细粒度路由策略与多字段联合路由
有时候业务需求下,需要使用多个字段联合决定路由值,比如 company_id
+ department_id
,以实现更细粒度的分片定位。
5.1 组合 routing 值
最常见的方法是将多个字段拼接在一起,形成一个复合 routing:
# 在索引时
PUT /dept_index/_doc/10?routing=companyA_departmentX
{
"company_id": "companyA",
"department_id": "departmentX",
"content": "Department data..."
}
# 查询时同样要传入相同路由
GET /dept_index/_search?routing=companyA_departmentX
{
"query": {
"bool": {
"must": [
{ "term": { "company_id": "companyA" } },
{ "term": { "department_id": "departmentX" } }
]
}
}
}
注意:
routing
值越复杂,MurmurHash3 计算开销也略高,但相对比全局搜索节省 IO 依旧收益巨大。- 保证索引与查询时使用完全一致的 routing 值,否则将无法定位到对应分片,导致查询不到结果。
5.2 动态计算 routing(脚本或客户端逻辑)
如果你不想在每次请求时手动拼接 routing,也可以在客户端或中间层封装一个路由计算函数。例如基于 Java Rest High Level Client 的示例:
// 伪代码:根据 company_id 和 department_id 生成 routing
public String computeRouting(String companyId, String departmentId) {
return companyId + "_" + departmentId;
}
// 索引时
IndexRequest req = new IndexRequest("dept_index")
.id("10")
.routing(computeRouting("companyA", "departmentX"))
.source("company_id", "companyA",
"department_id", "departmentX",
"content", "Department data...");
client.index(req, RequestOptions.DEFAULT);
// 查询时
SearchRequest searchReq = new SearchRequest("dept_index");
searchReq.routing(computeRouting("companyA", "departmentX"));
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
.query(QueryBuilders.boolQuery()
.must(QueryBuilders.termQuery("company_id", "companyA"))
.must(QueryBuilders.termQuery("department_id", "departmentX")));
searchReq.source(sourceBuilder);
SearchResponse resp = client.search(searchReq, RequestOptions.DEFAULT);
这样封装后,业务层只需关注传入 company_id
与 department_id
,底层自动计算 routing 值,确保查/写时一致。
六、路由与索引别名(Index Alias)的联合使用
为了让 routing 操作更加灵活与透明,常见做法是:
- 用索引别名(alias)维护业务级索引名称,例如
logs_current
→logs-2025.05
。 - 在别名配置时,指定该别名同时携带一个默认的
is_write_index
。 - 业务只针对别名做读写,底层索引的路由、分片数量可随时变更(无感知)。
# 创建索引 logs-2025.05
PUT /logs-2025.05
{
"settings": {
"index.number_of_shards": 5,
"index.number_of_replicas": 1
},
"mappings": {
"properties": {
"tenant_id": { "type": "keyword" },
"message": { "type": "text" }
},
"routing": {
"required": false,
"path": "tenant_id"
}
}
}
# 创建别名 logs_current,指向 logs-2025.05,并设置为写别名
POST /_aliases
{
"actions": [
{
"add": {
"index": "logs-2025.05",
"alias": "logs_current",
"is_write_index": true
}
}
]
}
# 业务通过别名操作(写入时可省略 routing 参数,自动通过 mapping获得 routing)
PUT /logs_current/_doc/2001
{
"tenant_id": "tenantB",
"message": "Some log message..."
}
# 查询租户 B 日志
GET /logs_current/_search?routing=tenantB
{
"query": { "term": { "tenant_id": "tenantB" } }
}
好处:
- 后续如果需要拆分或滚动索引(例如把 2025.05 数据切换到
logs-2025.06
),只需更新别名指向。- 业务层无需改动索引名称,路由逻辑依然沿用
tenant_id
。
七、路由优化与性能测试
7.1 比较全量搜 vs 路由搜
以一个包含 1 亿条日志数据的索引(5 个主分片)为例,通过测试观察搜索速度与资源消耗:
全量广播搜索:
GET /logs/_search { "query": { "term": { "tenant_id": "tenantA" } } }
- 每个主分片都需扫描各自的 inverted index,计算并返回符合
tenant_id="tenantA"
的结果,再由协调节点合并。 - 假设每个分片约 20 GB,需耗费 5×20 GB 的磁盘 I/O 才能完成过滤。
- 每个主分片都需扫描各自的 inverted index,计算并返回符合
基于 routing 的搜索:
GET /logs/_search?routing=tenantA { "query": { "term": { "tenant_id": "tenantA" } } }
- 只会访问某一个分片(约 20 GB 中真正包含 tenantA 数据的那部分),I/O 仅为该分片内对应文档集,速度可提升约 5 倍(理想情况)。
- CPU 消耗与网络通信量也明显下降。
7.2 Benchmark 测试示例
下面提供一个简单的 Python 脚本,演示如何通过 locust
或 elasticsearch-py
对比两种方式下的搜索响应时间(伪代码,仅供思路参考):
from elasticsearch import Elasticsearch
import time
es = Elasticsearch(["http://localhost:9200"])
def search_full_broadcast():
start = time.time()
es.search(index="logs", body={
"query": { "term": { "tenant_id": "tenantA" } }
})
return time.time() - start
def search_with_routing():
start = time.time()
es.search(index="logs", routing="tenantA", body={
"query": { "term": { "tenant_id": "tenantA" } }
})
return time.time() - start
# 多次测试并打印平均响应时间
N = 50
full_times = [search_full_broadcast() for _ in range(N)]
routing_times = [search_with_routing() for _ in range(N)]
print(f"Broadcast avg time: {sum(full_times)/N:.3f} s")
print(f"Routing avg time: {sum(routing_times)/N:.3f} s")
预期效果:
Broadcast avg time
可能在几百毫秒到上秒不等(取决于硬件与数据量)。Routing avg time
理想情况下能缩小到原来的 1/分片数 左右。例如分片数为 5,则理论提升到 1/5 左右。
八、自定义路由的注意事项与最佳实践
8.1 路由值分布要均匀
- 如果所有文档的 routing 值都落在有限的少数几个值,例如只有 3 个 routing 值,但主分片数是 10,这 3 个分片就会被过度“打热点”,其他分片几乎空闲,导致负载不均衡。
- 最佳实践: 根据业务特征,选择具有高基数且分布均匀的字段作为 routing 值。例如
user_id
、tenant_id
、session_id
等。
8.2 避免路由 key 频繁变更
- 如果业务层逻辑中经常动态修改 routing 值(例如用户归属发生变动),则更新时可能先发 DELETE,再发 INDEX,导致额外 I/O。
- 建议: 尽量将 routing 值设计为不需频繁变更的字段(如乐观的“部门 ID”、“公司 ID”),若业务确实需要“迁移”,则要做好批量 reindex 或别名切换等操作。
8.3 确保索引设置与查询保持一致
- 假设在索引时某些文档使用
routing=A
,但后续查询忘记带routing=A
,此时将打到所有分片,性能无法提升。 - 推荐在客户端封装统一的路由逻辑,确保索引与查询两端的 routing 方法一致。
8.4 注意跨索引聚合场景
- 如果你在一条查询中需要同时跨多个索引并汇总结果,而这些索引可能用不同的 routing 逻辑,Elasticsearch 无法向多个 routing 路径发送请求。
- 对于跨索引聚合,若需要 routing,建议分两次查询并在客户端合并。
8.5 与别名/插入模板结合
通过索引模板动态给新索引配置
mapping.routing.path
。例如:PUT /_template/logs_template { "index_patterns": [ "logs-*" ], "order": 1, "settings": { "number_of_shards": 5, "number_of_replicas": 1 }, "mappings": { "properties": { "tenant_id": { "type": "keyword" } }, "routing": { "required": false, "path": "tenant_id" } } }
- 新创建的索引会自动应用该模板,无需每次手工指定。
九、常见问题排查
更新/删除时提示
routing is required
- 原因:如果索引 mapping 中未设置
routing.path
或routing.required: false
,则 update/delete 需要显式传入 routing。 解决:
- 在 URL 上带
?routing=xxx
; - 或在 mapping 中声明
routing.path
,让系统自动获取。
- 在 URL 上带
- 原因:如果索引 mapping 中未设置
路由后仍然访问了非目标分片
原因:
- 可能是 mapping 中未声明
routing.path
,却在查询时仅传入routing
而查询字段不基于 routing; - 或者 query\_body 中缺少对 routing 字段的过滤,导致子查询还是需要全量分片做归并;
- 可能是 mapping 中未声明
解决:
- 确保查询条件中包含
{"term": {"tenant_id": "xxx"}}
,和 URL 上的routing=xxx
保持一致。 - 如果只是想 fetch 某个 id 的文档,可使用
GET /my_index/_doc/1?routing=xxx
。
- 确保查询条件中包含
分片热点严重,负载不均衡
排查:
curl -X GET "http://<HOST>:9200/_cat/allocation?v&pretty"
查看每个节点的
shards
、disk.indices
、disk.percent
等指标。解决:
- 检查 routing 值是否过于集中,改为高基数值;
- 增加主分片数目或扩容节点数量;
- 考虑将热点数据分到一个独立索引,并做冷热分离。
修改路由后文档不再能查询到
- 场景:业务中把文档从
routing=a
改成routing=b
,但旧 routing 值仍存在,但新查询时忘记传入新 routing。 解决:
- 必须使用新 routing 值,或者先将文档 reindex 到新 index 中。
- 建议对文档的 routing 字段做一次批量更新流程,保证索引与查询保持一致。
- 场景:业务中把文档从
十、总结
通过本文,我们深入讲解了 Elasticsearch 的自定义路由机制与搜索优化的思路,核心要点包括:
路由原理:
- 路由值通过 MurmurHash3 算法对主分片数取模,实现将同一 routing 值的文档映射到同一分片。
- 查询时传入 routing,可避免全量广播,只访问目标分片。
配置方法:
- 在
mappings.routing.path
中声明字段(如tenant_id
)自动作为 routing 值,或在索引、查询 URL 上显式传入?routing=
。 - 在别名(alias)与索引模板中统一定义 routing,降低运维成本。
- 在
典型场景:
- 多租户场景:用
tenant_id
进行路由,大幅减少 IO 与 CPU 消耗。 - 热点数据隔离:将高频访问用户或业务分配在固定分片,避免其他分片受到影响。
- 细粒度路由:使用复合 routing 值(如
company_id_department_id
)实现更精确的分片定位。
- 多租户场景:用
性能收益:
- 路由查询在理想情况下可将 IO 降低到 “1 / 主分片数” 量级。
- 降低网络带宽占用、CPU 计算量与 GC 压力。
最佳实践与注意事项:
- 保证 routing 值分布均匀,避免单点热点。
- 索引与查询时使用同一 routing 计算逻辑。
- 谨慎调整 routing 字段,避免频繁变更导致额外索引/删除。
- 在路由值与分片数不匹配时,可考虑增加主分片数量或扩容集群。
掌握自定义路由后,你能够在海量文档与高并发查询场景下,通过只访问目标分片,实现精准查询与资源节省。如果后续需要进一步提升聚合性能,还可以结合 join
、nested
、composite aggregation
等特性,并配合路由将分布式聚合的压力最小化。希望这篇详解能帮助你在实际项目中通过灵活的 routing 策略,显著提升 Elasticsearch 搜索性能与集群稳定性。
评论已关闭