springboot微服務Lucence實現Mysql全文檢索功能

一、前言

對於一個業務量穩步上升的微服務系統來說,數據規模在可預期的時間段內也是逐漸增長的。使用過mysql的同學應該知道,mysql單表的數據量是有性能瓶頸的,對於硬件配置一般的服務器來說,單表百萬級數據量單表查詢問題不大,但是在大規模頻繁調用的微服務系統中,一旦該表涉及關聯查詢的表比較多時,將出現明顯的性能問題,此時不管是開發人員,還是DBA,此時就要考慮數據庫或表的性能調優瞭。

1.1 常規調優手段

遇到上面的性能問題之後不要慌,一般有下面幾種常規的手段可以來應對:

1.1.1 加索引

分析你的業務代碼中最影響性能的查詢sql,給字段添加必要而合適的索引。

1.1.2 代碼層優化

比如:循環查詢改為批量查詢,條件允許的情況下使用緩存,使用異步等

1.1.3 減少關聯表查詢

將非必要關聯的表抽離到業務層代碼中,以查詢結果集帶入到下一步的查詢邏輯,多表關聯很容易引發查詢性能問題

1.1.4 分庫分表

減少單表的數據量,從而提升查詢性能,或者將寬表拆解成窄表

1.1.5 引入第三方存儲

mysql + es進行雙寫,將一些業務簡單但查詢性能較差的邏輯放在es去做

總結

以上方案各有優劣,需要視情況而定,各種方案在實際的開發中都可以落地,開發和維護成本都不一樣,需要綜合評估,當然還有其他的方案,比如做數據冷熱分離,讀寫分離等,需視情況來定。

二、一個棘手的問題

2.1 前置準備

2.1.1 創建一張表

CREATE TABLE `t_content` (
  `id` varchar(32) NOT NULL,
  `title` varchar(255) DEFAULT NULL,
  `price` varchar(32) DEFAULT NULL,
  `descs` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2.1.2 插入一些數據

隨機造一些數據

INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('001', '測試用於', '10', '測試用於');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('1', 'Java面向對象', '10', 'Java面向對象從入門到精通,簡單上手');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('10', 'Java開發實戰經典', '51', '本書是一本綜合講解Java核心技術的書籍,在書中使用大量的代碼及案例進行知識點的分析與運用');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('11', 'Effective Java', '10', '本書介紹瞭在Java編程中57條極具實用價值的經驗規則,這些經驗規則涵蓋瞭大多數開發人員每天所面臨的問題的解決方案');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('12', '分佈式 Java 應用:基礎與實踐', '15', '本書介紹瞭編寫分佈式Java應用涉及的眾多知識點,分為瞭基於Java實現網絡通信、RPC;基於SOA實現大型分佈式Java應用');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('13', 'http權威指南', '11', '超文本傳輸協議(Hypertext Transfer Protocol,HTTP)是在萬維網上進行通信時所使用的協議方案');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('14', 'Spring', '15', '這是啥,還需要學習嗎?Java程序員必備書籍');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('15', '深入理解 Java 虛擬機', '18', '作為一位Java程序員,你是否也曾經想深入理解Java虛擬機,但是卻被它的復雜和深奧拒之門外');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('16', 'springboot實戰', '11', '完成對於springboot的理解,是每個Java程序員必備的姿勢');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('17', 'springmvc學習', '72', 'springmvc學習指南');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('18', 'vue入門到放棄', '20', 'vue入門到放棄書籍信息');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('19', 'vue入門到精通', '20', 'vue入門到精通相關書籍信息');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('2', 'Java面向對象java', '10', 'Java面向對象從入門到精通,簡單上手');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('20', 'vue之旅', '20', '由淺入深地全面介紹vue技術,包含大量案例與代碼');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('21', 'vue實戰', '20', '以實戰為導向,系統講解如何使用 ');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('22', 'vue入門與實踐', '20', '現已得到蘋果、微軟、谷歌等主流廠商全面支持');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('23', 'Vue.js應用測試', '20', 'Vue.js創始人尤雨溪鼎力推薦!Vue官方測試工具作者親筆撰寫,Vue.js應用測試完全學習指南');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('24', 'PHP和MySQL Web開發', '20', '本書是利用PHP和MySQL構建數據庫驅動的Web應用程序的權威指南');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('25', 'Web高效編程與優化實踐', '20', '從思想提升和內容修煉兩個維度,圍繞前端工程師必備的前端技術和編程基礎');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('26', 'Vue.js 2.x實踐指南', '20', '本書旨在讓初學者能夠快速上手vue技術棧,並能夠利用所學知識獨立動手進行項目開發');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('27', '初始vue', '20', '解開vue的面紗');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('28', '什麼是vue', '20', '一步一步的瞭解vue相關信息');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('29', '深入淺出vue', '20', '深入淺出vue,慢慢掌握');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('3', 'Java面向編程', '15', 'Java面向對象編程書籍');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('30', '三天vue實戰', '20', '三天掌握vue開發');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('31', '不知火舞', '20', '不知名的vue');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('32', '娜可露露', '20', '一招秒人');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('33', '宮本武藏', '20', '我就是一個超級兵');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('34', 'vue宮本vue', '20', '我就是一個超級兵');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('4', 'JavaScript入門', '18', 'JavaScript入門編程書籍');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('5', '深入理解Java編程', '13', '十三四天掌握Java基礎');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('6', '從入門到放棄_Java', '20', '一門從入門到放棄的書籍');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('7', 'Head First Java', '30', '《Head First Java》是一本完整地面向對象(object-oriented,OO)程序設計和Java的學習指導用書');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('8', 'Java 核心技術:卷1 基礎知識', '22', '全書共14章,包括Java基本的程序結構、對象與類、繼承、接口與內部類、圖形程序設計、事件處理、Swing用戶界面組件');
INSERT INTO `bank1`.`t_content`(`id`, `title`, `price`, `descs`) VALUES ('9', 'Java 編程思想', '12', '本書贏得瞭全球程序員的廣泛贊譽,即使是最晦澀的概念,在Bruce Eckel的文字親和力和小而直接的編程示例面前也會化解於無形');

2.2 問題引發

2.2.1 關鍵字模糊查詢

來看下面這個sql

select * from t_content tc 
where 
tc.title like concat('%','深入', '%') or tc.descs like concat('%','深入', '%')

這是一個簡單的關鍵字查詢,傳入關鍵字,隻要記錄的title字段或者descs字段包含瞭關鍵字都可以查出來;

2.2.2 執行計劃分析

對mysql索引略有瞭解的同學通過explain關鍵字可以知道,即便你的查詢字段本身有索引,使用like查詢時要走索引也是有條件的,我們不妨給這兩個字段加上索引

create index idx_title on t_content(title);
create index idx_descs on t_content(descs);

加完索引後,使用explain分析執行計劃,是否走索引一目瞭然;

也就是說,like雖然好用,但是在數據集非常大的情況下,要是不走索引的情況下,那個查詢性能是可想而知的。

2.2.3 需求引出

通過上面的like查詢分析,以及最終的效果預測來看,如果在單表數據量非常大的情況下,性能是非常糟糕的,就算是使用前綴匹配勉強走索引,並不能很好的滿足需求,那麼有人會問,那就可以考慮使用本文開頭的那些方案,比如分表,或者引入es都是不錯的選擇。

事實上,在真實的生產環境中,每一種方案的落地,都需要多方面綜合因素的評估考慮,比如:

1)開發投入的時間成本和人力成本;

