한 노드에서 하나의 작업이 이미 실행되고 있는 경우 다른 노드(또는 쓰레드)에서의 실행은 기다리지 않고 단순히 건너뛴다.
ShedLock은 Mongo, JDBC database, Redis, Hazelcast, ZooKeeper 등과 같은 외부 저장소를 사용한다.
ShedLock은 분산 스케줄러가 아니며 단지 Lock일 뿐이라는 점에 주의해야 한다.
분산 스케줄러가 필요한 경우에는 다른 프로젝트를 이용해야 한다. (db-scheduler, JobRunr)
ShedLock은 병렬로 실행할 준비가 되지 않았지만 안전하게 반복적으로 실행할 수 있는 Scheduled 된 작업이 있는 상황에서 사용하도록 설계되었다.
또한 Lock은 시간 기반이며 ShedLock은 노드의 시간이 동기화된다고 가정한다.
JDK 17 버전 이상, 그리고 Spring6 이상을 사용한다면 5.1.0 버전을 사용해야 한다.
그 이하의 JDK 버전을 사용한다면 4.44.0 버전을 사용해야 한다.
ShedLock은 세가지 파트로 구성된다.
ShedLock을 사용하려면 다음의 과정을 따라야한다.
1. Scheduled locking 활성화 및 설정
2. Scheduled 된 task에 대해 애너테이션 달기
3. Lock Provider 설정
먼저 ShedLock 프로젝트를 import 해야 한다.
gradle에서 다음과 같이 의존성을 추가할 수 있다.
implementation("net.javacrumbs.shedlock:shedlock-spring:5.8.0")
이제 라이브러리를 Spring과 통합해야 한다.
schedule locking을 활성화하려면 @EnableSchedulerLock
애너테이션을 사용해야 한다.
@Configuration
@EnableScheduling // Spring 스케쥴링 기능 사용
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
class ScheduleConfig {
}
package com.tistory.tech.gyutech.service
import net.javacrumbs.shedlock.core.LockAssert
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
@Service
class ScheduledService {
private val log = LoggerFactory.getLogger(this::class.java)
@Scheduled(fixedRate = 1000)
@SchedulerLock(name = "scheduledTaskName")
fun scheduledTask() {
// To assert that the lock is held (prevents misconfiguration errors)
LockAssert.assertLocked()
// do something
log.info("scheduled task")
}
}
[[@Scheduled]]는 Spring Framework에서 제공하는 스케줄링 기능이기 때문에, Spring의 Bean으로 등록되어 있어야 한다.
@SchedulerLock
애너테이션에는 여러가지 목적이 있다.
우선, 해당 애너테이션이 달린 메서드만 Lock을 사용하고, 다른 스케쥴링 된 Task들은 ShedLock 라이브러리가 무시할 수 있다.
Lock에 대한 이름도 지정할 수 있다.
동일한 이름을 가진 task는 동시에 오직 하나만 실행될 수 있다.
실행 중인 노드가 종료될 경우 Lock을 유지해야 하는 시간을 지정하는 lockAtMostFor
속성을 설정할 수도 있다.
이는 대체 조치일 뿐이며 일반적인 상황에서는 작업이 완료되자마자 Lock이 해제된다.(lockAtLeastFor
가 지정되지 않은 경우 아래 참조).
lockAtMostFor
를 일반적인 작업 수행 시간보다 훨씬 긴 값으로 설정해야 한다.
작업이 lockAtMostFor
보다 오래 걸리는 경우, 결과 동작은 예측할 수 없다. (둘 이상의 프로세스가 Lock을 갖게 된다.)
@SchedulerLock
에 lockAtMostFor
를 지정하지 않으면 @EnableSchedulerLock
의 기본값이 사용된다.
마지막으로 잠금을 유지해야 하는 최소 시간을 지정하는 lockAtLeastFor
속성을 설정할 수 있다.
주요 목적은 매우 짧은 작업과 노드 간의 시간 차이가 있는 경우 여러 노드에서 실행되는 것을 방지하는 것이다.
모든 애너테이션은 SpEL(Spring Expression Language)을 지원한다.
정리
lockAtMostFor
: 실행중인 노드가 죽었을 경우, Lock을 지속할 시간 => 일반적인 실행 시간보다 길어야한다.
=> 짧은 경우, 다른 노드에서 판단할 수가 없기 때문
lockAtLeastFor
: Lock을 유지할 최소 시간 => 노드간의 시간 차이가 있을 경우, 지나치게 빨리 끝나는 작업은 시작되었는지도 모르고 재실행 될 수 있다.
15분마다 실행하고 일반적으로 실행하는 데 몇 분이 걸리는 작업이 있다고 가정해보자.
또한 최대 15분에 한 번씩 실행하려고 합니다. 이 경우 다음과 같이 구성할 수 있다.
import net.javacrumbs.shedlock.core.SchedulerLock;
@Scheduled(cron = "0 */15 * * * *")
@SchedulerLock(name = "scheduledTaskName", lockAtMostFor = "14m", lockAtLeastFor = "14m")
fun scheduledTask() {
// do something
}
lockAtMostFor
를 설정하면 노드가 종료되더라도 잠금이 해제된다.
lockAtLeastFor
를 설정하여 15분 안에 두 번 이상 실행되지 않도록 한다.
lockAtMostFor
는 작업을 실행하는 노드가 종료되는 경우를 대비한 안전망일 뿐이므로 최대 예상 실행 시간보다 훨씬 큰 시간으로 설정해야 한다.
작업이 lockAtMostFor
보다 오래 걸리면 다시 실행될 수 있으며 결과를 예측할 수 없다(더 많은 프로세스가 Lock을 보유하게 된다).
LockProvider에는 여러 가지 구현이 있다.
name
은 기본 키여야 한다.)# MySQL, MariaDB
CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name));
# Postgres
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));
# Oracle
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));
# MS SQL
CREATE TABLE shedlock(name VARCHAR(64) NOT NULL, lock_until datetime2 NOT NULL,
locked_at datetime2 NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name));
# DB2
CREATE TABLE shedlock(name VARCHAR(64) NOT NULL PRIMARY KEY, lock_until TIMESTAMP NOT NULL,
locked_at TIMESTAMP NOT NULL, locked_by VARCHAR(255) NOT NULL);
아니면 this liquibase 변경 세트를 사용한다 .
implementation("net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.8.0")
@Bean
fun lockProvider(dataSource: DataSource): LockProvider {
return JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(JdbcTemplate(dataSource))
.usingDbTime() // Works on Postgres, MySQL, MariaDb, MS SQL, Oracle, DB2, HSQL and H2
.build()
)
}
usingDbTime()
을 지정하면, Lock Provider는 DB 서버 시간을 기반으로한 UTC time을 사용한다.
이 옵션을 지정하지 않으면 앱 서버의 시간이 사용된다. (앱 서버의 시간이 동기화되지 않아 다양한 Lock 문제가 발생할 수 있음).
DB 엔진에 특화된 SQL을 사용하여 INSERT 충돌을 방지하므로, usingDbTime()
옵션을 사용하는 것이 좋다. 자세한 내용은 다음의 글을 참조.
보다 세분화된 구성을 위해서는 Configuration
객체의 다른 옵션을 사용하면 된다.
new JdbcTemplateLockProvider(builder()
.withTableName("shdlck")
.withColumnNames(new ColumnNames("n", "lck_untl", "lckd_at", "lckd_by"))
.withJdbcTemplate(new JdbcTemplate(getDatasource()))
.withLockedByValue("my-value")
.withDbUpperCase(true)
.build())
스키마를 지정해야 하는 경우 일반적인 dot 표기법을 사용하여 테이블 이름에 설정할 수 있다.
new JdbcTemplateLockProvider(datasource, "my_schema.shedlock")
대소문자를 구분하는 테이블 및 열 이름이 있는 데이터베이스를 사용하려면 .withDbUpperCase(true)
플래그를 사용할 수 있다.
Default 값은 false
(소문자)이다.
DB 테이블에서 Lcok Row을 수동으로 삭제하지 말아야한다.
ShedLock은 현재 존재하는 Lock rows에 대한 in-memory 캐시가 존재한다. 따라서 애플리케이션을 재실행하기 전까지 자동으로 row가 재생성되지 않는다.
필요한 경우 여러 Lock이 유지될 위험을 감수하면서 row/document를 수정할 수 있다.
LockProvider
인터페이스를 구현하여 라이브러리의 동작을 커스터마이징 할 수 있다.
잠금을 획득한 후 특별한 동작을 구현하고 싶다고 가정해보자.
다음과 같은 예를 들 수 있다.
public class MyLockProvider implements LockProvider {
private final LockProvider delegate;
public MyLockProvider(LockProvider delegate) {
this.delegate = delegate;
}
@Override
public Optional<SimpleLock> lock(LockConfiguration lockConfiguration) {
Optional<SimpleLock> lock = delegate.lock(lockConfiguration);
if (lock.isPresent()) {
// do something
}
return lock;
}
}
class MyLockProvider(private val delegate: LockProvider) : LockProvider {
override fun lock(lockConfiguration: LockConfiguration): Optional<SimpleLock> {
return delegate.lock(lockConfiguration).apply {
ifPresent {
// do something
}
}
}
}
Duration을 지정해야 하는 모든 애너테이션은 다음 형식을 지원한다.
1s
, 5ms
, 5m
, 1d
(Since 4.0.0)100
(only Spring integration)PT15M
(see Duration.parse() documentation)현재 보유한 Lock을 연장해야 하는 몇 가지 사용 사례가 있다.
다음과 같은 방법으로 LockExtender를 사용할 수 있다.
LockExtender.extendActiveLock(Duration.ofMinutes(5), ZERO);
모든 Lock Provider 구현이 Lock 연장을 지원하는 것은 아니다.
ShedLock은 두 가지 Spring 통합 모드를 지원한다.
Scheduled 된 메서드(PROXY_METHOD) 주변에서 AOP 프록시를 사용하는 것과 TaskScheduler(PROXY_SCHEDULER)를 프록시하는 것이다.
버전 4.0.0부터 Spring 통합의 기본 모드는 애너테이션이 달린 메서드 주변의 AOP 프록시이다.
이 모드의 가장 큰 장점은 기본 Spring 스케줄링 메커니즘을 어떻게든 변경하려는 다른 프레임워크와 잘 작동한다는 것이다.
단점은 메소드를 직접 호출해도 Lock이 적용된다는 점이다.
메서드가 값을 반환하고 잠금이 다른 프로세스에 의해 유지되는 경우 null 또는 빈 Optional이 반환된다(기본 반환 유형은 지원되지 않음).
final
및 private
메서드는 프록시되지 않으므로 Scheduled 된 메서드를 public
및 non-final
으로 설정하거나 TaskScheduler
프록시를 사용해야 한다.
AOP 구성 오류, 애너테이션 누락 등과 같은 설정 오류를 방지하려면 LockAssert
를 사용하여 Lock 의 동작을 assert 할 수 있다.
@Scheduled(fixedRate = 5000)
@SchedulerLock(name = "scheduledTaskName", lockAtLeastFor = "4s")
fun scheduledTask() {
// To assert that the lock is held (prevents misconfiguration errors)
LockAssert.assertLocked()
// do something
log.info("scheduled task")
}
단위 테스트에서는 지정된 쓰레드에서 LockAssert.TestHelper.makeAllAssertsPass(true)
를 호출하여 assertion을 끌 수 있다(예시 참고).
라이브러리는 Kotlin으로 테스트되었으며 정상적으로 작동한다.
유일한 문제는 final
메소드에서 작동하지 않는 Spring AOP이다.
@Component
주석과 함께 @SchedulerLock
을 사용하면 Kotlin Spring 컴파일러 플러그인이 자동으로 메서드를 'open' 해주기 때문에 모든 것이 작동해야한다.
@Component
주석이 없으면 직접 메서드를 open
해야 한다. (자세한 내용은 이 문제를 참조)
ShedLock의 Lock에는 만료 시간이 있어 다음과 같은 문제가 발생할 수 있다.
lockAtMostFor
보다 오래 실행되면 작업이 두 번 이상 실행될 수 있다.lockAtLeastFor
또는 최소 실행 시간보다 크면 작업이 두 번 이상 실행될 수 있다.