深夜の低トラフィック帯や DB 再起動の直後だけ、ログに HikariPool-1 - Failed to validate connection ... (Possibly consider using a shorter maxLifetime value) が並ぶ。日中は何も出ないので放置しがちですが、同じ時間帯に marked as brokenConnection is closed が混ざって業務処理が落ちていると無視できませんよね。

この記事では、各メッセージがプールのどの段階で出ているのかを整理し、maxLifetimekeepaliveTime を自分の環境値で決める方法、DB 再起動後に回復しないケースの切り分け、Docker Compose での再現手順をまとめます。

対象は Spring Boot 3.x(HikariCP 5.x〜6.x。keepaliveTime は HikariCP 4.0.1 以降で有効)で、MySQL Connector/J と PostgreSQL JDBC の両方を扱います。なお Connection is not available, request timed out はプール枯渇という別の問題です。maximumPoolSize の決め方やコネクションリークの検出は HikariCPチューニング記事 を参照してください。

ログメッセージはプールのどの段階で出ているか

まず実物のログを見ましょう。

WARN  com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection com.mysql.cj.jdbc.ConnectionImpl@1a2b3c4d (No operations allowed after connection closed.). Possibly consider using a shorter maxLifetime value.

WARN  com.zaxxer.hikari.pool.ProxyConnection : HikariPool-1 - Connection com.mysql.cj.jdbc.ConnectionImpl@5e6f7a8b marked as broken because of SQLSTATE(08S01), ErrorCode(0)
com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure

org.springframework.transaction.TransactionSystemException: Could not roll back JDBC transaction
Caused by: java.sql.SQLException: Connection is closed
メッセージ出どころと発生タイミング実害
Failed to validate connectionHikariCP。借り出し時(または keepalive)の isValid() 検証で例外。WARN基本なし。再作成ぶんの遅延だけ
Communications link failure、No operations allowed after connection closed.Connector/J。検証をすり抜けた切断済み接続を使用中に SQLException として露見その処理は失敗する
This connection has been closed.、An I/O error occurred while sending to the backend.pgjdbc。同上その処理は失敗する
marked as brokenHikariCP。上の例外を検出した直後に WARN を出し、返却時にプールから破棄同じ事象の記録
Connection is closedHikariCP。破棄済みまたは返却済みの接続を使おうとした直前のログで切断起因か use-after-close かを切り分ける

Failed to validate connection 単体なら HikariCP が死んだ接続を正しく弾いている ということです。ただし死んだ接続が多数残っていて validationTimeout が長いと、検証と破棄のループが connectionTimeout を使い切り、プール枯渇側の Connection is not available に転じることがあります。

java.sql.SQLException: Connection is closed は、直前のログで2つに切り分けます。同じスレッドの直前に marked as broken やドライバの切断例外が出ていれば、切断起因です。HikariCP は marked as broken の時点でその接続の中身を閉じた状態に差し替えるため、同じ @Transactional 内の後続 SQL や Spring のロールバック処理が Connection is closed として失敗します。根本原因は使用中の DB・NW 切断そのものなので、本記事の maxLifetimekeepaliveTime で発生頻度を下げられます。

一方、切断ログを伴わず単独で出るなら、返却済みの接続をアプリが使い続けている use-after-close です。典型は @Transactional を抜けた後に Stream や ResultSet を読む、自己呼び出しで @Transactional が効いていない、といったパターンです。

// NG: readOnly トランザクションを抜けた後で Stream を消費している
Stream<Order> stream = orderService.streamAll(); // 中で @Transactional(readOnly = true)
stream.forEach(this::export);                    // ここで Connection is closed

// OK: 消費までを @Transactional の範囲に収める
@Transactional(readOnly = true)
public void exportAll() {
    try (Stream<Order> stream = orderRepository.streamAll()) {
        stream.forEach(this::export);
    }
}

こちらは設定では直らないので、トランザクション境界を見直しましょう。

HikariCP がコネクションを借り出すときの検証フロー

getConnection() でプールから接続を取り出すとき、HikariCP は最後に使われてから 500ms(aliveBypassWindowMs)以上経った接続だけを JDBC の isValid() で検証します。この検証は validationTimeout(デフォルト 5 秒)以内に終わる必要があり、失敗した接続は捨てて作り直します。後述の keepalive も同じ検証処理を使うので、同じ WARN が出ます。

maxLifetime による退役は接続ごとに個別のタスクとしてスケジュールされ、「作成時刻 + maxLifetime」から最大 2.5% だけ手前へずらして実行されます。30 秒周期の housekeeper は idleTimeout 超過の破棄と minimumIdle までの補充を担当します。

