Elasticsearch 不只是一个搜索引擎。围绕它的分片、副本、别名、ILM 等原语,生产环境中沉淀出了一批反复出现的架构模式。这些模式解决的问题各不相同——有的管数据生命周期,有的管多租户隔离,有的管读写分离——但核心思路一致:用 ES 的原语组合出业务需要的数据分布和访问路径。

下面整理十个常见的 ES 架构模式。“时间分区索引 + 别名滚动”——每月一个物理索引、基于别名查询、定期淘汰老索引——是日志和时序场景的基石,配置也最精细,放在第一个。

模式速查

模式 解决的问题 核心原语 典型场景
时间分区索引 + 别名滚动 数据无限增长的生命周期管理 Index Template + Alias + ILM/Rollover 日志、订单流水、审计
冷热分层存储 不同时效数据的成本优化 Node Attribute + Allocation Filter + ILM 日志、监控指标
读写分离 写入不影响搜索延迟 写别名 + 读别名 + Replica 调度 高写入吞吐 + 低延迟搜索
多租户索引隔离 租户间数据和性能隔离 Index-per-tenant / Routing / Filtered Alias SaaS 平台
Reindex 在线迁移 Mapping 变更、分片数调整 Reindex API + Alias 切换 Schema 演进
父子与嵌套建模 关联数据的搜索 Nested / Join field 商品-SKU、文章-评论
跨集群检索 多集群联合查询 CCS (Cross-Cluster Search) 多机房、多业务线
搜索模板 + 应用层网关 查询标准化与权限控制 Search Template + API 网关 开放搜索平台
快照与灾备 数据备份与跨集群恢复 Snapshot/Restore + SLM 容灾、数据归档
CQRS 异构索引 不同查询视角用不同索引 多 Index + 事件驱动同步 商品搜索 + 详情页 + 推荐

模式一:时间分区索引 + 别名滚动

这是 ES 生产环境中最常见也最精细的架构模式。核心思路:按时间周期(天/周/月)创建物理索引,应用层始终通过一个逻辑别名读写,老索引按保留策略定期淘汰。

问题

业务数据持续增长,单个索引不可能无限膨胀。分片数在创建时固定,单索引写满后无法水平扩展。历史数据需要定期清理,但删除文档只打标记不释放空间——删除整个索引才能真正回收。

典型场景:

  • 应用日志:每天写入几十 GB,保留 90 天。
  • 订单流水:每月写入量稳定,保留 3 年,3 年前的数据彻底删除。
  • 审计记录:监管要求保留 7 年,超期销毁。

设计

整体架构由四个组件协作:

1
2
3
4
5
6
7
Index Template ─── 定义新索引的 mapping + settings + ILM 绑定

├── ILM Policy ──── 定义 rollover 条件 + 生命周期阶段

├── Write Alias ─── 应用写入的逻辑入口,始终指向最新索引

└── Read Alias ──── 应用查询的逻辑入口,指向所有活跃索引

时间分区索引的完整生命周期:

flowchart LR
    subgraph 应用层
        W["写入请求"] --> WA["write alias<br/>logs-write"]
        R["查询请求"] --> RA["read alias<br/>logs"]
    end

    subgraph 物理索引
        I1["logs-2026.04<br/>(已关闭写入)"]
        I2["logs-2026.05<br/>(已关闭写入)"]
        I3["logs-2026.06<br/>(当前写入)"]
    end

    WA -->|is_write_index=true| I3
    RA --> I1
    RA --> I2
    RA --> I3

    subgraph 淘汰
        D["logs-2026.01<br/>DELETE"]
    end

    I1 -.->|min_age 到期| D

完整配置

第一步:创建 ILM Policy

这个 policy 定义了索引从创建到删除的完整生命周期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
PUT _ilm/policy/logs-lifecycle
{
"policy": {
"phases": {
"hot": {
"min_age": "0ms",
"actions": {
"rollover": {
"max_primary_shard_size": "50gb",
"max_age": "30d"
},
"set_priority": { "priority": 100 }
}
},
"warm": {
"min_age": "30d",
"actions": {
"forcemerge": { "max_num_segments": 1 },
"shrink": { "number_of_shards": 1 },
"allocate": { "require": { "data": "warm" } },
"set_priority": { "priority": 50 }
}
},
"cold": {
"min_age": "90d",
"actions": {
"allocate": { "require": { "data": "cold" } },
"set_priority": { "priority": 0 }
}
},
"delete": {
"min_age": "1095d",
"actions": { "delete": {} }
}
}
}
}

