Skip to content
Published on

Spring Boot DB接続完全ガイド:JPA・HikariCP・トランザクション・QueryDSL・マルチDB

Authors

目次

  1. 依存関係の設定
  2. DataSource設定(HikariCP)
  3. Entity設計パターン
  4. Repositoryパターン
  5. QueryDSLの設定と活用
  6. トランザクション管理
  7. N+1問題の解決
  8. マルチデータソースの設定
  9. R2DBC非同期接続
  10. スロークエリログと監視
  11. クイズ

1. 依存関係の設定

Maven依存関係

<!-- JPA/Hibernate -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- PostgreSQLドライバ -->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

<!-- MySQLドライバ(オプション) -->
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

<!-- QueryDSL -->
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
    <classifier>jakarta</classifier>
</dependency>
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
    <classifier>jakarta</classifier>
    <scope>provided</scope>
</dependency>

<!-- Flyway マイグレーション -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>

2. DataSource設定(HikariCP)

Spring Boot 2.x以降ではHikariCPがデフォルトのコネクションプールとして使用されます。

全体設定例

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: user
    password: secret
    driver-class-name: org.postgresql.Driver
    hikari:
      pool-name: HikariCP-Main
      maximum-pool-size: 10 # 最大コネクション数
      minimum-idle: 5 # 最小アイドルコネクション
      connection-timeout: 30000 # コネクション取得タイムアウト(30秒)
      idle-timeout: 600000 # アイドルコネクション保持時間(10分)
      max-lifetime: 1800000 # コネクション最大寿命(30分)
      connection-test-query: SELECT 1
      auto-commit: true
      leak-detection-threshold: 60000 # コネクションリーク検出(60秒)
  jpa:
    hibernate:
      ddl-auto: validate # prod: validate, dev: update/create-drop
    show-sql: false
    open-in-view: false # OSIVの無効化(推奨)
    properties:
      hibernate:
        format_sql: true
        use_sql_comments: true
        default_batch_fetch_size: 100
        jdbc:
          batch_size: 50
        order_inserts: true
        order_updates: true
        dialect: org.hibernate.dialect.PostgreSQLDialect

HikariCPプールサイズの計算式

最適プールサイズ = Tn x (Cm - 1) + 1
  • Tn:最大同時スレッド数
  • Cm:単一トランザクションで必要な最大コネクション数

実務でよく使われる別の計算式:

コネクション数 = (CPUコア数 x 2) + 有効スピンドル数

HikariCPのドキュメントでは、多くのケースで10個以下のコネクションで十分と説明しています。

OSIVの無効化(Open Session In View)

spring:
  jpa:
    open-in-view: false

OSIVを有効にするとHTTPレスポンス完了までDBコネクションを保持し、コネクションプールが枯渇する可能性があります。サービス層でトランザクションを管理するパターンが推奨されます。


3. Entity設計パターン

基本Entity構造

@Entity
@Table(name = "users", indexes = {
    @Index(name = "idx_user_email", columnList = "email"),
    @Index(name = "idx_user_created_at", columnList = "created_at")
})
@EntityListeners(AuditingEntityListener.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 100)
    private String email;

    @Column(nullable = false, length = 50)
    private String name;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private UserStatus status = UserStatus.ACTIVE;

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    @Version
    private Long version;  // 楽観的ロック

    @Builder
    public User(String email, String name) {
        this.email = email;
        this.name = name;
    }
}

JPA Auditingの有効化

@Configuration
@EnableJpaAuditing
public class JpaConfig {
    // 必要に応じてAuditorAwareの実装を追加
}

BaseEntity抽象クラス

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

4. Repositoryパターン

JpaRepositoryの基本メソッド活用

public interface UserRepository extends JpaRepository<User, Long> {

    // メソッド名ベースのクエリ
    Optional<User> findByEmail(String email);
    List<User> findByStatusAndCreatedAtAfter(UserStatus status, LocalDateTime date);
    boolean existsByEmail(String email);
    long countByStatus(UserStatus status);

    // @QueryによるJPQL
    @Query("SELECT u FROM User u WHERE u.email = :email AND u.status = 'ACTIVE'")
    Optional<User> findActiveUserByEmail(@Param("email") String email);

    // ネイティブクエリ
    @Query(value = "SELECT * FROM users WHERE created_at > :date LIMIT :limit",
           nativeQuery = true)
    List<User> findRecentUsersNative(@Param("date") LocalDateTime date,
                                     @Param("limit") int limit);

    // インターフェースベースのProjection
    @Query("SELECT u.id AS id, u.email AS email FROM User u WHERE u.status = :status")
    List<UserSummary> findUserSummariesByStatus(@Param("status") UserStatus status);