2)技術的復雜度;

3)與現有技術架構的兼容性;

4)數據的兼容性;

5)數據適配與遷移成本;

6)後續的項目維護成本…

在小編真實的業務場景下,結合現實情況,在盡可能不引入新的存儲中間件,以及盡可能減少人力投入成本與數據遷移成本的情況下,這裡提出一種使用mysql結合lucene實現全文檢索的優化方案,接下來將詳細介紹下該方案的實現。

三、lucence與全文檢索

說到全文檢索,相信沒人不知道es的,就算沒有用過,對es查詢的高性能也有耳聞,這得力於es底層強大的分詞與索引技術,es是一個基於Lucene的實時的分佈式搜索和分析引擎,能夠達到近實時搜索,穩定,可靠,快速,安裝使用方便,提供瞭豐富的API,開箱即用。

3.1 Lucene概念

Lucene是一套用於全文檢索和搜尋的開源程式庫,由 Apache 軟件基金會支持和提供。Lucene 提供瞭一個簡單卻強大的應用程式接口,能夠做全文索引和搜尋。在 Java 開發環境裡 Lucene 是一個成熟的免費開源工具。就其本身而言,Lucene 是當前以及最近幾年最受歡迎的免費 Java 信息檢索程序庫。——《百度百科》

3.2 全文檢索

何為全文檢索?舉例來說,假如要在一個文件中查找某個字符串,最直接的辦法就是從頭開始檢索,查到瞭就OK。這種方式對小數據量文件來說,簡單實用。但對於大數據量文件來說,就比較吃力瞭。 

