从 LIKE 查询到毫秒级搜索:Elasticsearch 核心原理与 Java 实战

写在前面

"商品名称模糊搜索,LIKE '%关键词%' 跑了 8 秒,还越跑越慢。"这可能是很多后端开发者第一次真正考虑引入 Elasticsearch 的契机。MySQL 的 B+ 树索引为精确匹配和范围查询而生,一旦遇到全文检索、模糊匹配、多条件动态组合,它的短板就会暴露无遗。

但 Elasticsearch 并不是"换个数据库"那么简单。如果不理解它为什么快、适合什么、不适合什么,很容易从一个坑跳进另一个坑。这篇文章按照 "为什么需要它 → 它凭什么快 → Java 怎么用 → 生产上怎么不踩坑 → 怎么选型" 的顺序展开,代码部分全部基于官方 Java Client 8.x,可直接复制运行。

一、先搞清楚:ES 不是来替代 MySQL 的

很多团队引入 ES 时的第一个误解是把它当数据库用。实际上两者的设计目标截然不同。

MySQL 的核心竞争力在于强一致性与事务 :行级锁支撑高并发更新,Redo Log 保证崩溃恢复,外键与 JOIN 处理复杂关联,ACID 让"A 扣钱、B 加钱"要么全成功要么全回滚。这些恰恰是 ES 的短板:不支持跨文档事务 ,更新本质是"标记删除 + 写入新文档" ,高频更新代价很高,不支持 JOIN,跨索引关联需要应用层拼装。

ES 的核心竞争力则在于检索:倒排索引把"扫描全部文档找内容"变成"查词典 + 取倒排表",分布式分片让数据量和查询吞吐可以水平扩展,近实时(NRT)机制让写入后约 1 秒即可被检索到。

一个务实的判断标准是:需要事务保证的核心数据留在 MySQL;需要"被搜索"的数据同步一份到 ES。ES 里存的是为检索而优化的副本,而不是数据的"真理来源"。

二、倒排索引:ES 快的根本原因

2.1 从"翻书找词"到"查索引找页"

正排索引的逻辑是"文档 ID → 文档内容"。用 MySQL 的 LIKE '%华为手机%' 查询 1000 万条商品时,数据库不得不逐行扫描每一条记录,判断内容是否包含目标字符串,时间复杂度是 O(n)。

倒排索引把这个方向反了过来: "关键词 → 包含该关键词的文档 ID 列表" 。就像一本书末尾的术语索引页,你不需要翻遍全书,直接查"华为手机"出现在哪几页即可。

2.2 三层结构撑起毫秒级检索

倒排索引本身并不是一个简单的 Map,Lucene 用三层结构兼顾了查询速度与内存效率:

Term Dictionary(词项词典) 存储所有文档分词后产生的不重复 Term,按字典序排序,支持二分查找。但如果 Term 数量达到千万级,词典本身无法全部放入内存,二分查找依然会产生多次磁盘 IO。

Term Index(词项索引) 正是为了解决这个问题而存在。它基于 FST(有限状态自动机)为 Term Dictionary 构建前缀索引,将相同前缀的 Term 合并存储,内存占用远小于词典本身,可以常驻内存。查询时先在 FST 中微秒级定位到 Term 的大致偏移,再对词典做一次磁盘读取即可命中目标。

Posting List(倒排表) 存储包含某个 Term 的所有文档 ID,以及词频、位置、偏移量等信息,是相关性打分和高亮的依据。为了压缩亿级文档 ID 的存储开销,Lucene 主要使用 FOR(Frame of Reference,增量编码)+ 可变字节编码 对 Posting List 进行压缩。

值得一提的是 Roaring Bitmap :它主要用在 filter 结果缓存 等位图场景,而不是 Posting List 的存储格式。当一个 filter 条件被命中后,其结果会被缓存为位图;后续查询对多个缓存位图做按位与运算,可以快速求出交集。这是 ES 在多条件过滤场景下性能极高的底层原因之一。

三、分布式与近实时:数据写进去之后发生了什么

3.1 分片:水平扩展的基本单位

一个索引在物理上被拆成多个主分片(Primary Shard) ,分布在不同的节点上。写入一条文档时,ES 默认用文档 _id 做哈希,对主分片数取余,决定这条文档落到哪个分片:

text 复制代码
shard = hash(_id) % number_of_primary_shards

