1.2 java面试题 Elasticsearch

Elasticsearch(后面简称ES)是 Java 面试里一个高频中间件,而且因为它本身就是用 Java 写的,面试官往往会从原理、Java 客户端演进、常见坑、调优这几个角度深挖。我从老练的 Java 后端视角给你拆开讲。


1. 一句话定位

基于 Lucene 的分布式搜索与分析引擎 ,天然集群化,暴露 RESTful 接口。适合全文搜索、日志分析、监控指标聚合,最核心的两个特点是:近实时(NRT)水平扩展

2. 核心概念(面试必考,必须精确)

  • 索引(Index):逻辑上的"数据库",对应一个命名空间。
  • 文档(Document):类似数据库里的一行记录,JSON 格式。
  • 分片(Shard) :一个索引实际被切分成多个分片,每个分片是一个独立的 Lucene 实例。分片解决水平扩展写入的问题。
  • 副本(Replica) :每个主分片可以有多个副本,解决高可用和读吞吐
  • 节点(Node):一个 ES 实例,有不同角色(主节点、数据节点、协调节点等)。
  • 倒排索引 :根基,单词→文档列表的映射,加上列式存储 doc values 用于排序聚合。

3. 写入与查询模型(考验理解深度)

写入流程(重视数据安全)

  • 请求落到协调节点,根据 _routing 算出目标主分片,先写 translog(WAL),再写内存 buffer。
  • 默认每秒 refresh,将 buffer 生成新 segment 并打开,此时数据才能被搜索到(近实时)。
  • 定期 flush:内存 segment 提交到磁盘,translog 清空。
  • 副本同步由主分片把操作发给副本,保证一致性(ES 内部用 _version 做乐观锁)。

查询流程(Query Then Fetch)

  • 协调节点将查询广播到相关分片(主或副),各分片返回 doc id + 排序值。
  • 协调节点合并排序后,取 top N 的 doc id,再请求对应分片拿完整文档内容。

4. Java 如何玩转(客户端演进路线)

这是一个很有经验的踩坑点,能体现你一直跟着版本走:

  • TransportClient(7.x 彻底移除):直接走二进制协议,已死,提了就是减分。
  • RestHighLevelClient(7.15 废弃):封装 HTTP,用 DSL 风格编码,目前大量老项目在用。
  • 新官方 Java API Clientco.elastic.clients:elasticsearch-java):基于 Jackson 的强类型 API,支持 lambda builder 写法,与 ES 8.x 对齐。
  • Spring Data Elasticsearch :迎合 Spring 生态,用 ElasticsearchRestTemplateReactiveElasticsearchClient,操作类似于 JPA,日常开发足够。

真实场景 :现在新项目我会直接用官方的 Java API Client,把 DSL 对象化,配合 IndexRequestSearchRequest 构建,批量写入用 BulkRequest,并自定义错误处理。

5. 老司机必谈的硬核问题与优化

  • 深分页灾难

    from+size 超过深度如 10000 条时,协调节点要从各分片拉 from+size 条数据再排序,内存和 CPU 吃紧。

    解决方案:

    • Scroll:适合后台全量导出,生成快照,不用于实时分页。
    • Search After:实时分页首选,利用上一页最后一条的排序值去请求下一页,无深度上限。
  • 避免脑裂

    主节点脑裂导致集群多主。早期通过 discovery.zen.minimum_master_nodes 设成 (master候选/2)+1,现在 7.x 以后用 cluster.initial_master_nodesdiscovery.seed_hosts 配合投票配置自动处理,但专用主节点仍要规避与数据节点混布。

  • 查询性能十板斧

    1. 能用 filter 不用 query:filter 无评分,可缓存。
    2. 字符串精确匹配用 keyword,分词搜索才用 text
    3. 批量拉取用 mget 而不是循环 get
    4. 避免脚本、通配符开头、正则等昂贵操作。
    5. 索引 mapping 预先定义,禁用 dynamic mapping 爆炸:动态映射可能导致字段爆炸,曾见过一个索引产生几万列直接 OOM。
    6. 排序聚合依赖 doc values,确保字段开启(数字、keyword 默认开),慎用 fielddata。
    7. 写入瓶颈时调大 refresh_interval(如 30s),批量用 bulk 且控制一次大小 5~15MB。
    8. 分片规划:单分片 10~50GB 为宜,过多分片拖垮 master。
    9. 使用索引模板 + 生命周期管理(ILM)做日志轮转。
    10. 监控慢查询和 hot_threads。
  • 与数据库同步的常见组合

    • 日志:Filebeat → Kafka → Logstash/ES Ingest → Elasticsearch。
    • 业务数据:Canal/Debezium 监听 MySQL binlog → MQ → 自研消费端写入 ES,保证最终一致。
      面试时多说一句:处理乱序和幂等,可用 _id 指定业务主键,upsert 保证唯一。

