CLOVER🍀

That was when it all began.

Hibernate Searchで、インデックスを手動変更する

個人的に続けていたHibernate Searchネタですが、ちょっと間が空きましたけど今回で一区切りです。

お題は、インデックスの手動メンテナンス。

Hibernate Searchを使っていると、通常JPAのEntityManagerに対して永続化すると勝手にインデキシングされるため、あまり意識しませんがすでにテーブルにデータが入っている場合などは、明示的にインデックス作成を行う必要があります。また、必要に応じてオプティマイズも実行するのでしょう。

今回は、このあたりを調べてみました。

準備

では、まずはHibernate Searchを使う準備。

Maven依存関係は、このように定義。

    <dependency>
      <groupId>org.hibernate</groupId>
      <artifactId>hibernate-entitymanager</artifactId>
      <version>4.3.8.Final</version>
    </dependency>
    <dependency>
      <groupId>org.hibernate</groupId>
      <artifactId>hibernate-search-orm</artifactId>
      <version>5.1.0.Final</version>
    </dependency>
    <dependency>
      <groupId>org.apache.lucene</groupId>
      <artifactId>lucene-analyzers-kuromoji</artifactId>
      <version>4.10.4</version>
    </dependency>

Hibernate Search、つい最近5.1.0.Finalが出ましたね!あとKuromojiを使ってますが、そんなに意味はありません。

データベースはMySQL、テストコードにはJUnitとAssertJを使うものとします。

テーブルとEntity定義

使うテーブルは、簡単にこんな感じ。

CREATE TABLE contents(
    id INT AUTO_INCREMENT,
    value VARCHAR(255),
    PRIMARY KEY(id)
);

対するEntityの定義。
src/main/java/org/littlewings/hibernate/indexing/Contents.java

package org.littlewings.hibernate.indexing;

import javax.persistence.*;

import org.hibernate.search.annotations.*;

@Entity
@Table(name = "contents")
@Indexed
public class Contents {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column
    private String value;

    public Contents() { }

    public Contents(String value) {
        this.value = value;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getValue() { return value; }
    public void setValue(String value) { this.value = value; }
}

persistence.xml

JPAで利用するpersistence.xmlは、このように定義しました。
src/test/resources/META-INF/persistence.xml

<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd"
             version="2.1">
  <persistence-unit name="search.pu" transaction-type="RESOURCE_LOCAL">
    <provider>org.hibernate.ejb.HibernatePersistence</provider>
    <class>org.littlewings.hibernate.indexing.Contents</class>
    <properties>
      <property name="javax.persistence.jdbc.url" value="jdbc:mysql://localhost:3306/practice?useUnicode=true&amp;characterEncoding=utf-8&amp;characterSetResults=utf-8&amp;useServerPrepStmts=true&amp;useLocalSessionState=true&amp;elideSetAutoCommits=true&amp;alwaysSendSetIsolation=false" />
      <property name="javax.persistence.jdbc.user" value="kazuhira" />
      <property name="javax.persistence.jdbc.password" value="password" />

      <!-- Hibernate -->
      <property name="hibernate.dialect" value="org.hibernate.dialect.MySQL5InnoDBDialect" />
      <property name="hibernate.show_sql" value="true" />
      <property name="hibernate.format_sql" value="true" />

      <!-- Hibernate Search -->
      <property name="hibernate.search.default.directory_provider" value="ram" />
      <property name="hibernate.search.analyzer" value="org.apache.lucene.analysis.ja.JapaneseAnalyzer" />
      <property name="hibernate.search.lucene_version" value="LUCENE_4_10_4" />
    </properties>
  </persistence-unit>
</persistence>

ここまで、特に変わったところはありません。

テストコードの雛形

動作確認に使う、テストコードの雛形です。
src/test/java/org/littlewings/hibernate/indexing/ManualIndexingTest.java

package org.littlewings.hibernate.indexing;

import static org.assertj.core.api.Assertions.*;

import java.sql.*;
import java.util.*;
import java.util.concurrent.*;
import javax.persistence.*;

import org.hibernate.search.MassIndexer;
import org.hibernate.search.jpa.*;
import org.hibernate.search.query.dsl.*;

import org.junit.*;

public class ManualIndexingTest {
    private static EntityManager em;

