Spring Bootで@Scheduledを使ったバッチを書いて、いざKubernetesで複数Pod稼働させたら全Podで同じジョブが同時に走ってしまった、という経験はありませんか。データ更新系だと整合性が壊れたり、外部APIに二重リクエストを飛ばしたりと、地味に怖い問題ですよね。

この記事では、ShedLockを使って@Scheduledの重複実行を分散ロックで防ぐ方法を、JDBCとRedis両パターンで紹介します。@Scheduledそのものの基本構文はSpring Bootで@Scheduledを使った定期実行ジョブの書き方で扱っているので、必要に応じて参照してください。

なぜ分散環境で@Scheduledが重複実行されるのか

@ScheduledはJVMプロセス内のスケジューラスレッドで動きます。つまり、Podが3つあれば3つのJVMがそれぞれ独立してジョブを発火させます。Spring Boot自体は他Podの存在を知らないので、止めようがありません。

参照系のジョブなら無害ですが、「日次の請求バッチ」「在庫の補正処理」のような更新系では、二重実行で実害が出ます。Kubernetesに乗せた瞬間に表面化する典型的な落とし穴です。

解決策はいくつかありますが、アプリ側で 分散ロック を掛けるのが最もコスト低めです。Quartz Clusterのように専用スケジューラを導入する手もありますが、既存の@Scheduled資産をそのまま活かせるShedLockが現実的な選択肢になります。

ShedLockの仕組み

ShedLockは、ジョブの実行直前に共有ストア(DBやRedis)へロックレコードを書き込み、書き込めたインスタンスだけがジョブを実行する、というシンプルな設計です。

  • ジョブ起動時にロック獲得を試みる
  • 既にロックがあれば、そのインスタンスはスキップして何もしない
  • ジョブが終わったらロックを解放する
  • 万一プロセスが落ちても、lockAtMostFor を過ぎれば自動で解放される

この「自動解放のためのタイムアウト」が肝で、後述するlockAtMostForの設計指針に直結します。

依存関係を追加する

Spring Boot 3.x には ShedLock 5.x 系を組み合わせます。build.gradleはこんな感じです。

dependencies {
    implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0'

    // JDBC を使う場合
    implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.16.0'

    // Redis を使う場合
    implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.16.0'
}

両方入れる必要はありません。ロックストアとして使う方だけを追加してください。Mavenを使っている場合も同じgroupId/artifactIdでOKです。

@EnableSchedulerLockで有効化する

設定クラスに@EnableSchedulerLockを付けると、ShedLockがアスペクトを差し込んでくれます。@EnableSchedulingと併用するのを忘れずに。

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT30M")
public class SchedulerConfig {
}

defaultLockAtMostForは、各ジョブの@SchedulerLockで値を明示しなかった場合の既定値です。あくまでフォールバックなので、実運用ではジョブごとに個別指定したほうが安全です。

JDBC LockProviderのセットアップ

既にRDBを使っているプロジェクトなら、JDBCが一番手軽です。まずロック情報を保存するテーブルを作ります。

CREATE TABLE shedlock (
    name       VARCHAR(64)  NOT NULL,
    lock_until TIMESTAMP(3) NOT NULL,
    locked_at  TIMESTAMP(3) NOT NULL,
    locked_by  VARCHAR(255) NOT NULL,
    PRIMARY KEY (name)
);

次にLockProviderをBean定義します。

@Configuration
public class JdbcLockConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
            JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(new JdbcTemplate(dataSource))
                .usingDbTime()
                .build()
        );
    }
}

usingDbTime()を付けておくと、DBサーバの時刻でロックを判定してくれるので、Pod間の時刻ズレに左右されません。地味ですが大事な設定です。

application.ymlは通常のDataSource設定があれば十分です。

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/appdb
    username: app
    password: secret

Redis LockProviderのセットアップ

高頻度ジョブや、DBに余計な負荷を掛けたくないケースではRedisが向いています。Bean定義はこれだけです。

@Configuration
public class RedisLockConfig {

    @Bean
    public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
        return new RedisLockProvider(connectionFactory, "my-app");
    }
}