6. 典型项目中的角色

我在做电商搜索时:

  • 商品信息全量/增量同步到 ES,搜索、筛选、聚合属性(如品牌、价格区间)全部 ES 承担。
  • 实时关键词补全用 completion suggester
  • 日志栈 ELK 做错误日志收集和告警。
  • 注意:ES 不替代关系型数据库,只做数据的检索镜像分析层,原表永远以 DB 为准。

这些点如果能在面试中条理清楚地讲出来,并穿插版本变迁、实际踩过的坑和性能优化经验 ,就能给面试官留下很深的印象。你有什么具体方向想深挖的,我继续展开。

行,直接给你上 Java 代码,从实战角度把刚才的点串起来,用 Elasticsearch 8.x 官方 Java API Client + Spring Boot 示例,并且我会刻意展示几个老手才知道的细节。


环境准备(Maven依赖)

xml 复制代码
<dependency>
    <groupId>co.elastic.clients</groupId>
    <artifactId>elasticsearch-java</artifactId>
    <version>8.13.0</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

客户端初始化(高可用的正确姿势):

java 复制代码
@Configuration
public class ElasticsearchConfig {
    @Bean
    public ElasticsearchClient elasticsearchClient() {
        RestClient restClient = RestClient.builder(
            new HttpHost("es-node1", 9200),
            new HttpHost("es-node2", 9200),
            new HttpHost("es-node3", 9200)
        ).setRequestConfigCallback(builder ->
            builder.setConnectTimeout(5000).setSocketTimeout(60000) // 查询超时设长
        ).setHttpClientConfigCallback(httpClientBuilder ->
            httpClientBuilder.setMaxConnTotal(200).setMaxConnPerRoute(100)
        ).build();

        ElasticsearchTransport transport = new RestClientTransport(
            restClient, new JacksonJsonpMapper()
        );
        return new ElasticsearchClient(transport);
    }
}

1. 索引创建(硬核 mapping 控制,拒绝字段爆炸)

老手会提前定义严格 mapping,禁用动态映射防止字段数不可控,指定分片与副本。

java 复制代码
public void createProductIndex(ElasticsearchClient client) throws IOException {
    client.indices().create(c -> c
        .index("products")
        .settings(s -> s
            .numberOfShards("3")      // 3个主分片
            .numberOfReplicas("1")    // 1个副本
            .refreshInterval(t -> t.time("30s")) // 调大refresh间隔减少写入压力
        )
        .mappings(m -> m
            .dynamic(DynamicMapping.Strict) // 严格模式,非法字段写入直接报错
            .properties("id", p -> p.keyword(k -> k))
            .properties("name", p -> p
                .text(t -> t.analyzer("ik_max_word").searchAnalyzer("ik_smart"))
            )
            .properties("brand", p -> p.keyword(k -> k))
            .properties("price", p -> p.double_(d -> d))
            .properties("category", p -> p.keyword(k -> k))
            .properties("tags", p -> p.keyword(k -> k))
            .properties("createTime", p -> p.date(d -> d.format("yyyy-MM-dd HH:mm:ss")))
        )
    );
}

2. 批量写入(Bulk + 业务主键,保证幂等)

真实同步中,我们用 _id 指定主键,做 upsert,不产生重复文档。

java 复制代码
public void bulkIndexProducts(ElasticsearchClient client, List<Product> products) throws IOException {
    BulkRequest.Builder br = new BulkRequest.Builder();
    for (Product p : products) {
        br.operations(op -> op
            .index(idx -> idx
                .index("products")
                .id(p.getId().toString()) // 业务主键作为文档_id
                .document(p)              // 自动序列化
            )
        );
    }
    BulkResponse response = client.bulk(br.build());

    // 老手逻辑:检查失败项并记录
    if (response.errors()) {
        for (BulkResponseItem item : response.items()) {
            if (item.error() != null) {
                log.error("Bulk索引失败 id:{}, error:{}", item.id(), item.error().reason());
            }
        }
    }
}

3. 搜索套路(精确匹配 + 全文检索 + 聚合)

一个典型的电商筛选:关键词搜名称,品牌、分类精确过滤,按价格聚合。