文件中的數據是屬於非結構化數據,也就是說它沒有什麼結構可言(不像我們數據庫中的信息,可以一行一行的去匹配查詢),要解決上面提到的效率問題,首先我們得將非結構化數據中的一部分信息提取出來,重新組織,使其變得有一定結構(說白瞭,就是變成關系數據庫型一行一行的數據),然後對這些有一定結構的數據進行搜索,從而達到搜索相對較快的目的。這就叫全文搜索。即先建立索引(表結構,把文件中的關鍵詞提取出來),再對索引進行搜索的過程。

3.3 Lucene 建立索引的過程

那麼 Lucene 中是如何建立索引的呢?比如有如下兩篇文章,內容如下:

文章1內容:Tom lives in Guangzhou, I live in Guangzhou too.
文章2內容:He once lived in Shanghai.

第一步是將文檔傳給分詞組件(Tokenizer),分詞組件會將文檔分成一個個單詞,並去除標點符號和停詞。所謂的停詞指的是沒有特別意義的詞,比如英文中的 a,the,too 等。經過分詞後,得到詞元(Token) 。如下:

文章1經過分詞後的結果:[Tom] [lives] [Guangzhou] [I] [live] [Guangzhou]
文章2經過分詞後的結果:[He] [lives] [Shanghai]

然後將詞元傳給語言處理組件(Linguistic Processor),對於英語,語言處理組件一般會將字母變為小寫,將單詞縮減為詞根形式,如 ”lives” 到 ”live” 等,將單詞轉變為詞根形式,如 ”drove” 到 ”drive” 等。然後得到詞(Term)。如下:

文章1經過處理後的結果:[tom] [live] [guangzhou] [i] [live] [guangzhou]
文章2經過處理後的結果:[he] [live] [shanghai]

最後將得到的詞傳給索引組件(Indexer),索引組件經過處理,得到下面的索引結構,其實這個也是倒排索引建立的詳細過程;

關鍵詞 文章號[出現頻率] 出現位置
guangzhou 1[2] 3,6
he 2[1] 1
i 1[1] 4
live 1[2],2[1] 2,5,2
shanghai 2[1] 3
tom 1[1] 1

以上就是Lucene 索引結構中最核心的部分。它的關鍵字是按字符順序排列的,因此 Lucene 可以用二元搜索算法快速定位關鍵詞。實現時 Lucene 將上面三列分別作為詞典文件(Term Dictionary)、頻率文件(frequencies)和位置文件(positions)保存。其中詞典文件不僅保存有每個關鍵詞,還保留瞭指向頻率文件和位置文件的指針,通過指針可以找到該關鍵字的頻率信息和位置信息。搜索的過程是先對詞典二元查找、找到該詞,通過指向頻率文件的指針讀出所有文章號,然後返回結果,然後就可以在具體的文章中根據出現位置找到該詞瞭。所以Lucene在第一次建立索引的時候可能會比較慢,但是以後就不需要每次都建立索引瞭,就快瞭。

四、基於Lucence解決方案

知道瞭Lucene的分詞及創建索引的原理,下面就來實現一個具體的業務員需求,需求內容如下:

基於mysql數據庫中一張現有的數據表,通過引入lucence,實現如下功能:

1)使用lucence替代mysql實現關鍵字數據查詢;

2)添加新用戶後,再次檢索,能夠從lucence檢索數據;

4.1 需求分解與實現思路

在正式開始編碼之前,先來對需求進行分解以及實現做一個規劃

4.1.1 準備一張測試表

就以本文開開篇的t_content表為例,假如線上系統的數據表已經在投產運行中瞭;

4.1.2 關鍵實現思路

從需求描述看,關鍵字查詢走lucence,必須滿足下面幾個條件

  • 數據表數據初始化到lucence索引中;
  • 對現有的查詢邏輯進行改造,關鍵字查詢使用lucence相關的API;
  • 對數據進行增刪改的時候需要同步更新lucence索引庫數據;

以查詢為例,參考下圖的實現流程

4.2 Lucene API介紹

工欲善其事必先利其器,為瞭更好的編碼,有必要對Lucene中涉及到常用的API做一下介紹。

4.2.1 索引創建相關

與索引創建相關的核心API對象或屬性主要有下面這些

Document

Document 是用來描述文檔的,這裡的文檔可以指一個 HTML 頁面,一封電子郵件,或者是一個文本文件。一個 Document 對象由多個 Field 對象組成,可以把一個 Document 對象想象成數據庫中的一個記錄,而每個 Field 對象就是記錄的一個字段。

Field

Field 對象是用來描述一個文檔的某個屬性的,比如一封電子郵件的標題和內容可以用兩個 Field 對象分別描述。

Analyzer

在一個文檔被索引之前,首先需要對文檔內容進行分詞處理,這部分工作就是由 Analyzer 來做的。Analyzer 類是一個抽象類,它有多個實現。針對不同的語言和應用需要選擇適合的 Analyzer。Analyzer 把分詞後的內容交給 IndexWriter 來建立索引。

