ES+MySQL優雅的實現模糊搜尋

👉 這是一個或許對你有用的社群
🐱 一對一交流/面試小冊/簡歷最佳化/求職解惑,歡迎加入芋道快速開發平臺知識星球。下面是星球提供的部分資料:
👉這是一個或許對你有用的開源專案
國產 Star 破 10w+ 的開源專案,前端包括管理後臺 + 微信小程式,後端支援單體和微服務架構。
功能涵蓋 RBAC 許可權、SaaS 多租戶、資料許可權、商城、支付、工作流、大屏報表、微信公眾號、ERPCRMAI 大模型等等功能:
  • Boot 多模組架構:https://gitee.com/zhijiantianya/ruoyi-vue-pro
  • Cloud 微服務架構:https://gitee.com/zhijiantianya/yudao-cloud
  • 影片教程:https://doc.iocoder.cn
【國內首批】支援 JDK 17/21 + SpringBoot 3.3、JDK 8/11 + Spring Boot 2.7 雙版本 

1. 技術選型

使用 Elasticsearch (ES) 結合 MySQL 進行資料儲存和查詢,而不是直接從 MySQL 中進行查詢,主要是為了彌補傳統關係型資料庫(如 MySQL)在處理大規模、高併發和複雜搜尋查詢時的效能瓶頸。具體來說,ES 與 MySQL 結合使用的優勢包括以下幾個方面:
  • Elasticsearch優化了全文搜尋: MySQL 在處理複雜的文字搜尋(如模糊匹配、全文搜尋)時效能較差。尤其是當查詢的資料量和文字內容增大時,MySQL 的效能會急劇下降。而 Elasticsearch 專門為高效的文字搜尋設計,能夠透過倒排索引和分散式架構最佳化查詢效能,適用於大規模資料集的全文搜尋,查詢速度通常比 MySQL 快得多。
  • 高效的複雜查詢: Elasticsearch 對於複雜的查詢,如多條件搜尋、範圍查詢、聚合查詢等,提供了比 MySQL 更高效的執行方式。Elasticsearch 支援文件級的分詞、詞彙匹配、近似匹配等複雜查詢方式,這在 MySQL 中是非常難以高效實現的。
  • 即時搜尋: Elasticsearch 提供了快速的即時資料檢索能力,尤其適用於需要快速反饋結果的場景。與之相比,MySQL 在高併發時處理複雜查詢的能力相對較弱。
基於 Spring Boot + MyBatis Plus + Vue & Element 實現的後臺管理系統 + 使用者小程式,支援 RBAC 動態許可權、多租戶、資料許可權、工作流、三方登入、支付、簡訊、商城等功能
  • 專案地址:https://github.com/YunaiV/ruoyi-vue-pro
  • 影片教程:https://doc.iocoder.cn/video/

2. 建立elasticsearch公共包

當然這裡我是使用微服務的思想,不直接將ES服務直接匯入,在業務模組下。如果只是學習使用,或者簡單的開發中,可以直接將元件(服務)直接匯入到需要使用該元件的服務中。
因為這裡不需要對ES做過多的配置,但是在以後的開發中卻說不準,這樣建立ES服務,然後再在需要使用的服務中匯入ES依賴,這樣似乎是很麻煩,但是在以後進行統一管理還是比較方便的。
ES作為一個公共的元件,我選擇在common公共包下面單獨建立一個ES的服務。
基於 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 實現的後臺管理系統 + 使用者小程式,支援 RBAC 動態許可權、多租戶、資料許可權、工作流、三方登入、支付、簡訊、商城等功能
  • 專案地址:https://github.com/YunaiV/yudao-cloud
  • 影片教程:https://doc.iocoder.cn/video/

3. 匯入依賴

<dependency>

    <groupId>org.springframework.boot</groupId>

    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>

</dependency>

在需要的服務中再匯入elasticsearch我們自己的服務

4. 資料庫準備