max_age: 30d 表示每个物理索引最多服务 30 天的写入。delete.min_age: 1095d(3 年)是从索引创建时算起的保留期。

第二步:创建 Index Template

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PUT _index_template/logs-template
{
"index_patterns": ["logs-*"],
"template": {
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"index.lifecycle.name": "logs-lifecycle",
"index.lifecycle.rollover_alias": "logs-write"
},
"mappings": {
"properties": {
"@timestamp": { "type": "date" },
"message": { "type": "text" },
"level": { "type": "keyword" },
"service": { "type": "keyword" }
}
},
"aliases": {
"logs": {}
}
}
}

Template 做了三件事:新索引自动继承 mapping、settings 和 ILM 绑定;新索引自动加入 logs 读别名;rollover_alias 告诉 ILM 哪个别名需要滚动。

第三步:引导第一个索引

1
2
3
4
5
6
7
PUT logs-000001
{
"aliases": {
"logs-write": { "is_write_index": true },
"logs": {}
}
}

is_write_index: true 标记当前索引为写入目标。后续 rollover 时,ILM 会自动创建 logs-000002,把 is_write_index 从旧索引摘掉、挂到新索引上。

第四步:应用层使用别名

1
2
3
4
5
6
7
# 写入——始终用 write alias
POST /logs-write/_doc
{ "@timestamp": "2026-06-28T10:00:00Z", "message": "user login", "level": "INFO", "service": "auth" }

# 查询——用 read alias,自动搜索所有活跃索引
GET /logs/_search
{ "query": { "range": { "@timestamp": { "gte": "now-7d" } } } }

应用层不需要知道物理索引名。写入走 logs-write,读取走 logs。物理索引的创建、切换、淘汰全部由 ILM 自动完成。

月度分区的变体

"每月一个物理索引"模式可以通过两种方式实现。

方式一:ILM rollover + max_age: 30d

上面的配置就是这种方式。ILM 每 30 天(或 shard 大小到 50GB)自动 rollover,命名为 logs-000001logs-000002……优点是全自动,缺点是索引名是递增序号,不直观。

方式二:外部调度 + 显式命名

每月由运维脚本或定时任务创建新索引,显式用 logs-2026.06 这样的命名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 每月 1 号执行
PUT logs-2026.07
{
"aliases": {
"logs-write": { "is_write_index": true },
"logs": {}
}
}

# 把上个月的索引摘掉 write alias
POST /_aliases
{
"actions": [
{ "remove": { "index": "logs-2026.06", "alias": "logs-write", "is_write_index": true } },
{ "add": { "index": "logs-2026.07", "alias": "logs-write", "is_write_index": true } }
]
}

_aliases API 的多个 action 是原子的。切换瞬间不会有请求写到错误的索引上。

淘汰老索引:

1
2
3
4
5
6
# 每月 1 号执行:删除 3 年前的索引
DELETE logs-2023.07

# 或者用 curator / 自定义脚本批量清理
curator_cli --config config.yml delete_indices \
--filter_list '[{"filtertype":"age","source":"name","timestring":"%Y.%m","unit":"months","unit_count":36,"direction":"older"}]'

删除索引是原子操作,瞬间回收所有磁盘空间。这是时间分区优于"在单索引中删除文档"的根本原因。

这个模式为什么有效

特性 单大索引 时间分区索引
磁盘回收 删除文档只打标记,merge 后才回收 删除整个索引,瞬间回收
分片规划 分片数固定,无法随数据增长调整 每个新索引可以独立设定分片数
Mapping 演进 修改已有字段困难 新索引可以用新的 mapping
搜索范围 全量搜索 按时间范围只查必要的索引
运维操作 force merge 影响写入 旧索引关闭写入后安全 force merge
保留策略 需要逐条删除 删除索引等于删除一个时间段的全部数据

取舍

时间分区索引适合数据有天然时间属性、查询通常带时间范围过滤的场景。不适合的情况:

  • 数据没有时间属性(如商品目录、用户画像)。
  • 查询经常跨全部历史数据且不带时间范围。
  • 总数据量很小,单索引就能容纳。