java 复制代码
public SearchResponse<Product> searchProducts(ElasticsearchClient client) throws IOException {
    String keyword = "手机";
    String brand = "华为";
    int from = 0, size = 20;

    return client.search(s -> s
        .index("products")
        .from(from).size(size)
        .query(q -> q
            .bool(b -> {
                // 必须条件:关键词匹配(用multi_match也可以)
                b.must(m -> m.match(t -> t.field("name").query(keyword)));
                // 过滤条件:品牌精确匹配,不参与评分,可缓存
                if (brand != null) {
                    b.filter(f -> f.term(t -> t.field("brand").value(brand)));
                }
                return b;
            })
        )
        // 排序:先按评分,再按价格升序
        .sort(so -> so.score(sc -> sc.order(SortOrder.Desc)))
        .sort(so -> so.field(f -> f.field("price").order(SortOrder.Asc)))
        // 聚合:价格区间统计(老练:用filter聚合防止全局统计干扰)
        .aggregations("price_ranges", a -> a
            .range(r -> r
                .field("price")
                .ranges(ra -> ra.to("100"))     // <100
                .ranges(ra -> ra.from("100").to("500"))
                .ranges(ra -> ra.from("500"))   // >=500
            )
        )
        .highlight(h -> h
            .fields("name", hf -> hf.preTags("<em>").postTags("</em>"))
        )
    , Product.class);
}

查询结果解析

java 复制代码
SearchResponse<Product> response = searchProducts(client);
// 命中总数
long total = response.hits().total().value();
// 提取文档
for (Hit<Product> hit : response.hits().hits()) {
    Product p = hit.source();
    // 取高亮结果
    Map<String, List<String>> hl = hit.highlight();
}
// 取出聚合
RangeAggregate priceRanges = response.aggregations().get("price_ranges").range();
for (RangeBucket bucket : priceRanges.buckets().array()) {
    System.out.println(bucket.key() + " -> " + bucket.docCount());
}

4. 深分页的正确打开方式:Search After

传统 from+size 深翻页直接 OOM,这里用 search_after 无上限翻页。

java 复制代码
public SearchResponse<Product> searchAfterPage(ElasticsearchClient client,
                                               Object[] lastSortValues) throws IOException {
    return client.search(s -> s
        .index("products")
        .size(20)
        .query(q -> q.matchAll(ma -> ma))
        .sort(so -> so.field(f -> f.field("price").order(SortOrder.Asc)))
        .sort(so -> so.field(f -> f.field("id").order(SortOrder.Asc))) // tiebreaker 保证排序唯一
        .searchAfter(lastSortValues) // 传入上一页最后一条的排序值数组
    , Product.class);
}

调用逻辑:前端传回上一页返回的 sort 数组,第一页为 null。


5. 性能调优的代码体现(写入调优)

java 复制代码
// 大批量导入前,临时关闭刷新和副本,事后恢复
public void fastImport(ElasticsearchClient client, List<Product> batch) throws IOException {
    // 1. 暂停刷新和副本
    client.indices().putSettings(s -> s
        .index("products")
        .settings(st -> st
            .refreshInterval(t -> t.time("-1"))
            .numberOfReplicas("0")
        )
    );
    // 2. 批量写入
    bulkIndexProducts(client, batch);
    // 3. 强制合并段(减少碎片)
    client.indices().forcemerge(f -> f.index("products").maxNumSegments(1));
    // 4. 恢复设置
    client.indices().putSettings(s -> s
        .index("products")
        .settings(st -> st
            .refreshInterval(t -> t.time("30s"))
            .numberOfReplicas("1")
        )
    );
}

6. 与 MySQL 同步的小型工业实现(Canal 消费简化版)

java 复制代码
// 模拟从Canal拿到binlog变更
public void syncFromBinlog(ElasticsearchClient client, String table, String operation, Product row) {
    switch (operation) {
        case "INSERT":
        case "UPDATE":
            // upsert 保证幂等
            client.update(u -> u
                .index("products")
                .id(row.getId().toString())
                .upsert(row) // 不存在则插入
                .doc(row)
            , Product.class);
            break;
        case "DELETE":
            client.delete(d -> d.index("products").id(row.getId().toString()));
            break;
    }
}

上面的代码覆盖了 索引管理、Mapping 设计、写入幂等、复杂查询与聚合、深分页替代方案、写入调优、数据同步 这几个面试中最爱问的"有代码"的环节。如果你把这些写在简历里并能讲清楚,面试官就知道你是真的在生产环境玩过 ES。

还需要继续细化哪个场景,直接说。