Elasticsearch 深度解析:从倒排索引原理到亿级数据生产实战

摘要 :Elasticsearch 基于 Lucene 构建,是分布式搜索与分析的事实标准。本文从 FST(Finite State Transducer)的倒排索引词典结构出发,深入解析 refresh/flush/translog 的写链路时序、query_then_fetch 的分布式读流程、_routing 分片路由算法,以及 Spring Boot 2.x 与 RestHighLevelClient 的深度集成,覆盖索引设计、 Mapping 优化、聚合分析、数据同步、集群调优等生产级完整方案。


一、引言

关系型数据库的索引(B+Tree)是为精确查询和范围扫描设计的,面对全文搜索、模糊匹配、聚合分析时性能急剧下降。Elasticsearch 的核心优势在于:

  1. 倒排索引:从"文档找词"变为"词找文档",全文检索复杂度从 O(n) 降至 O(1)
  2. 分布式原生:分片(Shard)自动路由、副本(Replica)自动同步、集群节点自动发现
  3. 列式存储(doc_values):聚合分析无需遍历文档,直接从排序后的列存读取
  4. 近实时搜索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. 返回客户端
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 无事务,最终一致
聚合 需全表扫描 列存预排序,毫秒级
适用 强一致事务 全文搜索、日志分析、指标聚合

相关推荐
garmin Chen1 小时前
Elasticsearch(1):Elasticsearch核心原理与基础操作总结
java·大数据·笔记·elasticsearch·搜索引擎·全文检索
humors2212 小时前
聊聊密码为啥会“白设”
大数据·运维·服务器·网络·网络安全
Sharewinfo_BJ2 小时前
Power BI 5月重磅更新:8大新功能全面提升数据分析效率
大数据·人工智能·数据分析
中电金信2 小时前
中电金信分布式核心系统与鲲鹏实现“原生开发”,共筑数智金融新范式
大数据·人工智能
一切皆是因缘际会2 小时前
AI高速迭代下的技术风险与理性突围
大数据·数据结构·人工智能·架构
SEO_juper2 小时前
“不可替代内容”=GEO 核心:AI 抄不走的经验、数据、案例
大数据·人工智能·seo·geo·谷歌优化·2026·谷歌算法更新
superantwmhsxx2 小时前
GPT-5.5:面向下一代智能应用的技术展望
大数据·人工智能·gpt
weixin_468466852 小时前
Crawl4Ai 智能数据采集与场景化应用指南
大数据·人工智能·爬虫·python·数据分析
涛思数据(TDengine)2 小时前
TDengine IDMP 1.0.18 上线:MCP、CLI、过程分析与可视化能力持续升级
大数据·人工智能·tdengine