模式提炼:不可变分区 + 逻辑别名 + 定期淘汰

1
物理分区按时间生成 → 别名提供统一入口 → 旧分区整体删除
系统 物理分区 逻辑入口 淘汰方式
ES 时间分区 logs-2026.06 Alias DELETE index
Kafka Topic partition + retention Topic name Log retention
MySQL 分区表 PARTITION BY RANGE 表名 DROP PARTITION
Hive dt= 分区目录 表名 ALTER TABLE DROP PARTITION

模式二:冷热分层存储

问题

日志、监控等时序数据有明显的访问热度衰减:最近几天的数据频繁搜索,几个月前的数据偶尔回溯,更老的数据可能一年才查一次。把所有数据放在 SSD 上成本过高。

设计

给节点打标签区分存储层,用 ILM 自动把索引从 hot 节点迁移到 warm/cold 节点。

1
2
3
4
5
6
7
8
# elasticsearch.yml — hot 节点
node.attr.data: hot

# elasticsearch.yml — warm 节点
node.attr.data: warm

# elasticsearch.yml — cold 节点
node.attr.data: cold
flowchart LR
    subgraph Hot 节点 SSD
        H1["logs-2026.06<br/>活跃写入+搜索"]
    end

    subgraph Warm 节点 HDD
        W1["logs-2026.04<br/>只读搜索"]
        W2["logs-2026.05<br/>只读搜索"]
    end

    subgraph Cold 节点 大容量HDD
        C1["logs-2025.*<br/>低频搜索"]
    end

    H1 -->|30d 后 ILM 迁移| W1
    W1 -->|90d 后 ILM 迁移| C1

ILM policy 中的 allocate action 控制迁移:

1
2
3
4
5
6
7
"warm": {
"min_age": "30d",
"actions": {
"allocate": { "require": { "data": "warm" } },
"forcemerge": { "max_num_segments": 1 }
}
}

索引迁移到 warm 后不再写入,可以安全做 force merge 减少 segment 数。

取舍

优势 代价
存储成本按数据热度分配 需要运维多种节点类型
热数据搜索不受冷数据拖累 节点间数据迁移消耗网络带宽
与 ILM 天然配合 冷节点搜索延迟可能较高

冷热分层几乎总是和模式一(时间分区)配合使用。它们的关系:时间分区管理索引的创建和删除,冷热分层管理索引在不同存储层之间的迁移。

模式三:读写分离

问题

高吞吐写入场景下,写入和搜索竞争同一组节点的资源。写入引发大量 refresh 和 merge,拖慢搜索响应。搜索的耗时直接影响用户体验。

设计

通过节点角色分离和别名路由,让写入请求和搜索请求落到不同节点上。

flowchart LR
    App["应用"] -->|写入| WA["write alias"]
    App -->|查询| RA["read alias"]

    WA --> Ingest["Ingest 节点<br/>数据预处理"]
    Ingest --> Data["Data 节点<br/>Primary Shard"]
    Data -->|副本同步| Search["Search 节点<br/>Replica Shard"]
    RA --> Search

关键配置:

1
2
3
4
5
6
7
# 搜索专用节点
node.roles: [data_content, search]
node.attr.role: search

# 写入专用节点
node.roles: [data_content, ingest]
node.attr.role: ingest

搜索请求通过 preference 参数路由到 replica shard 所在的搜索节点:

1
2
GET /logs/_search?preference=_only_nodes:search_node_1,search_node_2
{ "query": { "match": { "message": "error" } } }

也可以通过 coordinating-only 节点(node.roles: [])作为搜索入口,把请求分发到持有 replica 的搜索节点。

取舍

读写分离增加了节点数和运维复杂度。数据量不大、写入吞吐不高时,混合节点更简单。读写分离适合写入量级达到数千 TPS、搜索延迟 SLA 要求在百毫秒级的场景。

模式四:多租户索引隔离

问题

SaaS 平台为多个租户提供搜索能力,需要在数据隔离、性能隔离和运维成本之间找平衡。

设计

三种策略,从强隔离到弱隔离:

flowchart TB
    subgraph 方案A:Index per Tenant
        T1A["tenant-a-products"]
        T2A["tenant-b-products"]
        T3A["tenant-c-products"]
    end

    subgraph 方案B:Routing 分区
        Shared["products<br/>routing=tenant_id"]
        S1["Shard 0: tenant-a"]
        S2["Shard 1: tenant-b"]
        S3["Shard 2: tenant-c"]
        Shared --> S1
        Shared --> S2
        Shared --> S3
    end

    subgraph 方案C:Filtered Alias
        Base["products-shared"]
        FA["alias: tenant-a-view<br/>filter: tenant_id=a"]
        FB["alias: tenant-b-view<br/>filter: tenant_id=b"]
        Base --> FA
        Base --> FB
    end
策略 数据隔离 性能隔离 运维成本 适合场景
Index per tenant 高(索引数 = 租户数) 租户数少、数据量差异大
Routing 分区 租户数多、数据量均匀
Filtered alias 租户数多、隔离要求低

Index per tenant 配置:

1
2
3
4
5
6
# 创建租户专属索引
PUT /tenant-acme-products
{
"settings": { "number_of_shards": 2 },
"mappings": { "properties": { "name": { "type": "text" }, "price": { "type": "float" } } }
}

Routing 分区配置:

1
2
3
4
5
PUT /products/_doc/1?routing=tenant-acme
{ "tenant_id": "tenant-acme", "name": "laptop", "price": 999 }

GET /products/_search?routing=tenant-acme
{ "query": { "bool": { "filter": { "term": { "tenant_id": "tenant-acme" } } } } }

Filtered alias 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /_aliases
{
"actions": [
{
"add": {
"index": "products-shared",
"alias": "tenant-acme-view",
"filter": { "term": { "tenant_id": "tenant-acme" } }
}
}
]
}

# 租户通过自己的 alias 查询,自动过滤
GET /tenant-acme-view/_search
{ "query": { "match": { "name": "laptop" } } }

取舍

Index-per-tenant 在租户数超过几百后,集群的 shard 总数、cluster state 大小和 master 节点压力会成为瓶颈。Routing 分区在大租户和小租户混合时容易出现数据倾斜。Filtered alias 最轻量,但无法阻止"吵闹邻居"——一个租户的大查询影响同索引其他租户。

生产环境常见的折中是分级混合:大租户用独立索引,中小租户共享索引 + routing 分区。

模式五:Reindex 在线迁移

问题

Mapping 需要修改已有字段类型(如把 text 改成 keyword),或者分片数需要调整。ES 不允许修改已有字段的类型,也不允许修改已有索引的分片数。

设计

创建新索引(新 mapping/新分片数)→ reindex 数据 → 别名切换。整个过程对应用层透明。

sequenceDiagram
    participant App
    participant Alias as products alias
    participant Old as products-v1
    participant New as products-v2

    App->>Alias: 正常读写
    Alias->>Old: 路由到 v1

    Note over New: 创建 products-v2(新 mapping)
    Note over Old,New: POST _reindex 从 v1 到 v2

    Note over Alias: 原子切换别名
    App->>Alias: 正常读写
    Alias->>New: 路由到 v2

    Note over Old: 确认无流量后删除 v1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 1. 创建新索引
PUT /products-v2
{
"settings": { "number_of_shards": 5 },
"mappings": {
"properties": {
"name": { "type": "text" },
"category": { "type": "keyword" },
"price": { "type": "scaled_float", "scaling_factor": 100 }
}
}
}

# 2. Reindex
POST /_reindex?wait_for_completion=false
{
"source": { "index": "products-v1" },
"dest": { "index": "products-v2" }
}

# 3. 原子切换别名
POST /_aliases
{
"actions": [
{ "remove": { "index": "products-v1", "alias": "products" } },
{ "add": { "index": "products-v2", "alias": "products" } }
]
}

# 4. 确认后删除旧索引
DELETE /products-v1

大索引 reindex 时间可能很长。wait_for_completion=false 让 reindex 在后台执行,返回 task ID。用 GET _tasks/{task_id} 监控进度。

增量同步的处理

reindex 期间如果源索引仍有写入,目标索引会缺少增量数据。两种处理方式:

  • 双写:reindex 开始后,应用同时写入新旧索引。切换别名后停止写旧索引。
  • 时间戳追赶:reindex 完成后,按时间戳范围再跑一次增量 reindex,追上差量。

取舍