IndexWriter

IndexWriter 是 Lucene 用來創建索引的一個核心的類,他的作用是把一個個的 Document 對象加到索引中來。

Directory

這個類代表瞭 Lucene 的索引的存儲的位置,這是一個抽象類,它目前有兩個實現,第一個是 FSDirectory,它表示一個存儲在文件系統中的索引的位置。第二個是 RAMDirectory,它表示一個存儲在內存當中的索引的位置。

4.2.2 文檔檢索相關

使用過es的同學對下面的這些API對象應該不陌生,用起來的時候語法操作上確實很像。

Query

這是一個抽象類,他有多個實現,比如 TermQuery, BooleanQuery, PrefixQuery. 這個類的目的是把用戶輸入的查詢字符串封裝成 Lucene 能夠識別的 Query。

Term

Term 是搜索的基本單位,一個 Term 對象有兩個 String 類型的域組成。生成一個 Term 對象可以有如下一條語句來完成:Term term = new Term(“fieldName”,”queryWord”); 其中第一個參數代表瞭要在文檔的哪一個 Field 上進行查找,第二個參數代表瞭要查詢的關鍵詞。

TermQuery

TermQuery 是抽象類 Query 的一個子類,它同時也是 Lucene 支持的最為基本的一個查詢類。生成一個 TermQuery 對象由如下語句完成: TermQuery termQuery = new TermQuery(new Term(“fieldName”,”queryWord”)); 它的構造函數隻接受一個參數,那就是一個 Term 對象。

IndexSearcher

IndexSearcher 是用來在建立好的索引上進行搜索的。它隻能以隻讀的方式打開一個索引,所以可以有多個 IndexSearcher 的實例在一個索引上進行操作。

Hits

Hits 是用來保存搜索的結果的。

4.3 框架整合流程

接下來,將詳細介紹基於springboot整合lucence,實現上面的需求,完整的工程目錄如下

4.3.1 引入依賴

這裡省略瞭其他非核心的依賴,可以根據自身需要導入

<dependencies>
 
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
 
        
        <!-- lucene核心庫 -->
        <dependency>
            <groupId>org.apache.lucene</groupId>
            <artifactId>lucene-core</artifactId>
            <version>7.6.0</version>
        </dependency>
        <!-- Lucene的查詢解析器 -->
        <dependency>
            <groupId>org.apache.lucene</groupId>
            <artifactId>lucene-queryparser</artifactId>
            <version>7.6.0</version>
        </dependency>
        <!-- lucene的默認分詞器庫,適用於英文分詞 -->
        <dependency>
            <groupId>org.apache.lucene</groupId>
            <artifactId>lucene-analyzers-common</artifactId>
            <version>7.6.0</version>
        </dependency>
        <!-- lucene的高亮顯示 -->
        <dependency>
            <groupId>org.apache.lucene</groupId>
            <artifactId>lucene-highlighter</artifactId>
            <version>7.6.0</version>
        </dependency>
        <!-- smartcn中文分詞器 -->
        <dependency>
            <groupId>org.apache.lucene</groupId>
            <artifactId>lucene-analyzers-smartcn</artifactId>
            <version>7.6.0</version>
        </dependency>
        <!-- ik分詞器 -->
        <dependency>
            <groupId>com.janeluo</groupId>
            <artifactId>ikanalyzer</artifactId>
            <version>2012_u6</version>
        </dependency>
 
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
 
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.1</version>
        </dependency>
 
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.11</version>
        </dependency>
 
    </dependencies>

4.3.2 添加配置文件

此處主要配置mysql的連接,邏輯中將涉及到數據表的DB操作

server:
  port: 8083
 
spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://IP:3306/bank1?autoReconnect=true&useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false
    username: root
    password: root
 
