Elasticsearch vs MySQL 模糊匹配性能对比
Elasticsearch 响应延迟:
多个实测项目在重负载或大规模索引 (百万量级文档)下能稳定返回 < 100 ms 级别结果。
MySQL 模糊 查询表现:
-
小数据量(< 10 万):MySQL
LIKE '%关键字%'
或内置全文索引,与 ES 差异不大。 -
大数据量(> 百万):MySQL 往往要扫描大量行或计算全文向量,响应会从数百毫秒飙升到秒级,且无法并行拆分。
性能差距数量 级:
业内高负载场景中,ES 对全文/模糊搜索的优化可带来10 × 以上 的速度提升------MySQL 需要线上查询所有匹配,ES 只处理 Top N 并行执行
一、Elasticsearch 是什么
1.1 Elasticsearch 是什么?
Elasticsearch(简称 ES)是一个开源、分布式的搜索引擎,本质上是基于 Apache Lucene 构建而成的一个"搜索数据库"。
🔎 你可以把 Elasticsearch 理解为:
📖 一个能存储 JSON 文档,并能超快搜索、模糊匹配、分词、高亮、聚合分析的 NoSQL 数据库!
1.2 Elasticsearch 的典型应用场景
场景 | 示例描述 |
---|---|
商品搜索引擎 | 淘宝商品关键词模糊搜索、销量排序、价格区间筛选等 |
日志搜索 | ELK 堆栈:Filebeat → Logstash → Elasticsearch + Kibana |
知识问答系统 | 输入"如何使用 Spring Boot",模糊匹配最相关的回答内容 |
新闻推荐 | 根据关键词、标签、点击热度推荐相似内容 |
分析报表系统 | 动态聚合统计、TopN 排行、趋势图等 |
1.3 ES 和 MySQL 有什么区别?
特性 | MySQL | Elasticsearch |
---|---|---|
存储模型 | 行(Row) | 文档(Document,JSON 格式) |
检索方式 | 精确查找(=)为主 | 全文检索 + 模糊匹配 + 分词 + 相关性计算 |
数据结构要求 | 需要预先定义字段、类型 | 支持动态映射,JSON 灵活 |
聚合分析 | SQL GROUP BY 、SUM() |
更强大的聚合分析 terms , avg , stats 等 |
查询性能(模糊) | LIKE/REGEXP 效率低 | 倒排索引效率高 |
二、Elasticsearch 底层原理
2.1 ES 的存储核心是「倒排索引」
什么是倒排索引?
你可以这样理解:
📝 正排索引(MySQL):记录 → 字段
🧠 倒排索引(ES):词语 → 哪些文档有这个词
举个例子:
假设你有 3 条文档:
🔁 倒排索引就是这样的结构:
词项(Term) | 出现在哪些文档 |
---|---|
elasticsearch |
doc1, doc2, doc3 |
spring |
doc3 |
java |
doc1 |
search |
doc2 |
🧠 有了这个结构:
- 你搜索关键词 "elasticsearch" → 不用全表扫,只看这个倒排索引,就知道有哪些文档有这个词!
对比正排 vs 倒排
正排索引(MySQL) | 倒排索引(ES) |
---|---|
一条记录所有字段 | 一个词出现在哪些记录 |
查询慢(like '%xx%') | 查询快(直接查词 → 文档列表) |
优化靠索引 + where 条件 | 内建为搜索引擎,天生支持模糊查 |
2.2 ES 是怎么执行查询的?
🔍 当你发起一个搜索请求:
-
ES 把搜索词分词(如 "java 开发" → "java", "开发")
-
查倒排索引,看哪些文档包含这些词
-
计算"相关性得分"(TF-IDF/BM25)来排序
-
返回最匹配的结果列表(支持分页、过滤、高亮)
三、Spring Data Elasticsearch
3.1 添加依赖(适用于 Spring Boot 3.x)
在 pom.xml
中加入以下依赖:
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
3.2 配置 application.yml
XML
spring:
elasticsearch:
uris: http://localhost:9200
username: elastic # 如果没开启安全认证,可不填
password: changeme
🔍 说明:
-
uris
: 你的 Elasticsearch 服务地址(支持多个,如["http://host1:9200", "http://host2:9200"]
) -
如果你用的是 Elastic Stack 8.x,默认启用了用户认证,请在 Kibana 中设置 elastic 用户的密码后填入
-
云服务(如阿里云、ESCloud)也是支持的,只要能访问对应地址即可
3.3 创建 ES 实体类(支持 MyBatis-Plus 和 Elasticsearch 公用一个实体类)
java
@Data
@Document(indexName = "user_index") // ES索引名
@TableName("user_info") // MySQL 表名
public class User {
@Id // ES 文档主键
@TableId(type = IdType.ASSIGN_ID) // MySQL 主键(雪花算法)
private Long id;
@TableField("open_id")
@Field(type = FieldType.Keyword) // 精确搜索(不分词)
private String openId;
@TableField("nick_name")
@Field(type = FieldType.Text, analyzer = "ik_max_word") // 中文模糊搜索
private String nickName;
@TableField("age")
@Field(type = FieldType.Integer)
private Integer age;
@TableField("bio")
@Field(type = FieldType.Text, analyzer = "ik_smart") // 简略分词搜索
private String bio;
}
🔍 说明:
-
@Document
:表明这是一个 Elasticsearch 文档类 -
@Id
:ES 中的_id
主键字段,字符串或 Long 类型都可以 -
@Field
:对应索引的字段类型,控制是否分词、如何存储、是否支持聚合等
在实体类中,只有需要在 Elasticsearch 中被搜索、排序、过滤、聚合的字段才应该加
@Field
注解 。其他字段如果仅用于数据库存储、业务展示、不参与搜索功能 ,就可以不加
@Field
,从而避免 ES 创建无用索引字段,节省资源。
| 问题点 | 正确做法 |
| 两套映射冲突吗? | 不会,MyBatis 和 ES 只认自己注解 |
| 字段名不一致怎么办? | 用 @TableField("列名")
和 @Field(type=...)
来明确指定 |
主键类型不同可以吗? | 可以,但建议都使用 Long 类型的雪花算法 ID |
---|
3.4 创建 Repository 接口
java
public interface UserRepository extends ElasticsearchRepository<User, Long> {
// 精确匹配(字段必须是 Keyword 类型)
List<User> findByOpenId(String openId);
// 模糊匹配(字段必须是 Text 类型)
List<User> findByNickNameContaining(String keyword);
// 范围查询(数值或时间)
List<User> findByAgeBetween(Integer min, Integer max);
// 多字段联合查询
List<User> findByNickNameContainingOrBioContaining(String kw1, String kw2);
// 排序 + 分页(组合使用 PageRequest)
Page<User> findByAgeGreaterThan(Integer age, Pageable pageable);
}
🔍 说明:
-
ElasticsearchRepository<T, ID>
是 Spring Data 提供的接口,自动实现常用查询 -
方法命名遵循约定(如
findByXxxContaining
→ 对应模糊搜索)
3.5 服务层使用示例
java
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
// 保存或更新
public void save(User user) {
userRepository.save(user);
}
// 按主键查询
public User findById(Long id) {
return userRepository.findById(id).orElse(null);
}
// 删除
public void delete(Long id) {
userRepository.deleteById(id);
}
// 模糊搜索 + 分页
public Page<User> searchByKeyword(String keyword, int page, int size) {
Pageable pageable = PageRequest.of(page, size);
return userRepository.findByNickNameContaining(keyword, pageable);
}
// 多条件联合查询
public List<User> multiFieldSearch(String keyword) {
return userRepository.findByNickNameContainingOrBioContaining(keyword, keyword);
}
}
五、FieldType 设置与映射规则
5.1 常见 MySQL 类型 → ES FieldType 映射
MySQL 类型 | ES 类型 | 推荐 FieldType |
场景 |
---|---|---|---|
varchar / char |
keyword / text |
Keyword 或 Text |
精确匹配 vs 分词搜索 |
int |
integer |
Integer |
数值筛选、排序 |
bigint |
long |
Long |
用户ID、主键 |
decimal / float |
double / float |
Double |
金额、评分 |
datetime / timestamp |
date |
Date |
时间区间筛选 |
text / longtext |
text |
Text |
全文搜索字段 |
5.2 Text vs Keyword 的核心区别
特性 | Text |
Keyword |
---|---|---|
是否分词 | ✅ 是 | ❌ 否 |
是否支持模糊搜索 | ✅ 是 | ❌ 否(只能精确匹配) |
是否支持聚合 | ❌ 否 | ✅ 是(group by 可用) |
是否支持排序 | ❌ 否 | ✅ 是 |
使用场景 | 用户名、描述、正文内容等模糊字段 | ID、手机号、类别、地区码等 |
5.3 高级技巧:Text + Keyword 多字段组合映射
java
@Field(type = FieldType.Text, analyzer = "ik_max_word")
@MultiField(
mainField = @Field(type = FieldType.Text),
otherFields = {
@InnerField(suffix = "keyword", type = FieldType.Keyword)
}
)
private String name;
🔍 ES 会创建两个字段:
字段名 | 类型 | 用途 |
---|---|---|
name |
text |
分词搜索,适合用户输入模糊匹配 |
name.keyword |
keyword |
精确匹配、排序、聚合 |
六、Repository 接口提供的操作
6.1 基础 CRUD 操作
java
userRepository.save(user); // 新增或更新
userRepository.findById("1"); // 按 ID 查找
userRepository.findAll(); // 查询全部
userRepository.existsById("1"); // 判断是否存在
userRepository.deleteById("1"); // 删除
userRepository.count(); // 总文档数量
❗注意:save()方法如果文档
@Id
主键冲突会执行更新操作。
6.2 分页与排序查询
java
Pageable pageable = PageRequest.of(0, 10, Sort.by("age").descending());
Page<User> page = userRepository.findByAgeGreaterThan(18, pageable);
page.getContent(); // 当前页的数据列表
page.getTotalPages(); // 总页数
page.getTotalElements();// 总条数
PageRequest.of(pageIndex, pageSize, Sort...)
是核心入口。
6.3 模糊查询(文本匹配)
方式一:使用命名派生查询(字段类型为 Text
)
java
List<User> users = userRepository.findByNickNameContaining("张");
Containing
相当于%张%
模糊匹配,底层使用match
查询。
方式二:中文精准分词(必须设置 analyzer)
实体字段:
java
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String bio;
搜索方法:
java
List<User> users = userRepository.findByBioContaining("Java 开发");
6.4 组合查询(AND / OR)
java
List<User> list1 = userRepository.findByNickNameContainingAndBioContaining("张", "程序员"); // AND
List<User> list2 = userRepository.findByNickNameContainingOrBioContaining("张", "开发者"); // OR
6.5 范围查询(年龄、时间等)
数值范围
java
List<User> users = userRepository.findByAgeBetween(18, 30);
List<User> users = userRepository.findByAgeGreaterThan(25);
List<User> users = userRepository.findByAgeLessThanEqual(40);
时间范围
java
List<LogDoc> logs = logRepository.findByCreateTimeBetween(
LocalDateTime.now().minusDays(7),
LocalDateTime.now()
);
⚠️ ES 字段要设置为
@Field(type = FieldType.Date)
,格式一般为 ISO8601。
6.6 自定义 DSL 查询(复杂条件)
使用 NativeSearchQueryBuilder
构建复杂查询 DSL(非方法名自动派生):
java
QueryBuilder query = QueryBuilders
.boolQuery()
.must(QueryBuilders.matchQuery("bio", "程序员"))
.filter(QueryBuilders.rangeQuery("age").gte(20).lte(35));
NativeSearchQuery nativeQuery = new NativeSearchQueryBuilder()
.withQuery(query)
.withSort(SortBuilders.fieldSort("age").order(SortOrder.DESC))
.withPageable(PageRequest.of(0, 10))
.build();
SearchHits<User> hits = elasticsearchRestTemplate.search(nativeQuery, User.class);
List<User> result = hits.getSearchHits().stream()
.map(SearchHit::getContent).toList();
6.7 聚合查询(如 group by)
示例:统计每个年龄的人数分布(terms 聚合)
java
NativeSearchQuery query = new NativeSearchQueryBuilder()
.addAggregation(AggregationBuilders.terms("ageAgg").field("age"))
.build();
Aggregations aggs = elasticsearchRestTemplate.search(query, User.class).getAggregations();
Terms ageAgg = aggs.get("ageAgg");
for (Terms.Bucket bucket : ageAgg.getBuckets()) {
System.out.println("年龄: " + bucket.getKeyAsString() + " 人数: " + bucket.getDocCount());
}
聚合类型说明:
聚合类型 | 含义 | 类似 SQL |
---|---|---|
Terms | 按字段分组 | GROUP BY |
Avg / Max / Min | 平均、最大、最小 | AVG(col) |
Histogram | 范围段分组(如时间/金额段) | GROUP BY 时间段 |
Nested | 嵌套字段聚合 | 子表统计 |
6.8 删除操作
java
userRepository.deleteById("123");
userRepository.deleteAllById(List.of("1", "2", "3"));
userRepository.deleteAll(); // 清空索引数据
6.9 索引操作(索引创建、刷新、存在性检查)
java
// 判断索引是否存在
boolean exists = elasticsearchRestTemplate.indexOps(User.class).exists();
// 创建索引(若无)
if (!exists) {
elasticsearchRestTemplate.indexOps(User.class).create();
elasticsearchRestTemplate.indexOps(User.class).putMapping(
elasticsearchRestTemplate.indexOps(User.class).createMapping());
}
// 删除索引
elasticsearchRestTemplate.indexOps(User.class).delete();
方法总结表
类型 | 方法/关键词 |
---|---|
新增或更新 | save(user) |
删除 | deleteById(id) , deleteAll() |
查询所有 | findAll() , findAll(Pageable) |
精确查询 | findByOpenId(String) |
模糊匹配 | findByXxxContaining(String) |
多字段匹配 | findByAContainingAndBContaining(...) |
分页排序 | PageRequest.of(page, size, Sort.by(...)) |
数值范围 | findByAgeBetween(min, max) |
时间范围 | findByCreateTimeBetween(start, end) |
原生查询 | NativeSearchQueryBuilder + QueryBuilders |
聚合分析 | AggregationBuilders.terms/avg/histogram(...) |
判断索引存在 | indexOps(clazz).exists() |
创建索引 | indexOps(clazz).create() + putMapping() |