    // 更新クエリ
    @Modifying(clearAutomatically = true)
    @Transactional
    @Query("UPDATE User u SET u.status = :status WHERE u.id IN :ids")
    int updateStatusByIds(@Param("ids") List<Long> ids, @Param("status") UserStatus status);
}

Projectionインターフェース

public interface UserSummary {
    Long getId();
    String getEmail();
}

Specification API(動的クエリ)

public class UserSpecification {

    public static Specification<User> hasStatus(UserStatus status) {
        return (root, query, criteriaBuilder) ->
            criteriaBuilder.equal(root.get("status"), status);
    }

    public static Specification<User> emailContains(String keyword) {
        return (root, query, criteriaBuilder) ->
            criteriaBuilder.like(root.get("email"), "%" + keyword + "%");
    }

    public static Specification<User> createdAfter(LocalDateTime date) {
        return (root, query, criteriaBuilder) ->
            criteriaBuilder.greaterThan(root.get("createdAt"), date);
    }
}

// 使用例
List<User> users = userRepository.findAll(
    UserSpecification.hasStatus(UserStatus.ACTIVE)
        .and(UserSpecification.emailContains("example.com"))
);

5. QueryDSLの設定と活用

QueryDSL Maven Pluginの設定

<plugin>
    <groupId>com.mysema.maven</groupId>
    <artifactId>apt-maven-plugin</artifactId>
    <version>1.1.3</version>
    <executions>
        <execution>
            <goals>
                <goal>process</goal>
            </goals>
            <configuration>
                <outputDirectory>target/generated-sources/java</outputDirectory>
                <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
            </configuration>
        </execution>
    </executions>
</plugin>

JPAQueryFactory Beanの登録

@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

QueryDSL Repositoryの実装

@Repository
@RequiredArgsConstructor
public class UserQueryRepository {

    private final JPAQueryFactory queryFactory;

    public Page<User> findActiveUsers(UserSearchCondition condition, Pageable pageable) {
        List<User> content = queryFactory
            .selectFrom(user)
            .where(
                statusEq(condition.getStatus()),
                emailContains(condition.getEmail()),
                createdAtAfter(condition.getFromDate())
            )
            .orderBy(user.createdAt.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

        JPAQuery<Long> countQuery = queryFactory
            .select(user.count())
            .from(user)
            .where(
                statusEq(condition.getStatus()),
                emailContains(condition.getEmail())
            );

        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

    private BooleanExpression statusEq(UserStatus status) {
        return status != null ? user.status.eq(status) : null;
    }

    private BooleanExpression emailContains(String email) {
        return StringUtils.hasText(email) ? user.email.containsIgnoreCase(email) : null;
    }

    private BooleanExpression createdAtAfter(LocalDateTime date) {
        return date != null ? user.createdAt.after(date) : null;
    }
}

6. トランザクション管理

@Transactionalの主要オプション

@Service
@Transactional(readOnly = true)  // クラスレベル:デフォルトで読み取り専用
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    // 読み取り専用(クラスレベルのreadOnly=trueを継承)
    public UserResponse getUser(Long id) {
        return userRepository.findById(id)
            .map(UserResponse::from)
            .orElseThrow(() -> new EntityNotFoundException("User not found: " + id));
    }

    // 書き込み操作:readOnly=falseで上書き
    @Transactional
    public UserResponse createUser(CreateUserRequest request) {
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new DuplicateEmailException(request.getEmail());
        }
        User user = User.builder()
            .email(request.getEmail())
            .name(request.getName())
            .build();
        return UserResponse.from(userRepository.save(user));
    }

    // すべての例外でロールバック
    @Transactional(rollbackFor = Exception.class)
    public void processPayment(PaymentRequest request) {
        // 決済処理ロジック
    }

    // 独立したトランザクションで実行(親トランザクションとは独立してコミット/ロールバック)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveAuditLog(String message) {
        // 監査ログは必ず保存
    }

    // トランザクションなしで実行
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void callExternalApi() {
        // DB操作のない外部API呼び出し
    }
}

トランザクション伝播レベル

伝播レベル説明
REQUIRED既存トランザクションを使用、なければ新規作成(デフォルト)
REQUIRES_NEW常に新しいトランザクションを作成、既存トランザクションを一時停止
SUPPORTS既存トランザクションを使用、なければトランザクションなしで実行
NOT_SUPPORTEDトランザクションなしで実行、既存トランザクションを一時停止
MANDATORY既存トランザクションが必須、なければ例外
NEVERトランザクションなしで実行、既存トランザクションがあれば例外
NESTEDネストされたトランザクション(Savepointを活用)

分離レベル(Isolation Level)

