十分鐘學會使用 Elasticsearch 優雅搭建自己的搜尋系統(附原始碼)

Java知音_發表於2020-05-09

十分鐘學會使用 Elasticsearch 優雅搭建自己的搜尋系統(附原始碼)

作者海向,Java知音撰稿人,前58同城後端研發工程師,現某知名金融科技類公司Java工程師,熱愛技術研究,技術分享。如果您有好的作品分享,公眾號選單欄“關於我們”中檢視投稿方式。

什麼是elasticsearch

Elasticsearch 是一個開源的高度可擴充套件的全文搜尋和分析引擎,擁有查詢近實時的超強效能。

大名鼎鼎的Lucene 搜尋引擎被廣泛用於搜尋領域,但是操作複雜繁瑣,總是讓開發者敬而遠之。而 Elasticsearch將 Lucene 作為其核心來實現所有索引和搜尋的功能,通過簡單的 RESTful 語法來隱藏掉 Lucene 的複雜性,從而讓全文搜尋變得簡單

ES在Lucene基礎上,提供了一些分散式的實現:叢集,分片,複製等。

搜尋為什麼不用MySQL而用es

我們本文案例是一個迷你商品搜尋系統,為什麼不考慮使用MySQL來實現搜尋功能呢?原因如下:

  • MySQL預設使用innodb引擎,底層採用b+樹的方式來實現,而Es底層使用倒排索引的方式實現,使用倒排索引支援各種維度的分詞,可以掌控不同粒度的搜尋需求。(MYSQL8版本也支援了全文檢索,使用倒排索引實現,有興趣可以去看看兩者的差別)

  • 如果使用MySQL的%key%的模糊匹配來與es的搜尋進行比較,在8萬資料量時他們的耗時已經達到40:1左右,毫無疑問在速度方面es完勝。

es在大廠中的應用情況

  • es運用最廣泛的是elk組合來對日誌進行搜尋分析

  • 58安全部門、京東訂單中心幾乎全採用es來完成相關資訊的儲存與檢索

  • es在tob的專案中也用於各種檢索與分析

  • 在c端產品中,企業通常自己基於Lucene封裝自己的搜尋系統,為了適配公司營銷戰略、推薦系統等會有更多定製化的搜尋需求

es客戶端選型

spring-boot-starter-data-elasticsearch

我相信你看到的網上各類公開課視訊或者小專案均推薦使用這款springboot整合過的es客戶端,但是我們要say no!

十分鐘學會使用 Elasticsearch 優雅搭建自己的搜尋系統(附原始碼)

此圖是引入的最新版本的依賴,我們可以看到它所使用的es-high-client也為6.8.7,而es7.x版本都已經更新很久了,這裡許多新特性都無法使用,所以版本滯後是他最大的問題。而且它的底層也是highclient,我們操作highclient可以更靈活。我呆過的兩個公司均未採用此客戶端。

elasticsearch-rest-high-level-client

這是官方推薦的客戶端,支援最新的es,其實使用起來也很便利,因為是官方推薦所以在特性的操作上肯定優於前者。而且該客戶端與TransportClient不同,不存在併發瓶頸的問題,官方首推,必為精品!

搭建自己的迷你搜尋系統

引入es相關依賴,除此之外需引入springboot-web依賴、jackson依賴以及lombok依賴等。

    <properties>
        <es.version>7.3.2</es.version>
    </properties>
        <!-- high client-->
    <dependency>
        <groupId>org.elasticsearch.client</groupId>
        <artifactId>elasticsearch-rest-high-level-client</artifactId>
        <version>${es.version}</version>
        <exclusions>
            <exclusion>
                <groupId>org.elasticsearch.client</groupId>
                <artifactId>elasticsearch-rest-client</artifactId>
            </exclusion>
            <exclusion>
                <groupId>org.elasticsearch</groupId>
                <artifactId>elasticsearch</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>org.elasticsearch</groupId>
        <artifactId>elasticsearch</artifactId>
        <version>${es.version}</version>
    </dependency>

    <!--rest low client high client以來低版本client所以需要引入-->
    <dependency>
        <groupId>org.elasticsearch.client</groupId>
        <artifactId>elasticsearch-rest-client</artifactId>
        <version>${es.version}</version>
    </dependency>

es配置檔案es-config.properties

es.host=localhost
es.port=9200
es.token=es-token
es.charset=UTF-8
es.scheme=http