这个设计有一个关键约束:主分片数在索引创建后不可修改。因为一旦分片数变化,同样的 _id 计算出的余数就会不同,历史数据将无法被路由到正确的位置。这也是后面分片规划需要提前评估的根本原因。

每个主分片可以有零个或多个副本(Replica) ,副本不仅承担故障转移,还能分担搜索请求,提升查询吞吐。

3.2 近实时:为什么写入后 1 秒才能搜到

ES 的写入路径可以概括为三步:write → refresh → flush。

文档首先写入内存缓冲区和 translog (事务日志,保证宕机恢复),此时文档不可被搜索 。默认每 1 秒触发一次 refresh ,内存缓冲区中的数据被写入一个新的 Segment(段),同时打开新的 Searcher,文档才变得可见。这也是 ES 被称为"近实时"而非实时的原因。

Segment 是不可变的 。删除操作只是在 .del 文件中做标记,更新操作则是"标记旧文档为已删除 + 写入一条新文档",真正的物理删除和清理发生在后台的段合并(Merge) 阶段。这解释了为什么 ES 不适合高频更新场景:每一次"更新"都在产生新的段和待清理的旧数据。

四、Java 实战:官方客户端从零到可用

ES 8.x 推荐使用官方的 Elasticsearch Java Client (co.elastic.clients:elasticsearch-java),它基于底层 REST Client 构建,提供类型安全的 Fluent DSL,不依赖 Spring Data 的注解体系,灵活度更高。旧版的 RestHighLevelClient 已不再建议用于新项目。

下面这套示例覆盖了日常开发最核心的操作路径,可直接复制运行。

4.1 依赖与客户端初始化

Maven 核心依赖(版本尽量与服务端保持一致,这里以 8.14.3 为例):

xml 复制代码
<dependencies>
    <dependency>
        <groupId>co.elastic.clients</groupId>
        <artifactId>elasticsearch-java</artifactId>
        <version>8.14.3</version>
    </dependency>
    <!-- 底层 HTTP 客户端,示例中直接使用了 RestClient,建议显式声明 -->
    <dependency>
        <groupId>org.elasticsearch.client</groupId>
        <artifactId>elasticsearch-rest-client</artifactId>
        <version>8.14.3</version>
    </dependency>
    <!-- Jackson 用于 POJO 与 JSON 之间的序列化 -->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.17.0</version>
    </dependency>
</dependencies>

ElasticsearchClient 是线程安全的,通常作为单例全局复用。客户端由三层构成:RestClient (HTTP 层)→ RestClientTransport (Jackson 序列化层)→ ElasticsearchClient(类型化业务 API):

java 复制代码
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.elasticsearch.client.RestClient;

public class EsClientFactory {

    public static ElasticsearchClient createClient() {
        // 1. 基础认证(ES 未开启安全认证时可省略)
        BasicCredentialsProvider credentials = new BasicCredentialsProvider();
        credentials.setCredentials(
                AuthScope.ANY,
                new UsernamePasswordCredentials("elastic", "your_password")
        );

        // 2. 底层 HTTP 客户端
        RestClient restClient = RestClient.builder(
                        new HttpHost("localhost", 9200, "http"))
                .setHttpClientConfigCallback(http ->
                        http.setDefaultCredentialsProvider(credentials))
                .build();

        // 3. 带 Jackson 序列化的 Transport
        RestClientTransport transport = new RestClientTransport(
                restClient, new JacksonJsonpMapper());

        // 4. 类型化客户端
        return new ElasticsearchClient(transport);
    }
}

如果你使用 Spring Boot,把上面的逻辑封装成 @Bean 即可注入使用:

java 复制代码
@Configuration
public class EsConfig {
    @Bean
    public ElasticsearchClient elasticsearchClient() {
        // ... 同上 ...
    }
}

4.2 定义一个普通 POJO

不需要加任何 ES 注解,Jackson 会自动完成映射(示例使用 Lombok 简化代码,也可手写 getter/setter):

java 复制代码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {
    private String sku;
    private String name;
    private String description;
    private double price;
    private String category;
}

4.3 创建索引与写入文档

显式创建索引是最稳妥的做法,避免依赖动态 Mapping 带来意外的字段类型:

java 复制代码
esClient.indices().create(c -> c.index("products"));

写入使用 index(),它是 upsert 语义 :文档不存在则创建,已存在则覆盖。这里显式指定 _id 为 sku,方便后续按 ID 更新或删除:

