MySQL LIKE '%...%'太慢? Spring Data JPA 模糊查询优化实战
2025-05-04 05:57:19
告别 MySQL LIKE '%...%'
噩梦:优化 Spring Data JPA 模糊查询性能
咱们在用 Spring Data JPA 开发时,经常会遇到需要在数据库某个字段上做模糊搜索的需求。JpaSpecificationExecutor
配合 CriteriaBuilder
的 like
方法通常是第一反应。但如果场景稍微复杂点,比如今天要聊的这种情况,就可能掉进性能陷阱里。
挠头的难题:混合数据下的模糊搜索
想象一下,你的 MySQL 表 t1
里有个字段 c1
,类型是 VARCHAR(400)
。这个字段的设计有点“放飞自我”,里面啥都存:
- 普通文本:
"abcd"
- 数字字符串:
"12345.6"
- 简单的 JSON:
{"key1":"value1"}
- 复杂的 JSON:
{"key1":"value1","key2":"value2"}
现在,产品要求用户能在这个 c1
字段里进行模糊搜索。用户输入的可能是:
- 文本的一部分:
"cd"
(匹配"abcd"
) - 数字的一部分:
"34"
(匹配"12345.6"
) - 整个 JSON 串:
{"key1":"value1"}
- JSON 里的值:
"value1"
(匹配{"key1":"value1"}
和{"key1":"value1","key2":"value2"}
) - JSON 的一部分(比如
{"key1"
或"value1"}
)
简单说,用户想输入任意片段,无论它是普通文本还是 JSON 的一部分,都能找到包含这个片段的记录。
你可能立刻想到在 JpaSpecificationExecutor
的 Specification
实现里这么写:
// import jakarta.persistence.criteria.Predicate; // JPA 2.2+
// import javax.persistence.criteria.Predicate; // Older JPA
// ... in your Specification implementation ...
@Override
public Predicate toPredicate(Root<YourEntity> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
// ... other predicates
String searchValue = // get the user input search value like "34" or "value1"
Predicate p = criteriaBuilder.like(root.get("c1"), "%" + searchValue + "%");
// ... combine predicates
return p; // or combined predicate
}
代码看起来没毛病,逻辑也符合“模糊搜索”的字面意思。一跑起来,完了,慢得像蜗牛。尤其数据量一大,数据库 CPU 飙升,接口响应超时。
为啥 LIKE '%...%'
这么慢?
问题就出在 like(root.get("c1"), "%" + searchValue + "%")
这行代码生成的 SQL 上。它大致对应 SQL 里的:
SELECT * FROM t1 WHERE c1 LIKE '%some_value%';
这种前后都有百分号 的 LIKE
查询,是数据库索引的“克星”。
- 索引失效: 数据库索引(比如 B-Tree 索引)通常依赖于从左到右匹配前缀。当你使用前导通配符
%
时,数据库不知道要查找的值从哪里开始,索引就没法快速定位,只能放弃使用索引(针对c1
列的普通索引)。 - 全表扫描 (Full Table Scan): 索引失效的直接后果就是全表扫描。数据库必须一行一行地检查
c1
字段,看它是否包含some_value
这个子串。数据量越大,扫描的行数越多,查询时间自然指数级增长。 - 混合内容的挑战:
c1
字段里既有文本又有 JSON 字符串,这让事情更麻烦。数据库看到的都是字符串,它不会智能地区分 JSON 结构进行优化。每次LIKE
都得对整个 400 字符长度的字符串进行子串匹配。
既然直接 LIKE
行不通,我们得换个思路。
解决方案大比拼
面对这种性能瓶颈,没有一招鲜吃遍天的银弹。得根据具体情况权衡利弊,选择合适的方案,甚至组合使用。下面列出几种可行的思路:
方案一:MySQL 全文索引 (Full-Text Search)
如果你的模糊搜索主要针对的是字段中的“单词”或“短语”,而不是任意字符片段(比如 34
这种数字中间的部分),可以考虑 MySQL 自带的全文索引。
原理和作用:
全文索引 (FTS) 会对文本内容进行分词(按空格、标点等),然后建立一个特殊的索引,记录每个词出现在哪些文档(行)中。查询时使用 MATCH() AGAINST()
语法,MySQL 会利用这个索引快速找到包含指定词语的行,效率远高于 LIKE '%...%'
。
操作步骤和代码示例:
-
给
c1
字段添加全文索引 (需要 MyISAM 引擎或 InnoDB 引擎 - MySQL 5.6+ 支持 InnoDB FTS):-- 确保你的 t1 表引擎是 InnoDB (或者 MyISAM) ALTER TABLE t1 ADD FULLTEXT INDEX ft_index_c1 (c1);
-
在 JPA 中使用
MATCH AGAINST
:
标准的 JPACriteriaBuilder
没有直接提供MATCH AGAINST
函数。你有几种方式在 JPA 中使用它:-
使用原生 SQL 查询: 在 Repository 接口中定义一个方法,使用
@Query
注解并设置nativeQuery = true
。import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface T1Repository extends JpaRepository<YourEntity, Long>, JpaSpecificationExecutor<YourEntity> { @Query(value = "SELECT * FROM t1 WHERE MATCH(c1) AGAINST (:searchTerm IN BOOLEAN MODE)", nativeQuery = true) List<YourEntity> findByC1FullTextSearch(@Param("searchTerm") String searchTerm); // 你也可以在 Specification 中构建包含原生SQL片段的查询,但这比较复杂且可能破坏类型安全 }
注意:
IN BOOLEAN MODE
提供更灵活的搜索,比如使用+
(必须包含),-
(排除),*
(通配符) 等。普通模式 (IN NATURAL LANGUAGE MODE
,默认) 对自然语言查询更友好。 -
注册自定义 SQL 函数: 可以通过配置,让 Hibernate/JPA 知道
MATCH
是一个合法的函数,然后在CriteriaBuilder
中使用criteriaBuilder.function()
调用。这相对复杂,需要根据你的 JPA Provider (Hibernate) 版本进行配置。
例如,可能需要在application.properties
或Dialect
子类中注册:# Example for Hibernate specific property (syntax might vary) # spring.jpa.properties.hibernate.dialect = com.yourcompany.YourMySQLCustomDialect
然后在
YourMySQLCustomDialect
中注册函数。或者在 Criteria API 中尝试(需要确保 JPA Provider 能翻译):
// Inside Specification... // String searchTermInBooleanMode = buildBooleanModeSearchTerm(searchValue); // e.g., add +* if needed Expression<Boolean> matchExpression = criteriaBuilder.function( "MATCH", Boolean.class, root.get("c1") ).against( criteriaBuilder.literal(searchTermInBooleanMode + " IN BOOLEAN MODE") // Or handle mode separately if function expects it ); Predicate p = criteriaBuilder.isTrue(matchExpression);
这种方式不保证所有 JPA 实现都能完美转换,原生查询通常更可靠。
-
额外建议:
- 分词和最小词长: MySQL FTS 对中文分词支持有限(尤其在 5.7 版本,内置分词器可能效果不佳),且有
ft_min_word_len
(最小索引词长,默认 InnoDB 是 3) 和ft_max_word_len
参数限制。如果你的搜索词很短(比如少于 3 个字符),或者中文搜索效果不好,FTS 可能不适用或需要调整配置、使用第三方分词插件(如ngram
,MySQL 5.7.6+ 支持 InnoDB 的 ngram parser)。 - 不适合任意子串: FTS 主要解决的是“包含某个词”的问题,对于
"34"
这种非单词边界的子串搜索,或者 JSON 内部结构的部分匹配,效果不佳。它看到{"key1":"value1"}
可能会把它分解成"key1"
,"value1"
(取决于标点处理),但搜"ey1"
就很难匹配。
进阶使用技巧:
- 研究
IN BOOLEAN MODE
的各种操作符,实现更复杂的搜索逻辑。 - 调整
ft_min_word_len
,ft_max_word_len
和停用词列表 (stopword list) 来优化索引和查询行为。注意修改这些参数后需要重建索引 (REPAIR TABLE t1 QUICK
)。
方案二:拥抱外部搜索引擎 (Elasticsearch/OpenSearch)
如果全文索引无法满足你对任意子串、JSON 内容以及高性能的要求,那么引入一个专门的搜索引擎是更优的选择。Elasticsearch (ES) 或其分支 OpenSearch 是常见的选择。
原理和作用:
你需要将 t1
表中的数据(或至少 c1
字段和主键)同步到 Elasticsearch 集群中。ES 会对数据进行强大的分词、索引。查询时,你的应用不再直接查 MySQL,而是请求 ES。ES 提供了丰富的查询 DSL (Domain Specific Language),支持精确匹配、模糊查询 (fuzzy query)、短语匹配、通配符查询、对 JSON 结构化查询等,并且性能极高。
操作步骤和代码示例:
- 部署 Elasticsearch/OpenSearch: 你需要一个运行中的 ES/OS 集群。
- 数据同步:
- 一次性全量导入: 使用工具 (如 Logstash, ES 自带的 reindex API,或自定义脚本) 将现有数据导入 ES。
- 增量同步: 当
t1
表发生增删改时,需要将变更同步到 ES。常用方式:- 应用层同步: 在你的 Spring Boot 应用中,当保存、更新、删除
YourEntity
时,同时调用 ES 的 API 操作对应文档。简单直接,但有数据不一致风险(比如数据库成功,ES 失败)。 - 消息队列 (MQ): 应用将变更事件发送到 Kafka/RabbitMQ 等,然后有一个独立的消费者服务负责从 MQ 读取事件并更新 ES。更解耦,更可靠。
- 数据库 Binlog: 使用 Canal、Debezium 等工具监听 MySQL 的 binlog,将变更事件解析出来推送到 ES。对应用代码无侵入,但运维复杂度稍高。
- 应用层同步: 在你的 Spring Boot 应用中,当保存、更新、删除
- Spring Boot 集成 Elasticsearch:
-
添加依赖:
spring-boot-starter-data-elasticsearch
。 -
配置 ES 连接信息 (
spring.elasticsearch.*
)。 -
创建对应的 Elasticsearch 文档实体 (
@Document
),可以只包含需要的字段(如 id 和 c1)。import org.springframework.data.annotation.Id; import org.springframework.data.elasticsearch.annotations.Document; import org.springframework.data.elasticsearch.annotations.Field; import org.springframework.data.elasticsearch.annotations.FieldType; @Document(indexName = "t1_index") // ES 索引名 public class T1Document { @Id private Long id; // 对应数据库 t1 表的主键 @Field(type = FieldType.Text, analyzer = "standard") // 使用标准分词器处理,支持全文搜索 private String c1; // getters and setters }
-
创建 Elasticsearch Repository 接口:
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; public interface T1ElasticsearchRepository extends ElasticsearchRepository<T1Document, Long> { // Spring Data ES 会自动生成类似 SQL 的查询 // 或者使用更强大的查询方式 // 使用 Query String Query 或其他 ES 查询方式 // 例如,一个简单的通配符或模糊查询 (需要根据具体需求选择 ES 查询类型) // 下面是一个示意,具体查询需要用 ES 的查询DSL 构建 List<T1Document> findByC1Containing(String partialValue); // 类似 LIKE '%...%' // 推荐使用 ElasticsearchRestTemplate 或 Criteria API for ES 来构建复杂查询 }
-
在你的服务中注入
T1ElasticsearchRepository
或ElasticsearchRestTemplate
,用它来执行搜索。
-
额外建议:
- 安全: 保护好你的 ES 集群,设置认证、授权,限制网络访问。
- 数据一致性: 仔细设计和监控你的数据同步方案,确保 ES 和 MySQL 之间的数据延迟和不一致性在可接受范围内。
进阶使用技巧:
- 利用 ES 的 Custom Analyzer 对
c1
字段进行更精细的分词控制,比如结合standard
tokenizer 和lowercase
,asciifolding
filter 处理文本,或者使用nGram
tokenizer 来支持任意子串匹配。 - 对 JSON 内容,可以将 JSON 对象在索引时就解析为 ES 的嵌套 (Nested) 或对象 (Object) 类型,从而支持对特定 key 或 value 的精确/模糊查询。
- 研究 ES 的 Fuzzy Query ,它可以处理拼写错误或轻微的字符差异。
方案三:改造表结构 (Normalization/Splitting)
这是一个数据库设计层面的调整。如果可能,尽量避免把结构化(JSON)和非结构化(文本)数据混在一个 VARCHAR
字段里。
原理和作用:
分析 c1
字段里存储的数据模式和常见的查询需求。尝试将可结构化的信息提取出来,放到专门的列中,并对这些列建立合适的索引。
操作步骤:
-
分析数据和查询: 确定
c1
中 JSON 最常见的结构是什么?用户最常搜索的是 JSON 的 key、value,还是整个 JSON 片段,或者是普通文本? -
添加新列:
- 比如,如果经常搜索某个特定的 key (假设是
"userId"
),可以添加一个user_id VARCHAR(...)
或INT
列。 - 如果普通文本内容也很重要,可以添加一个
c1_text TEXT
列,并考虑对其建立全文索引。 - 如果 JSON 结构相对固定,甚至可以考虑将其完全打平到多个列。
- 对于 MySQL 5.7,可以考虑使用 Generated Columns (如果需要基于原 c1 列自动生成某些值并索引),但其功能相对 MySQL 8+ 有限。
- 比如,如果经常搜索某个特定的 key (假设是
-
数据迁移和填充:
- 编写脚本将
t1
表中c1
字段的数据解析出来,填充到新添加的列中。对于存量数据,这是一次性的工作。 - 修改应用程序代码,在插入和更新
t1
表时,不仅写入c1
,也要同时解析并填充新增的列。可以使用数据库触发器 (Trigger) 自动完成一部分工作,但在应用层处理通常更灵活。
- 编写脚本将
-
修改查询逻辑:
-
更新你的
JpaSpecificationExecutor
实现,让它查询新添加的、有索引的列 ,而不是(或不仅仅是)慢速的c1
列。 -
例如,如果用户输入的是数字,可能优先去
user_id
列查;如果输入的是普通单词,可能去c1_text
列进行全文搜索;只有实在没法分类,或者用户就是要搜原始c1
的片段时,才回退到(可能仍然很慢的)LIKE '%...%'
查询c1
本身(如果这个需求无法放弃)。// Inside Specification... simplified example Predicate p1 = null; Predicate p2 = null; if (isLikelyUserId(searchValue)) { p1 = criteriaBuilder.equal(root.get("userId"), parseUserId(searchValue)); } else { // Maybe use MATCH against c1_text if it exists and makes sense // p2 = criteriaBuilder.isTrue(criteriaBuilder.function("MATCH", Boolean.class, root.get("c1Text")).against(...)); // Fallback to LIKE on c1 if absolutely necessary AND acceptable performance-wise // p2 = criteriaBuilder.like(root.get("c1"), "%" + searchValue + "%"); } // Combine p1, p2 using OR or AND as needed // return criteriaBuilder.or(p1, p2);
-
额外建议:
- 这是一个侵入性较大的改动,需要仔细评估工作量和对现有系统的影响。
- 对于历史数据迁移,务必做好备份和测试。
方案四:(备选/长远考虑) 升级 MySQL 版本
虽然你的当前版本是 MySQL 5.7,但值得一提的是,升级到 MySQL 8.0+ 会带来一些相关的改进。
原理和作用:
MySQL 8.0 对 JSON 支持有了长足进步:
- 原生 JSON 数据类型: 可以直接存储 JSON,数据库能理解其结构。
- JSON 函数增强: 提供了更多强大的函数(如
JSON_EXTRACT
,JSON_CONTAINS
,JSON_SEARCH
,->
,->>
操作符)来查询 JSON 内容。 - 多值索引 (Multi-Valued Indexes): 可以对 JSON 数组中的元素或 JSON 对象的键值建立索引,极大提升对 JSON 内容的查询性能。
- 生成的列 (Generated Columns) + 索引: 可以创建基于 JSON 字段提取出的值的虚拟列或存储列,并对其建立索引。
操作步骤:
- 规划并执行数据库升级到 MySQL 8.0 或更高版本。
- 如果可行,考虑将
c1
字段类型改为JSON
。 - 利用新的 JSON 函数和索引特性来优化查询。
额外建议:
- 数据库升级本身是个大工程,需要充分测试。
- 即使升级了,对于非常复杂的全文/模糊搜索需求,Elasticsearch 等外部引擎可能仍然是更优解。但对于 JSON 结构化查询,MySQL 8+ 的能力强了很多。
如何选择?
没有完美的方案,只有最合适的方案。你需要根据自己的情况来权衡:
- 搜索精度要求? 如果只需要搜单词/短语,MySQL FTS (方案一) 也许够用,且实现相对简单。如果需要任意子串匹配、JSON 结构内搜索、容错模糊查询,Elasticsearch (方案二) 是王道。
- 对系统复杂度的容忍度? 引入 ES 会增加运维负担和架构复杂度。FTS 或 表结构改造 (方案三) 对基础设施要求低,但可能牺牲部分查询灵活性或需要大量数据迁移工作。
- 预算和时间? ES 需要额外的服务器资源。表结构改造和数据迁移需要开发和测试时间。数据库 升级 (方案四) 也是一个需要投入资源的项目。
- 数据实时性要求? 使用 ES 时,数据同步延迟是个需要考虑的问题。FTS 和表结构改造(如果填充逻辑及时)基本是实时的。
通常的路径可能是:
- 先尝试 FTS :如果能满足大部分需求,且能接受其限制(比如对非单词边界匹配无力),这是最快见效的方案。
- 评估 ES :如果 FTS 不行,或者预见到未来搜索需求会更复杂,认真考虑引入 ES。初期投入大,长期收益高。
- 考虑表结构 :如果数据模式允许,且查询场景相对固定,改造表结构是治本之策,能极大提升特定查询的性能。
- 升级 MySQL :作为长期计划的一部分,升级可以改善 JSON 处理能力,但可能不会完全解决所有模糊文本搜索问题。
希望以上分析能帮你找到解决 LIKE '%...%'
性能问题的有效方法!