つまり警告が出るのは 退役の前に誰かが外から接続を切っている からです。DB 側やネットワーク機器の切断は HikariCP からは見えないので、長くアイドルした深夜帯に警告が集中するわけです。

根本原因1: DB側のタイムアウトが maxLifetime より短い

一番多いのがこのパターンです。maxLifetime のデフォルトは 30 分(1800000ms)ですが、DB 側がそれより短くアイドル接続を切っていると、プールには死んだ接続が残ります。

まず自分の環境の値を確認しましょう。

-- MySQL(JDBC は非対話型なので wait_timeout が効く)
SHOW VARIABLES LIKE 'wait_timeout';

-- PostgreSQL
SHOW idle_session_timeout;                 -- PG14 以降。0 なら無効
SHOW idle_in_transaction_session_timeout;  -- トランザクション中のアイドルのみ対象

MySQL の wait_timeout はデフォルト 8 時間ですが、クラウドの DB サービスや運用ポリシーで数分から数十分に短縮されていることがよくあります。PostgreSQL は素の状態ではアイドル接続を切りませんが、上の2つが設定されていれば同じことが起きます。なお statement_timeout は実行中クエリの打ち切りなので無関係です。tcp_keepalives_* はサーバ側から keepalive を送る設定で、アイドル切断の原因ではなく、むしろ NAT や FW の切断を防ぐ側に働きます。

HikariCP の公式ドキュメントも maxLifetime について「DB やインフラが課す接続時間の上限より数秒短くすることを強く推奨する」と明記しています。

根本原因2: ファイアウォール・NAT・ロードバランサのアイドル切断

DB 側の値が十分長いのに警告が出るなら、経路上のネットワーク機器を疑いましょう。ファイアウォールや NAT、ロードバランサはアイドルな TCP セッションを 5〜15 分程度で黙って破棄します。AWS の NLB は 350 秒、Azure Load Balancer はデフォルト 4 分です。クラウド DB のプロキシ経由でも同様です。

厄介なのは、この切断が DB からもアプリからも見えないことです。DB 側には接続が残ったままに見え、アプリ側は次の借り出しで初めて検証に失敗します。「wait_timeout は 8 時間のままなのに 10 分アイドルすると警告が出る」なら、ほぼこちらが原因です。

maxLifetime と keepaliveTime の決め方

ルールはシンプルで、maxLifetimeDB 側タイムアウトとネットワーク機器のアイドルタイムアウトのうち最短の値より 30 秒〜数分短く します。

たとえば wait_timeout=600 秒で、間に NLB(350 秒)が挟まっているなら最短は 350 秒です。そこから余裕を見て maxLifetime を 300 秒(300000ms)にする、という計算になります。ただし短くしすぎると接続の作り直しが増えてレイテンシと DB 負荷が上がるので、数分を切るような値にするなら次の keepaliveTime と組み合わせましょう。

keepaliveTime は、アイドルしている接続に定期的に isValid() の ping を送って「まだ使っている」とネットワーク機器や DB に知らせる設定です。30 秒以上で maxLifetime より短い値が必須で、目安はアイドルタイムアウトの半分程度です。NLB の 350 秒なら 120〜180 秒くらいになります。

maxLifetime は接続を退役させる、keepaliveTime は接続を延命させるアプローチです。両方設定しておけば、延命で切断を防ぎつつ、寿命が来た接続は計画的に入れ替えられます。

application.yml の設定例と各値の整合条件

MySQL 向けの例です。

spring:
  datasource:
    url: jdbc:mysql://db:3306/app
    hikari:
      maximum-pool-size: 10
      minimum-idle: 2               # 省略すると maximum-pool-size と同値の固定サイズプール
      connection-timeout: 3000      # ms
      validation-timeout: 1000      # connection-timeout より短く
      max-lifetime: 300000          # 5分。最短タイムアウトより短く
      keepalive-time: 120000        # 2分。max-lifetime より短く
      idle-timeout: 240000          # 4分。max-lifetime より短く

PostgreSQL 向けは hikari 以下は同じで、URL を jdbc:postgresql://db:5432/app?tcpKeepAlive=true にします。pgjdbc の tcpKeepAlive はデフォルト false なので明示が必要で、OS レベルの TCP keepalive が補助的に効くようになります。Connector/J の tcpKeepAlive はデフォルト true です。