/*

 Navicat Premium Data Transfer
 Source Server         : docker-oj

 Source Server Type    : MySQL

 Source Server Version : 50744

 Source Host           : localhost:3307

 Source Schema         : bitoj_dev
 Target Server Type    : MySQL

 Target Server Version : 50744

 File Encoding         : 65001
 Date: 04/12/2024 12:12:41

*/

SET NAMES utf8mb4;

SET FOREIGN_KEY_CHECKS = 

0

;
-- ----------------------------

-- Table structure 

for

 tb_question

-- ----------------------------

DROP TABLE IF EXISTS `tb_question`;

CREATE TABLE `tb_question`  (

  `question_id` bigint(

20

) UNSIGNED NOT NULL COMMENT 

'題目id'

,

  `title` varchar(

50

) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,

  `difficulty` tinyint(

4

) NOT NULL COMMENT 

'題目難度1:簡單  2:中等 3:困難'

,

  `time_limit` 

int

(

11

) NOT NULL COMMENT 

'時間限制'

,

  `space_limit` 

int

(

11

) NOT NULL COMMENT 

'空間限制'

,

  `content` varchar(

1000

) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 

'題目內容'

,

  `question_case` varchar(

1000

) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 

'題目用例'

,

  `default_code` varchar(

1000

) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 

'預設程式碼塊'

,

  `main_func` varchar(

500

) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 

'main函式'

,

  `create_by` bigint(

20

) UNSIGNED NOT NULL COMMENT 

'建立人'

,

  `create_time` datetime NOT NULL COMMENT 

'建立時間'

,

  `update_by` bigint(

20

) UNSIGNED NULL DEFAULT NULL COMMENT 

'更新人'

,

  `update_time` datetime NULL DEFAULT NULL COMMENT 

'更新時間'

,

  `is_del` tinyint(

4

) NOT NULL DEFAULT 

0

 COMMENT 

'邏輯刪除標誌位 0:未被刪除 1:被刪除'

,

PRIMARY 

KEY(`question_id`)

 USING BTREE

) ENGINE 

= InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------

-- Records of tb_question

-- ----------------------------

INSERT INTO `tb_question` VALUES (

1860314392613736449

'兩數相加'

2

1000

256

'給定兩個非負整數,分別用連結串列表示,每個節點表示一位數字。將這兩個數字相加並以相同形式返回結果。'

'[{\"input\":\"[2,4,3]\\n[5,6,4]\",\"output\":\"[7,0,8]\"}, {\"input\":\"[0]\\n[0]\",\"output\":\"[0]\"}]'

'public ListNode addTwoNumbers(ListNode l1, ListNode l2) {\\n    // TODO: 實現你的演算法\\n}'

'public static void main(String[] args) {\\n    ListNode l1 = new ListNode(2, new ListNode(4, new ListNode(3)));\\n    ListNode l2 = new ListNode(5, new ListNode(6, new ListNode(4)));\\n    ListNode result = addTwoNumbers(l1, l2);\\n    System.out.println(result);\\n}'

1

'2024-11-23 21:28:09'

1

, NULL, 

0

);

INSERT INTO `tb_question` VALUES (

1860315513155604481

'test'

2

12

12

'<p>113厄爾</p>'

'222'

'22'

'222'

1

'2024-11-23 21:32:36'

1

, NULL, 

0

);

INSERT INTO `tb_question` VALUES (

1860317209277616130

'兩數相加2'

2

1000

256

'給定兩個非負整數,分別用連結串列表示,每個節點表示一位數字。將這兩個數字相加並以相同形式返回結果。'

'[{\"input\":\"[2,4,3]\\n[5,6,4]\",\"output\":\"[7,0,8]\"}, {\"input\":\"[0]\\n[0]\",\"output\":\"[0]\"}]'

'public ListNode addTwoNumbers(ListNode l1, ListNode l2) {\\n    // TODO: 實現你的演算法\\n}'

'public static void main(String[] args) {\\n    ListNode l1 = new ListNode(2, new ListNode(4, new ListNode(3)));\\n    ListNode l2 = new ListNode(5, new ListNode(6, new ListNode(4)));\\n    ListNode result = addTwoNumbers(l1, l2);\\n    System.out.println(result);\\n}'

1

'2024-11-23 21:39:20'

1

, NULL, 

0

);