Reindex 消耗集群资源(CPU、I/O、网络)。数据量大时可能需要数小时。应在低峰期执行,或通过 requests_per_second 参数限流。

模式六:父子与嵌套建模

问题

关联数据的搜索。商品和 SKU、文章和评论、订单和商品行——这些一对多关系在关系型数据库中用 JOIN 处理,ES 没有通用 JOIN。

设计

ES 提供两种建模方式:

方式 存储 搜索 更新 适合场景
Nested 父子文档存在同一个 Lucene 文档中 nested query,同一个 shard 内 更新子文档需要 reindex 整个父文档 子文档数量少、更新不频繁
Join field 父子文档是独立的 Lucene 文档,routing 保证同 shard has_child / has_parent query 子文档独立更新 子文档数量多、频繁更新

Nested 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
PUT /products
{
"mappings": {
"properties": {
"name": { "type": "text" },
"skus": {
"type": "nested",
"properties": {
"color": { "type": "keyword" },
"price": { "type": "float" },
"stock": { "type": "integer" }
}
}
}
}
}

# 查询:找到有红色且价格低于 100 的 SKU 的商品
GET /products/_search
{
"query": {
"nested": {
"path": "skus",
"query": {
"bool": {
"must": [
{ "term": { "skus.color": "red" } },
{ "range": { "skus.price": { "lt": 100 } } }
]
}
}
}
}
}

Join field 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
PUT /qa
{
"mappings": {
"properties": {
"relation": {
"type": "join",
"relations": { "question": "answer" }
},
"title": { "type": "text" },
"body": { "type": "text" }
}
}
}

# 写入问题
PUT /qa/_doc/q1
{ "title": "ES 怎么做 JOIN", "relation": { "name": "question" } }

# 写入回答(routing 保证同 shard)
PUT /qa/_doc/a1?routing=q1
{ "body": "用 nested 或 join field", "relation": { "name": "answer", "parent": "q1" } }

# 查询:找到有答案包含 "nested" 的问题
GET /qa/_search
{
"query": {
"has_child": {
"type": "answer",
"query": { "match": { "body": "nested" } }
}
}
}

取舍

能用宽表反范式(把子文档的关键字段冗余到父文档)就尽量用宽表。宽表搜索最快、最简单。只有在"子文档有独立的搜索维度"或"子文档更新频繁且不想 reindex 父文档"时,才考虑 nested 或 join field。

Join field 比 nested 更重——has_child/has_parent 查询需要在 shard 内做 block join,性能不如普通查询。Join 层级不要超过一层。

模式七:跨集群检索 (CCS)

问题

组织内有多个 ES 集群——不同业务线各自维护集群,或者多地域部署了独立集群。需要在一次查询中同时搜索多个集群的数据。

设计

CCS (Cross-Cluster Search) 让一个集群以远程集群的形式连接另一个集群,查询时用 cluster:index 语法跨集群搜索。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 在本地集群配置远程集群连接
PUT /_cluster/settings
{
"persistent": {
"cluster.remote.cluster_beijing": {
"seeds": ["beijing-node1:9300", "beijing-node2:9300"]
},
"cluster.remote.cluster_shanghai": {
"seeds": ["shanghai-node1:9300"]
}
}
}

# 跨集群搜索
GET /logs,cluster_beijing:logs,cluster_shanghai:logs/_search
{
"query": { "match": { "message": "error" } }
}

CCS 的执行流程和普通搜索类似——本地节点作为 coordinating node,向远程集群的 shard 发送 query-then-fetch 请求,收集结果后合并返回。

取舍

适合 CCS 不适合 CCS
多机房偶尔联合查询 高频跨集群写入
合规要求数据不出本地,但允许查询 需要跨集群 JOIN 或聚合精度要求极高
业务线独立运维,偶尔全局检索 网络延迟敏感的实时场景

CCS 的聚合结果在跨集群场景下可能有精度损失(terms 聚合的长尾问题在跨集群时更明显)。需要精确聚合时,考虑把数据同步到一个集群中做聚合。

模式八:搜索模板 + 应用层网关

问题

多个业务方接入同一个 ES 集群,每个团队自己写 DSL 查询。查询质量参差不齐——有的查询缺少 filter 导致全表扫描,有的查询嵌套层级过深拖慢整个集群。

设计

用 Search Template 把查询逻辑沉淀在 ES 侧,应用层只传参数。在此基础上加一层 API 网关做权限控制和限流。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 注册搜索模板
PUT _scripts/product-search
{
"script": {
"lang": "mustache",
"source": {
"query": {
"bool": {
"must": [
{ "match": { "name": "{{query_text}}" } }
],
"filter": [
{ "term": { "category": "{{category}}" } },
{ "range": { "price": { "gte": "{{min_price}}", "lte": "{{max_price}}" } } }
]
}
},
"from": "{{from}}{{^from}}0{{/from}}",
"size": "{{size}}{{^size}}10{{/size}}"
}
}
}

# 使用模板搜索
GET /products/_search/template
{
"id": "product-search",
"params": {
"query_text": "laptop",
"category": "electronics",
"min_price": 500,
"max_price": 2000
}
}

取舍

搜索模板降低了接入方写出危险查询的概率,但增加了模板管理的运维成本。模板变更需要版本管理和灰度验证。适合有多个接入方的内部搜索平台。

模式九:快照与灾备

问题

集群故障、误操作删除索引、跨集群迁移数据。ES 数据存在分布式 shard 中,单纯的文件备份难以保证一致性。

设计

Snapshot API 提供了增量快照能力,配合 SLM (Snapshot Lifecycle Management) 可以自动化定期备份。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 注册快照仓库(S3)
PUT /_snapshot/my-backup
{
"type": "s3",
"settings": {
"bucket": "es-snapshots",
"region": "cn-hangzhou"
}
}

# 手动创建快照
PUT /_snapshot/my-backup/snapshot-2026-06-28?wait_for_completion=false
{ "indices": "logs-*,products" }

# 从快照恢复
POST /_snapshot/my-backup/snapshot-2026-06-28/_restore
{
"indices": "products",
"rename_pattern": "(.+)",
"rename_replacement": "restored-$1"
}

SLM 自动化:

1
2
3
4
5
6
7
8
9
10
11
12
PUT /_slm/policy/nightly-backup
{
"schedule": "0 30 2 * * ?",
"name": "<nightly-{now/d}>",
"repository": "my-backup",
"config": { "indices": ["logs-*", "products"] },
"retention": {
"expire_after": "30d",
"min_count": 5,
"max_count": 50
}
}

快照是增量的——第一次全量,后续只备份新增和变更的 segment。恢复时可以重命名索引,避免和现有索引冲突。

取舍

快照恢复的速度取决于存储后端和数据量。S3 恢复大索引可能需要数小时。Searchable snapshots(ES 7.10+)可以直接从快照搜索数据而不完全恢复,适合归档数据的偶尔查询。

模式十:CQRS 异构索引

问题

同一份业务数据有多种查询视角。商品数据需要支持全文搜索(商品列表页)、精确过滤(后台管理)、个性化排序(推荐系统)。一个索引的 mapping 和分片策略很难同时满足所有查询模式。

设计

为不同查询视角建不同的索引,用事件驱动保持数据一致性。

flowchart TB
    DB[("商品数据库<br/>事实源")] --> CDC["CDC / Outbox"]

    CDC --> Consumer["事件消费者"]

    Consumer --> I1["products-search<br/>全文搜索<br/>宽表 + text 字段"]
    Consumer --> I2["products-admin<br/>管理后台<br/>keyword 字段 + 聚合优化"]
    Consumer --> I3["products-recommend<br/>推荐系统<br/>向量字段 + 用户特征"]

    App1["商品列表页"] --> I1
    App2["运营后台"] --> I2
    App3["推荐引擎"] --> I3

每个索引针对自己的查询模式优化:

索引 优化方向 Mapping 特点
products-search 全文搜索 + 分面过滤 text 字段 + keyword 字段 + 自定义分词
products-admin 精确过滤 + 聚合统计 keyword 为主 + 聚合优化
products-recommend 向量相似度 dense_vector + 用户行为特征

取舍

优势 代价
每个查询视角独立优化 数据冗余,存储成本增加
某个索引故障不影响其他视角 事件驱动同步的延迟和一致性
索引可以独立扩容和调优 需要维护多条同步链路

CQRS 索引模式和前面的时间分区、冷热分层是正交的。一个 CQRS 索引内部仍然可以按时间分区、按冷热分层。