各値には整合条件があります。idleTimeout < maxLifetimekeepaliveTime < maxLifetimemaxLifetime >= 30000 を破ると、HikariCP が起動時に警告を出して値を無効化または補正します。また minimumIdle を省略または maximumPoolSize と同値にした固定サイズプールでは idleTimeout は効かず、設定していると3行目の WARN が出ます。

WARN com.zaxxer.hikari.HikariConfig : HikariPool-1 - idleTimeout is close to or more than maxLifetime, disabling it.
WARN com.zaxxer.hikari.HikariConfig : HikariPool-1 - keepaliveTime is greater than or equal to maxLifetime, disabling it.
WARN com.zaxxer.hikari.HikariConfig : HikariPool-1 - idleTimeout has been set but has no effect because the pool is operating as a fixed size pool.

一方 validationTimeout < connectionTimeout は自動補正の対象ではなく(250ms 未満の下限だけ補正)、自分で守る必要があります。MySQL の URL パラメータ全般は MySQL接続設定の記事 にまとめてあります。

connectionTestQuery を安易に設定しない理由

検索すると「connection-test-query: SELECT 1 を設定すれば直る」という古い情報がよく出てきますが、Spring Boot 3.x では基本的に不要です。

Connector/J 8.x 以降や pgjdbc 42.x のような JDBC4 対応ドライバでは、isValid() がプロトコルレベルの軽量な ping を使います。connectionTestQuery を設定するとそれが SQL の往復に置き換わり、毎回の借り出しが少しずつ重くなります。HikariCP 公式も「JDBC4 対応ドライバなら設定しないことを強く推奨する」としています。

そもそもこの設定は検証の「方法」を変えるだけで、切断された接続が検出される「タイミング」は変わりません。Failed to validate connection の対策にはならないので、maxLifetimekeepaliveTime で対処しましょう。

DB再起動・フェイルオーバー後にプールが自然回復する仕組み

DB を再起動すると、プール内の接続はすべて切断されます。その後は借り出しのたびに「検証失敗、破棄、再作成」が走り、接続が順次入れ替わっていきます。この間 Failed to validate connection が接続数ぶん連続して出ますが、これは正常な回復過程です。

回復にかかる時間は、おおむね接続数ぶんの再作成に connectionTimeout を足した程度で、通常は数秒から数十秒です。補充は借り出し要求と 30 秒ごとの housekeeper で非同期に走るため、アプリの再起動は必要ありません。ただし再起動の瞬間に使用中だった接続はドライバの例外と marked as broken になり、そのリクエストは失敗します。ここをゼロに近づけたいなら Spring Retry でのリトライが選択肢になります。

回復しないケースの切り分け

数分待っても警告が止まらない、接続が一切作れないという場合は、次の順に疑います。

DNS キャッシュ から確認します。フェイルオーバーで DB エンドポイントの IP が変わっても、どこかで古い IP がキャッシュされていると旧サーバに接続し続けます。JVM の networkaddress.cache.ttl は、セキュリティマネージャを使わない通常構成なら既定 30 秒なので、素の状態では問題になりません。落とし穴は、この値を -1(無期限)や長い秒数に明示している環境です(古い構成でセキュリティマネージャを有効にしていると既定が -1)。

# $JAVA_HOME/conf/security/java.security や -Dsun.net.inetaddr.ttl を確認
# こうなっていたら 30 秒以下に戻す
networkaddress.cache.ttl=-1

JVM 側が問題なければ、コンテナや OS のリゾルバのキャッシュと、DB エンドポイント側 DNS の TTL(RDS は 5 秒)を見ます。dig で今の IP を引き、ss でアプリが実際に接続している先と比べると早いです。

validationTimeout が短すぎる ケースもあります。起動直後の DB は応答が遅く、検証がタイムアウトし続けると作ったそばから接続が破棄されます。DEBUG ログで検証失敗の理由がタイムアウトなら、validationTimeout を少し伸ばしてみましょう。

connectionInitSql の失敗 も見落としがちです。再作成のたびに実行される初期化 SQL がフェイルオーバー先で権限不足などで失敗すると、接続が一つも作れません。

なお、アプリ起動時に DB が落ちている場合は initializationFailTimeout(デフォルト 1ms)により起動失敗になります。Spring Data JPA(Hibernate)を使っていれば ddl-auto の値に関わらず起動時のメタデータ取得で接続するため(hibernate.boot.allow_jdbc_metadata_access の既定が true)、Flyway や Liquibase、schema.sql、DB を使う CommandLineRunner と同様に該当します。切り分けは 起動失敗の記事 を参照してください。