INSERT INTO `tb_question` VALUES (

1860319609832869890

'兩數相加21'

2

1000

256

'<p>給定兩個非負整數,分別用連結串列表示,每個節點表示一位數字。將這兩個數字相加並以相同形式返回結果。</p>'

'[{\"input\":\"[2,4,3]\\n[5,6,4]\",\"output\":\"[7,0,8]\"}, {\"input\":\"[0]\\n[0]\",\"output\":\"[0]\"}]'

'public ListNode addTwoNumbers(ListNode l1, ListNode l2) {\\n    // TODO: 實現你的演算法\\n}'

'public static void main(String[] args) {\\n    ListNode l1 = new ListNode(2, new ListNode(4, new ListNode(3)));\\n    ListNode l2 = new ListNode(5, new ListNode(6, new ListNode(4)));\\n    ListNode result = addTwoNumbers(l1, l2);\\n    System.out.println(result);\\n}'

1

'2024-11-23 21:48:53'

1

'2024-11-24 16:03:57'

0

);

INSERT INTO `tb_question` VALUES (

1860319646323314689

'兩數相加3'

2

1000

256

'給定兩個非負整數,分別用連結串列表示,每個節點表示一位數字。將這兩個數字相加並以相同形式返回結果。'

'[{\"input\":\"[2,4,3]\\n[5,6,4]\",\"output\":\"[7,0,8]\"}, {\"input\":\"[0]\\n[0]\",\"output\":\"[0]\"}]'

'public ListNode addTwoNumbers(ListNode l1, ListNode l2) {\\n    // TODO: 實現你的演算法\\n}'

'public static void main(String[] args) {\\n    ListNode l1 = new ListNode(2, new ListNode(4, new ListNode(3)));\\n    ListNode l2 = new ListNode(5, new ListNode(6, new ListNode(4)));\\n    ListNode result = addTwoNumbers(l1, l2);\\n    System.out.println(result);\\n}'

1

'2024-11-23 21:49:01'

1

, NULL, 

0

);

INSERT INTO `tb_question` VALUES (

1860331174208598018

'兩數相加3秀愛'

2

1000

256

'<p>給定兩個非負整數,分別用連結串列表示,每個節點表示一位數字。將這兩個數字相加並以相同形式返回結果。</p>'

'[{\"input\":\"[2,4,3]\\n[5,6,4]\",\"output\":\"[7,0,8]\"}, {\"input\":\"[0]\\n[0]\",\"output\":\"[0]\"}]'

'public ListNode addTwoNumbers(ListNode l1, ListNode l2) {\\n    // TODO: 實現你的演算法\\n}'

'public static void main(String[] args) {\\n    ListNode l1 = new ListNode(2, new ListNode(4, new ListNode(3)));\\n    ListNode l2 = new ListNode(5, new ListNode(6, new ListNode(4)));\\n    ListNode result = addTwoNumbers(l1, l2);\\n    System.out.println(result);\\n}'

1

'2024-11-23 22:34:50'

1

'2024-11-24 15:58:17'

0

);

INSERT INTO `tb_question` VALUES (

1860524253771296769

'21'

1

2

2

'<p>2</p>'

'2'

'2'

'2'

1

'2024-11-24 11:22:04'

1

'2024-11-24 15:58:07'

0

);
SET FOREIGN_KEY_CHECKS = 

1

;

現在的需求是:透過題目的題目或者是題目內容來對題目進行檢索。
為ES和mysql建立對應的實體類:
ES:
import

 org.springframework.data.elasticsearch.annotations.Document;