mybatis:
  type-aliases-package: com.congge.entity
  mapper-locations: classpath:mybatis/*.xml

4.3.3 自定義IK分詞器

默認情況下如果不設置,檢索數據時將會走標準分詞器,但是內置的標準分詞器對於中文的分詞效果並不友好,所以這裡建議自定義中文分詞器(lucence提供瞭一種中文分詞器:SmartChineseAnalyzer),可以給程序上提供更好的擴展性;

import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.Tokenizer;
 
public class MyIKAnalyzer extends Analyzer {
 
    private boolean useSmart;
    public MyIKAnalyzer() {
        this(false);
    }
 
    public MyIKAnalyzer(boolean useSmart) {
        this.useSmart = useSmart;
    }
    @Override
    protected TokenStreamComponents createComponents(String s) {
        Tokenizer _MyIKTokenizer = new MyIKTokenizer(this.useSmart());
        return new TokenStreamComponents(_MyIKTokenizer);
    }
    public boolean useSmart() {
        return this.useSmart;
    }
    public void setUseSmart(boolean useSmart) {
        this.useSmart = useSmart;
    }
 
}
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.analysis.tokenattributes.OffsetAttribute;
import org.apache.lucene.analysis.tokenattributes.TypeAttribute;
import org.wltea.analyzer.core.IKSegmenter;
import org.wltea.analyzer.core.Lexeme;
 
import java.io.IOException;
 
public class MyIKTokenizer extends Tokenizer {
 
    private IKSegmenter _IKImplement;
    private final CharTermAttribute termAtt = (CharTermAttribute)this.addAttribute(CharTermAttribute.class);
    private final OffsetAttribute offsetAtt = (OffsetAttribute)this.addAttribute(OffsetAttribute.class);
    private final TypeAttribute typeAtt = (TypeAttribute)this.addAttribute(TypeAttribute.class);
    private int endPosition;
    //useSmart:設置是否使用智能分詞。默認為false,使用細粒度分詞,這裡如果更改為TRUE,那麼搜索到的結果可能就少的很多
    public MyIKTokenizer(boolean useSmart) {
        this._IKImplement = new IKSegmenter(this.input, useSmart);
    }
    @Override
    public boolean incrementToken() throws IOException {
        this.clearAttributes();
        Lexeme nextLexeme = this._IKImplement.next();
        if (nextLexeme != null) {
            this.termAtt.append(nextLexeme.getLexemeText());
            this.termAtt.setLength(nextLexeme.getLength());
            this.offsetAtt.setOffset(nextLexeme.getBeginPosition(), nextLexeme.getEndPosition());
            this.endPosition = nextLexeme.getEndPosition();
            this.typeAtt.setType(nextLexeme.getLexemeTypeString());
            return true;
        } else {
            return false;
        }
    }
    @Override
    public void reset() throws IOException {
        super.reset();
        this._IKImplement.reset(this.input);
    }
    @Override
    public final void end() {
        int finalOffset = this.correctOffset(this.endPosition);
        this.offsetAtt.setOffset(finalOffset, finalOffset);
    }
 
}

4.3.4 定義實體類

與數據表進行影視

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Content {
 
    private String id;
 
    private String title;
 
    private String price;
 
    private String descs;
 
}

4.3.5 編寫數據表測試接口

測試與mybatis的整合是否成功

    @GetMapping("/save")
    public String save(){
        Content content = new Content();
        content.setId("1101");
        content.setTitle("測試數據");
        content.setPrice("10");
        content.setDescs("測試數據");
        return contentService.save(content);
    }

實現類 

    @Autowired
    private ContentDao contentDao;
 
    public String save(Content content) {
        contentDao.save(content);
        return "success";
    }

啟動工程後,執行上面的接口調用,看到數據庫增加一條數據後,說明整合過程完成;

4.4 索引操作與數據檢索

4.4.1 索引數據初始化

按照上面的實現思路的流程,首先需要對現有的mysql數據進行索引文件的創建,這裡直接通過接口操作

在真實的業務場景下,需要考慮更合理的方式,即做到對用戶無感,可以在程序初始化的時候做這一步操作。

    /**
     * 將數據庫數據初始化到index中  //localhost:8083/initDbDataToIndex
     * @return
     * @throws Exception
     */
    @GetMapping("/initDbDataToIndex")
    public String initDbDataToIndex() throws Exception{
        //查詢數據庫數據
        List<Content> dbList = contentService.queryAll();
        // 創建文檔的集合
        Collection<Document> docs = new ArrayList<>();
        for (int i = 0; i < dbList.size(); i++) {
            Document document = new Document();
            //StringField會創建索引,但是不會被分詞,TextField,即創建索引又會被分詞。
            document.add(new StringField("id", (i + 1) + "", Field.Store.YES));
            document.add(new StringField("title", dbList.get(i).getTitle(), Field.Store.YES));
            document.add(new StringField("price", dbList.get(i).getPrice(), Field.Store.YES));
            document.add(new TextField("descs", dbList.get(i).getDescs(), Field.Store.YES));
            docs.add(document);
        }
        // 索引目錄類,指定索引在硬盤中的位置
        Directory directory = FSDirectory.open(FileSystems.getDefault().getPath("D:\\Lucene\\indexDir"));
        Analyzer analyzer = new MyIKAnalyzer();
        IndexWriterConfig conf = new IndexWriterConfig(analyzer);
        // 設置打開方式:OpenMode.APPEND 會在索引庫的基礎上追加新索引。OpenMode.CREATE會先清空原來數據,再提交新的索引
        conf.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
        // 創建索引的寫出工具類。參數:索引的目錄和配置信息
        IndexWriter indexWriter = new IndexWriter(directory, conf);
        // 把文檔集合交給IndexWriter
        indexWriter.addDocuments(docs);
        indexWriter.commit();
        indexWriter.close();
        return "initDbDataToIndex success";
    }