    @BeforeClass
    public static void setUpClass() {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("search.pu");
        em = emf.createEntityManager();
    }

    @AfterClass
    public static void tearDownClass() {
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        em.createNativeQuery("TRUNCATE TABLE contents").executeUpdate();

        tx.commit();
    }

    @After
    public void tearDown() {
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        em.createQuery("DELETE FROM Contents").executeUpdate();

        tx.commit();
    }

    public void insertData(String... values) throws SQLException {
        em.unwrap(org.hibernate.Session.class).doWork(connection -> {
            connection.setAutoCommit(false);

            for (String value : values) {
                try (PreparedStatement ps = connection.prepareStatement("INSERT INTO contents (value) VALUES (?)")) {
                    ps.setString(1, value);
                    ps.executeUpdate();
                }
            }

            connection.commit();
        });
    }

    // ここに、テストを書く!!
}

テストケースの終了ごとに全件DELETE、またテストクラスの終了時にはTRUNCATEするようにしています。また、JPAの管理外でデータを突っ込みたいので、HibernatのSessionを引っ張り出してきて直接JDBCでINSERTできるようなヘルパーメソッドも用意しました。

これらを使って、インデックスの手動変更のテストを書いていきます。

再インデキシング(MassIndexer)

いきなりですが、全インデックスをパージの上、インデキシングをやり直すMassIndexerをご紹介。

Using a MassIndexer
http://docs.jboss.org/hibernate/search/5.1/reference/en-US/html_single/#search-batchindex-massindexer

これを使うと、インデックスの再構築を行うことができます。

MassIndexerを使ってみる

まずは、テストデータを登録します。

        insertData("Hibernate", "日本語", "Lucene", "全文検索");

FullTextEntityManagerを使って、LuceneのQueryをJPAのQueryに変換して検索。

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Contents.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.all().createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Contents.class);

        List<Contents> contents = jpaQuery.getResultList();

        assertThat(contents)
            .isEmpty();

JPAの管理外でデータを登録したので、ヒット件数が0件となります。

ここで、FullTextEntityManagerからMassIndexerを取得し、インデックスを再構築します。

        MassIndexer indexer = ftem.createIndexer();
        indexer.startAndWait();

MassIndexer#startAndWaitは、インデキシング完了まで待ち合わせを行います。

すると、今度はデータを登録した件数(4件)になりました。

        contents = jpaQuery.getResultList();

        assertThat(contents)
            .hasSize(4);

インデックスに反映されたようですね。

非同期に実行する

先ほどは、MassIndexer#startAndWaitでインデキシング完了まで待ち合わせを行いましたが、MassIndexer#startを利用することで非同期に実行することもできます。

        MassIndexer indexer = ftem.createIndexer();
        Future<?> future = indexer.start();
        future.get();

Futureの戻り値はありません。この例だと、すぐにFuture#getするのであまり意味はないですが…。

MassIndexer利用時のパラメータ

MassIndexerは、利用する時にいろいろとパラメータを設定することができます。以下に、デフォルト値を適用した形で紹介します。

        MassIndexer indexer =
            ftem
            .createIndexer(Contents.class)  // インデキシング対象のEntityクラス。何も指定しない場合は、全Entityが対象
            .cacheMode(org.hibernate.CacheMode.IGNORE)  // インデキシング時に主キーを元にデータベースに問い合わせをかける際に、Hibernateに対して指定するCacheMode
            .typesToIndexInParallel(1)  // Entityクラスレベルでの並列度
            .idFetchSize(100)  // Entityに対するのフェッチサイズ
            .threadsToLoadObjects(6) // LuceneのDocumentレベル(Entityインスタンスレベル)での並列度
            .batchSizeToLoadObjects(10)  // インデキシング処理内部でのキューに渡すListのサイズ
            .limitIndexedObjectsTo(0)  // 処理対象のレコード件数。0は制限なし、全レコード対象
            .purgeAllOnStart(true)  // 最初にLuceneのインデックスをパージする場合true
            .optimizeAfterPurge(true)  // パージ後にLuceneのインデックスをOptimizeする場合true
            .optimizeOnFinish(true);  // インデキシング後にLuceneのインデックスをOptimizeする場合true

        Future<?> future = indexer.start();
        future.get();