// ファントムリードを防止
@Transactional(isolation = Isolation.SERIALIZABLE)

// 繰り返し可能な読み取りを保証
@Transactional(isolation = Isolation.REPEATABLE_READ)

// ダーティリードを防止(多くのDBのデフォルト)
@Transactional(isolation = Isolation.READ_COMMITTED)

7. N+1問題の解決

N+1問題は、1回のクエリでN件の結果を取得した後、各結果に対してN回の追加クエリが発行されるパフォーマンス問題です。

問題の状況

// N+1発生例
List<Order> orders = orderRepository.findAll();
orders.forEach(order -> {
    System.out.println(order.getUser().getName()); // OrderごとにUserクエリが発行される
});

解決策1:@EntityGraph

@EntityGraph(attributePaths = {"user", "orderItems"})
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatusWithUserAndItems(@Param("status") OrderStatus status);

解決策2:Fetch Join

@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.user JOIN FETCH o.orderItems WHERE o.status = :status")
List<Order> findByStatusWithFetch(@Param("status") OrderStatus status);

注意:コレクション(一対多)のFetch Joinとページングを組み合わせると、Hibernateがすべてのデータをメモリにロードしてからページングを適用するHHH90003004警告が発生し、OutOfMemoryErrorのリスクがあります。ページングが必要な場合は@BatchSizeを使用してください。

解決策3:BatchSize

@Entity
public class User {
    @OneToMany(mappedBy = "user")
    @BatchSize(size = 100)   // INクエリで一度に100件ずつ取得
    private List<Order> orders;
}

またはグローバル設定:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

8. マルチデータソースの設定

2つのDataSourceの設定

@Configuration
@EnableTransactionManagement
public class DataSourceConfig {

    @Primary
    @Bean(name = "primaryDataSource")
    @ConfigurationProperties("spring.datasource.primary")
    public DataSource primaryDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean(name = "secondaryDataSource")
    @ConfigurationProperties("spring.datasource.secondary")
    public DataSource secondaryDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Primary
    @Bean(name = "primaryEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory(
            EntityManagerFactoryBuilder builder,
            @Qualifier("primaryDataSource") DataSource dataSource) {
        return builder
            .dataSource(dataSource)
            .packages("com.example.domain.primary")
            .persistenceUnit("primary")
            .build();
    }

    @Primary
    @Bean(name = "primaryTransactionManager")
    public PlatformTransactionManager primaryTransactionManager(
            @Qualifier("primaryEntityManagerFactory") EntityManagerFactory emf) {
        return new JpaTransactionManager(emf);
    }
}

application.ymlのマルチデータソース設定

spring:
  datasource:
    primary:
      url: jdbc:postgresql://primary-db:5432/maindb
      username: ${DB_PRIMARY_USERNAME}
      password: ${DB_PRIMARY_PASSWORD}
      hikari:
        maximum-pool-size: 10
    secondary:
      url: jdbc:postgresql://secondary-db:5432/analyticsdb
      username: ${DB_SECONDARY_USERNAME}
      password: ${DB_SECONDARY_PASSWORD}
      hikari:
        maximum-pool-size: 5

読み取り/書き込み分離(AbstractRoutingDataSource)

public class RoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
            ? DataSourceType.READ
            : DataSourceType.WRITE;
    }
}

public enum DataSourceType {
    READ, WRITE
}

9. R2DBC非同期接続

リアクティブスタック(Spring WebFlux)を使用する場合、R2DBCで非同期DB接続を設定します。

依存関係

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>r2dbc-postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

設定

spring:
  r2dbc:
    url: r2dbc:postgresql://localhost:5432/mydb
    username: user
    password: secret
    pool:
      initial-size: 5
      max-size: 10
      max-idle-time: 30m
      validation-query: SELECT 1

R2DBC Repository

public interface UserReactiveRepository extends ReactiveCrudRepository<User, Long> {
    Mono<User> findByEmail(String email);
    Flux<User> findByStatus(UserStatus status);
}

@Service
@RequiredArgsConstructor
public class UserReactiveService {

    private final UserReactiveRepository userRepository;

    public Mono<UserResponse> getUser(Long id) {
        return userRepository.findById(id)
            .map(UserResponse::from)
            .switchIfEmpty(Mono.error(new EntityNotFoundException("User not found: " + id)));
    }

    public Flux<UserResponse> getAllActiveUsers() {
        return userRepository.findByStatus(UserStatus.ACTIVE)
            .map(UserResponse::from);
    }
}

10. スロークエリログと監視

p6spyの設定

<dependency>
    <groupId>com.github.gavlyukovskiy</groupId>
    <artifactId>p6spy-spring-boot-starter</artifactId>
    <version>1.9.0</version>