第2引数の"my-app"はキーのプレフィックスです。同じRedisを複数アプリで共有する場合は、ここで名前空間を分けておきます。

spring:
  data:
    redis:
      host: localhost
      port: 6379

Redis側ではTTL付きのキーとしてロックを管理するので、プロセスが落ちても自動で消えます。Redisとの連携全般はSpring BootとRedisを統合してキャッシュを実装する方法も参考になります。

@SchedulerLockをジョブに付ける

ここまで来たら、あとはジョブメソッドに@SchedulerLockを付けるだけです。

@Component
public class BillingJob {

    @Scheduled(cron = "0 0 2 * * *")
    @SchedulerLock(
        name = "BillingJob_dailySettlement",
        lockAtMostFor = "PT20M",
        lockAtLeastFor = "PT1M"
    )
    public void dailySettlement() {
        // 重い日次バッチ処理
    }
}

各引数のポイントは以下の通りです。

  • name必須かつグローバルにユニーク 。これがロックレコードのキーになります
  • lockAtMostFor はジョブの最大実行時間より 長めに 設定します。例えば通常10分のジョブなら20分など。プロセスが突然死しても、この時間が経てば他Podが拾える設計です
  • lockAtLeastFor は最低保持時間。短時間で終わるジョブが直後に別Podで再実行されるのを防ぎたいときに使います

@Scheduledと同じpublicメソッドに付ける、というのも案外忘れがちです。private メソッドだとAOPが効かずロックが掛かりません。

ロック衝突時の動作を確認する

ローカルで同じアプリを2つ起動して、ジョブ時刻を待ってみてください。先にロックを取った方だけがジョブを実行し、もう一方はスキップされるはずです。

ログレベルをDEBUGにすると、こんなログが出ます。

DEBUG n.j.s.core.DefaultLockingTaskExecutor - Not executing 'BillingJob_dailySettlement'. It's locked.

DBを使っている場合は、shedlockテーブルを直接覗くと現在のロック状況が一目で分かります。

SELECT name, lock_until, locked_at, locked_by FROM shedlock;

RedisならKEYS my-app:*で同様に確認できます。本番では使わないでくださいね。

JDBCとRedis、どちらを選ぶか

結論から言うと、既存DBがあるならJDBCで十分 なケースが大半です。判断軸を整理しておきます。

  • 既にRDBを使っており、ジョブ頻度が分・時間単位 → JDBC
  • 秒オーダーの高頻度ジョブ、または既にRedisが運用に組み込まれている → Redis
  • DB障害時にジョブも止まって問題ないか → 問題なければJDBCで運用負荷増を避ける
  • 監視・バックアップ体制が整っているのはDB/Redisのどちらか → 慣れている方

ロックストアを増やすのは運用負荷の純増なので、迷ったら既存資産を使う方向で考えると良いです。

ハマりやすいポイント

最後に、実装時に踏みやすい地雷をまとめておきます。

name の重複・未指定nameが同じだと別ジョブ同士が排他になってしまいます。逆に異なるアプリで同じnameを使ってしまうケースもあるので、<アプリ名>_<ジョブ名>のような命名規約を決めておくと安全です。

lockAtMostFor が短すぎる 。ジョブが想定より長引いてlockAtMostForを超えると、ロックが解放されて別Podが同じジョブを開始してしまいます。実行時間にバッファを乗せて設定しましょう。

ロックはトランザクション外 。ShedLockはジョブメソッドの外側でロックを掛けるので、ジョブ内のトランザクションがロールバックされてもロック自体は維持されます。整合性の前提が崩れないか確認しておくと安心です。

まとめ

ShedLockは、@Scheduledの資産をそのままに、依存追加と数行の設定で分散ロックを実現してくれる手軽なライブラリです。Kubernetesで複数Pod運用に踏み出すタイミングで、ぜひ導入を検討してみてください。

スケジューラ自体の使い方を改めて整理したい場合は@Scheduledの記事を、複数Pod運用そのものについてはSpring BootアプリをKubernetesにデプロイする方法もあわせてどうぞ。