es.client.connectTimeOut=5000
es.client.socketTimeout=15000

封裝RestHighLevelClient

@Configuration
@PropertySource("classpath:es-config.properties")
public class RestHighLevelClientConfig {

    @Value("${es.host}")
    private String host;
    @Value("${es.port}")
    private int port;
    @Value("${es.scheme}")
    private String scheme;
    @Value("${es.token}")
    private String token;
    @Value("${es.charset}")
    private String charSet;
    @Value("${es.client.connectTimeOut}")
    private int connectTimeOut;
    @Value("${es.client.socketTimeout}")
    private int socketTimeout;

    @Bean
    public RestClientBuilder restClientBuilder() {
        RestClientBuilder restClientBuilder = RestClient.builder(
                new HttpHost(host, port, scheme)
        );

        Header[] defaultHeaders = new Header[]{
                new BasicHeader("Accept", "*/*"),
                new BasicHeader("Charset", charSet),
                //設定token 是為了安全 閘道器可以驗證token來決定是否發起請求 我們這裡只做象徵性配置
                new BasicHeader("E_TOKEN", token)
        };
        restClientBuilder.setDefaultHeaders(defaultHeaders);
        restClientBuilder.setFailureListener(new RestClient.FailureListener(){
            @Override
            public void onFailure(Node node) {
                System.out.println("監聽某個es節點失敗");
            }
        });
        restClientBuilder.setRequestConfigCallback(builder ->
                builder.setConnectTimeout(connectTimeOut).setSocketTimeout(socketTimeout));
        return restClientBuilder;
    }

    @Bean
    public RestHighLevelClient restHighLevelClient(RestClientBuilder restClientBuilder) {
        return new RestHighLevelClient(restClientBuilder);
    }
}

封裝es常用操作

@Service
public class RestHighLevelClientService {

    @Autowired
    private RestHighLevelClient client;

    @Autowired
    private ObjectMapper mapper;

    /**
     * 建立索引
     * @param indexName
     * @param settings
     * @param mapping
     * @return
     * @throws IOException
     */
    public CreateIndexResponse createIndex(String indexName, String settings, String mapping) throws IOException {
        CreateIndexRequest request = new CreateIndexRequest(indexName);
        if (null != settings && !"".equals(settings)) {
            request.settings(settings, XContentType.JSON);
        }
        if (null != mapping && !"".equals(mapping)) {
            request.mapping(mapping, XContentType.JSON);
        }
        return client.indices().create(request, RequestOptions.DEFAULT);
    }

    /**
     * 判斷 index 是否存在
     */
    public boolean indexExists(String indexName) throws IOException {
        GetIndexRequest request = new GetIndexRequest(indexName);
        return client.indices().exists(request, RequestOptions.DEFAULT);
    }

    /**
     * 搜尋
    */
    public SearchResponse search(String field, String key, String rangeField, String 
                                 from, String to,String termField, String termVal, 
                                 String ... indexNames) throws IOException{
        SearchRequest request = new SearchRequest(indexNames);

        SearchSourceBuilder builder = new SearchSourceBuilder();
        BoolQueryBuilder boolQueryBuilder = new BoolQueryBuilder();
        boolQueryBuilder.must(new MatchQueryBuilder(field, key)).must(new RangeQueryBuilder(rangeField).from(from).to(to)).must(new TermQueryBuilder(termField, termVal));
        builder.query(boolQueryBuilder);
        request.source(builder);
        log.info("[搜尋語句為:{}]",request.source().toString());
        return client.search(request, RequestOptions.DEFAULT);
    }

    /**
     * 批量匯入
     * @param indexName
     * @param isAutoId 使用自動id 還是使用傳入物件的id
     * @param source
     * @return
     * @throws IOException
     */
    public BulkResponse importAll(String indexName, boolean isAutoId, String  source) throws IOException{
        if (0 == source.length()){
            //todo 丟擲異常 匯入資料為空
        }
        BulkRequest request = new BulkRequest();
        JsonNode jsonNode = mapper.readTree(source);

        if (jsonNode.isArray()) {
            for (JsonNode node : jsonNode) {
                if (isAutoId) {
                    request.add(new IndexRequest(indexName).source(node.asText(), XContentType.JSON));
                } else {
                    request.add(new IndexRequest(indexName)
                            .id(node.get("id").asText())
                            .source(node.asText(), XContentType.JSON));
                }
            }
        }
        return client.bulk(request, RequestOptions.DEFAULT);
    }

建立索引,這裡的settings是設定索引是否設定複製節點、設定分片個數,mappings就和資料庫中的表結構一樣,用來指定各個欄位的型別,同時也可以設定欄位是否分詞(我們這裡使用ik中文分詞器)、採用什麼分詞方式。

