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 无事务,最终一致
聚合 需全表扫描 列存预排序,毫秒级
适用 强一致事务 全文搜索、日志分析、指标聚合

相关推荐
雪碧聊技术6 小时前
Badge 应用场景与落地实践指南
大数据·人工智能
望江东浪7 小时前
我的 Claude Code 效率工具全套配置分享
大数据·elasticsearch·搜索引擎
chaoyuanl8 小时前
现有游乐设施 XR 数字化升级改造方案
大数据·科技·3d·xr·娱乐
LL334455678 小时前
创业自动化平台怎么选
大数据·人工智能
珠海西格电力10 小时前
云边端协同架构:零碳园区管理系统的技术底座
大数据·运维·人工智能·算法·架构·能源
cc57250265310 小时前
大数据专业是不是热门专业
大数据
汇策研习社10 小时前
StdDev标准差指标深度解析:量化市场波动的核心工具
大数据·经验分享·金融·区块链·fastbull
大大大大晴天11 小时前
Hudi技术内幕: Concurrency Control原理与实践
大数据
阿里云大数据AI技术12 小时前
DataWorks Data Agent 实战课堂(一):解锁你的7×24h全能“数据搭子”DataWorks AI助理!
大数据·人工智能·agent
SelectDB12 小时前
美团数十 PB 规模 Apache Doris 实践:从统一 OLAP 到 AI-Native 数据基座
大数据·数据库·性能优化