java 复制代码
Product product = new Product("bk-1", "山地自行车", "27速 铝合金车架", 1599.0, "运动户外");

IndexResponse resp = esClient.index(i -> i
        .index("products")
        .id(product.getSku())
        .document(product)
);

System.out.println("写入结果:" + resp.result()); // Created

4.4 单条件全文搜索

最简单的全文检索,match 会对查询词进行分词后匹配 name 字段:

java 复制代码
String keyword = "自行车";

SearchResponse<Product> response = esClient.search(s -> s
        .index("products")
        .query(q -> q
                .match(m -> m
                        .field("name")
                        .query(keyword)
                )
        ),
        Product.class
);

for (Hit<Product> hit : response.hits().hits()) {
    System.out.println(hit.source());
}

4.5 多条件组合:BoolQuery(最常用)

这是日常开发中用得最多的查询形态。场景:搜索商品,必须 匹配关键词(description 字段),过滤 价格区间和分类,并允许可选匹配名称以提升相关性。

java 复制代码
SearchResponse<Product> response = esClient.search(s -> s
        .index("products")
        .query(q -> q
                .bool(b -> b
                        // must:全文匹配,参与相关性打分
                        .must(m -> m.match(
                                t -> t.field("description").query("铝合金")
                        ))
                        // filter:价格区间,不参与打分,可被缓存
                        .filter(f -> f.range(
                                r -> r.field("price").gte(JsonData.of(500)).lte(JsonData.of(2000))
                        ))
                        // filter:分类精确匹配
                        .filter(f -> f.term(
                                t -> t.field("category").value("运动户外")
                        ))
                        // should:可选,命中名称的文档得分更高
                        .should(sh -> sh.match(
                                t -> t.field("name").query("自行车")
                        ))
                )
        ),
        Product.class
);

代码中用到的 JsonData.of(...) 来自 co.elastic.clients.json.JsonData。

这里的关键区分是 must 与 filter :must 中的条件参与 _score 相关性计算,适合"用户输入的关键词";filter 中的条件只做"是/否"判断,不计算分数,其结果可以被缓存为位图,多条件间通过位运算快速求交集。价格区间、状态、分类这类确定性条件,能放 filter 就不要放 must。

4.6 更新与删除

ES 的 update API 里,doc 和 upsert 承担的是两种不同的语义:

  • doc :文档已存在时,执行局部更新;
  • upsert :文档不存在 时,作为插入内容。文档已存在时,upsert 不生效。

所以,"存在则更新,不存在则插入"需要 同时传 doc 和 upsert 才能实现。如果只传 upsert,文档已存在时该请求相当于 noop,不会修改任何内容:

java 复制代码
Product updated = new Product("bk-1", "山地自行车 Pro", "27速 碳纤维车架", 2999.0, "运动户外");

esClient.update(u -> u
        .index("products")
        .id("bk-1")
        .doc(updated)       // 文档存在时:执行更新
        .upsert(updated),   // 文档不存在时:执行插入
        Product.class
);

注意 :ES 的更新本质是"标记旧文档删除 + 写入新文档"。如果你只想修改部分字段 ,应使用 doc 传入一个只包含待改字段的对象,而不是整体替换。

java 复制代码
// 删除单条文档
esClient.delete(d -> d.index("products").id("bk-1"));

// 删除整个索引(谨慎操作)
esClient.indices().delete(d -> d.index("products"));

4.7 写入后立刻查询的坑

由于 refresh 的 1 秒延迟,下面的代码大概率查不到刚写入的文档:

java 复制代码
esClient.index(i -> i.index("products").id("1002").document(product));
// 紧接着执行 search ------ 可能返回空结果

如果业务确实需要"写完立即可查"(例如接口返回新创建的记录后立刻让用户搜索),可以在写入请求上显式指定 refresh 策略:

java 复制代码
import co.elastic.clients.elasticsearch.core.Refresh;

esClient.index(i -> i
        .index("products")
        .id(product.getSku())
        .document(product)
        .refresh(Refresh.WaitFor)   // 等待本次写入对搜索可见
);

但 Refresh.WaitFor 会引入额外等待,只应在确实需要的单条写入上使用,批量导入场景逐条等待 refresh 会严重拖慢性能。

五、生产实践:几个决定成败的细节

5.1 分片不是越多越好

一个常见的错误是给索引设置几十个分片,以为"分片多 = 并行度高"。实际上每个分片都是一个独立的 Lucene 实例,分片过多会带来显著的管理开销和堆内存压力。

