Spring JPA Elasticsearch連携は、リレーショナルデータベースの堅牢なデータ管理と、Elasticsearchの高速な全文検索を組み合わせることで、エンタープライズアプリケーションの検索機能を劇的に強化します。本記事では、Spring BootプロジェクトにおけるJPA EntityとElasticsearchドキュメントの同期パターン、Hibernate Searchの導入による透過的なインデックス管理、イベント駆動の非同期更新アーキテクチャについて詳しく解説します。全文検索機能を実装する際の設計指針から、具体的なコード例、よくあるアンチパターンまで、実践的な知識を提供します。
実行環境と前提条件#
本記事の内容を実践するにあたり、以下の環境を前提としています。
| 項目 |
バージョン・要件 |
| Java |
17以上 |
| Spring Boot |
3.4.x |
| Hibernate ORM |
6.6.x |
| Hibernate Search |
7.2.x(Hibernate ORM 6.6対応版) |
| Elasticsearch |
8.x |
| Spring Data Elasticsearch |
5.4.x(オプション) |
| ビルドツール |
Maven または Gradle |
事前に以下の準備を完了してください。
- JDK 17以上のインストール
- Elasticsearch 8.xのローカル環境またはクラスター
- Spring Boot + JPAプロジェクトの基本構成
- Docker(Elasticsearch実行用、オプション)
アーキテクチャの選択肢#
RDBと全文検索エンジンを連携させるアプローチには、主に3つの選択肢があります。それぞれのメリット・デメリットを理解した上で、要件に最適なアーキテクチャを選択することが重要です。
graph TD
subgraph "アプローチ1: Hibernate Search"
A1[JPA Entity] -->|透過的同期| B1[Elasticsearch]
A1 -->|自動管理| C1[Lucene/ES Index]
end
subgraph "アプローチ2: Spring Data Elasticsearch"
A2[JPA Entity] -->|手動同期| B2[ES Repository]
B2 --> C2[Elasticsearch]
end
subgraph "アプローチ3: イベント駆動"
A3[JPA Entity] -->|イベント発行| B3[Event Handler]
B3 -->|非同期更新| C3[Elasticsearch]
end
| アプローチ |
メリット |
デメリット |
適用シーン |
| Hibernate Search |
透過的な同期、JPA統合 |
学習コスト、設定の複雑さ |
エンティティ中心の全文検索 |
| Spring Data Elasticsearch |
柔軟なインデックス設計 |
手動同期が必要 |
独立した検索マイクロサービス |
| イベント駆動 |
疎結合、スケーラビリティ |
実装コスト、整合性管理 |
大規模分散システム |
Hibernate Searchの導入#
Hibernate Searchは、Hibernate ORMと密接に統合された全文検索ソリューションです。JPAエンティティの変更を透過的にElasticsearchへ同期し、アノテーションベースでインデックスマッピングを定義できます。
依存関係の追加#
Mavenの場合、pom.xmlに以下を追加します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.hibernate.search</groupId>
<artifactId>hibernate-search-bom</artifactId>
<version>7.2.1.Final</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Hibernate Search ORM Mapper -->
<dependency>
<groupId>org.hibernate.search</groupId>
<artifactId>hibernate-search-mapper-orm</artifactId>
</dependency>
<!-- Elasticsearch Backend -->
<dependency>
<groupId>org.hibernate.search</groupId>
<artifactId>hibernate-search-backend-elasticsearch</artifactId>
</dependency>
</dependencies>
|
Gradleの場合は以下のように記述します。
1
2
3
4
5
|
dependencies {
implementation platform('org.hibernate.search:hibernate-search-bom:7.2.1.Final')
implementation 'org.hibernate.search:hibernate-search-mapper-orm'
implementation 'org.hibernate.search:hibernate-search-backend-elasticsearch'
}
|
Elasticsearch接続設定#
application.ymlでElasticsearchへの接続を設定します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
spring:
jpa:
properties:
hibernate:
search:
backend:
type: elasticsearch
hosts: localhost:9200
protocol: http
# 認証が必要な場合
# username: elastic
# password: changeme
schema_management:
strategy: create-or-validate
# 開発時の自動インデックス作成
automatic_indexing:
synchronization:
strategy: sync
|
本番環境ではsynchronization.strategyをasyncに設定し、パフォーマンスを最適化することを推奨します。
エンティティへのインデックスマッピング#
Hibernate Searchでは、@Indexedアノテーションでエンティティをインデックス対象として指定し、@FullTextFieldなどのアノテーションで各フィールドのマッピングを定義します。
基本的なマッピング例#
以下は、商品エンティティに全文検索機能を追加する例です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
|
package com.example.domain.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.GenericField;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.Indexed;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.IndexedEmbedded;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.KeywordField;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "products")
@Indexed // Elasticsearchへのインデックス対象として指定
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@FullTextField(analyzer = "japanese") // 日本語アナライザーで全文検索
@KeywordField(name = "name_keyword", normalizer = "lowercase") // ソート・集計用
private String name;
@FullTextField(analyzer = "japanese")
private String description;
@GenericField // 数値フィールドはGenericFieldを使用
private BigDecimal price;
@KeywordField // 完全一致検索用
private String sku;
@GenericField
private LocalDateTime createdAt;
@ManyToOne
@IndexedEmbedded // 関連エンティティのフィールドをインデックスに埋め込み
private Category category;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public String getSku() { return sku; }
public void setSku(String sku) { this.sku = sku; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public Category getCategory() { return category; }
public void setCategory(Category category) { this.category = category; }
}
|
関連エンティティのマッピング#
@IndexedEmbeddedを使用すると、関連エンティティのフィールドを親エンティティのインデックスに含めることができます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
package com.example.domain.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField;
import org.hibernate.search.mapper.pojo.mapping.definition.annotation.KeywordField;
@Entity
@Table(name = "categories")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@FullTextField(analyzer = "japanese")
@KeywordField(name = "name_keyword")
private String name;
@FullTextField(analyzer = "japanese")
private String description;
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
|
この設定により、Productのインデックスにはcategory.nameやcategory.descriptionフィールドが含まれ、カテゴリ名での検索が可能になります。
日本語アナライザーの設定#
日本語テキストを適切にトークン化するには、カスタムアナライザーを定義します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
package com.example.config;
import org.hibernate.search.backend.elasticsearch.analysis.ElasticsearchAnalysisConfigurationContext;
import org.hibernate.search.backend.elasticsearch.analysis.ElasticsearchAnalysisConfigurer;
import org.springframework.stereotype.Component;
@Component("elasticsearchAnalysisConfigurer")
public class CustomElasticsearchAnalysisConfigurer implements ElasticsearchAnalysisConfigurer {
@Override
public void configure(ElasticsearchAnalysisConfigurationContext context) {
// 日本語アナライザー(kuromoji使用)
context.analyzer("japanese").custom()
.tokenizer("kuromoji_tokenizer")
.tokenFilters(
"kuromoji_baseform",
"kuromoji_part_of_speech",
"cjk_width",
"lowercase"
);
// 英語アナライザー
context.analyzer("english").custom()
.tokenizer("standard")
.tokenFilters("lowercase", "snowball_english", "asciifolding");
context.tokenFilter("snowball_english")
.type("snowball")
.param("language", "English");
// 正規化用ノーマライザー
context.normalizer("lowercase").custom()
.tokenFilters("lowercase", "asciifolding");
}
}
|
application.ymlでこのConfigurerを指定します。
1
2
3
4
5
6
7
8
|
spring:
jpa:
properties:
hibernate:
search:
backend:
analysis:
configurer: "class:com.example.config.CustomElasticsearchAnalysisConfigurer"
|
検索機能の実装#
Hibernate SearchのSearchSessionを使用して、型安全な検索クエリを構築できます。
検索サービスの実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
|
package com.example.service;
import com.example.domain.entity.Product;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.search.engine.search.query.SearchResult;
import org.hibernate.search.mapper.orm.Search;
import org.hibernate.search.mapper.orm.session.SearchSession;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
@Transactional(readOnly = true)
public class ProductSearchService {
@PersistenceContext
private EntityManager entityManager;
/**
* キーワードによる商品検索
*/
public SearchResult<Product> searchByKeyword(String keyword, int offset, int limit) {
SearchSession searchSession = Search.session(entityManager);
return searchSession.search(Product.class)
.where(f -> f.match()
.fields("name", "description", "category.name")
.matching(keyword))
.fetch(offset, limit);
}
/**
* 複合条件での検索
*/
public SearchResult<Product> searchWithFilters(
String keyword,
String categoryName,
BigDecimal minPrice,
BigDecimal maxPrice,
int offset,
int limit) {
SearchSession searchSession = Search.session(entityManager);
return searchSession.search(Product.class)
.where(f -> f.bool(b -> {
// キーワード検索(オプショナル)
if (keyword != null && !keyword.isBlank()) {
b.must(f.match()
.fields("name", "description")
.matching(keyword));
}
// カテゴリフィルター
if (categoryName != null && !categoryName.isBlank()) {
b.must(f.match()
.field("category.name_keyword")
.matching(categoryName));
}
// 価格範囲フィルター
if (minPrice != null || maxPrice != null) {
b.must(f.range()
.field("price")
.between(minPrice, maxPrice));
}
}))
.sort(f -> f.field("createdAt").desc())
.fetch(offset, limit);
}
/**
* ファセット検索(集計)
*/
public SearchResult<Product> searchWithAggregation(String keyword) {
SearchSession searchSession = Search.session(entityManager);
return searchSession.search(Product.class)
.where(f -> f.match()
.fields("name", "description")
.matching(keyword))
.aggregation(key -> key.terms()
.field("category.name_keyword", String.class))
.fetch(20);
}
}
|
検索結果とRDBデータの結合#
Hibernate Searchの検索結果は、デフォルトでJPAエンティティとして返されます。これにより、検索結果に対して追加のJPA操作(遅延読み込み、関連データの取得など)を行うことができます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
package com.example.service;
import com.example.domain.entity.Product;
import com.example.dto.ProductSearchResultDto;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.search.engine.search.query.SearchResult;
import org.hibernate.search.mapper.orm.Search;
import org.hibernate.search.mapper.orm.session.SearchSession;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional(readOnly = true)
public class ProductSearchWithJoinService {
@PersistenceContext
private EntityManager entityManager;
/**
* 検索結果をDTOに変換し、追加のRDBデータを結合
*/
public List<ProductSearchResultDto> searchAndEnrich(String keyword) {
SearchSession searchSession = Search.session(entityManager);
// Elasticsearchで検索
SearchResult<Product> result = searchSession.search(Product.class)
.where(f -> f.match()
.fields("name", "description")
.matching(keyword))
.fetch(20);
// 検索結果のIDを取得
List<Long> productIds = result.hits().stream()
.map(Product::getId)
.toList();
// JPAで追加データをフェッチ(N+1問題を回避)
List<Product> enrichedProducts = entityManager.createQuery(
"SELECT DISTINCT p FROM Product p " +
"LEFT JOIN FETCH p.category " +
"LEFT JOIN FETCH p.reviews " +
"WHERE p.id IN :ids", Product.class)
.setParameter("ids", productIds)
.getResultList();
// DTOに変換
return enrichedProducts.stream()
.map(this::toDto)
.toList();
}
private ProductSearchResultDto toDto(Product product) {
return new ProductSearchResultDto(
product.getId(),
product.getName(),
product.getDescription(),
product.getPrice(),
product.getCategory() != null ? product.getCategory().getName() : null
);
}
}
|
イベント駆動による非同期インデックス更新#
Hibernate Searchのデフォルトの同期方式では、エンティティの変更がトランザクションコミット時にElasticsearchへ即座に反映されます。しかし、高負荷環境では非同期更新によるパフォーマンス最適化が必要になる場合があります。
ApplicationEventPublisherを使用した実装#
Spring のApplicationEventPublisherを使用して、エンティティ変更イベントを非同期で処理するパターンを実装します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
package com.example.event;
import com.example.domain.entity.Product;
/**
* 商品インデックス更新イベント
*/
public record ProductIndexEvent(
Long productId,
IndexAction action,
Product product
) {
public enum IndexAction {
CREATE, UPDATE, DELETE
}
public static ProductIndexEvent create(Product product) {
return new ProductIndexEvent(product.getId(), IndexAction.CREATE, product);
}
public static ProductIndexEvent update(Product product) {
return new ProductIndexEvent(product.getId(), IndexAction.UPDATE, product);
}
public static ProductIndexEvent delete(Long productId) {
return new ProductIndexEvent(productId, IndexAction.DELETE, null);
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
package com.example.service;
import com.example.domain.entity.Product;
import com.example.domain.repository.ProductRepository;
import com.example.event.ProductIndexEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductService {
private final ProductRepository productRepository;
private final ApplicationEventPublisher eventPublisher;
public ProductService(ProductRepository productRepository,
ApplicationEventPublisher eventPublisher) {
this.productRepository = productRepository;
this.eventPublisher = eventPublisher;
}
@Transactional
public Product createProduct(Product product) {
Product saved = productRepository.save(product);
// トランザクションコミット後にイベントを発行
eventPublisher.publishEvent(ProductIndexEvent.create(saved));
return saved;
}
@Transactional
public Product updateProduct(Long id, Product productData) {
Product existing = productRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Product not found: " + id));
existing.setName(productData.getName());
existing.setDescription(productData.getDescription());
existing.setPrice(productData.getPrice());
Product updated = productRepository.save(existing);
eventPublisher.publishEvent(ProductIndexEvent.update(updated));
return updated;
}
@Transactional
public void deleteProduct(Long id) {
productRepository.deleteById(id);
eventPublisher.publishEvent(ProductIndexEvent.delete(id));
}
}
|
非同期イベントリスナーの実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
package com.example.event;
import com.example.domain.entity.Product;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.search.mapper.orm.Search;
import org.hibernate.search.mapper.orm.session.SearchSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class ProductIndexEventListener {
private static final Logger log = LoggerFactory.getLogger(ProductIndexEventListener.class);
@PersistenceContext
private EntityManager entityManager;
/**
* トランザクションコミット後に非同期でインデックスを更新
*/
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleProductIndexEvent(ProductIndexEvent event) {
try {
SearchSession searchSession = Search.session(entityManager);
switch (event.action()) {
case CREATE, UPDATE -> {
// 最新のエンティティを再取得してインデックス
Product product = entityManager.find(Product.class, event.productId());
if (product != null) {
searchSession.indexingPlan().addOrUpdate(product);
}
}
case DELETE -> {
searchSession.indexingPlan().purge(Product.class, event.productId(), null);
}
}
log.info("Successfully processed index event: {} for product {}",
event.action(), event.productId());
} catch (Exception e) {
log.error("Failed to process index event for product {}: {}",
event.productId(), e.getMessage(), e);
// リトライロジックやDead Letter Queue への送信を実装
}
}
}
|
@Asyncを有効化するために、設定クラスでアノテーションを追加します。
1
2
3
4
5
6
7
8
9
10
|
package com.example.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
public class AsyncConfig {
// カスタムExecutorの設定も可能
}
|
Spring Data Elasticsearchとの併用#
Hibernate Searchとは別に、Spring Data Elasticsearchを使用して、より柔軟なインデックス設計を行うことも可能です。この場合、JPA Entityとは独立したElasticsearchドキュメントを定義します。
依存関係の追加#
1
2
3
4
|
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
|
Elasticsearchドキュメントの定義#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
package com.example.elasticsearch.document;
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;
import org.springframework.data.elasticsearch.annotations.Setting;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Document(indexName = "products")
@Setting(settingPath = "/elasticsearch/settings/product-settings.json")
public class ProductDocument {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "kuromoji")
private String name;
@Field(type = FieldType.Text, analyzer = "kuromoji")
private String description;
@Field(type = FieldType.Double)
private BigDecimal price;
@Field(type = FieldType.Keyword)
private String categoryName;
@Field(type = FieldType.Date)
private LocalDateTime createdAt;
// Getters and Setters
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public String getCategoryName() { return categoryName; }
public void setCategoryName(String categoryName) { this.categoryName = categoryName; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
|
Elasticsearchリポジトリ#
1
2
3
4
5
6
7
8
9
10
11
12
13
|
package com.example.elasticsearch.repository;
import com.example.elasticsearch.document.ProductDocument;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import java.util.List;
public interface ProductDocumentRepository extends ElasticsearchRepository<ProductDocument, String> {
List<ProductDocument> findByNameContaining(String name);
List<ProductDocument> findByCategoryName(String categoryName);
}
|
JPA Entityからドキュメントへの同期#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
package com.example.sync;
import com.example.domain.entity.Product;
import com.example.elasticsearch.document.ProductDocument;
import com.example.elasticsearch.repository.ProductDocumentRepository;
import org.springframework.stereotype.Component;
@Component
public class ProductDocumentSync {
private final ProductDocumentRepository documentRepository;
public ProductDocumentSync(ProductDocumentRepository documentRepository) {
this.documentRepository = documentRepository;
}
public void syncToElasticsearch(Product product) {
ProductDocument document = toDocument(product);
documentRepository.save(document);
}
public void deleteFromElasticsearch(Long productId) {
documentRepository.deleteById(String.valueOf(productId));
}
private ProductDocument toDocument(Product product) {
ProductDocument doc = new ProductDocument();
doc.setId(String.valueOf(product.getId()));
doc.setName(product.getName());
doc.setDescription(product.getDescription());
doc.setPrice(product.getPrice());
doc.setCategoryName(
product.getCategory() != null ? product.getCategory().getName() : null
);
doc.setCreatedAt(product.getCreatedAt());
return doc;
}
}
|
初期データのマスインデックス#
既存のRDBデータをElasticsearchに一括インデックスするには、Hibernate SearchのMassIndexerを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
package com.example.service;
import com.example.domain.entity.Product;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.search.mapper.orm.Search;
import org.hibernate.search.mapper.orm.massindexing.MassIndexer;
import org.hibernate.search.mapper.orm.session.SearchSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class IndexMaintenanceService {
private static final Logger log = LoggerFactory.getLogger(IndexMaintenanceService.class);
@PersistenceContext
private EntityManager entityManager;
/**
* 全商品データの再インデックス
*/
@Transactional
public void reindexAllProducts() throws InterruptedException {
SearchSession searchSession = Search.session(entityManager);
MassIndexer indexer = searchSession.massIndexer(Product.class)
.threadsToLoadObjects(4) // データベース読み込みスレッド数
.batchSizeToLoadObjects(100) // バッチサイズ
.cacheMode(org.hibernate.CacheMode.IGNORE);
log.info("Starting mass indexing for Product entities...");
indexer.startAndWait();
log.info("Mass indexing completed.");
}
/**
* インデックスの再作成(スキーマ変更時)
*/
@Transactional
public void recreateIndex() throws InterruptedException {
SearchSession searchSession = Search.session(entityManager);
// 既存インデックスを削除して再作成
searchSession.schemaManager(Product.class).dropAndCreate();
// データを再インデックス
reindexAllProducts();
}
}
|
アプリケーション起動時に自動実行する場合は、ApplicationRunnerを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
package com.example.config;
import com.example.service.IndexMaintenanceService;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("!test") // テスト環境では実行しない
public class InitialIndexRunner implements ApplicationRunner {
private final IndexMaintenanceService indexMaintenanceService;
public InitialIndexRunner(IndexMaintenanceService indexMaintenanceService) {
this.indexMaintenanceService = indexMaintenanceService;
}
@Override
public void run(ApplicationArguments args) throws Exception {
indexMaintenanceService.reindexAllProducts();
}
}
|
よくある誤解とアンチパターン#
誤解1: ElasticsearchをRDBの代替として使う#
Elasticsearchは全文検索に最適化されていますが、トランザクション処理やACID特性を持ちません。マスターデータは必ずRDBに保持し、Elasticsearchは検索用のセカンダリインデックスとして使用してください。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// アンチパターン: Elasticsearchのみにデータを保存
@PostMapping("/products")
public void createProduct(@RequestBody ProductDocument doc) {
// RDBへの保存なしでElasticsearchに直接保存
productDocumentRepository.save(doc); // NG
}
// 正しいパターン: RDBをマスターとして使用
@PostMapping("/products")
public Product createProduct(@RequestBody ProductRequest request) {
Product product = productRepository.save(toEntity(request)); // RDBに保存
// Hibernate Searchが自動的にインデックス同期
return product;
}
|
誤解2: リアルタイム同期を過信する#
Elasticsearchにはデフォルトで1秒のリフレッシュ間隔があり、書き込み直後のデータが検索結果に反映されない場合があります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// アンチパターン: 保存直後に検索結果を期待
@Transactional
public void saveAndSearch() {
productRepository.save(newProduct);
// この時点ではまだインデックスに反映されていない可能性がある
SearchResult<Product> result = searchService.searchByKeyword("新商品");
}
// 正しいパターン: 整合性が必要な場合は待機またはrefresh
@Transactional
public void saveAndSearchWithRefresh() {
Product saved = productRepository.save(newProduct);
entityManager.flush();
// 明示的にインデックスをリフレッシュ(パフォーマンスに影響あり)
Search.session(entityManager).indexingPlan().execute();
}
|
誤解3: N+1問題の軽視#
検索結果のエンティティに対して遅延読み込みを行うと、N+1問題が発生します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// アンチパターン: 検索結果に対するN+1クエリ
public List<ProductDto> searchProducts(String keyword) {
SearchResult<Product> result = searchSession.search(Product.class)
.where(f -> f.match().field("name").matching(keyword))
.fetch(100);
return result.hits().stream()
.map(p -> new ProductDto(
p.getName(),
p.getCategory().getName())) // カテゴリ取得でN+1発生
.toList();
}
// 正しいパターン: 検索後にJPAで一括フェッチ
public List<ProductDto> searchProductsOptimized(String keyword) {
List<Long> ids = /* 検索で取得したID */;
List<Product> products = entityManager.createQuery(
"SELECT p FROM Product p JOIN FETCH p.category WHERE p.id IN :ids",
Product.class)
.setParameter("ids", ids)
.getResultList();
return products.stream()
.map(p -> new ProductDto(p.getName(), p.getCategory().getName()))
.toList();
}
|
誤解4: 全フィールドをインデックス対象にする#
必要のないフィールドまでインデックスに含めると、インデックスサイズが膨大になり、パフォーマンスが低下します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// アンチパターン: 全フィールドに@FullTextFieldを付与
@Entity
@Indexed
public class Product {
@FullTextField
private String name; // OK: 検索対象
@FullTextField
private String internalNote; // NG: 内部メモは検索不要
@GenericField
private byte[] imageData; // NG: バイナリデータはインデックス不可
}
// 正しいパターン: 検索に必要なフィールドのみ
@Entity
@Indexed
public class Product {
@FullTextField
private String name;
private String internalNote; // アノテーションなし = インデックス対象外
private byte[] imageData; // インデックス対象外
}
|
まとめと実践Tips#
Spring JPA Elasticsearch連携を実装する際の重要なポイントを整理します。
アーキテクチャ選択のガイドライン#
- シンプルな全文検索が必要で、既存のJPAエンティティを活用したい場合は「Hibernate Search」を選択
- 検索マイクロサービスとして独立させたい場合や、複雑なインデックス設計が必要な場合は「Spring Data Elasticsearch」を選択
- 高負荷環境でスケーラビリティを重視する場合は「イベント駆動アーキテクチャ」を採用
本番運用のためのTips#
- Elasticsearchのヘルスチェックを組み込み、障害時にもRDBからのフォールバック検索を可能にする
- インデックス更新の失敗時はリトライキューやDead Letter Queueを実装し、データ欠損を防ぐ
- 定期的なフルリインデックスのジョブを設定し、RDBとElasticsearchの整合性を担保する
- 検索クエリのスロークエリログを監視し、パフォーマンス劣化を早期に検知する
テスト戦略#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
@SpringBootTest
@Testcontainers
class ProductSearchIntegrationTest {
@Container
static ElasticsearchContainer elasticsearch =
new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:8.15.0")
.withEnv("xpack.security.enabled", "false");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.jpa.properties.hibernate.search.backend.hosts",
elasticsearch::getHttpHostAddress);
}
@Test
void testProductSearch() {
// テスト実装
}
}
|
Spring JPA Elasticsearch連携は、正しく設計・実装することで、RDBの堅牢性とElasticsearchの検索パフォーマンスを両立できる強力なアーキテクチャです。本記事で解説したパターンとアンチパターンを参考に、要件に最適なソリューションを構築してください。
参考リンク#