写在前面
“商品名称模糊搜索,LIKE '%关键词%' 跑了 8 秒,还越跑越慢。”这可能是很多后端开发者第一次真正考虑引入 Elasticsearch 的契机。MySQL 的 B+ 树索引为精确匹配和范围查询而生,一旦遇到全文检索、模糊匹配、多条件动态组合,它的短板就会暴露无遗。
但 Elasticsearch 并不是“换个数据库”那么简单。如果不理解它为什么快、适合什么、不适合什么,很容易从一个坑跳进另一个坑。这篇文章按照 “为什么需要它 → 它凭什么快 → 数据怎么从 MySQL 进来 → Java 怎么查 → 生产上怎么不踩坑” 的顺序展开,代码基于官方 Java Client 8.x + MyBatis,是一个「MySQL 为真理来源、ES 为检索副本」的完整落地链路,每一段代码都会讲清楚“在干嘛、为什么这么写、写错会怎样”。
一、先搞清楚:ES 不是来替代 MySQL 的
很多团队引入 ES 时的第一个误解是把它当数据库用。实际上两者的设计目标截然不同。
MySQL 的核心竞争力在于强一致性与事务:行级锁支撑高并发更新,Redo Log 保证崩溃恢复,外键与 JOIN 处理复杂关联,ACID 让“A 扣钱、B 加钱”要么全成功要么全回滚。这些恰恰是 ES 的短板:不支持跨文档事务,更新本质是“标记删除 + 写入新文档” ,高频更新代价很高,不支持 JOIN,跨索引关联需要应用层拼装。
ES 的核心竞争力则在于检索:倒排索引把“扫描全部文档找内容”变成“查词典 + 取倒排表”,分布式分片让数据量和查询吞吐可以水平扩展,近实时(NRT)机制让写入后约 1 秒即可被检索到。
一个务实的判断标准是:需要事务保证的核心数据留在 MySQL;需要“被搜索”的数据同步一份到 ES。ES 里存的是为检索而优化的副本,而不是数据的“真理来源”——这篇文章第四节的数据流也是严格按这个原则设计的:应用只写 MySQL,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 做哈希,对主分片数取余,决定这条文档落到哪个分片:
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 实战:MySQL 经 MyBatis 同步到 ES,再由 ES 提供检索
真实的生产架构里,ES 的写入几乎不来自业务接口,而来自同步任务。下面这套示例完整模拟了这个链路:
业务写 MySQL → MyBatis 读 MySQL → Bulk 写入 ES → 查询走 ES
4.1 依赖与客户端初始化
Maven 核心依赖(ES 客户端版本尽量与服务端保持一致,这里以 8.14.3 为例;MyBatis Starter 用 3.0.x,对应 Spring Boot 3):
<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>
<!-- MyBatis + 数据源 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Jackson 用于 POJO 与 JSON 之间的序列化 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.0</version>
</dependency>
</dependencies>
MyBatis 的配置里,一定要开启下划线转驼峰,否则 update_time 这类字段映射不上:
mybatis:
mapper-locations: classpath:mapper/**/*.xml
type-aliases-package: com.example.product.domain
configuration:
map-underscore-to-camel-case: true
ElasticsearchClient 是线程安全的,作为单例 Bean 全局复用。客户端由三层构成:
@Configuration
public class EsConfig {
@Bean
public ElasticsearchClient elasticsearchClient() {
BasicCredentialsProvider credentials = new BasicCredentialsProvider();
credentials.setCredentials(
AuthScope.ANY,
new UsernamePasswordCredentials("elastic", "your_password"));
// 第一层:HTTP 客户端,只负责收发请求,认证也配在这一层
RestClient restClient = RestClient.builder(
new HttpHost("localhost", 9200, "http"))
.setHttpClientConfigCallback(http ->
http.setDefaultCredentialsProvider(credentials))
.build();
// 第二层:序列化层,负责 Java 对象 ↔ JSON 的转换
RestClientTransport transport = new RestClientTransport(
restClient, new JacksonJsonpMapper());
// 第三层:类型安全的业务 API
return new ElasticsearchClient(transport);
}
}
这三层不是故意绕,每一层各管一件事:
RestClient(HTTP 层) :只负责发 HTTP 请求。它不知道什么是索引、什么是文档,你给它 URL 和 JSON,它发出去、收回来。RestClientTransport(序列化层) :负责“Java 对象 ↔ JSON”的转换。JacksonJsonpMapper就是告诉它用 Jackson 来序列化。没有这层,你传给 ES 的就得是手拼的 JSON 字符串。ElasticsearchClient(业务 API 层) :你写.index("products").id("bk-1").document(product),它帮你组装成正确的请求,再交给下面两层发出去。
为什么要用 @Bean 全局单例:ElasticsearchClient 内部维护了 HTTP 连接池,是线程安全的,整个应用共用一个实例即可。每次请求都 new 一个,等于每次重建连接池,性能会很差。
4.2 表结构、DO 与 Doc 为什么要分开
商品表结构如下。关键是为同步任务保留 update_time 和逻辑删除标记 deleted,并给 update_time 建索引:
CREATE TABLE t_product (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sku VARCHAR(32) NOT NULL UNIQUE,
name VARCHAR(128) NOT NULL,
description VARCHAR(512),
price DECIMAL(10,2) NOT NULL,
category VARCHAR(32) NOT NULL,
deleted TINYINT NOT NULL DEFAULT 0,
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
KEY idx_update_time (update_time)
) ENGINE=InnoDB;
这里要区分两个模型,它们职责不同:
// 数据库实体:字段和表一一对应
@Data
public class ProductDO {
private Long id; // 自增主键
private String sku;
private String name;
private String description;
private BigDecimal price; // 数据库是 DECIMAL
private String category;
private Integer deleted; // 逻辑删除标记:0 正常,1 已删除
private LocalDateTime updateTime;
}
// ES 文档:只放需要被搜索的字段
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ProductDoc {
private String sku;
private String name;
private String description;
private double price; // ES 里用 double 就够
private String category;
private LocalDateTime updateTime;
// 没有 id、没有 deleted
}
很多人图省事,直接把 ProductDO 扔进 ES,能跑,但会留三个隐患:
id和sku重复:ES 的文档标识用_id,我们选sku做_id。如果 DO 里再带一个id字段,ES 里会多存一份无用的自增 ID,还容易和_id混淆。deleted不该进 ES:逻辑删除标记是“同步任务判断要不要删文档”的依据,不是“文档内容”。如果把它写进 ES,查询时你还得每次加deleted = 0的过滤条件,等于把 MySQL 的麻烦搬进了 ES。- 类型不一致:数据库用
BigDecimal,ES 里double就够用(搜索场景不要求金融级精度)。两个类分开,转换逻辑集中在toDoc()一处,改起来方便。
一句话:ProductDO 是“数据库长什么样”,ProductDoc 是“ES 该长什么样”,职责不同就别合并。
ES 侧索引建议显式创建 Mapping,不要依赖动态推断。name/description 需要全文检索用 text,sku/category 需要精确过滤用 keyword:
esClient.indices().create(c -> c
.index("products")
.mappings(m -> m
.properties("sku", p -> p.keyword(k -> k))
.properties("name", p -> p.text(t -> t))
.properties("description", p -> p.text(t -> t))
.properties("price", p -> p.double_(d -> d))
.properties("category", p -> p.keyword(k -> k))
.properties("updateTime", p -> p.date(d -> d))
)
);
4.3 Mapper:为什么用游标分页,不写 OFFSET
Mapper 接口只保留同步任务需要的两个游标查询:
@Mapper
public interface ProductMapper {
/** 全量同步:按主键游标分批拉取 */
List<ProductDO> selectByCursor(@Param("lastId") long lastId,
@Param("limit") int limit);
/** 增量同步:按 update_time 游标拉取 */
List<ProductDO> selectIncremental(@Param("since") LocalDateTime since,
@Param("limit") int limit);
}
对应的 XML。注意全量用的是 id > #{lastId} 而不是 OFFSET:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.product.mapper.ProductMapper">
<sql id="cols">
id, sku, name, description, price, category, deleted, update_time
</sql>
<select id="selectByCursor" resultType="ProductDO">
SELECT <include refid="cols"/>
FROM t_product
WHERE id > #{lastId}
ORDER BY id
LIMIT #{limit}
</select>
<select id="selectIncremental" resultType="ProductDO">
SELECT <include refid="cols"/>
FROM t_product
WHERE update_time >= #{since}
ORDER BY update_time, id
LIMIT #{limit}
</select>
</mapper>
为什么不用 LIMIT offset, size?LIMIT 1000000, 1000 的意思是“先扫描前 100 万行,丢掉,再取 1000 行”。翻到后面,MySQL 每次都要白扫上百万行,全量同步 1000 万数据时会慢到不可接受。
WHERE id > #{lastId} 则是“从上次停下的位置继续往后取”。因为 id 是主键、有索引,MySQL 可以直接定位到 lastId 那一行,往后读 1000 行就停,不管翻到第几页,速度都一样。
4.4 全量同步:MyBatis 游标 + Bulk 批量写入
全量同步有两个必须避开的坑:不要用 LIMIT offset, size 深分页,不要逐条调用 index() 。正确做法是按主键游标分批读、每批用 Bulk 写:
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductSyncService {
private final ProductMapper productMapper;
private final ElasticsearchClient esClient;
private static final int BATCH_SIZE = 1000;
public void fullSync() {
long lastId = 0L; // 第一次从 0 开始
int total = 0;
while (true) {
List<ProductDO> rows = productMapper.selectByCursor(lastId, BATCH_SIZE);
if (rows.isEmpty()) break; // 取不到 = 读完了
writeBatch(rows);
lastId = rows.get(rows.size() - 1).getId(); // 记住本批最后一条的 id
total += rows.size();
log.info("全量同步进度:已处理 {} 条,lastId={}", total, lastId);
}
// 全部导入完成后统一 refresh 一次,让数据对搜索可见
esClient.indices().refresh(r -> r.index("products"));
log.info("全量同步完成,共 {} 条", total);
}
lastId 每轮更新成本批最后一条的 id,下一轮从它之后继续,直到查不出数据为止。
下面是批量写入的核心:
private void writeBatch(List<ProductDO> rows) {
BulkRequest.Builder br = new BulkRequest.Builder();
for (ProductDO row : rows) {
if (row.getDeleted() != null && row.getDeleted() == 1) {
// 源库已逻辑删除 → 直接从 ES 删掉
br.operations(op -> op.delete(d -> d
.index("products")
.id(row.getSku())));
} else {
// 正常数据 → upsert 写入
br.operations(op -> op.index(idx -> idx
.index("products")
.id(row.getSku()) // 用 sku 作为 _id,保证幂等覆盖
.document(toDoc(row))));
}
}
BulkResponse resp = esClient.bulk(br.build());
// Bulk 不会因为单条失败而抛异常,必须显式检查
if (resp.errors()) {
for (BulkResponseItem item : resp.items()) {
if (item.error() != null) {
log.error("写入失败 id={}, reason={}",
item.id(), item.error().reason());
}
}
throw new IllegalStateException("Bulk 写入存在失败项,同步中断");
}
}
private ProductDoc toDoc(ProductDO row) {
return new ProductDoc(
row.getSku(), row.getName(), row.getDescription(),
row.getPrice().doubleValue(), row.getCategory(), row.getUpdateTime());
}
}
这段代码里有四个细节值得展开:
为什么用 Bulk:如果循环里对每条数据调一次 esClient.index(),1000 条就是 1000 次 HTTP 往返。Bulk 把 1000 个操作打包成一个请求发出去,网络开销从 1000 次降到 1 次,全量同步的速度差出几十倍。
op.index 和 op.delete 的区别:op.index 是 upsert:_id 不存在就新建,存在就整个覆盖。所以同一个 sku 重复同步不会产生重复文档,任务可以安全重跑。op.delete 是删除。源库 deleted = 1 的记录,我们不是把它写进 ES 再标记,而是直接从 ES 删掉——ES 里不该存在“已删除”的文档。
为什么 _id 用 sku 而不是 id:sku 是业务唯一键,稳定不变。就算数据库迁移导致自增 id 变了,用 sku 做 _id 也不会出现“同一条商品在 ES 里变成两条”。
为什么必须检查 resp.errors() :Bulk 的设计是“尽力而为”:1000 条里失败 3 条,剩下 997 条照常写入,HTTP 状态码依然可能是 200,不会抛异常。如果你不检查 resp.errors(),任务会显示“成功”,但那 3 条数据永远丢了,而且没人知道。所以必须逐条看 item.error(),有失败就记日志并中断,让人工介入。
4.5 增量同步:为什么游标要回退 2 秒
全量跑完之后,增量任务按 update_time 定期拉取变更:
/** 增量同步:每 10 秒跑一次,拉取最近变更 */
@Scheduled(fixedDelay = 10_000)
public void incrementalSync() {
// 游标持久化在 DB 或 Redis 中;这里用内存变量示意
// 留 2 秒回退窗口,避免同一时刻边界数据被跳过
LocalDateTime since = lastSyncTime.minusSeconds(2);
List<ProductDO> rows = productMapper.selectIncremental(since, BATCH_SIZE);
if (rows.isEmpty()) return;
writeBatch(rows);
lastSyncTime = rows.get(rows.size() - 1).getUpdateTime();
}
假设上次同步到 10:00:00.500,直接从这个时间往后查,10:00:00.500 那一毫秒内的其他记录可能被跳过(游标只记了最后一条的时间)。往前退 2 秒,把边界附近的数据重新拉一遍。重拉的数据会走 Bulk 的 index 覆盖写入,幂等,不会产生重复,代价只是多写几条。
这就是所谓的“宁可重复,不可遗漏”——同步任务的重跑成本很低,丢数据的排查成本很高。
生产环境如果对同步延迟要求更高(秒级以内),
update_time轮询就不够用了,通常会换成 Canal / Debezium 订阅 binlog,把变更事件推到 MQ,消费端再写 ES。思路是一样的:ES 的写入永远来自 CDC 通道,而不是业务代码双写——双写一旦一边失败,数据就永久不一致了。
4.6 搜索:单条件与 BoolQuery 多条件组合
数据同步到位后,查询侧就是纯读。单条件全文检索如下,match 会对查询词分词后匹配 name 字段:
String keyword = "自行车";
SearchResponse<ProductDoc> response = esClient.search(s -> s
.index("products")
.query(q -> q
.match(m -> m.field("name").query(keyword)))
.size(20),
ProductDoc.class
);
for (Hit<ProductDoc> hit : response.hits().hits()) {
System.out.println(hit.source());
}
真实业务更多是多条件组合。下面的场景覆盖了绝大多数商品搜索:关键词必须命中描述,价格和分类做硬过滤,名称命中可加分:
SearchResponse<ProductDoc> 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("自行车")))
))
.from(0).size(20)
.sort(so -> so.field(f -> f.field("price").order(SortOrder.Asc)))
.sort(so -> so.field(f -> f.field("_score").order(SortOrder.Desc))),
ProductDoc.class
);
用购物搜索类比,这三个关键字各管一件事:
- ①
must(必须命中,且参与打分) :用户输入的“铝合金”。文档里description必须包含这个词,而且包含得越好、得分越高,排名越靠前。这里的关键词来自用户,是需要“算相关性”的。 - ② ③
filter(必须命中,但不打分) :价格 500–2000、分类是“运动户外”。这是用户点的筛选框,只有“符合/不符合”两种结果,没有“多符合”一说,所以不需要算分。不算分的好处是 ES 可以把过滤结果缓存成位图,多次查询直接做位运算,快很多。 - ④
should(可选命中,命中加分) :名称里也出现“自行车”的文档,在同等条件下排名更靠前。不命中也不影响结果被返回。
一句话记法:must 放“用户打的关键词”,filter 放“下拉框选的条件”,should 放“锦上添花的加分项”。把价格、分类塞进 must 也能查出来,但会白白多算一遍相关性分数,QPS 高的时候就是性能差距。
最后的两个 sort 是按顺序生效的:先按价格升序;价格相同的,再按相关性得分降序。第二个 sort 的作用是“价格一样时,谁更相关谁排前面”,避免同价位商品顺序随机。
4.7 关于写入后立刻可查
由于 refresh 的 1 秒延迟,同步任务刚写完的一批数据,紧接着的查询可能看不到。正确的做法不是给每条写入加 Refresh.WaitFor(那会让全量同步慢一个数量级),而是在批量导入结束后统一触发一次——也就是 4.4 里 fullSync() 结尾那行 refresh()。
只有在极少数“接口创建后要求用户立即搜到”的场景,才在单条写入上使用 .refresh(Refresh.WaitFor),且必须清楚它的性能代价。
五、生产实践:几个决定成败的细节
5.1 分片不是越多越好
一个常见的错误是给索引设置几十个分片,以为“分片多 = 并行度高”。实际上每个分片都是一个独立的 Lucene 实例,分片过多会带来显著的管理开销和堆内存压力。
业界较为一致的实践是:单个分片大小控制在 10GB–50GB,同时每个节点的分片数控制在“每 1GB 堆内存约 20 个分片”以内。规划时按“当前数据量 ÷ 30GB”估算主分片数,并预留一定的增长空间。由于主分片数创建后不可改,这一步值得在索引设计阶段多花时间。副本数方面,生产环境至少设置为 1,才能容忍单个节点宕机而不丢数据。
5.2 Mapping 设计:text 还是 keyword
默认动态 Mapping 会把字符串字段同时映射为 text 和 keyword,既浪费存储又容易踩“聚合时字段不可用”的坑。4.2 中的显式 Mapping 就是推荐做法:需要全文检索的字段用 text(中文场景需安装 IK 等中文分词插件),需要精确匹配、排序或聚合的字段用 keyword,数值和日期用对应类型。
5.3 同步任务本身要有可观测性
同步是 ES 可用性的命门。生产上至少要做到三件事:
- 记录游标:
lastId/lastSyncTime持久化,任务重启后从断点续传,而不是从头再跑; - 监控延迟:
MySQL 最新 update_time - 游标时间超过阈值就告警,比“ES 里查不到数据”早得多发现问题; - 定期对账:低峰期抽样比对 MySQL 与 ES 的文档数量和关键字段,处理 Bulk 失败项与逻辑删除遗漏。
5.4 Mapper 层的一个小建议
selectByCursor 和 selectIncremental 都只查固定列、不做动态 SQL,因此直接用 resultType + 驼峰映射就够了,没必要写 resultMap。但要注意:不要在同步 Mapper 里复用业务查询的 Base_Column_List,避免业务侧加字段时意外把大字段(如详情富文本)带进同步查询,白白拖慢批量读。
六、选型速查:什么时候该用 ES,什么时候不该
| 场景 | 优先选择 | 原因 |
|---|---|---|
| 主键/唯一键精确查询 | MySQL | B+ 树单行读取最快,ES 无优势 |
| 强事务(支付、订单状态流转) | MySQL | ES 不支持 ACID 事务 |
| 高频更新(秒级多次) | MySQL | ES 更新 = 删除 + 重建,代价高 |
| 多表复杂关联 | MySQL | ES 不支持 JOIN |
| 全文检索 / 模糊匹配 / 拼写容错 | ES | 倒排索引 + 分词器,MySQL LIKE 无法比拟 |
| 多条件动态组合筛选 | ES | BoolQuery + filter 位图缓存,无需精心设计复合索引 |
| 日志分析 / 可观测性 | ES | ELK 生态事实标准,聚合分析能力强 |
| 海量数据(千万级以上)的灵活检索 | ES | 分片水平扩展,查询延迟可控在毫秒级 |
总结
一句话概括:把事务和强一致交给 MySQL,把“被搜索”交给 Elasticsearch。两者的数据流是单向的:业务只写 MySQL,同步任务负责把需要检索的字段搬进 ES,查询侧按一致性要求分流。
回到代码层面,这篇文章给出一条完整的落地链路,核心代码点可以归纳为下面这张表:
| 代码 | 一句话 |
|---|---|
| 三层客户端 | HTTP / 序列化 / 业务 API 各管一层,客户端全局单例复用 |
| DO vs Doc | 数据库实体和 ES 文档分开,别把 deleted 塞进 ES |
| 游标分页 | WHERE id > #{lastId} 替代 OFFSET,翻到第几页都快 |
| Bulk | 批量打包发一次 HTTP;index 是 upsert,delete 直接删;必须检查 errors() |
| BoolQuery | must 关键词、filter 硬条件、should 加分项 |
| 增量回退 2 秒 | 宁可重复写,不可漏数据,Bulk 覆盖是幂等的 |
| 批量后 refresh | 全量导入结束统一 refresh 一次,不要逐条等待 |
比记住 API 更重要的,是理解倒排索引为什么快、分片为什么这样设计、以及为什么 ES 只能做副本而不能做真理来源。这些底层认知,才是你在真实业务场景中做出正确技术判断的依据。