結合上文對API中的代碼進行理解,執行完這個接口後,t_content表這個數據就被lucence創建索引瞭,在 D:\\Lucene\\indexDir 這個目錄下;

索引目錄文件如下

 註意點:

實際業務中表的數據量可能非常大,為瞭盡可能避免索引文件過大對本地磁盤空間的壓力,最好不要對所有字段進行索引,而是選擇經常用於搜索的那些字段即可;

4.4.2 關鍵字檢索

創建完數據的索引之後,就可以體驗下檢索的效果瞭,對單個字段進行關鍵字檢索

    /**
     * 單個字段根據關鍵字查詢文檔  localhost:8083/query/keyword?text=一步
     *
     * @param text
     * @return
     * @throws Exception
     */
    @GetMapping("/query/keyword")
    public Object searchKeyWord(@RequestParam("text") String text) throws Exception {
        Directory directory = FSDirectory.open(FileSystems.getDefault().getPath(INDEX_PATH));
        IndexReader reader = DirectoryReader.open(directory);
        IndexSearcher searcher = new IndexSearcher(reader);
        QueryParser parser = new QueryParser("descs", new MyIKAnalyzer(true));
        Query query = parser.parse(text);
        TopDocs topDocs = searcher.search(query, Integer.MAX_VALUE);
        log.info("本次搜索共找到" + topDocs.totalHits + "條數據");
        ScoreDoc[] scoreDocs = topDocs.scoreDocs;
        List<Content> list = new ArrayList<>();
        for (ScoreDoc scoreDoc : scoreDocs) {
            int docID = scoreDoc.doc;
            Document doc = reader.document(docID);
            Content content = new Content();
            content.setId(doc.get("id"));
            content.setTitle(doc.get("title"));
            content.setDescs(doc.get("descs"));
            list.add(content);
        }
        return list;
    }

隨便找一下關鍵詞測試下效果

4.4.3 修改索引

當數據庫添加一條新的數據時,就需要給索引文件進行追加操作,否則後續搜索時將不能被搜出來;

    /**
     * 添加新數據的時候,將索引追加進去  //localhost:8083/updateIndex?desc=模擬Java面試寶典
     * @param desc
     * @return
     * @throws Exception
     */
    @GetMapping("/updateIndex")
    public String updateIndex(String desc) throws Exception {
 
        Content content = new Content();
        content.setId("1102");
        content.setTitle(desc);
        content.setPrice("10");
        content.setDescs(desc);
        contentService.save(content);
 
        // 創建目錄對象
        Directory directory = FSDirectory.open(FileSystems.getDefault().getPath("D:\\Lucene\\indexDir"));
        // 創建配置對象
        IndexWriterConfig conf = new IndexWriterConfig(new MyIKAnalyzer());
        // 創建索引寫出工具
        IndexWriter writer = new IndexWriter(directory, conf);
        // 創建新的文檔數據
        Document doc = new Document();
        doc.add(new StringField("id", "1102", Field.Store.YES));
 
        doc.add(new StringField("title", content.getTitle(), Field.Store.YES));
        doc.add(new StringField("price", content.getPrice(), Field.Store.YES));
        doc.add(new TextField("descs", content.getDescs(), Field.Store.YES));
 
        writer.updateDocument(new Term("id", "1102"), doc);
        writer.commit();
        writer.close();
        return "updateIndex success";
    }

執行上面的接口後,檢查數據表將新增一條數據

此時再次執行上述的搜索接口,可以看到數據能夠被成功檢索瞭;

4.4.4 刪除索引

刪除一條數據時,需要同步刪除索引文件中的數據

    /**
     * 刪除一個數據對應的索引信息  localhost:8083/deleteIndex?id=1102
     * @return
     * @throws Exception
     */
    @GetMapping("/deleteIndex")
    public String deleteIndex(String id) throws Exception {
        contentService.deleteById(id);
        // 創建目錄對象
        Directory directory = FSDirectory.open(FileSystems.getDefault().getPath("D:\\Lucene\\indexDir"));
        // 創建配置對象
        IndexWriterConfig conf = new IndexWriterConfig(new IKAnalyzer());
        // 創建索引寫出工具
        IndexWriter writer = new IndexWriter(directory, conf);
        // 根據詞條進行刪除
        writer.deleteDocuments(new Term("id", id));
        writer.commit();
        writer.close();
        return "deleteIndex success";
    }

