返回

MySQL LIKE '%...%'太慢? Spring Data JPA 模糊查询优化实战

mysql

告别 MySQL LIKE '%...%' 噩梦:优化 Spring Data JPA 模糊查询性能

咱们在用 Spring Data JPA 开发时,经常会遇到需要在数据库某个字段上做模糊搜索的需求。JpaSpecificationExecutor 配合 CriteriaBuilderlike 方法通常是第一反应。但如果场景稍微复杂点,比如今天要聊的这种情况,就可能掉进性能陷阱里。

挠头的难题:混合数据下的模糊搜索

想象一下,你的 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 的一部分,都能找到包含这个片段的记录。

你可能立刻想到在 JpaSpecificationExecutorSpecification 实现里这么写:

// 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 查询,是数据库索引的“克星”。

  1. 索引失效: 数据库索引(比如 B-Tree 索引)通常依赖于从左到右匹配前缀。当你使用前导通配符 % 时,数据库不知道要查找的值从哪里开始,索引就没法快速定位,只能放弃使用索引(针对 c1 列的普通索引)。
  2. 全表扫描 (Full Table Scan): 索引失效的直接后果就是全表扫描。数据库必须一行一行地检查 c1 字段,看它是否包含 some_value 这个子串。数据量越大,扫描的行数越多,查询时间自然指数级增长。
  3. 混合内容的挑战: c1 字段里既有文本又有 JSON 字符串,这让事情更麻烦。数据库看到的都是字符串,它不会智能地区分 JSON 结构进行优化。每次 LIKE 都得对整个 400 字符长度的字符串进行子串匹配。

既然直接 LIKE 行不通,我们得换个思路。

解决方案大比拼

面对这种性能瓶颈,没有一招鲜吃遍天的银弹。得根据具体情况权衡利弊,选择合适的方案,甚至组合使用。下面列出几种可行的思路:

方案一:MySQL 全文索引 (Full-Text Search)

如果你的模糊搜索主要针对的是字段中的“单词”或“短语”,而不是任意字符片段(比如 34 这种数字中间的部分),可以考虑 MySQL 自带的全文索引。

原理和作用:

全文索引 (FTS) 会对文本内容进行分词(按空格、标点等),然后建立一个特殊的索引,记录每个词出现在哪些文档(行)中。查询时使用 MATCH() AGAINST() 语法,MySQL 会利用这个索引快速找到包含指定词语的行,效率远高于 LIKE '%...%'

操作步骤和代码示例:

  1. c1 字段添加全文索引 (需要 MyISAM 引擎或 InnoDB 引擎 - MySQL 5.6+ 支持 InnoDB FTS):

    -- 确保你的 t1 表引擎是 InnoDB (或者 MyISAM)
    ALTER TABLE t1 ADD FULLTEXT INDEX ft_index_c1 (c1);
    
  2. 在 JPA 中使用 MATCH AGAINST
    标准的 JPA CriteriaBuilder 没有直接提供 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.propertiesDialect 子类中注册:

      # 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 结构化查询等,并且性能极高。

操作步骤和代码示例:

  1. 部署 Elasticsearch/OpenSearch: 你需要一个运行中的 ES/OS 集群。
  2. 数据同步:
    • 一次性全量导入: 使用工具 (如 Logstash, ES 自带的 reindex API,或自定义脚本) 将现有数据导入 ES。
    • 增量同步:t1 表发生增删改时,需要将变更同步到 ES。常用方式:
      • 应用层同步: 在你的 Spring Boot 应用中,当保存、更新、删除 YourEntity 时,同时调用 ES 的 API 操作对应文档。简单直接,但有数据不一致风险(比如数据库成功,ES 失败)。
      • 消息队列 (MQ): 应用将变更事件发送到 Kafka/RabbitMQ 等,然后有一个独立的消费者服务负责从 MQ 读取事件并更新 ES。更解耦,更可靠。
      • 数据库 Binlog: 使用 Canal、Debezium 等工具监听 MySQL 的 binlog,将变更事件解析出来推送到 ES。对应用代码无侵入,但运维复杂度稍高。
  3. 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 来构建复杂查询
      }
      
    • 在你的服务中注入 T1ElasticsearchRepositoryElasticsearchRestTemplate,用它来执行搜索。

额外建议:

  • 安全: 保护好你的 ES 集群,设置认证、授权,限制网络访问。
  • 数据一致性: 仔细设计和监控你的数据同步方案,确保 ES 和 MySQL 之间的数据延迟和不一致性在可接受范围内。