</dependency>

src/main/resources/spy.properties

appender=com.p6spy.engine.spy.appender.Slf4JLogger
logMessageFormat=com.p6spy.engine.spy.appender.CustomLineFormat
customLogMessageFormat=%(currentTime)|%(executionTime)ms|%(category)|connection%(connectionId)|%(sqlSingleLine)
filter=true
execution=true

Hibernate統計の有効化

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true
        session:
          events:
            log:
              LOG_QUERIES_SLOWER_THAN_MS: 100 # 100ms以上かかるクエリをログ出力

Micrometer + Prometheus監視

management:
  endpoints:
    web:
      exposure:
        include: metrics,health,prometheus
  metrics:
    export:
      prometheus:
        enabled: true

主要な監視メトリクス:

  • jdbc.connections.active:現在アクティブなコネクション数
  • jdbc.connections.max:最大コネクションプールサイズ
  • hibernate.query.executions.total:クエリ総実行回数
  • hibernate.second.level.cache.hits:2次キャッシュのヒット率

Actuatorエンドポイント

GET /actuator/metrics/jdbc.connections.active
GET /actuator/metrics/jdbc.connections.pending
GET /actuator/metrics/hikaricp.connections.timeout

11. クイズ

Q1. HikariCPのmaximum-pool-sizeを大きくしすぎると何が起きますか?

答え: DBサーバーの最大コネクション数を超えたり、過剰なコンテキストスイッチングによるパフォーマンス低下が発生します。

解説: コネクションが多ければ性能が向上するわけではありません。各コネクションはDBサーバー側でもメモリとCPUリソースを消費します。HikariCPのドキュメントでは、多くのケースで10個以下のコネクションで十分と説明しています。8コアサーバーで200のコネクションを開いても、CPUは同時に8タスクしか処理できないため、残りのコネクションは待機状態になります。最適なプールサイズは負荷テストで決定する必要があります。

Q2. @Transactional(readOnly=true)がパフォーマンスを向上させる理由は?

答え: DBに読み取り専用ヒントを送り、不要なダーティチェッキング(変更検知)を無効化し、スナップショット生成を最適化するためです。

解説: readOnly=trueを設定すると、Hibernateは1次キャッシュ(Persistence Context)でのダーティチェッキングを行わないため、メモリ使用量とCPUオーバーヘッドが削減されます。一部のDBドライバはこのヒントを使ってリードレプリカへのルーティングや最適化された実行計画を使用します。PostgreSQLではトランザクション開始時のスナップショット生成をスキップする追加最適化も可能です。

Q3. コレクションのFetch Joinとページングを組み合わせてはいけない理由は?

答え: コレクション(一対多)のFetch Joinとページングを組み合わせると、Hibernateがすべてのデータをメモリにロードしてからページングを適用するHHH90003004警告が発生し、OutOfMemoryErrorのリスクがあります。

解説: コレクションのFetch Joinはデカルト積を生成し、結果行数が増加します。DBレベルでLIMIT/OFFSETを適用すると予期しない結果になるため、Hibernateはすべてのデータをメモリに取得してからJavaレベルでページングを処理します。代わりに@BatchSizeやdefault_batch_fetch_size設定を使用すると、INクエリでN+1を解決しながらページングも正常に動作します。

Q4. REQUIRES_NEWトランザクション伝播を使用すべき代表的なケースは?

答え: 監査ログや通知メール送信など、親トランザクションの成功/失敗に関わらず必ず保存/実行されなければならない操作です。

解説: REQUIRES_NEWに設定されたメソッドは親トランザクションを一時停止し、独立した新しいトランザクションを開始します。親トランザクションがロールバックされても、REQUIRES_NEWトランザクションは独立してコミットされます。例えば、注文処理が失敗しても「注文試行失敗」の監査ログはDBに必ず保存されなければならないケースに使用します。

Q5. R2DBCを使用する場合、標準のJPA @Transactionalをそのまま使用できますか?

答え: そのままでは使用できません。R2DBCはリアクティブスタックであるため、スレッドローカルベースの従来のトランザクション管理ではなく、ReactiveTransactionManagerTransactionalOperatorが必要です。

解説: R2DBCはブロッキングJDBCと異なり、非同期/ノンブロッキングで動作します。標準の@TransactionalはThread-Localベースで動作するため、リアクティブパイプラインでは正しく機能しません。Spring BootはR2DBC環境で自動的にR2dbcTransactionManagerをBeanとして登録します。@Transactionalもリアクティブコンテキストでのサポートがありますが、MonoまたはFluxを返すメソッドでのみ正しく動作します。