執行刪除接口

刪除成功後再次執行上面的查詢接口,此時就查不到數據瞭

4.4.5 分頁查詢

在實際開發中,分頁查詢是很常見的,使用lucence也可以實現分頁查詢,看下面的代碼

    /**
     * 分頁查詢   //localhost:8083/query/page?text=vue&page=1&pageSize=10
     *
     * @param text
     * @return
     * @throws Exception
     */
    @GetMapping("/query/page")
    public Object queryForPage(@RequestParam("text") String text,
                               @RequestParam(value = "page",defaultValue = "1") int page,
                               @RequestParam(value = "pageSize",defaultValue = "10") int pageSize) throws Exception {
        Map<String, Object> resMap = new HashMap<>();
        Directory directory = FSDirectory.open(FileSystems.getDefault().getPath(INDEX_PATH));
        IndexReader reader = DirectoryReader.open(directory);
        IndexSearcher searcher = new IndexSearcher(reader);
        QueryParser parser = new QueryParser("descs", new MyIKAnalyzer(true));
        Query query = parser.parse(text);
        TopDocs topDocs = IndexUtils.searchByPage(page, pageSize, searcher, query);
        log.info("本次搜索共找到" + topDocs.totalHits + "條數據");
        ScoreDoc[] scoreDocs = topDocs.scoreDocs;
        List<Content> list = new ArrayList<>();
        List<String> idList = new ArrayList<>();
        for (ScoreDoc scoreDoc : scoreDocs) {
            int docID = scoreDoc.doc;
            Document doc = reader.document(docID);
            idList.add(doc.get("id"));;
        }
        if(!CollectionUtils.isEmpty(idList)){
            list = contentService.queryContentByIdList(idList);
        }
        resMap.put("page", page);
        resMap.put("pageSize", pageSize);
        resMap.put("total", topDocs.totalHits);
        resMap.put("list", list);
        return resMap;
    }

執行上面的接口查詢,看到如下的效果

4.4.6 多字段查詢

還記得在本文開頭使用like關鍵字查詢嗎?在mysql的like查詢中,如果希望從多個字段中匹配出某個關鍵字,使用like的or即可,使用lucence也可以滿足,不過有個前提,那就是匹配的字段都需要提前分詞,為瞭達到這個目的,首先刪除本地已經創建的索引文件,然後在初始化索引文件接口中將title與descs兩個字段都設置為分詞;

上面的代碼修改後再次執行重新生成索引文件

完整的查詢代碼

    /**
     * 多字段查詢解析  //localhost:8083/query/multi?text=面向對象
     *
     * @param text
     * @return
     * @throws Exception
     */
    @GetMapping("/query/multi")
    public Object multiFieldQuery(String text) throws Exception {
        String[] str = {"title", "descs"};
        Directory directory = FSDirectory.open(FileSystems.getDefault().getPath(INDEX_PATH));
        // 索引讀取工具
        IndexReader reader = DirectoryReader.open(directory);
        // 索引搜索工具
        IndexSearcher searcher = new IndexSearcher(reader);
        // 創建查詢解析器,兩個參數:默認要查詢的字段的名稱,分詞器
        MultiFieldQueryParser parser = new MultiFieldQueryParser(str, new MyIKAnalyzer());
        // 創建查詢對象
        Query query = parser.parse(text);
        // 獲取前十條記錄
        TopDocs topDocs = searcher.search(query, 100);
        // 獲取得分文檔對象(ScoreDoc)數組.SocreDoc中包含:文檔的編號、文檔的得分
        ScoreDoc[] scoreDocs = topDocs.scoreDocs;
        List<Content> list = new ArrayList<>();
        for (ScoreDoc scoreDoc : scoreDocs) {
            // 取出文檔編號
            int docID = scoreDoc.doc;
            // 根據編號去找文檔
            Document doc = reader.document(docID);
            Content content = new Content();
            content.setId(doc.get("id"));
            content.setTitle(doc.get("title"));
            content.setDescs(doc.get("descs"));
            list.add(content);
        }
        return list;
    }

比如在數據表中有條數據的title中含有”什麼“;

調用一下接口測試,可以看到數據就查到瞭;

4.4.7 數據高亮展示