ES 架构选型边界

和 Redis 用例全解的总结思路一致——知道不适合什么比知道适合什么更重要。

需求 ES 合适的条件 替代方案
全文搜索 核心能力 Solr、Meilisearch、Typesense
日志分析 与 ILM/时间分区天然配合 ClickHouse、Loki
时序指标 可以做,但不是最优 Prometheus、InfluxDB、TDengine
OLAP 聚合 Aggregation 够用,但大规模不如专用引擎 ClickHouse、Druid、StarRocks
事务写入 不支持 ACID 事务 RDBMS
强一致读 近实时(refresh_interval),不是实时 数据库直读
关系型 JOIN nested/join field 有限支持 RDBMS、图数据库
向量搜索 8.0+ 支持 kNN Milvus、Pinecone(专用场景)
键值查询 可以做但杀鸡用牛刀 Redis、DynamoDB

生产设计原则

ES 不是事实源

和 Redis 一样,ES 应该被视为派生存储。事实源是数据库或事件日志。ES 的数据可以从事实源重建。这个原则影响所有架构决策——备份策略、一致性要求、故障恢复路径。

别名是架构的关键抽象

别名让物理索引对应用层透明。时间分区靠别名滚动,reindex 靠别名切换,多租户靠别名过滤,读写分离靠别名路由。几乎所有模式都依赖别名。生产环境中,应用层直接使用物理索引名是需要 review 的信号。

分片数决定了索引的扩展上限

分片数在创建时固定。太少限制写入吞吐和数据容量,太多增加 cluster state 和搜索开销。官方建议每个 shard 10GB–50GB。时间分区模式的好处之一就是每个新索引可以根据当前数据量调整分片数。

Mapping 是数据模型,不是事后配置

字段类型一旦创建不可修改。text vs keywordnested vs object、是否启用 doc_values——这些选择在写入第一条数据前就需要确定。Mapping 错误通常需要 reindex 修复。

ILM 管理数据生命周期,不要手工脚本

手写 cron 脚本管理索引创建、迁移、删除容易出错,且缺少状态跟踪。ILM 提供了声明式的生命周期管理,有状态机、有进度追踪、有错误重试。能用 ILM 就用 ILM。

面试速查表

场景关键词 模式 核心配置 必讲边界
日志、时序、保留策略 时间分区 + 别名 Index Template + ILM + Alias 删索引 vs 删文档,分片数不可变
成本优化、SSD/HDD 冷热分层 node.attr + allocation filter 迁移带宽,冷节点延迟
高写入 + 低延迟搜索 读写分离 节点角色 + preference 路由 副本同步延迟
SaaS、多租户 多租户隔离 index-per-tenant / routing / filtered alias shard 总数上限,吵闹邻居
Schema 变更、分片调整 Reindex 迁移 Reindex API + 别名原子切换 增量同步,资源消耗
一对多关系 父子/嵌套 nested / join field 优先宽表,join 性能差
多集群、多机房 CCS cluster.remote 配置 聚合精度,网络延迟
查询标准化 搜索模板 Search Template + 网关 模板版本管理
备份恢复 快照 Snapshot + SLM 恢复速度,searchable snapshots
多查询视角 CQRS 异构索引 多索引 + CDC 同步 数据冗余,同步延迟

与 Redis 用例全解的对照

这篇文章和 Redis 经典用例全解 形成互补。Redis 的模式集中在"短生命周期状态、原子操作、实时计算";ES 的模式集中在"海量数据分布、生命周期管理、多视角查询"。

维度 Redis 模式 ES 模式
数据生命周期 TTL 自动过期 ILM + 时间分区 + 删索引
数据分布 Hash slot + 一致性哈希 固定分母哈希 + shard allocation
查询能力 Key 精确匹配 + ZSet 范围 全文搜索 + 聚合 + 向量
事实源角色 派生状态,不是事实源 派生存储,不是事实源
多租户 Key 前缀隔离 Index / routing / filtered alias
容灾 RDB + AOF + Sentinel Snapshot + SLM + CCS
一致性 最终一致(主从异步复制) 近实时(refresh_interval)

两个系统在架构中的共同定位:数据库保存事实,Redis 保存热路径和派生状态,ES 保存可搜索的派生索引。三者配合才是完整的数据架构。

参考资料