Docker Compose で再現して修正を確認する

理屈が分かったら、手元で再現して直ることを確認しておきましょう。MySQL の wait_timeout を 60 秒にして起動します。

services:
  db:
    image: mysql:8.4
    command: --wait_timeout=60
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: app
    ports:
      - "3306:3306"

アプリはホスト側で起動するので、接続先は localhost です。切り替える箇所はコメントの2行だけです。

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/app
    username: root
    password: root
    hikari:
      maximum-pool-size: 3
      # max-lifetime: 50000    # パターン2で有効化(wait_timeout=60 より短く)
      # keepalive-time: 30000  # パターン3で有効化
logging:
  level:
    com.zaxxer.hikari: DEBUG

エンドポイントは SELECT 1 を投げるだけで十分です。

@RestController
public class PingController {

    private final JdbcTemplate jdbcTemplate;

    public PingController(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @GetMapping("/ping")
    public Integer ping() {
        // 借り出し → SELECT 1 → 返却
        return jdbcTemplate.queryForObject("SELECT 1", Integer.class);
    }
}

一度 /ping を叩いてから 70 秒ほど放置し、再度叩きます。3パターンの主要行を抜粋すると、次のようなログになります。

# パターン1: max-lifetime デフォルト。放置後の1回目のリクエスト
WARN  HikariPool-1 - Failed to validate connection com.mysql.cj.jdbc.ConnectionImpl@6d2a1f (No operations allowed after connection closed.). Possibly consider using a shorter maxLifetime value.
DEBUG HikariPool-1 - Closing connection com.mysql.cj.jdbc.ConnectionImpl@6d2a1f: (connection is dead)
DEBUG HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@7c31b0

# パターン2: max-lifetime: 50000。50秒弱ごとに接続が入れ替わり、警告は出ない
DEBUG HikariPool-1 - Closing connection com.mysql.cj.jdbc.ConnectionImpl@6d2a1f: (connection has passed maxLifetime)
DEBUG HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@0e8f42

# パターン3: keepalive-time: 30000。30秒弱ごとに ping が飛び、警告は出ない
DEBUG HikariPool-1 - keepalive: connection com.mysql.cj.jdbc.ConnectionImpl@6d2a1f is alive

パターン2は接続ごとの退役タスクが wait_timeout より先に接続を入れ替えるので、死ぬ前に消えます。パターン3は ping で MySQL 側のアイドルカウンタがリセットされ、そもそも切られません。どちらでも警告は消えますが、退役と延命という意味の違いは押さえておきましょう。

PostgreSQL で再現するなら command: -c idle_session_timeout=60000 を指定すれば、同じ手順で確認できます。

本番での観測方法

設定を変えた後は、本番で効いているかを数値で見ておきましょう。Actuator の hikaricp.connections.creation は接続の作り直しの指標で、急増する時間帯があれば、まだどこかで切られています。一方 hikaricp.connections.timeoutactivepending はプール枯渇側の指標なので、役割を分けて見ると切り分けが楽になります。

management:
  endpoints:
    web:
      exposure:
        include: health, metrics, prometheus
logging:
  level:
    com.zaxxer.hikari: DEBUG   # 調査中だけ有効にする

com.zaxxer.hikari を DEBUG にすると、housekeeper が 30 秒ごとに統計を出します。idleTimeout が有効なら Before cleanup statsAfter cleanup stats、固定サイズプールや idleTimeout=0 なら Pool stats (total=10, active=0, idle=10, waiting=0) という文言です。ログ量が多いので本番では期間限定にしてください。Actuator の導入は Actuator入門 を、DB ヘルスチェックを監視に組み込む際の注意は カスタムHealthIndicatorの記事 を参考にしてください。

まとめ

Failed to validate connection は借り出し時の検証失敗で、HikariCP が死んだ接続を正しく弾いた証拠です。実害があるのは検証をすり抜けて使用中に切れたときで、ドライバの例外と marked as broken、それに続く Connection is closed として現れます。切断ログを伴わない Connection is closed だけは use-after-close で、アプリ側の修正が必要でした。

対処の軸は、maxLifetime を DB とネットワーク機器のタイムアウトの最短値より短くすること、そして keepaliveTime をさらに短くしてアイドル接続を延命することの2つです。connectionTestQuery は不要で、DB 再起動後は通常自然回復します。回復しなければ DNS キャッシュと validationTimeout を疑ってください。

プールサイズやコネクションリークが絡む Connection is not available については、HikariCPチューニング記事 で扱っています。