意味はだいたいコメントに書いてあるので、そちらを見てください…。

再インデックス対象のEntityは、可変長引数で複数指定できます。何も指定しない場合は、全Entityが対象です。

Entityレベルのスレッド数や、LuceneのDocumentレベルのスレッド数、フェッチサイズなどがチューニング項目になるでしょう。MassIndexerを利用する時は、内部でExecutors#newFixedThreadPoolが使われ、Entityごとの処理、Lucene Documentの登録処理を並列化することができます。

なので、ちょっと並列度を上げるとJDBC接続をけっこう使うことになります。計算式は、ドキュメントにも書かれていますが

typesToIndexInParallel × (threadsToLoadObjects + 1)

となり、これだけのスレッドからデータベースに並列アクセスが行われることになります。

また、内部的にインデキシング処理がキューに溜められるようにしてあるようです。

あと、注意事項としては(デフォルトでインデックスをパージするので)インデックスがなくなってしまうため、検索した時にヒットしなくなることでしょうね。メンテナンスには、ご用心…。

Entity単位のインデックス追加、パージ

MassIndexerは一括処理な形態ですが、Entityのインスタンス単位でインデックス登録や削除を行うこともできます。

とりあえず、先ほどのテストケースと同じ状態を作ります。

        insertData("Hibernate", "日本語", "Lucene", "全文検索");

        Query rawQuery = em.createQuery("SELECT c FROM Contents c");
        List<Contents> contentsFromJpa = rawQuery.getResultList();

        assertThat(contentsFromJpa)
            .hasSize(4);

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Contents.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.all().createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Contents.class);

        List<Contents> contentsFromFullText = jpaQuery.getResultList();

        assertThat(contentsFromFullText)
            .isEmpty();

そうそう、普通にJPAを使ってQueryを投げると追加した4件が取得できますが、FullTextEntityManagerを使った場合には0件ヒットとなる例にしておきました…。

FullTextEntityManager#index

FullTextEntityManagerのインデックスを使うことで、単一のEntityのインスタンスに対してインデックスを作成することができます。

Adding instances to the index
http://docs.jboss.org/hibernate/search/5.1/reference/en-US/html_single/#_adding_instances_to_the_index

        EntityTransaction tx = ftem.getTransaction();
        tx.begin();
        contentsFromJpa
            .stream()
            .forEach(ftem::index);  // 単一のEntityをインデキシング
        tx.commit();  // 要コミット

このオペレーションには、トランザクションが実行してあることと、反映にはコミットが必要です。

コミット後、FullTextEntityManagerから作成したQueryでも、検索がヒットするようになります。

        assertThat(jpaQuery.getResultList())
            .hasSize(4);
FullTextEntityManager#purge

続いて、インデックスのパージ。FullTextEntityManager#purgeを使うことで、実行することができます。

Deleting instances from the index
http://docs.jboss.org/hibernate/search/5.1/reference/en-US/html_single/#_deleting_instances_from_the_index

        tx = ftem.getTransaction();
        tx.begin();
        ftem.purge(Contents.class, contentsFromJpa.get(0).getId());  // 単一のEntityに対するインデックスをパージ
        ftem.purge(Contents.class, contentsFromJpa.get(1).getId());  // 要主キー
        tx.commit();  // 要コミット

こちらも、要トランザクションです。また、パージするEntityのClassクラスと、削除するレコードを特定するための主キーを渡す必要があります。