业界较为一致的实践是:单个分片大小控制在 10GB--50GB ,同时每个节点的分片数控制在"每 1GB 堆内存约 20 个分片"以内。规划时按"当前数据量 ÷ 30GB"估算主分片数,并预留一定的增长空间。由于主分片数创建后不可改,这一步值得在索引设计阶段多花时间。副本数方面,生产环境至少设置为 1,才能容忍单个节点宕机而不丢数据。

5.2 Mapping 设计:text 还是 keyword

默认动态 Mapping 会把字符串字段同时映射为 text(分词,用于全文搜索)和 keyword(不分词,用于精确过滤、排序、聚合)。这在开发阶段很方便,但会造成不必要的存储和索引开销。

更稳妥的做法是显式定义 Mapping :需要全文检索的字段用 text(中文场景需配合 IK 等中文分词插件),需要精确匹配、排序或聚合的字段(如状态码、分类 ID、标签)用 keyword,数值和日期用对应类型。避免让 ES 替你猜测字段的用途。

5.3 不要用 ES 做"主数据库"

这是最重要的一条。ES 不支持事务,极端故障场景下的数据可靠性依赖副本和持久化配置,不适合作为唯一的数据存储。推荐的架构是:MySQL 作为写入的真理来源,通过同步机制(业务双写、Canal 订阅 binlog、或 Logstash 管道)将需要检索的字段同步到 ES;查询侧对一致性要求高的场景走 MySQL,对检索灵活性和性能要求高的场景走 ES。

六、选型速查:什么时候该用 ES,什么时候不该

场景 优先选择 原因
主键/唯一键精确查询 MySQL B+ 树单行读取最快,ES 无优势
强事务(支付、订单状态流转) MySQL ES 不支持 ACID 事务
高频更新(秒级多次) MySQL ES 更新 = 删除 + 重建,代价高
多表复杂关联 MySQL ES 不支持 JOIN
全文检索 / 模糊匹配 / 拼写容错 ES 倒排索引 + 分词器,MySQL LIKE 无法比拟
多条件动态组合筛选 ES BoolQuery + filter 缓存,无需精心设计复合索引
日志分析 / 可观测性 ES ELK 生态事实标准,聚合分析能力强
海量数据(千万级以上)的灵活检索 ES 分片水平扩展,查询延迟可控在毫秒级

总结

一句话概括:把事务和强一致交给 MySQL,把"被搜索"交给 Elasticsearch。两者不是替代关系,而是一套系统中各司其职的协作关系。

回到代码层面,本文的 Java 示例贯穿了"创建索引 → 写入 → 单条件/多条件搜索 → 更新 → 删除"这条完整链路,全部使用 Lambda 风格的 Fluent DSL,类型安全、IDE 可自动补全,不需要手动拼 JSON 字符串。如果需要聚合、高亮、分页排序等进阶功能,可以继续在 search() 的 Builder 中链式追加。

但比记住 API 更重要的,是理解倒排索引为什么快、分片为什么这样设计、must 和 filter 的本质区别、doc 与 upsert 的语义差异,以及 refresh 延迟会带来什么。这些底层认知,才是你在真实业务场景中做出正确技术判断的依据。

相关推荐
皮皮林5511 小时前
Java 导出 Excel 利器:JXLS
后端
Flynt2 小时前
我把真实的 ES 查询怼到 7059 星的 Rust 搜索引擎上:1366/1369 的兼容承诺,裂了 3 个缝
elasticsearch·搜索引擎·rust
tianbin9113 小时前
大模型训练与推理:API中转站在企业AI落地中的隐藏价值
大数据·后端·ai
打工仔折腾 AI4 小时前
用 Dashlet 把极空间 NAS 服务入口整理成统一导航首页的实践
java·开发语言·人工智能·后端·python·rpc·ai agent 实战
环境栈笔记4 小时前
指纹浏览器 WebRTC 怎么设置?从 ICE 候选、代理出口到功能验证
前端·人工智能·后端·自动化
一只小猪妖5 小时前
分布式唯一 性ID生成方法
后端
小蒜学长5 小时前
微信小程序-高校自习室管理系统(代码+数据库+LW)
java·spring boot·后端·微信小程序·自习室管理
M1A15 小时前
告别代码风格之争:在 VS Code 中用 Black 一键格式化 Python
后端