   @Test
    public void createIdx() throws IOException {
        String settings = "" +
                "  {\n" +
                "      \"number_of_shards\" : \"2\",\n" +
                "      \"number_of_replicas\" : \"0\"\n" +
                "   }";
        String mappings = "" +
                "{\n" +
                "    \"properties\": {\n" +
                "      \"itemId\" : {\n" +
                "        \"type\": \"keyword\",\n" +
                "        \"ignore_above\": 64\n" +
                "      },\n" +
                "      \"urlId\" : {\n" +
                "        \"type\": \"keyword\",\n" +
                "        \"ignore_above\": 64\n" +
                "      },\n" +
                "      \"sellAddress\" : {\n" +
                "        \"type\": \"text\",\n" +
                "        \"analyzer\": \"ik_max_word\", \n" +
                "        \"search_analyzer\": \"ik_smart\",\n" +
                "        \"fields\": {\n" +
                "          \"keyword\" : {\"ignore_above\" : 256, \"type\" : \"keyword\"}\n" +
                "        }\n" +
                "      },\n" +
                "      \"courierFee\" : {\n" +
                "        \"type\": \"text\n" +
                "      },\n" +
                "      \"promotions\" : {\n" +
                "        \"type\": \"text\",\n" +
                "        \"analyzer\": \"ik_max_word\", \n" +
                "        \"search_analyzer\": \"ik_smart\",\n" +
                "        \"fields\": {\n" +
                "          \"keyword\" : {\"ignore_above\" : 256, \"type\" : \"keyword\"}\n" +
                "        }\n" +
                "      },\n" +
                "      \"originalPrice\" : {\n" +
                "        \"type\": \"keyword\",\n" +
                "        \"ignore_above\": 64\n" +
                "      },\n" +
                "      \"startTime\" : {\n" +
                "        \"type\": \"date\",\n" +
                "        \"format\": \"yyyy-MM-dd HH:mm:ss\"\n" +
                "      },\n" +
                "      \"endTime\" : {\n" +
                "        \"type\": \"date\",\n" +
                "        \"format\": \"yyyy-MM-dd HH:mm:ss\"\n" +
                "      },\n" +
                "      \"title\" : {\n" +
                "        \"type\": \"text\",\n" +
                "        \"analyzer\": \"ik_max_word\", \n" +
                "        \"search_analyzer\": \"ik_smart\",\n" +
                "        \"fields\": {\n" +
                "          \"keyword\" : {\"ignore_above\" : 256, \"type\" : \"keyword\"}\n" +
                "        }\n" +
                "      },\n" +
                "      \"serviceGuarantee\" : {\n" +
                "        \"type\": \"text\",\n" +
                "        \"analyzer\": \"ik_max_word\", \n" +
                "        \"search_analyzer\": \"ik_smart\",\n" +
                "        \"fields\": {\n" +
                "          \"keyword\" : {\"ignore_above\" : 256, \"type\" : \"keyword\"}\n" +
                "        }\n" +
                "      },\n" +
                "      \"venue\" : {\n" +
                "        \"type\": \"text\",\n" +
                "        \"analyzer\": \"ik_max_word\", \n" +
                "        \"search_analyzer\": \"ik_smart\",\n" +
                "        \"fields\": {\n" +
                "          \"keyword\" : {\"ignore_above\" : 256, \"type\" : \"keyword\"}\n" +
                "        }\n" +
                "      },\n" +
                "      \"currentPrice\" : {\n" +
                "        \"type\": \"keyword\",\n" +
                "        \"ignore_above\": 64\n" +
                "      }\n" +
                "   }\n" +
                "}";
        clientService.createIndex("idx_item", settings, mappings);
    }

分詞技巧

  • 索引時最小分詞,搜尋時最大分詞,例如"Java知音"索引時分詞包含Java、知音、音、知等,最小粒度分詞可以讓我們匹配更多的檢索需求,但是我們搜尋時應該設定最大分詞,用“Java”和“知音”去匹配索引庫,得到的結果更貼近我們的目的,