进阶使用技巧:

  • 利用 ES 的 Custom Analyzerc1 字段进行更精细的分词控制,比如结合 standard tokenizer 和 lowercase, asciifolding filter 处理文本,或者使用 nGram tokenizer 来支持任意子串匹配。
  • 对 JSON 内容,可以将 JSON 对象在索引时就解析为 ES 的嵌套 (Nested) 或对象 (Object) 类型,从而支持对特定 key 或 value 的精确/模糊查询。
  • 研究 ES 的 Fuzzy Query ,它可以处理拼写错误或轻微的字符差异。

方案三:改造表结构 (Normalization/Splitting)

这是一个数据库设计层面的调整。如果可能,尽量避免把结构化(JSON)和非结构化(文本)数据混在一个 VARCHAR 字段里。

原理和作用:

分析 c1 字段里存储的数据模式和常见的查询需求。尝试将可结构化的信息提取出来,放到专门的列中,并对这些列建立合适的索引。

操作步骤:

  1. 分析数据和查询: 确定 c1 中 JSON 最常见的结构是什么?用户最常搜索的是 JSON 的 key、value,还是整个 JSON 片段,或者是普通文本?

  2. 添加新列:

    • 比如,如果经常搜索某个特定的 key (假设是 "userId" ),可以添加一个 user_id VARCHAR(...)INT 列。
    • 如果普通文本内容也很重要,可以添加一个 c1_text TEXT 列,并考虑对其建立全文索引。
    • 如果 JSON 结构相对固定,甚至可以考虑将其完全打平到多个列。
    • 对于 MySQL 5.7,可以考虑使用 Generated Columns (如果需要基于原 c1 列自动生成某些值并索引),但其功能相对 MySQL 8+ 有限。
  3. 数据迁移和填充:

    • 编写脚本将 t1 表中 c1 字段的数据解析出来,填充到新添加的列中。对于存量数据,这是一次性的工作。
    • 修改应用程序代码,在插入和更新 t1 表时,不仅写入 c1,也要同时解析并填充新增的列。可以使用数据库触发器 (Trigger) 自动完成一部分工作,但在应用层处理通常更灵活。
  4. 修改查询逻辑:

    • 更新你的 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 字段提取出的值的虚拟列或存储列,并对其建立索引。

操作步骤:

  1. 规划并执行数据库升级到 MySQL 8.0 或更高版本。
  2. 如果可行,考虑将 c1 字段类型改为 JSON
  3. 利用新的 JSON 函数和索引特性来优化查询。

额外建议:

  • 数据库升级本身是个大工程,需要充分测试。
  • 即使升级了,对于非常复杂的全文/模糊搜索需求,Elasticsearch 等外部引擎可能仍然是更优解。但对于 JSON 结构化查询,MySQL 8+ 的能力强了很多。

如何选择?

没有完美的方案,只有最合适的方案。你需要根据自己的情况来权衡:

  • 搜索精度要求? 如果只需要搜单词/短语,MySQL FTS (方案一) 也许够用,且实现相对简单。如果需要任意子串匹配、JSON 结构内搜索、容错模糊查询,Elasticsearch (方案二) 是王道。
  • 对系统复杂度的容忍度? 引入 ES 会增加运维负担和架构复杂度。FTS 或 表结构改造 (方案三) 对基础设施要求低,但可能牺牲部分查询灵活性或需要大量数据迁移工作。
  • 预算和时间? ES 需要额外的服务器资源。表结构改造和数据迁移需要开发和测试时间。数据库 升级 (方案四) 也是一个需要投入资源的项目。
  • 数据实时性要求? 使用 ES 时,数据同步延迟是个需要考虑的问题。FTS 和表结构改造(如果填充逻辑及时)基本是实时的。

通常的路径可能是:

  1. 先尝试 FTS :如果能满足大部分需求,且能接受其限制(比如对非单词边界匹配无力),这是最快见效的方案。
  2. 评估 ES :如果 FTS 不行,或者预见到未来搜索需求会更复杂,认真考虑引入 ES。初期投入大,长期收益高。
  3. 考虑表结构 :如果数据模式允许,且查询场景相对固定,改造表结构是治本之策,能极大提升特定查询的性能。
  4. 升级 MySQL :作为长期计划的一部分,升级可以改善 JSON 处理能力,但可能不会完全解决所有模糊文本搜索问题。

希望以上分析能帮你找到解决 LIKE '%...%' 性能问题的有效方法!