FullTextEntityManager#purgeAll

特定のEntityのインデックスを全削除する場合は、FulTextEntityManager#purgeAllを使います。

        tx = ftem.getTransaction();
        tx.begin();
        ftem.purgeAll(Contents.class);  // 特定のEntityのインデックスを全てパージ
        tx.commit(); // 要コミット

ここまで通すと、インデックスにはデータがなくて、データベースのみに残っている状態になります。

        // インデックスから検索するクエリは、0件になる
        assertThat(jpaQuery.getResultList())
            .isEmpty();

        // データベースのエンティティは残ったまま
        assertThat(rawQuery.getResultList())
            .hasSize(4);

オプティマイズ

手動でインデックスのオプティマイズを実行するには、FullTextEntityManagerから取得できるSearchFactoryのoptimizeメソッドを使用します。

Manual optimization
http://docs.jboss.org/hibernate/search/5.1/reference/en-US/html_single/#_manual_optimization

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);

        ftem.getSearchFactory().optimize();  // 全EntityのインデックスをOptimize
        ftem.getSearchFactory().optimize(Contents.class);  // 特定のEntityのインデックスをOptimize

引数の有無で、対象のEntityを絞ることもできます。

Flush

最後は、Flushです。

Using flushToIndexes()
http://docs.jboss.org/hibernate/search/5.1/reference/en-US/html_single/#search-batchindex-flushtoindexes

FullTextEntityManager#indexを利用する際に、一気にインデックスに反映するのではなく、ある程度溜まったところで反映したい時に使うようです。

先ほどまでと同じようなコードを用意します。

        insertData("Hibernate", "日本語", "Lucene", "全文検索");

        Query rawQuery = em.createQuery("SELECT c FROM Contents c");
        List<Contents> contentsFromJpa = rawQuery.getResultList();

        assertThat(contentsFromJpa)
            .hasSize(4);

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Contents.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.all().createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Contents.class);

        List<Contents> contentsFromFullText = jpaQuery.getResultList();

        assertThat(contentsFromFullText)
            .isEmpty();

とりあえず、ドキュメントに習ってCacheやFlushのモードを調整して…。

        ftem.setFlushMode(FlushModeType.COMMIT);
        ftem.setProperty("javax.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS);
        ftem.setProperty("javax.persistence.cache.storeMode", CacheStoreMode.BYPASS);

FullTextEntityManager#indexを使ってインデックス登録を行いますが、まだ検索はできないことを確認します。

        EntityTransaction tx = ftem.getTransaction();
        tx.begin();
        ftem.index(contentsFromJpa.get(0));
        ftem.index(contentsFromJpa.get(1));

        assertThat(jpaQuery.getResultList())
            .isEmpty();

その後、FullTextEntityManager#flushToIndexesを呼び出します。

        ftem.flushToIndexes();
        ftem.clear();

すると、インデックスに反映されます。

        assertThat(jpaQuery.getResultList())
            .hasSize(2);

残りも反映、最後にコミットしておしまいです。

        contentsFromJpa = rawQuery.getResultList();
        ftem.index(contentsFromJpa.get(2));
        ftem.index(contentsFromJpa.get(3));

        assertThat(jpaQuery.getResultList())
            .hasSize(2);

        tx.commit();

        assertThat(jpaQuery.getResultList())
            .hasSize(4);

ただし、

Be aware that, once flushed, the changes cannot be rolled back.

とあるように、Flushしてしまうとロールバックはできないようです。たぶん、Luceneのインデックスには反映されたままになるのでしょう…。

おわり

Hibernate Searchを使って、Luceneのインデックスの手動メンテナンスをちょっとだけ試してみました。多く使われるのは、MassIndexerやFullTextEntityManager#index、optimizeとかだったりするのではないかなと思いますが。

Hibernate Searchのコードもそれなりに追ったので、いろいろ勉強になりました。

これで、Hibernate Searchネタは一段落です。