摘要 :Elasticsearch 基于 Lucene 构建,是分布式搜索与分析的事实标准。本文从
FST(Finite State Transducer)的倒排索引词典结构出发,深入解析refresh/flush/translog的写链路时序、query_then_fetch的分布式读流程、_routing分片路由算法,以及 Spring Boot 2.x 与RestHighLevelClient的深度集成,覆盖索引设计、 Mapping 优化、聚合分析、数据同步、集群调优等生产级完整方案。
一、引言
关系型数据库的索引(B+Tree)是为精确查询和范围扫描设计的,面对全文搜索、模糊匹配、聚合分析时性能急剧下降。Elasticsearch 的核心优势在于:
- 倒排索引:从"文档找词"变为"词找文档",全文检索复杂度从 O(n) 降至 O(1)
- 分布式原生:分片(Shard)自动路由、副本(Replica)自动同步、集群节点自动发现
- 列式存储(doc_values):聚合分析无需遍历文档,直接从排序后的列存读取
- 近实时搜索 :
refresh_interval控制可见延迟(默认 1 秒),而非关系库的即时可见
Spring Boot 2.x 通过 spring-boot-starter-data-elasticsearch 提供自动配置,底层默认使用 RestHighLevelClient 通过 HTTP 协议与 ES 集群通信。
二、核心架构:集群、索引、分片、倒排索引
2.1 整体架构图
┌─────────────────────────────────────────────────────────────────────┐
│ Elasticsearch Cluster │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 集群状态 (Cluster State) │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ Index: orders│ │ Index: users │ │ Index: products │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ Primary P0 │ │ Primary P0 │ │ Primary P0 │ │ │
│ │ │ Primary P1 │ │ Primary P1 │ │ Primary P1 │ │ │
│ │ │ Replica R0 │ │ Replica R0 │ │ Replica R0 │ │ │
│ │ │ Replica R1 │ │ Replica R1 │ │ Replica R1 │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼──────────────────────────────────┐ │
│ │ ▼ │ │
│ │ Node-1 (Master+Data) Node-2 (Data) Node-3 (Data) │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ │
│ │ │ P0 (orders) │ │ R0 (orders) │ │ R1 (orders)│ │ │
│ │ │ R1 (users) │ │ P1 (orders) │ │ P1 (users) │ │ │
│ │ │ P0 (products)│ │ R0 (products)│ │ R1(products│ │ │
│ │ └──────────────┘ └──────────────┘ └────────────┘ │ │
│ │ │ │
│ │ Gateway: 持久化集群状态到磁盘 │ │
│ │ Discovery: Zen2 节点发现与选举 │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
分片分配策略:
- Primary 与 Replica 不分配在同一节点
- 尽量均匀分布到不同机架/可用区
- 基于 _routing 值哈希决定目标分片: shard = hash(_routing) % num_shards
2.2 单个分片内部结构
Lucene Segment(不可变倒排索引文件集合)
┌─────────────────────────────────────────────────────────────────┐
│ Lucene Index │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ Segment 0 │ │ Segment 1 │ │ Segment 2 (正在写入) │ │
│ │ (不可变) │ │ (不可变) │ │ (可变, 在内存中) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ .tim (Terms) │ │ .tim (Terms) │ │ In-Memory Buffer │ │
│ │ .tip (FST) │ │ .tip (FST) │ │ (IndexedDocuments) │ │
│ │ .doc (Postings│ │ .doc (Postings│ │ │ │
│ │ .dvd (DocValues│ │ .dvd (DocValues│ │ │ │
│ │ .fdx/.fdt │ │ .fdx/.fdt │ │ │ │
│ │ (StoredFields)│ │ (StoredFields)│ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────────────────┘ │
│ │
│ Translog: 保证数据持久性,每个分片独立 │
│ └─> 定期 flush 为新的 Segment ──> 触发 merge │
└─────────────────────────────────────────────────────────────────┘
三、倒排索引原理:FST、跳表、BKD Tree
3.1 倒排索引核心结构
文档集合:
Doc1: "Java programmer codes Java"
Doc2: "Java developer codes Python"
Doc3: "Python programmer codes"
倒排索引(Inverted Index):
┌──────────┬─────────────────────────────────────┐
│ Term │ Postings List │
├──────────┼─────────────────────────────────────┤
│ java │ [(Doc1, pos:[0,3], freq:2), │
│ │ (Doc2, pos:[0], freq:1)] │
├──────────┼─────────────────────────────────────┤
│ python │ [(Doc2, pos:[3], freq:1), │
│ │ (Doc3, pos:[0], freq:1)] │
├──────────┼─────────────────────────────────────┤
│ programmer│ [(Doc1, pos:[1], freq:1), │
│ │ (Doc3, pos:[1], freq:1)] │
├──────────┼─────────────────────────────────────┤
│ codes │ [(Doc1, pos:[2], freq:1), │
│ │ (Doc2, pos:[2], freq:1), │
│ │ (Doc3, pos:[2], freq:1)] │
├──────────┼─────────────────────────────────────┤
│ developer│ [(Doc2, pos:[1], freq:1)] │
└──────────┴─────────────────────────────────────┘
查询 "java programmer":
1. 词典中找 "java" → 拿到 Postings List [Doc1, Doc2]
2. 词典中找 "programmer" → 拿到 Postings List [Doc1, Doc3]
3. 求交集 → [Doc1]
复杂度: O(len(postings_java) + len(postings_programmer))
3.2 FST:词典存储的压缩数据结构
java
// Lucene 使用 FST (Finite State Transducer) 存储词典
// 特性:将有序字符串集压缩为有限状态机,共享前缀
// 传统 Trie 树存储: java, javascript, json, yaml
// root
// / \
// j y
// / \ \
// a s a
// / \ \
// v o m
// | \ \
// a n l
// \
// ...
// FST 进一步优化:将 Trie 的边标签和输出值合并到状态转移中
// 边不仅记录字符,还记录该字符对应的 Term 在 BlockTree 中的地址
// Lucene 的 .tip 文件存储 FST
// 查询 "java" 时:
// 1. 从 FST 根节点出发,按 'j'->'a'->'v'->'a' 转移
// 2. 到达终态,获取 .tim Block 的地址
// 3. 从 .tim 中读取 "java" 对应的 Postings 元数据
// FST 的空间效率:存储 M 个 Term 仅需 ~MB 级别内存
// 查询时间复杂度:O(len(term)),与索引规模无关
3.3 跳表(Skip List):加速倒排表交集
Postings List for "java": [1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29]
无跳表的交集计算(与 "codes" 的 [2, 3, 7, 12, 17, 22, 27] 求交):
1 vs 2 → 1 < 2, java 指针前移
3 vs 2 → 3 > 2, codes 指针前移
3 vs 3 → 命中! java 指针前移
5 vs 7 → 5 < 7, java 指针前移
7 vs 7 → 命中!
... 总共约 15+7 = 22 次比较
有跳表(Skip Interval = 4):
java Skip Levels:
Level 2: [1] ──skip──> [9] ──skip──> [17] ──skip──> [25]
Level 1: [1] ─skip─> [5] ─skip─> [9] ─skip─> [13] ─skip─> [17] ...
Level 0: [1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29]
查找 27:
Level 2: 1 < 27, skip to 9; 9 < 27, skip to 17; 17 < 27, skip to 25; 25 < 27, 下降
Level 1: 25 的下一跳 29 > 27, 从 25 下降
Level 0: 25 < 27, next is 27 → 命中!
比较次数: 约 6 次(vs 无跳表的 25 次)
// Lucene 中通过 .doc 文件的 SkipDatum 结构实现
// 跳表间隔: indexInterval (默认 128), skipInterval (默认 16)
3.4 BKD Tree:数值类型与地理坐标索引
// BKD Tree (Blocked K-D Tree): Lucene 6.0+ 引入
// 解决数值类型(long/int/float/double/geo_point)的范围查询效率问题
// 传统方式:将数值转为字符串,放入倒排索引
// 问题:范围查询需要展开为大量 Term 查询(如 1-1000 展开为 1000 个 Term)
// BKD Tree: 将 N 维数据点组织为平衡的 K-D Tree,存储在磁盘上
// 支持:
// - 范围查询: age > 18 AND age < 60
// - 地理查询: geo_distance, geo_bounding_box, geo_polygon
// - 排序: sort by price desc
// 存储文件: .dii (索引) + .dim (数据)
// 内部使用 MSB Radix Sort 构建,保证构建复杂度 O(n log n)
// 示例:geo_point 类型的 BKD Tree
// 文档坐标: (116.4074, 39.9042) 北京
// (121.4737, 31.2304) 上海
// 查询: 以 (116.4, 39.9) 为中心,100km 范围内的文档
// BKD Tree 可以快速剪枝不在范围内的分支
四、写入链路源码深度解析
4.1 写流程:Index → Translog → Refresh → Flush
Client 发送 Index 请求
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Coordinating Node │
│ 1. 解析 _routing (默认 _id) │
│ 2. 计算目标分片: shard = hash(routing) % num_shards │
│ 3. 转发请求到 Primary Shard 所在节点 │
└──────────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Primary Shard │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ 1. Check │ │ 2. Lucene │ │ 3. Translog │ │
│ │ Mapping │ │ Index │ │ (fsync 保证持久) │ │
│ │ (字段类型 │ │ (写入内存 │ │ │ │
│ │ 校验/动态 │ │ 缓冲区, │ │ 顺序写 WAL, │ │
│ │ 创建) │ │ 不立即刷盘) │ │ 默认 async 刷盘 │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 4. 并行转发给所有 Replica Shard (wait_for_active_shards)│ │
│ │ - wait_for_active_shards=1: 主分片确认即返回 │ │
│ │ - wait_for_active_shards=all: 所有副本确认才返回 │ │
│ │ - wait_for_active_shards=2: 1 主 + 1 副本确认 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 返回客户端响应 │
└──────────────────────────────────────────────────────────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
Refresh Flush Merge
(默认1s) (默认5s/512MB) (后台自动)
│ │ │
▼ ▼ ▼
内存缓冲中的文档 Translog 小段合并为
变为可搜索(创建 清空(已持久化) 更大段
新的 Segment, 新 Translog 减少文件数
但还未fsync)
4.2 Translog 的持久化保证
java
// org.elasticsearch.index.translog.Translog
public class Translog implements Closeable {
// ★ Translog 是 ES 的 WAL(Write-Ahead Log)
// 保证数据在写入 Lucene 索引之前先持久化到磁盘
public Location add(Operation operation) throws IOException {
// 1. 序列化操作
bytesStreamOutput.reset();
operation.writeTo(bytesStreamOutput);
// 2. 写入 Translog 文件(顺序追加写)
TranslogWriter current = currentWriter;
Location location = current.add(bytesStreamOutput.bytes());
// 3. 根据同步策略决定是否 fsync
if (syncNeeded(location.translogLocation + location.size)) {
// index.translog.durability: REQUEST(每次请求fsync)/ ASYNC(异步)
if (durability == Durability.REQUEST) {
current.sync(); // ★ fsync 到磁盘
}
}
return location;
}
// ★ Translog 的清理: 只有被 Flush 到 Lucene Segment 的 Translog 才能删除
// 这保证了: 如果节点崩溃,可以从 Translog 恢复未 Flush 的数据
}
4.3 Refresh 与 Flush 的区别
| 维度 | Refresh | Flush |
|---|---|---|
| 触发条件 | 默认每秒 / ?refresh=true |
默认 5 秒 或 Translog 满 512MB |
| 操作内容 | 内存缓冲 → 新 Segment(文件系统缓存) | Segment fsync 到磁盘 + 清空 Translog |
| 搜索可见性 | 使新写入数据可搜索 | 不直接影响搜索可见性 |
| 数据持久性 | 不保证(在文件系统缓存) | 保证(已 fsync 到磁盘) |
| 性能影响 | 轻量(创建新 Segment) | 较重(fsync + 可能的 Merge) |
java
// Refresh 控制
// index.refresh_interval: "1s" // 默认每秒刷新,设为 -1 禁用自动刷新(批量导入场景)
// index.max_refresh_listeners: 1000 // 每个分片最大并发 refresh 监听器
// Flush 控制
// index.translog.flush_threshold_size: 512mb // Translog 达到此大小触发 flush
// index.translog.sync_interval: 5s // 异步 sync 间隔
// index.translog.durability: request // request/async
五、读取链路源码深度解析
5.1 Query Then Fetch 两阶段搜索
Client 发送 Search 请求
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Coordinating Node │
│ 1. 解析查询,确定目标索引的分片列表 │
│ 2. 获取所有相关分片(Primary 或 Replica)的位置 │
│ 3. 广播查询到所有目标分片 │
└──────────────────────────┬───────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Shard 0 │ │ Shard 1 │ │ Shard 2 │
│ (Query │ │ (Query │ │ (Query │
│ Phase) │ │ Phase) │ │ Phase) │
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
│ ┌────────────┴───────────────┐
│ ▼ │
│ 每个分片本地执行: │
│ 1. 从倒排索引查 Term │
│ 2. 获取文档 ID 列表 │
│ 3. 计算相关性得分(_score) │
│ 4. 按得分排序 │
│ 5. 返回 Top N (from+size) │
│ 的文档 ID + 得分 │
│ │
└──────────┬───────────────────┘
│
▼
Coordinating Node
(Reduce / Fetch Phase)
1. 合并所有分片的局部 Top N
2. 全局排序,取最终的 from+size
3. 根据全局 Top N 的 _id
向对应分片发起 Fetch 请求
获取完整文档内容(_source)
4. 返回客户端
5.2 分页深度问题与 Search After
java
// 传统 from/size 分页的问题:
// from=10000, size=10 时:
// 每个分片需要返回 from+size=10010 条记录
// Coordinating Node 需要排序 分片数 * 10010 条
// 内存爆炸,默认 max_result_window=10000
// ★ Search After: 基于游标的无深度分页
// 原理: 用上一页最后一条记录的排序值作为下一页的查询条件
// 第一页
SearchSourceBuilder source = new SearchSourceBuilder();
source.size(10);
source.sort("createTime", SortOrder.DESC); // 必须有唯一排序字段
source.sort("_id", SortOrder.ASC); // _id 作为 tiebreaker
SearchResponse resp = client.search(request, RequestOptions.DEFAULT);
// 获取最后一页的 sort values
List<Object> searchAfter = resp.getHits().getHits()[9].getSortValues();
// 第二页
source.searchAfter(searchAfter.toArray()); // ★ 使用上一页最后一条的排序值
SearchResponse resp2 = client.search(request, RequestOptions.DEFAULT);
// ★ Scroll: 用于批量导出(非实时)
// 创建一个快照,后续查询基于该快照
// 缺点: 持有 segment 引用,期间 segment 不能被 merge 清理
SearchScrollRequest scrollRequest = new SearchScrollRequest(scrollId);
scrollRequest.scroll(TimeValue.timeValueMinutes(1));
// ★ Point In Time (PIT): ES 7.10+ 推荐替代 Scroll
// 保持索引视图一致性,不持有 segment 引用
OpenPointInTimeRequest pitReq = new OpenPointInTimeRequest("orders");
pitReq.keepAlive(TimeValue.timeValueMinutes(1));
OpenPointInTimeResponse pit = client.openPointInTime(pitReq, RequestOptions.DEFAULT);
六、Spring Boot 2.x 深度集成
6.1 完整配置(根层级)
yaml
# application.yml - Spring Boot 2.x + Elasticsearch 7.x
spring:
elasticsearch:
rest:
# ★ ES 集群节点地址(RestHighLevelClient)
uris: http://es-node1:9200,http://es-node2:9200,http://es-node3:9200
# 连接超时
connection-timeout: 5s
# 读取超时
read-timeout: 30s
# 最大连接数
max-connections: 100
# 每个路由的最大连接数
max-connections-per-route: 10
# ★ 数据 Elasticsearch 配置(Spring Data)
data:
elasticsearch:
# 集群名称
cluster-name: es-production
# 是否启用仓库支持
repositories:
enabled: true
# 自定义 ES 客户端配置
elasticsearch:
client:
# 连接池配置
connection-request-timeout: 3s
# 失败重试
max-retry: 3
# 压缩请求
compression-enabled: true
# 嗅探器:自动发现集群节点
sniff:
enabled: true
interval: 5m
timeout: 10s
# 索引配置模板
index:
number-of-shards: 5
number-of-replicas: 1
refresh-interval: 1s
# 日志
logging:
level:
org.elasticsearch.client: DEBUG
org.springframework.data.elasticsearch: DEBUG
6.2 RestHighLevelClient 配置类
java
@Configuration
public class ElasticsearchConfig {
@Value("${spring.elasticsearch.rest.uris}")
private String[] uris;
@Value("${spring.elasticsearch.rest.connection-timeout:5000}")
private int connectionTimeout;
@Value("${spring.elasticsearch.rest.read-timeout:30000}")
private int readTimeout;
/**
* ★ RestHighLevelClient: Spring Boot 2.x 默认客户端
* 基于 HTTP/REST 协议,与 ES 版本解耦(只要 REST API 兼容)
*/
@Bean(destroyMethod = "close")
public RestHighLevelClient elasticsearchClient() {
// 解析 URI 列表
HttpHost[] httpHosts = Arrays.stream(uris)
.map(HttpHost::create)
.toArray(HttpHost[]::new);
RestClientBuilder builder = RestClient.builder(httpHosts)
// ★ 连接超时
.setRequestConfigCallback(configBuilder ->
configBuilder
.setConnectTimeout(connectionTimeout)
.setSocketTimeout(readTimeout)
.setConnectionRequestTimeout(5000)
)
// ★ HTTP 客户端配置
.setHttpClientConfigCallback(httpClientBuilder -> {
// 认证(如果启用了 X-Pack Security)
CredentialsProvider credentialsProvider =
new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY,
new UsernamePasswordCredentials("elastic", "password"));
return httpClientBuilder
.setDefaultCredentialsProvider(credentialsProvider)
// 连接池
.setMaxConnTotal(100)
.setMaxConnPerRoute(10)
// Keep-Alive 策略
.setKeepAliveStrategy((response, context) ->
TimeUnit.MINUTES.toMillis(5))
// 失败重试(只针对连接级失败,非 HTTP 错误码)
.setRetryHandler(new DefaultHttpRequestRetryHandler(3, true));
)
// 请求压缩
.setCompressionEnabled(true);
return new RestHighLevelClient(builder);
}
/**
* ★ 节点嗅探器:自动发现集群中的新节点
*/
@Bean
public Sniffer elasticsearchSniffer(RestHighLevelClient client) {
return Sniffer.builder(client.getLowLevelClient())
.setSniffIntervalMillis((int) TimeUnit.MINUTES.toMillis(5))
.setSniffAfterFailureDelayMillis((int) TimeUnit.MINUTES.toMillis(1))
.build();
}
}
6.3 索引设计与 Mapping 管理
java
@Service
@Slf4j
public class IndexManagementService {
@Autowired
private RestHighLevelClient client;
/**
* ★ 创建索引 + 自定义 Mapping + Settings
* 生产环境建议显式定义 Mapping,禁用 dynamic: true
*/
public boolean createOrderIndex() {
String indexName = "orders";
// 检查索引是否存在
GetIndexRequest getIndexRequest = new GetIndexRequest(indexName);
boolean exists;
try {
exists = client.indices().exists(getIndexRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
throw new RuntimeException("Check index failed", e);
}
if (exists) {
log.info("Index {} already exists", indexName);
return false;
}
// ★ Settings 配置
Settings.Builder settings = Settings.builder()
// 分片与副本
.put("number_of_shards", 5)
.put("number_of_replicas", 1)
// 刷新间隔
.put("index.refresh_interval", "1s")
// Translog 持久化策略
.put("index.translog.durability", "async")
.put("index.translog.sync_interval", "5s")
// 最大结果窗口(控制 from/size 深度)
.put("index.max_result_window", 10000)
// 慢查询日志阈值
.put("index.search.slowlog.threshold.query.warn", "10s")
.put("index.search.slowlog.threshold.query.info", "5s")
// 合并策略
.put("index.merge.policy.max_merge_at_once", 10)
.put("index.merge.policy.segments_per_tier", 10);
// ★ Mapping 定义(显式定义所有字段)
XContentBuilder mapping = XContentFactory.jsonBuilder();
mapping.startObject();
{
mapping.startObject("properties");
{
// ★ 文本字段: 分词搜索
mapping.startObject("orderNo");
mapping.field("type", "keyword"); // 精确匹配,不分词
mapping.endObject();
mapping.startObject("title");
mapping.field("type", "text"); // 分词搜索
mapping.field("analyzer", "ik_max_word"); // IK 分词器
mapping.field("search_analyzer", "ik_smart");
// ★ fields: 多字段,同时支持 text 和 keyword
mapping.startObject("fields");
{
mapping.startObject("keyword");
mapping.field("type", "keyword");
mapping.field("ignore_above", 256);
mapping.endObject();
}
mapping.endObject();
mapping.endObject();
// ★ 数值字段: 范围查询、排序、聚合
mapping.startObject("amount");
mapping.field("type", "scaled_float"); // 定点数,精度高
mapping.field("scaling_factor", 100);
mapping.endObject();
mapping.startObject("quantity");
mapping.field("type", "integer");
mapping.endObject();
// ★ 日期字段
mapping.startObject("createTime");
mapping.field("type", "date");
mapping.field("format", "yyyy-MM-dd HH:mm:ss||strict_date_optional_time||epoch_millis");
mapping.endObject();
// ★ 布尔字段
mapping.startObject("paid");
mapping.field("type", "boolean");
mapping.endObject();
// ★ 嵌套对象
mapping.startObject("address");
mapping.field("type", "object");
mapping.startObject("properties");
{
mapping.startObject("province");
mapping.field("type", "keyword");
mapping.endObject();
mapping.startObject("city");
mapping.field("type", "keyword");
mapping.endObject();
}
mapping.endObject();
mapping.endObject();
// ★ 地理坐标
mapping.startObject("location");
mapping.field("type", "geo_point");
mapping.endObject();
}
mapping.endObject();
}
mapping.endObject();
// 创建索引请求
CreateIndexRequest createIndexRequest = new CreateIndexRequest(indexName)
.settings(settings)
.mapping(mapping);
try {
CreateIndexResponse response = client.indices()
.create(createIndexRequest, RequestOptions.DEFAULT);
log.info("Index {} created: {}", indexName, response.isAcknowledged());
return response.isAcknowledged();
} catch (IOException e) {
throw new RuntimeException("Create index failed", e);
}
}
/**
* ★ 索引模板(Index Template):自动应用到匹配的新索引
* 适用于按日期滚动索引的场景: orders-2024.01, orders-2024.02
*/
public void createIndexTemplate() {
IndexTemplateMetaData.Builder template = IndexTemplateMetaData.builder("orders_template")
.patterns(Collections.singletonList("orders-*"))
.settings(Settings.builder()
.put("number_of_shards", 5)
.put("number_of_replicas", 1))
.order(100);
PutIndexTemplateRequest request = new PutIndexTemplateRequest("orders_template")
.patterns(Collections.singletonList("orders-*"))
.settings(Settings.builder()
.put("number_of_shards", 5)
.put("number_of_replicas", 1))
.mapping(createMappingJson(), XContentType.JSON);
try {
client.indices().putTemplate(request, RequestOptions.DEFAULT);
} catch (IOException e) {
throw new RuntimeException("Create template failed", e);
}
}
private String createMappingJson() {
return "{\"properties\":{\"orderNo\":{\"type\":\"keyword\"},\"title\":{\"type\":\"text\",\"analyzer\":\"ik_max_word\"}}}";
}
}
6.4 文档 CRUD 与批量操作
java
@Service
@Slf4j
public class OrderDocumentService {
@Autowired
private RestHighLevelClient client;
private static final String INDEX = "orders";
/**
* ★ 单条写入(实时性要求高)
*/
public void saveOrder(OrderDocument order) {
try {
IndexRequest request = new IndexRequest(INDEX)
.id(order.getOrderNo()) // ★ 显式指定 _id
.source(JSON.toJSONString(order), XContentType.JSON)
.opType(DocWriteRequest.OpType.CREATE) // 已存在则报错
// .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE) // 立即刷新
;
IndexResponse response = client.index(request, RequestOptions.DEFAULT);
log.info("Order indexed: {}, result: {}",
response.getId(), response.getResult());
} catch (IOException e) {
throw new RuntimeException("Index document failed", e);
}
}
/**
* ★ 批量写入(导入/同步场景)
*/
public void bulkSaveOrders(List<OrderDocument> orders) {
BulkRequest bulkRequest = new BulkRequest();
bulkRequest.setRefreshPolicy(WriteRequest.RefreshPolicy.NONE); // 批量不刷新
for (OrderDocument order : orders) {
IndexRequest request = new IndexRequest(INDEX)
.id(order.getOrderNo())
.source(JSON.toJSONString(order), XContentType.JSON);
bulkRequest.add(request);
}
try {
BulkResponse response = client.bulk(bulkRequest, RequestOptions.DEFAULT);
// 处理失败项
if (response.hasFailures()) {
for (BulkItemResponse item : response.getItems()) {
if (item.isFailed()) {
log.error("Bulk item failed: {}, error: {}",
item.getId(), item.getFailureMessage());
}
}
}
log.info("Bulk completed: {} items, took {}ms",
orders.size(), response.getTook().getMillis());
} catch (IOException e) {
throw new RuntimeException("Bulk index failed", e);
}
}
/**
* ★ 异步批量写入(不阻塞主线程)
*/
public void asyncBulkSaveOrders(List<OrderDocument> orders) {
BulkRequest bulkRequest = new BulkRequest();
for (OrderDocument order : orders) {
bulkRequest.add(new IndexRequest(INDEX)
.id(order.getOrderNo())
.source(JSON.toJSONString(order), XContentType.JSON));
}
// ★ 异步执行
client.bulkAsync(bulkRequest, RequestOptions.DEFAULT, new ActionListener<>() {
@Override
public void onResponse(BulkResponse response) {
log.info("Async bulk completed, took {}ms", response.getTook().getMillis());
}
@Override
public void onFailure(Exception e) {
log.error("Async bulk failed", e);
}
});
}
/**
* ★ 根据 ID 查询
*/
public OrderDocument getById(String orderNo) {
GetRequest request = new GetRequest(INDEX, orderNo);
try {
GetResponse response = client.get(request, RequestOptions.DEFAULT);
if (response.isExists()) {
return JSON.parseObject(response.getSourceAsString(), OrderDocument.class);
}
return null;
} catch (IOException e) {
throw new RuntimeException("Get document failed", e);
}
}
/**
* ★ 更新(局部更新,非全量替换)
*/
public void updateStatus(String orderNo, String status) {
UpdateRequest request = new UpdateRequest(INDEX, orderNo)
.doc("{\"status\":\"" + status + "\"}", XContentType.JSON)
.docAsUpsert(true); // 文档不存在则创建
try {
client.update(request, RequestOptions.DEFAULT);
} catch (IOException e) {
throw new RuntimeException("Update document failed", e);
}
}
}
6.5 搜索查询深度实战
java
@Service
@Slf4j
public class OrderSearchService {
@Autowired
private RestHighLevelClient client;
private static final String INDEX = "orders";
/**
* ★ 全文搜索 + 高亮 + 排序 + 分页
*/
public SearchResult<OrderDocument> searchOrders(SearchRequest req) {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 1. 全文搜索(must 表示 AND)
if (StringUtils.hasText(req.getKeyword())) {
boolQuery.must(
QueryBuilders.multiMatchQuery(req.getKeyword())
.field("title", 3.0f) // title 权重 3
.field("title.keyword", 2.0f)
.field("description", 1.0f)
.type(MultiMatchQueryBuilder.Type.BEST_FIELDS)
.fuzziness(Fuzziness.AUTO) // 模糊匹配
);
}
// 2. 精确过滤(filter,不参与评分,可缓存)
if (StringUtils.hasText(req.getStatus())) {
boolQuery.filter(QueryBuilders.termQuery("status", req.getStatus()));
}
if (req.getStartTime() != null && req.getEndTime() != null) {
boolQuery.filter(QueryBuilders.rangeQuery("createTime")
.gte(req.getStartTime())
.lte(req.getEndTime())
.format("yyyy-MM-dd HH:mm:ss"));
}
if (req.getMinAmount() != null || req.getMaxAmount() != null) {
RangeQueryBuilder range = QueryBuilders.rangeQuery("amount");
if (req.getMinAmount() != null) range.gte(req.getMinAmount());
if (req.getMaxAmount() != null) range.lte(req.getMaxAmount());
boolQuery.filter(range);
}
// 3. 构建 SearchSource
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(boolQuery);
// 4. 高亮
HighlightBuilder highlight = new HighlightBuilder();
highlight.field("title", 100, 1) // 片段长度 100,片段数 1
.field("description", 100, 1)
.preTags("<em class='highlight'>")
.postTags("</em>")
.fragmentSize(150)
.numOfFragments(2);
sourceBuilder.highlighter(highlight);
// 5. 排序
if ("relevance".equals(req.getSortBy())) {
sourceBuilder.sort("_score", SortOrder.DESC);
} else {
sourceBuilder.sort(req.getSortBy(),
req.isAsc() ? SortOrder.ASC : SortOrder.DESC);
}
// tiebreaker: 相同得分按 _id 排序保证分页稳定
sourceBuilder.sort("_id", SortOrder.ASC);
// 6. 分页
sourceBuilder.from(req.getFrom());
sourceBuilder.size(req.getSize());
// 7. 聚合(统计信息)
sourceBuilder.aggregation(
AggregationBuilders.dateHistogram("by_day")
.field("createTime")
.calendarInterval(DateHistogramInterval.DAY)
.subAggregation(AggregationBuilders.sum("daily_amount").field("amount"))
);
// 8. 性能优化
sourceBuilder.fetchSource(true); // 返回 _source
sourceBuilder.trackTotalHits(true); // 精确计算总命中数
sourceBuilder.timeout(TimeValue.timeValueSeconds(10)); // 查询超时
// 执行查询
SearchRequest esRequest = new SearchRequest(INDEX).source(sourceBuilder);
try {
SearchResponse response = client.search(esRequest, RequestOptions.DEFAULT);
return parseResponse(response);
} catch (IOException e) {
throw new RuntimeException("Search failed", e);
}
}
/**
* ★ Search After 深度分页
*/
public SearchResult<OrderDocument> searchOrdersAfter(SearchRequest req,
Object[] searchAfter) {
SearchSourceBuilder source = new SearchSourceBuilder();
source.size(req.getSize());
source.query(buildQuery(req));
// 排序字段必须与 searchAfter 一一对应
source.sort("createTime", SortOrder.DESC);
source.sort("_id", SortOrder.ASC);
if (searchAfter != null) {
source.searchAfter(searchAfter);
}
SearchRequest esRequest = new SearchRequest(INDEX).source(source);
try {
SearchResponse response = client.search(esRequest, RequestOptions.DEFAULT);
return parseResponse(response);
} catch (IOException e) {
throw new RuntimeException("Search after failed", e);
}
}
/**
* ★ 聚合分析:订单统计
*/
public OrderStats aggregateOrderStats(String startDate, String endDate) {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery()
.filter(QueryBuilders.rangeQuery("createTime")
.gte(startDate).lte(endDate));
SearchSourceBuilder source = new SearchSourceBuilder();
source.query(boolQuery);
source.size(0); // 不需要文档
// 按状态聚合
source.aggregation(AggregationBuilders
.terms("by_status").field("status"));
// 金额统计
source.aggregation(AggregationBuilders
.stats("amount_stats").field("amount"));
// 每日趋势
source.aggregation(AggregationBuilders
.dateHistogram("daily").field("createTime")
.calendarInterval(DateHistogramInterval.DAY)
.subAggregation(AggregationBuilders.sum("sum_amount").field("amount")));
SearchRequest request = new SearchRequest(INDEX).source(source);
try {
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
return parseStats(response);
} catch (IOException e) {
throw new RuntimeException("Aggregation failed", e);
}
}
/**
* ★ Suggester: 搜索建议(自动补全)
*/
public List<String> suggestTitles(String prefix) {
// 需要字段类型为 completion
SearchSourceBuilder source = new SearchSourceBuilder();
source.suggest(new SuggestBuilder().addSuggestion("title_suggest",
SuggestBuilders.completionSuggestion("title.suggest")
.prefix(prefix)
.size(10)));
SearchRequest request = new SearchRequest(INDEX).source(source);
try {
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
CompletionSuggestion suggestion = response.getSuggest()
.getSuggestion("title_suggest");
return suggestion.getEntries().stream()
.flatMap(e -> e.getOptions().stream())
.map(CompletionSuggestion.Entry.Option::getText)
.map(Text::toString)
.collect(Collectors.toList());
} catch (IOException e) {
throw new RuntimeException("Suggest failed", e);
}
}
private SearchResult<OrderDocument> parseResponse(SearchResponse response) {
List<OrderDocument> list = Arrays.stream(response.getHits().getHits())
.map(hit -> {
OrderDocument doc = JSON.parseObject(hit.getSourceAsString(),
OrderDocument.class);
// 处理高亮
if (hit.getHighlightFields() != null) {
HighlightField titleHL = hit.getHighlightFields().get("title");
if (titleHL != null) {
doc.setHighlightTitle(
Arrays.stream(titleHL.getFragments())
.map(Text::toString)
.collect(Collectors.joining()));
}
}
return doc;
})
.collect(Collectors.toList());
// 获取最后一条的 sort values 用于 search_after
Object[] nextSearchAfter = null;
if (response.getHits().getHits().length > 0) {
nextSearchAfter = response.getHits().getHits()
[response.getHits().getHits().length - 1].getSortValues();
}
return SearchResult.<OrderDocument>builder()
.total(response.getHits().getTotalHits().value)
.data(list)
.nextSearchAfter(nextSearchAfter)
.build();
}
}
6.6 MySQL 双写一致性方案
java
/**
* ★ MySQL + ES 双写一致性方案
* 方案: Canal 监听 MySQL Binlog → 异步同步到 ES
* 优势: 业务代码不感知 ES,解耦写入
*/
@Component
@Slf4j
public class CanalEsSyncService {
@Autowired
private RestHighLevelClient esClient;
/**
* ★ Canal 监听到 Binlog 变更后调用
*/
@KafkaListener(topics = "example", groupId = "canal-es-sync")
public void onCanalMessage(ConsumerRecord<String, String> record) {
CanalMessage message = JSON.parseObject(record.value(), CanalMessage.class);
for (CanalRowData rowData : message.getData()) {
switch (message.getType()) {
case INSERT:
indexDocument(message.getTable(), rowData);
break;
case UPDATE:
// 乐观锁检查: 如果 ES 中的版本比 Binlog 旧才更新
updateDocument(message.getTable(), rowData);
break;
case DELETE:
deleteDocument(message.getTable(), rowData);
break;
}
}
}
private void indexDocument(String table, CanalRowData rowData) {
if (!"orders".equals(table)) return;
OrderDocument doc = convertToDocument(rowData);
IndexRequest request = new IndexRequest("orders")
.id(doc.getOrderNo())
.source(JSON.toJSONString(doc), XContentType.JSON)
.versionType(VersionType.EXTERNAL) // ★ 使用外部版本控制
.version(rowData.getTimestamp()); // 用 Binlog 时间戳作为版本
try {
esClient.index(request, RequestOptions.DEFAULT);
} catch (IOException e) {
log.error("Index to ES failed", e);
// 失败消息进入死信队列,人工介入
}
}
private void updateDocument(String table, CanalRowData rowData) {
// 外部版本控制: ES 只接受版本号更高的更新
// 这解决了乱序问题: 如果先收到 UPDATE 再收到 INSERT(网络乱序),
// 版本号低的会被拒绝
OrderDocument doc = convertToDocument(rowData);
UpdateRequest request = new UpdateRequest("orders", doc.getOrderNo())
.doc(JSON.toJSONString(doc), XContentType.JSON)
.versionType(VersionType.EXTERNAL)
.version(rowData.getTimestamp());
try {
esClient.update(request, RequestOptions.DEFAULT);
} catch (IOException e) {
log.error("Update ES failed", e);
}
}
private OrderDocument convertToDocument(CanalRowData rowData) {
OrderDocument doc = new OrderDocument();
doc.setOrderNo(rowData.getColumn("order_no"));
doc.setTitle(rowData.getColumn("title"));
doc.setAmount(new BigDecimal(rowData.getColumn("amount")));
doc.setStatus(rowData.getColumn("status"));
doc.setCreateTime(rowData.getColumn("create_time"));
return doc;
}
}
七、集群调优与监控
7.1 关键配置调优
yaml
# elasticsearch.yml - 生产环境节点配置
# ★ 集群发现(Zen2 Discovery)
cluster.name: es-production
node.name: ${HOSTNAME}
discovery.seed_hosts:
- es-node1:9300
- es-node2:9300
- es-node3:9300
cluster.initial_master_nodes:
- es-node1
- es-node2
- es-node3
# ★ 内存(禁止 swap)
bootstrap.memory_lock: true
# JVM: -Xms31g -Xmx31g (不超过 32GB,避免压缩指针失效)
# ★ 线程池
thread_pool:
search:
size: 16
queue_size: 1000
write:
size: 8
queue_size: 500
# ★ 断路器(防止 OOM)
indices.breaker.fielddata.limit: 60%
indices.breaker.request.limit: 60%
indices.breaker.total.limit: 70%
# ★ 索引写入优化
indices.memory.index_buffer_size: 20% # 堆内存的 20% 用于索引缓冲
indices.memory.min_index_buffer_size: 96mb
7.2 监控指标
java
@Component
public class EsHealthIndicator implements HealthIndicator {
@Autowired
private RestHighLevelClient client;
@Override
public Health health() {
try {
ClusterHealthRequest request = new ClusterHealthRequest();
request.timeout(TimeValue.timeValueSeconds(5));
ClusterHealthResponse response = client.cluster()
.health(request, RequestOptions.DEFAULT);
ClusterHealthStatus status = response.getStatus();
Map<String, Object> details = new HashMap<>();
details.put("cluster_name", response.getClusterName());
details.put("status", status.name());
details.put("active_shards", response.getActiveShards());
details.put("relocating_shards", response.getRelocatingShards());
details.put("unassigned_shards", response.getUnassignedShards());
details.put("pending_tasks", response.getNumberOfPendingTasks());
if (status == ClusterHealthStatus.GREEN) {
return Health.up().withDetails(details).build();
} else if (status == ClusterHealthStatus.YELLOW) {
return Health.status("YELLOW").withDetails(details).build();
} else {
return Health.down().withDetails(details).build();
}
} catch (IOException e) {
return Health.down().withException(e).build();
}
}
}
八、总结
| 维度 | 关系型数据库 | Elasticsearch |
|---|---|---|
| 数据模型 | 行存储 | 倒排索引 + 列存(doc_values) |
| 查询方式 | B+Tree 精确查询 | FST 分词搜索 + BKD Tree 范围查询 |
| 扩展性 | 垂直扩展为主 | 水平分片原生支持 |
| 事务 | ACID | 无事务,最终一致 |
| 聚合 | 需全表扫描 | 列存预排序,毫秒级 |
| 适用 | 强一致事务 | 全文搜索、日志分析、指标聚合 |