import

 lombok.Getter;

import

 lombok.Setter;

import

 org.springframework.data.annotation.Id;

import

 org.springframework.data.elasticsearch.annotations.DateFormat;

import

 org.springframework.data.elasticsearch.annotations.Document;

import

 org.springframework.data.elasticsearch.annotations.Field;

import

 org.springframework.data.elasticsearch.annotations.FieldType;

import

 java.time.LocalDateTime;

@Getter
@Setter
@Document

(indexName = 

"idx_question"

)

publicclassQuestionES

{

@Id
@Field

(type = FieldType.Long)

private

 Long questionId;

@Field

(type = FieldType.Text, analyzer = 

"ik_max_word"

, searchAnalyzer = 

"ik_max_word"

)

private

 String title;

@Field

(type = FieldType.Byte)

private

 Integer difficulty;

@Field

(type = FieldType.Long)

private

 Long timeLimit;

@Field

(type = FieldType.Long)

private

 Long spaceLimit;

@Field

(type = FieldType.Text, analyzer = 

"ik_max_word"

, searchAnalyzer = 

"ik_max_word"

)

private

 String content;

@Field

(type = FieldType.Text)

private

 String questionCase;

@Field

(type = FieldType.Text)

private

 String mainFunc;

@Field

(type = FieldType.Text)

private

 String defaultCode;

@Field

(type = FieldType.Date, format = DateFormat.date_hour_minute_second)

private

 LocalDateTime createTime;

}

mysql:
import

 com.baomidou.mybatisplus.annotation.IdType;

import

 com.baomidou.mybatisplus.annotation.TableId;

import

 com.baomidou.mybatisplus.annotation.TableName;

import

 com.guan.common.core.domain.BaseEntity;

import

 lombok.Getter;

import

 lombok.Setter;

@TableName

(

"tb_question"

)

@Getter
@Setter
publicclassQuestionextendsBaseEntity

{

@TableId

(type = IdType.ASSIGN_ID)

private

 Long questionId;

private

 String title;

private

 Integer difficulty;

private

 Long timeLimit;

private

 Long spaceLimit;

private

 String content;

private

 String questionCase;

private

 String defaultCode;

private

 String mainFunc;

}

4.1. @Document(indexName = "idx_question")

該註解表示這是一個 Elasticsearch 的文件(document)類。
indexName 屬性指定了在 Elasticsearch 中儲存該文件的索引名稱,即 idx_question。這意味著 Elasticsearch 會將這個類的資料儲存在名為 idx_question 的索引中。

4.2. Id

表示該欄位是文件的唯一識別符號。在 Elasticsearch 中,每個文件都必須有一個唯一的 ID,用來區分不同的文件。 在這裡,questionId 被標註為唯一識別符號,即 Elasticsearch 文件的 ID。

4.3. @Field

@Field 註解用於指定欄位在 Elasticsearch 中的型別、分析器等資訊。它是 Spring Data Elasticsearch 提供的一個註解,用於定義如何在 Elasticsearch 中對映資料。

5. 實現Repository 介面(ES)和Mapper(MySQL)

5.1. Elasticsearch — Repository 介面

Spring Data Elasticsearch 的 Repository 介面,用於與 Elasticsearch 互動。它繼承了 ElasticsearchRepository,這使得 Spring Data Elasticsearch 可以自動為它提供基本的 CRUD 操作。這個介面專門用於操作 QuestionES 型別的文件,並提供了一些自定義查詢方法。可以類比於用於操作資料庫的mapper介面類。
import

 com.guan.friend.domain.question.es.QuestionES;

import

 org.springframework.data.domain.Page;

import

 org.springframework.data.domain.Pageable;

import

 org.springframework.data.elasticsearch.annotations.Query;

import

 org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

import

 org.springframework.stereotype.Repository;

@Repository
publicinterfaceIQuestionRepositoryextendsElasticsearchRepository<QuestionESLong

{

Page<QuestionES> findQuestionByDifficulty(Integer difficulty, Pageable pageable)

;

//select  * from tb_question where (title like '%aaa%' or content like '%bbb%')  and difficulty = 1
@Query

(

"{\"bool\": {\"should\": [{ \"match\": { \"title\": \"?0\" } }, { \"match\": { \"content\": \"?1\" } }], \"minimum_should_match\": 1, \"must\": [{\"term\": {\"difficulty\": \"?2\"}}]}}"

)

Page<QuestionES> findByTitleOrContentAndDifficulty(String keywordTitle, String keywordContent,Integer difficulty,  Pageable pageable)

;

@Query

(

"{\"bool\": {\"should\": [{ \"match\": { \"title\": \"?0\" } }, { \"match\": { \"content\": \"?1\" } }], \"minimum_should_match\": 1}}"

)

Page<QuestionES> findByTitleOrContent(String keywordTitle, String keywordContent, Pageable pageable)

;
}

1. 方法:findQuestionByDifficulty
方法目的:透過問題的 difficulty(難度)欄位來查詢問題,並分頁返回結果。 返回一個 Page<QuestionES>,表示分頁查詢的結果。difficulty 引數是查詢條件,Pageable 引數是分頁資訊,Pageable 包含了頁數和每頁條數等資訊。
查詢型別:這個查詢方法是基於 Spring Data Elasticsearch 的查詢派發機制生成的,不需要手動編寫查詢語句。它會自動根據方法名推匯出對應的查詢操作。
2. 方法:findByTitleOrContentAndDifficulty
方法目的:根據標題 title 或內容 content 進行搜尋,並且需要匹配問題的難度 difficulty。
@Query 註解:該註解用於定義自定義的 Elasticsearch 查詢。查詢採用的是 Elasticsearch Query DSL(Elasticsearch 查詢語言)。

{

"bool"

: {

"should"

: [

      { 

"match"

: { 

"title"

"?0"

 } },

      { 

"match"

: { 

"content"

"?1"

 } }

    ],

"minimum_should_match"

1

,

"must"

: [

      { 

"term"

: { 

"difficulty"

"?2"

 } }

    ]

  }

}

  • should: 表示“或”條件,查詢中 title 或 content 欄位必須匹配給定的關鍵字(?0?1 分別是方法引數 keywordTitlekeywordContent)。minimum_should_match: 1 意味著至少一個 should 子句必須匹配。
  • must: 表示“且”條件,查詢中 difficulty 欄位必須匹配給定的難度(?2 是方法引數 difficulty)。該查詢會檢索標題或內容包含關鍵詞的文件,並且難度符合指定值。
3. 方法:findByTitleOrContent
方法目的:根據標題 title 或內容 content 進行搜尋,分頁返回結果。
該方法的查詢語句與 findByTitleOrContentAndDifficulty 方法類似,但沒有新增 difficulty 欄位的篩選條件。查詢的條件是標題或內容匹配給定的關鍵詞,minimum_should_match: 1 表示至少一個 should 子句匹配。

{

"bool"

: {

"should"

: [

      { 

"match"

: { 

"title"

"?0"

 } },

      { 

"match"

: { 

"content"

"?1"

 } }

    ],

"minimum_should_match"

1

  }

}

should:表示“或”條件,查詢中 title 或 content 欄位必須匹配給定的關鍵字(?0?1 分別是方法引數 keywordTitlekeywordContent)。minimum_should_match: 1 表示至少一個 should 子句匹配。

5.2. MySQL–Mapper

import

 com.baomidou.mybatisplus.core.mapper.BaseMapper;

import

 com.guan.friend.domain.question.Question;

publicinterfaceQuestionMapperextendsBaseMapper<Question

{
}

6. Service程式碼

import

 cn.hutool.core.bean.BeanUtil;

import

 cn.hutool.core.collection.CollectionUtil;

import

 cn.hutool.core.util.StrUtil;

import

 com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;

import

 com.guan.common.core.domain.TableDataInfo;

import

 com.guan.friend.domain.question.Question;

import

 com.guan.friend.domain.question.dto.QuestionQueryDTO;

import

 com.guan.friend.domain.question.es.QuestionES;

import

 com.guan.friend.domain.question.vo.QuestionVO;

import

 com.guan.friend.elasticsearch.IQuestionRepository;

import

 com.guan.friend.mapper.question.QuestionMapper;

import

 com.guan.friend.service.question.IQuestionService;

import

 jakarta.annotation.Resource;

import

 org.springframework.beans.factory.annotation.Autowired;

import

 org.springframework.data.domain.Page;

import

 org.springframework.data.domain.PageRequest;

import

 org.springframework.data.domain.Pageable;

import

 org.springframework.data.domain.Sort;

import

 org.springframework.stereotype.Service;

import

 java.util.List;

@Service
publicclassQuestionServiceImplimplementsIQuestionService

{

@Autowired
private

 IQuestionRepository questionRepository;

@Resource
private

 QuestionMapper questionMapper;

@Override
public TableDataInfo list(QuestionQueryDTO questionQueryDTO)

{

long

 count = questionRepository.count();

// 如果ES沒有資料,從資料庫同步
if

(count <= 

0

){

            refreshQuestion();

        }

// 指定排序規則是 按照建立時間 降序(新建立的題目在最上面)

        Sort orders = Sort.by(Sort.Direction.DESC, 

"createTime"

);

// 維護分頁

        Pageable pageable = PageRequest.

                of(questionQueryDTO.getPageNum() - 

1

, questionQueryDTO.getPageSize(), orders);

        Integer difficulty = questionQueryDTO.getDifficulty();

        String keywords = questionQueryDTO.getKeywords();
        Page<QuestionES> questionESPage;

if

(difficulty == 

null

 && StrUtil.isEmpty(keywords)){

// 查詢引數都為空

            questionESPage = questionRepository.findAll(pageable);

        }

elseif

(StrUtil.isEmpty(keywords)){

// 查詢題目或內容為空

            questionESPage = questionRepository.findQuestionByDifficulty(difficulty, pageable);

        }

elseif

(difficulty == 

null

){

// 查詢難度為空

            questionESPage = questionRepository.findByTitleOrContent(keywords, keywords, pageable);

        }

else

{

// 查詢條件都不為空

            questionESPage = questionRepository.findByTitleOrContentAndDifficulty(keywords, keywords, difficulty, pageable);

        }

// 獲取es中檢索到的全部資料的數量
long

 total = questionESPage.getTotalElements();

if

(total <= 

0

){

return

 TableDataInfo.empty();

        }

// 將ES的資料轉換成VO

        List<QuestionES> questionESList = questionESPage.getContent();

        List<QuestionVO> questionVOList = BeanUtil.copyToList(questionESList, QuestionVO

.class)

;

return

  TableDataInfo.success(questionVOList, total);

    }

privatevoidrefreshQuestion()

{

        List<Question> questionList = questionMapper.selectList(

new

 LambdaQueryWrapper<Question>());

if

(CollectionUtil.isEmpty(questionList)){

return

;

        }

// 將資料庫查到的題目列表資料 重新整理到 ES 中
// 轉換列表資料型別

        List<QuestionES> questionESList = BeanUtil.copyToList(questionList, QuestionES

.class)

;

        questionRepository.saveAll(questionESList);

    }

}

測試不傳入查詢條件:
測試檢索關鍵字
測試檢索關鍵字
測試檢索關鍵字+題目難度

歡迎加入我的知識星球,全面提升技術能力。
👉 加入方式,長按”或“掃描”下方二維碼噢
星球的內容包括:專案實戰、面試招聘、原始碼解析、學習路線。
文章有幫助的話,在看,轉發吧。
謝謝支援喲 (*^__^*)

相關文章