在一些網站上,當我們搜索關鍵詞時發現,我們搜索出來的內容裡面包含瞭關鍵詞的位置會以高亮進行展示,像es,lucence都支持結果的高亮現實,完整代碼如下

    /**
     * 數據高亮顯示  //localhost:8083/query/high-light?text=對象
     *
     * @param text
     * @return
     * @throws Exception
     */
    @GetMapping("/query/high-light")
    public Object queryHighLight(String text) throws Exception {
        String[] str = {"title", "descs"};
        Directory directory = FSDirectory.open(FileSystems.getDefault().getPath(INDEX_PATH));
        IndexReader reader = DirectoryReader.open(directory);
        IndexSearcher searcher = new IndexSearcher(reader);
        MultiFieldQueryParser parser = new MultiFieldQueryParser(str, new MyIKAnalyzer());
        Query query = parser.parse(text);
        TopDocs topDocs = searcher.search(query, Integer.MAX_VALUE);
        //高亮顯示設置
        SimpleHTMLFormatter simpleHTMLFormatter = new SimpleHTMLFormatter("<span style='color:red'>", "</span>");
        Highlighter highlighter = new Highlighter(simpleHTMLFormatter, new QueryScorer(query));
        //高亮後的段落范圍在100字內
        Fragmenter fragmenter = new SimpleFragmenter(100);
        highlighter.setTextFragmenter(fragmenter);
        ScoreDoc[] scoreDocs = topDocs.scoreDocs;
        List<Content> list = new ArrayList<>();
        for (ScoreDoc scoreDoc : scoreDocs) {
            int docID = scoreDoc.doc;
            Document doc = reader.document(docID);
            Content content = new Content();
            String title = highlighter.getBestFragment(new MyIKAnalyzer(), "title", doc.get("title"));
            if (title == null) {
                title = content.getTitle();
            }
            String descs = highlighter.getBestFragment(new MyIKAnalyzer(), "descs", doc.get("descs"));
            if (descs == null) {
                descs = content.getDescs();
            }
            content.setDescs(descs);
            content.setTitle(title);
            list.add(content);
        }
        return list;
    }

執行接口查詢,看到如下效果,如果將其中的html進行渲染的話就能清楚的看到效果瞭;

本文接口中涉及到的工具類如下

public class IndexUtils {
 
    public static final String INDEX_PATH = "D:\\Lucene\\indexDir";
 
    private static Directory dir;
 
    public static TopDocs searchByPage(int page, int perPage, IndexSearcher searcher, Query query) throws IOException {
        TopDocs result = null;
        if (query == null) {
            System.out.println(" Query is null return null ");
            return null;
        }
        ScoreDoc before = null;
        if (page != 1) {
            TopDocs docsBefore = searcher.search(query, (page - 1) * perPage);
            ScoreDoc[] scoreDocs = docsBefore.scoreDocs;
            if (scoreDocs.length > 0) {
                before = scoreDocs[scoreDocs.length - 1];
            }
        }
        result = searcher.searchAfter(before, query, perPage);
        return result;
    }
 
    /**
     * 獲取IndexWriter實例
     * @return
     * @throws Exception
     */
    public static IndexWriter getWriter() throws Exception {
        //使用中文分詞器
        SmartChineseAnalyzer analyzer = new SmartChineseAnalyzer();
        //將中文分詞器配到寫索引的配置中
        IndexWriterConfig config = new IndexWriterConfig(analyzer);
        //實例化寫索引對象
        IndexWriter writer = new IndexWriter(dir, config);
        return writer;
    }
 
}

4.5 方案完善

通過上面的接口效果演示,初步可以滿足提出的需求,但代碼部分比較粗糙,還需細致打磨,提出如下幾點可供繼續完善

4.5.1 索引文件管理

或許在你的項目中,不隻一張表需要通過lucence這種方式,那麼就涉及到索引目錄的管理,是放到一個目錄下管理還是分不同的目錄管理,這個是需要考慮的。

4.5.2 分佈式環境目錄管理

在集群或多節點部署情況下,為瞭數據檢索的準確性,索引文件一定需要集中在一個位置存儲,否則將造成數據不一致問題。

4.5.3 查詢兜底與數據容錯

如果lucence沒有查到數據,你是否還要去mysql中再查一次呢?因為可能會出現lucence索引文件在極端情況下被破壞的情況,這是所有走數據雙寫方案需要考慮的問題。

4.5.4 索引文件過大的問題

如果你的索引文件越來越大,可能面臨單機索引文件存儲受限的情況,這將涉及到索引文件拆分或者遷移的問題,這個是有必要提前規劃的。

五、寫在文末

本文以一個實際的需求案例出發,詳細說明瞭如何基於Lucence實現對mysql數據表的全文檢索,作為一種可落地的實施方案,對於解決類似的實際問題有一定的參考意義,希望對您有用。

到此這篇關於springboot微服務Lucence實現Mysql全文檢索的文章就介紹到這瞭,更多相關Mysql全文檢索內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: