ソフトウェアエンジニアの雑記

日々思ったことをまとめます

SpringBoot x ShedLockで簡単に同時実行制御を行う

SpringBootでバッチ処理を書くときに、SpringBatchが選択肢に上がると思いますが、設定だったりクラスを継承したりと面倒です。確かに中断後から処理を開始してくれたりと便利な部分もあるのですが、SpringBatchの仕様理解とかが必要になってきます。もっとカジュアルに同時実行制御を行いたい場合に、ShedLock が選択肢に入ってくると思います。今回は、簡単な紹介になります。

コードはここにおいておきます。GitHub - ko-sasaki/springboot-shedlock-example

build.gradle

依存関係は下記のようになります。(mavenの方は読み替えてください)

plugins {  
    id 'java'  
    id 'org.springframework.boot' version '3.4.8'  
    id 'io.spring.dependency-management' version '1.1.7'  
}  
  
group = 'net.ksasaki.sample'  
version = '0.0.1-SNAPSHOT'  
  
java {  
    toolchain {  
       languageVersion = JavaLanguageVersion.of(21)  
    }  
}  
  
configurations {  
    compileOnly {  
       extendsFrom annotationProcessor  
    }  
}  
  
repositories {  
    mavenCentral()  
}  
  
dependencies {  
    implementation 'org.springframework.boot:spring-boot-starter-web'  
    compileOnly 'org.projectlombok:lombok'  
    developmentOnly 'org.springframework.boot:spring-boot-docker-compose' // (1)
    developmentOnly 'org.springframework.boot:spring-boot-devtools'  
    runtimeOnly 'org.postgresql:postgresql'  
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'  
    annotationProcessor 'org.projectlombok:lombok'  
    testImplementation 'org.springframework.boot:spring-boot-starter-test'  
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'  

    
    implementation 'net.javacrumbs.shedlock:shedlock-spring:6.9.2'  // 追加
    implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.9.2'    // 追加


}  
  
tasks.named('test') {  
    useJUnitPlatform()  
}

追加が必要なのは、この2行になります。

環境まわり

今回DBはPostgreSQLを使用します。SpringBootは、開発モードでもアプリケーション起動時にdocker composeファイルを指定すると立ち上げてくれる便利な機能があります。今回はそれを使用します。( 設定ファイルの(1)の依存関係の追加が必要になります。)

compose.ymlは下記のように設定します。

name: shed-lock  
services:  
  db:  
      image: "postgres:latest"  
      container_name: "shed-lock-db"  
      environment:  
          POSTGRES_USER: "shedlock"  
          POSTGRES_PASSWORD: "shedlock"  
          POSTGRES_DB: "shedlock"  
      ports:  
      - "5432:5432"  
      volumes:  
        - database:/var/lib/postgresql/data  
        - ./postgres/init:/docker-entrypoint-initdb.d // init.sqlは後述
      networks:  
        - shedlock-network  
  
volumes:  
  database:  
networks:  
  shedlock-network:

基本的なdocker composeの設定になります。

application.ymlは下記のように設定します。

spring:
  application:
    name: schedule
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/shedlock
    username: shedlock
    password: shedlock

  docker:
    compose:
      file: compose.yml
  task:
    scheduling:
      pool:
        size: 10    // scheduleはデフォルトだと1スレッドしか立ち上がらない
  main:
    keep-alive: true  // virtual threadのみが動作していてもJVMが終了しない設定
  threads:
    virtual:
      enabled: true

注意点は、スレッド数とkeep-aliveになります。SpringがSchedulingとして使用するスレッドはデフォルトだと1なので、スケジュールを複数設置すると同時実行は1つのみですので、実際に使う場合は設定を変更しておくと良いと思います。spring.main.keep-alive は、バーチャルスレッドを有効にしている場合に、バーチャルスレッドは動作しているが、メインスレッドの処理が止まるとJVMが終了してしまうため、それを回避するための設定です。有効にしておくと不慮のJVM終了にはならないと思います。

テーブル定義

init.sqlは下記のようにします。プロダクション環境ではFlyway等のマイグレーションツールで設定してください。

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

アプリケーションコード

package net.ksasaki.sample.schedule;  
  
import lombok.extern.slf4j.Slf4j;  
import net.javacrumbs.shedlock.core.LockProvider;  
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;  
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;  
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;  
import org.springframework.boot.SpringApplication;  
import org.springframework.boot.autoconfigure.SpringBootApplication;  
import org.springframework.context.annotation.Bean;  
import org.springframework.context.annotation.Configuration;  
import org.springframework.jdbc.core.JdbcTemplate;  
import org.springframework.scheduling.annotation.EnableScheduling;  
import org.springframework.scheduling.annotation.Scheduled;  
import org.springframework.stereotype.Component;  
  
import javax.sql.DataSource;  
import java.time.LocalDateTime;  
  
@SpringBootApplication  
public class ScheduleApplication {  
  
    public static void main(String[] args) {  
       SpringApplication.run(ScheduleApplication.class, args);  
    }  
  
}  
  
  
@EnableScheduling  
@EnableSchedulerLock(defaultLockAtMostFor = "10m")  
@Configuration
class ScheduleConfig {   // ①
    @Bean  
    public LockProvider lockProvider(DataSource dataSource) {  
       return new JdbcTemplateLockProvider(  
             JdbcTemplateLockProvider.Configuration.builder()  
                   .withJdbcTemplate(new JdbcTemplate(dataSource))  
                   .withLockedByValue( "schedule")  
                   .usingDbTime()  
                   .build()  
       );  
    }  
}  
  
@Component  
@Slf4j  
class ScheduleComponent {  // ②
  
    @Scheduled(fixedRate = 1000, initialDelay = 1000)  // ③
    @SchedulerLock(name = "scheduleComponentA", lockAtLeastFor = "3s")  // ④
    public void runA() throws InterruptedException {  
       log.info("Scheduled task is running... A {}", LocalDateTime.now());  
    }  
  
    @Scheduled(fixedRate = 1000, initialDelay = 1000)  
    @SchedulerLock(name = "scheduleComponentB" , lockAtLeastFor = "4s")  
    public void runB() {  
       log.info("Scheduled task is running... B {}", LocalDateTime.now());  
    }  
  
    @Scheduled(fixedRate = 500, initialDelay = 1000)  
    @SchedulerLock(name = "scheduleComponentC" , lockAtLeastFor = "5s")  
    public void runC() {  
       log.info("Scheduled task is running... C {}", LocalDateTime.now());  
    }  
  
    @Scheduled(fixedRate = 1000, initialDelay = 1000)  
    @SchedulerLock(name = "scheduleComponentD", lockAtLeastFor = "2s")  
    public void run0() throws InterruptedException {  
       log.info("Scheduled task is running... D {}", LocalDateTime.now());  
    }  
}

1ファイルにしたので、長いですけど順を追って解説します。

ShedLockの設定

// ①の部分になりますが、LockProviderの設定が必要になります。基本的にはSpringのDataSourceオブジェクトを渡すくらいで処理が完了します。

@EnableScheduling // ①
@EnableSchedulerLock(defaultLockAtMostFor = "10m") // ②
@Configuration
class ScheduleConfig {
    @Bean  
    public LockProvider lockProvider(DataSource dataSource) {  
       return new JdbcTemplateLockProvider(  
             JdbcTemplateLockProvider.Configuration.builder()  
                   .withJdbcTemplate(new JdbcTemplate(dataSource))  
                   .withLockedByValue( "schedule")  // locked_byカラムのデータ
                   .usingDbTime()  
                   .build()  
       );  
    }  
}  

① スケジュール機能を有効にする

@EnableSchedulingでスケジュール機能を有効にします。

② ShedLock機能を有効にする

@EnableSchedulerLock(defaultLockAtMostFor = "10m")ShedLockを有効にします。defaultLockAtMostForは、デフォルトの最長ロック時間を指定します。

スケジュール設定部分

SpringBootを起動する部分は省いてスケジュール設定をする部分のみ説明します。

@Component
@Slf4j  
class ScheduleComponent {  // ③
  
    @Scheduled(fixedRate = 1000, initialDelay = 1000)  // ④
    @SchedulerLock(name = "scheduleComponentA", lockAtLeastFor = "3s")  // ⑤
    public void runA() throws InterruptedException {  
       log.info("Scheduled task is running... A {}", LocalDateTime.now());  
    }  
  
    @Scheduled(fixedRate = 1000, initialDelay = 1000)  
    @SchedulerLock(name = "scheduleComponentB" , lockAtLeastFor = "4s")  
    public void runB() {  
       log.info("Scheduled task is running... B {}", LocalDateTime.now());  
    }  
  
    @Scheduled(fixedRate = 500, initialDelay = 1000)  
    @SchedulerLock(name = "scheduleComponentC" , lockAtLeastFor = "5s")  
    public void runC() {  
       log.info("Scheduled task is running... C {}", LocalDateTime.now());  
    }  
  
    @Scheduled(fixedRate = 1000, initialDelay = 1000)  
    @SchedulerLock(name = "scheduleComponentD", lockAtLeastFor = "2s")  
    public void run0() throws InterruptedException {  
       log.info("Scheduled task is running... D {}", LocalDateTime.now());  
    }  
}

③ スケジュールクラスのDI設定

スケジュール設定をするには、DIに登録する必要がありますので、classに@Component、``を付与します。

④ スケジュールの設定

Springのスケジューリングの設定はアノテーションで設定を行う。fixedRate完了後、次の実行時までの時間をms単位で設定します。initialDelayは、SpringBoot起動後、初回の開始時間までの時間をms単位で設定します。

@Scheduled(fixedRate = 1000, initialDelay = 1000)  // ③

⑤ ShedLockの設定

ShedLock の設定を行います。

@SchedulerLock(name = "scheduleComponentA", lockAtLeastFor = "3s")  // ④
name: ロックする名前
lockAtLeastFor: 最小時間
lockAtMostFor: 最大時間

以上となります。nameはこのあと紹介するDBのデータでPKとなっています。lockAtLeastForは、タスクが短時間で完了してもロックし続ける時間になります。lockAtMostForは、ロックしつづける最大時間になります。

起動後

ログ

ログは下記のようになります。わかりにくいですが、ロックされている時間は動作せずにスキップされています。複数台で実行すると、片方のインスタンスのみで実行されているのがわかります。

テーブルの中身

テーブルの中身は下記のようになっています。ロックされて、それぞれ同時実行できないようになっています。@Scheduledの実行タイミングで、チェックにしてロックされていたらスキップするような挙動になっています。

まとめ

バッチ処理は複数台で同時実行を制御するのが一番面倒かなとおもいます。ShedLockはその部分のみを担ってくれます。SpringBatchにあるような分散処理系で便利な、クラッシュ時のリスタート、チェックポイント、スキップやチャンク指向処理など機能はありません。バッチ処理をSpringBatchでオンラインアプリケーションコードとバッチ処理のコードの乖離が進む傾向にあると思います。なるべくビジネスロジックは、オンラインアプリケーションコードに寄せた形でのバッチ処理を書くときに、ShedLockを使う選択肢はありだと思います。ぜひ試してみてください。