  • 對分詞欄位同時也設定keyword,便於後續排查錯誤時可以精確匹配搜尋,快速定位。

我們向es匯入十萬條淘寶雙11活動資料作為我們的樣本資料,資料結構如下所示

{
    "_id": "https://detail.tmall.com/item.htm?id=538528948719\u0026skuId=3216546934499",
    "賣家地址": "上海",
    "快遞費": "運費: 0.00元",
    "優惠活動": "滿199減10,滿299減30,滿499減60,可跨店",
    "商品ID": "538528948719",
    "原價": "2290.00",
    "活動開始時間": "2016-11-11 00:00:00",
    "活動結束時間": "2016-11-11 23:59:59",
    "標題": "【天貓海外直營】 ReFa CARAT RAY 黎琺 雙球滾輪波光美容儀",
    "服務保障": "正品保證;贈運費險;極速退款;七天退換",
    "會場": "進口尖貨",
    "現價": "1950.00"
}

呼叫上面封裝的批量匯入方法進行匯入

    @Test
    public void importAll() throws IOException {
        clientService.importAll("idx_item", true, itemService.getItemsJson());
    }

我們呼叫封裝的搜尋方法進行搜尋,搜尋產地為武漢、價格在11-149之間的相關酒產品,這與我們淘寶中設定篩選條件搜尋商品操作一致。

    @Test
    public void search() throws IOException {
        SearchResponse search = clientService.search("title", "酒", "currentPrice",
                "11", "149", "sellAddress", "武漢");
        SearchHits hits = search.getHits();
        SearchHit[] hits1 = hits.getHits();
        for (SearchHit documentFields : hits1) {
            System.out.println( documentFields.getSourceAsString());
        }
    }

我們得到以下搜尋結果,其中_score為某一項的得分,商品就是按照它來排序。

    {
      "_index": "idx_item",
      "_type": "_doc",
      "_id": "Rw3G7HEBDGgXwwHKFPCb",
      "_score": 10.995819,
      "_source": {
        "itemId": "525033055044",
        "urlId": "https://detail.tmall.com/item.htm?id=525033055044&skuId=def",
        "sellAddress": "湖北武漢",
        "courierFee": "快遞: 0.00",
        "promotions": "滿199減10,滿299減30,滿499減60,可跨店",
        "originalPrice": "3768.00",
        "startTime": "2016-11-01 00:00:00",
        "endTime": "2016-11-11 23:59:59",
        "title": "酒嗨酒 西班牙原瓶原裝進口紅酒蒙德干紅葡萄酒6只裝整箱送酒具",
        "serviceGuarantee": "破損包退;正品保證;公益寶貝;不支援7天退換;極速退款",
        "venue": "食品主會場",
        "currentPrice": "151.00"
      }
    }

擴充套件性思考

  • 商品搜尋權重擴充套件,我們可以利用多種收費方式智慧為不同店家提供增加權重,增加曝光度適應自身的營銷策略。同時我們經常發現淘寶搜尋前列的商品許多為我們之前檢視過的商品,這是通過記錄使用者行為,跑模型等方式智慧為這些商品增加權重。

  • 分詞擴充套件,也許因為某些商品的特殊性,我們可以自定義擴充套件分詞字典,更精準、人性化的搜尋。

  • 高亮功能,es提供highlight高亮功能,我們在淘寶上看到的商品展示中對搜尋關鍵字高亮,就是通過這種方式來實現。

專案地址

https://github.com/Motianshi/alldemo/tree/master/demo-search

END

Java面試題專欄

【71期】面試官:對併發熟悉嗎?談談你對Java中常用的幾種執行緒池的理解

【72期】面試官:對併發熟悉嗎?說一下synchronized與Lock的區別與使用

【73期】面試官:Spring 和 Spring Boot 的區別是什麼?

【74期】面試官:對多執行緒熟悉嗎,來談談執行緒池的好處?

【75期】面試官:說說Redis的過期鍵刪除策略吧!(高頻)

【76期】面試官問:List如何一邊遍歷,一邊刪除?

【77期】這一道面試題就考驗了你對Java的理解程度

【78期】別找了,Java集合面試問題這裡幫你總結好了!

【79期】別找了,回答Spring中Bean的生命週期,這裡幫你總結好了!

【80期】說出Java建立執行緒的三種方式及對比

十分鐘學會使用 Elasticsearch 優雅搭建自己的搜尋系統(附原始碼)

我知道你 “在看”

相關文章