티스토리 뷰
Spring Scheduler를 사용할 때, 같은 작업을 하는 스케줄러가 다중 인스턴스 환경에서 중복 실행되는 문제가 있습니다.
이 문제를 해결하기 위해 Quartz, ShedLock 등을 고려할 수 있습니다. 저는 그 중 ShedLock을 선택했는데, 그 이유는 해결하려는 문제가 클러스터 안의 서버 노드 중 스케줄링 작업을 선택하거나 예외 처리 등의 기능이 필요한 것이 아니라, 단순히 중복 작업을 방지하는 데 ShedLock이 충분하다고 판단했기 때문입니다
단일 환경에서 중복 실행 문제
위에서 설명한 중복 실행 문제는 모두 다중 인스턴스 환경에서 발생할 수 있지만, 단일 환경에서도 중복 실행 문제가 발생할 수 있습니다.
단일 환경에서 운영되는 서버라도 Blue/Green 무중단 배포 상황에서 두 개의 인스턴스가 동시에 실행되는 경우가 발생할 수 있으며, 이러한 상황에서는 두 개의 스케줄러가 동시에 실행될 수 있습니다.
ShedLock 동작 방식
ShedLock의 잠금 메커니즘을 이해하고 사용하기위해서 실제 MySQL에 어떤식으로 요청을 보내는지 확인했다.
2024-04-09T05:55:00.074790Z 69 Query SET autocommit=0
2024-04-09T05:55:00.085821Z 59 Query SET autocommit=0
2024-04-09T05:55:00.093634Z 69 Query INSERT IGNORE INTO shedlock(name, lock_until, locked_at, locked_by) VALUES('scheduledSendingEmailTask', TIMESTAMPADD(MICROSECOND, 240000000, UTC_TIMESTAMP(3)), UTC_TIMESTAMP(3), 'PowerMacBookAir.localdomain')
2024-04-09T05:55:00.108233Z 69 Query commit
2024-04-09T05:55:00.110982Z 69 Query SET autocommit=1
2024-04-09T05:55:00.115339Z 69 Query SET autocommit=0
2024-04-09T05:55:00.118853Z 69 Query UPDATE shedlock SET lock_until = TIMESTAMPADD(MICROSECOND, 240000000, UTC_TIMESTAMP(3)), locked_at = UTC_TIMESTAMP(3), locked_by = 'PowerMacBookAir.localdomain' WHERE name = 'scheduledSendingEmailTask' AND lock_until <= UTC_TIMESTAMP(3)
2024-04-09T05:55:00.119832Z 59 Query INSERT IGNORE INTO shedlock(name, lock_until, locked_at, locked_by) VALUES('scheduledSendingEmailTask', TIMESTAMPADD(MICROSECOND, 240000000, UTC_TIMESTAMP(3)), UTC_TIMESTAMP(3), 'PowerMacBookAir.localdomain')
2024-04-09T05:55:00.122711Z 69 Query commit
2024-04-09T05:55:00.137621Z 69 Query SET autocommit=1
2024-04-09T05:55:00.145986Z 59 Query commit
2024-04-09T05:55:00.148706Z 59 Query SET autocommit=1
2024-04-09T05:55:00.150784Z 59 Query SET autocommit=0
2024-04-09T05:55:00.153585Z 59 Query UPDATE shedlock SET lock_until = TIMESTAMPADD(MICROSECOND, 240000000, UTC_TIMESTAMP(3)), locked_at = UTC_TIMESTAMP(3), locked_by = 'PowerMacBookAir.localdomain' WHERE name = 'scheduledSendingEmailTask' AND lock_until <= UTC_TIMESTAMP(3)
2024-04-09T05:55:00.156705Z 59 Query commit
2024-04-09T05:55:00.157172Z 59 Query SET autocommit=1
2024-04-09T05:55:02.241843Z 69 Query SET autocommit=0
2024-04-09T05:55:02.242863Z 69 Query UPDATE shedlock SET lock_until = IF (TIMESTAMPADD(MICROSECOND, 240000000, locked_at) > UTC_TIMESTAMP(3) , TIMESTAMPADD(MICROSECOND, 240000000, locked_at), UTC_TIMESTAMP(3)) WHERE name = 'scheduledSendingEmailTask' AND locked_by = 'PowerMacBookAir.localdomain'
2024-04-09T05:55:02.244412Z 69 Query commit
2024-04-09T05:55:02.244662Z 69 Query SET autocommit=1
실제 MySQL로 보내는 요청을 살펴보면 Insert 와 Update 만으로 Lock을 구현하고있는것을 확인할 수 있다.
DefaultLockingTaskExecutor.class
public <T> LockingTaskExecutor.TaskResult<T> executeWithLock(LockingTaskExecutor.TaskWithResult<T> task, LockConfiguration lockConfig) throws Throwable {
Optional<SimpleLock> lock = this.lockProvider.lock(lockConfig);
if (lock.isPresent()) {
...
} else {
logger.debug("Not executing '{}'. It's locked.", lockName);
return TaskResult.notExecuted();
}
}
1. 스케줄러가 실행되면 executeWithLock 메서드를 실행하고 먼저 Provider를 통해서 lock을 획득하게 된다.
StorageBasedLockProvider.class
protected boolean doLock(LockConfiguration lockConfiguration) {
String name = lockConfiguration.getName();
boolean tryToCreateLockRecord = !this.lockRecordRegistry.lockRecordRecentlyCreated(name);
if (tryToCreateLockRecord) {
if (this.storageAccessor.insertRecord(lockConfiguration)) {
this.lockRecordRegistry.addLockRecord(name);
return true;
}
this.lockRecordRegistry.addLockRecord(name);
}
...
return this.storageAccessor.updateRecord(lockConfiguration);
}
JdbcTemplateStorageAccessor.class
public boolean insertRecord(@NonNull LockConfiguration lockConfiguration) {
String sql = this.sqlStatementsSource().getInsertStatement();
return this.execute(sql, lockConfiguration);
...
}
public boolean updateRecord(@NonNull LockConfiguration lockConfiguration) {
String sql = this.sqlStatementsSource().getUpdateStatement();
...
}
MySqlServerTimeStatementsSource.class
String getInsertStatement() {
String var10000 = this.tableName();
return "INSERT IGNORE INTO " + var10000 + "(" + this.name() + ", " + this.lockUntil() + ", " + this.lockedAt() + ", " + this.lockedBy() + ") VALUES(:name, TIMESTAMPADD(MICROSECOND, :lockAtMostForMicros, UTC_TIMESTAMP(3)), UTC_TIMESTAMP(3), :lockedBy)";
}
public String getUpdateStatement() {
String var10000 = this.tableName();
return "UPDATE " + var10000 + " SET " + this.lockUntil() + " = :lockUntil, " + this.lockedAt() + " = :now, " + this.lockedBy() + " = :lockedBy WHERE " + this.name() + " = :name AND " + this.lockUntil() + " <= :now";
}
2. INSERT IGNORE INTO 문을 사용하여 레코드를 생성합니다. 만약 두 개의 인스턴스에서 동일한 스케줄러를 실행한다면, 두 번째로 실행한 스케줄러에서 삽입된 중복된 레코드는 무시됩니다.
3. 처음 락 레코드를 삽입한다면 TRUE 반환, 기존 락 레코드가 존재하는경우 락 종료 시간 <= 현재 시간 인경우 변경
3.1 만약 동일 스케줄러가 실행된다면 락 종료 시간 > 현재 시간이 작기 때문에 UPDATE 되지 않고 0반환
JdbcTemplateStorageAccessor.class
private boolean execute(String sql, LockConfiguration lockConfiguration) throws TransactionException {
return (Boolean)this.transactionTemplate.execute((status) -> {
return this.jdbcTemplate.update(sql, this.params(lockConfiguration)) > 0;
});
}
StorageBasedLockProvider.class
public Optional<SimpleLock> lock(LockConfiguration lockConfiguration) {
boolean lockObtained = this.doLock(lockConfiguration);
return lockObtained ? Optional.of(new StorageLock(lockConfiguration, this.storageAccessor)) : Optional.empty();
}
3.2 0이 반환된다면 updateRecord가 false를 반환하게되고 false가 반환되면 lock메서드가 Optional.empty()를 반환하게 되면서
스케줄러가 실행되지 않게된다.
추가
Blue/Green 배포의 경우, Blue 버전이 종료될 때 Green 버전에서 실행 중인 스케줄러가 정상적으로 완료되지 못하고 종료되는 문제가 발생했습니다.
위 문제를 해결하기 위해 graceful shutdown을 도입했습니다.
2024-04-11T18:18:34.590+09:00 INFO 66431 --- [ionShutdownHook] o.s.c.support.DefaultLifecycleProcessor : Shutdown phase 2147483647 ends with 1 bean still running after timeout of 30000ms: [taskScheduler]
2024-04-11T18:18:34.592+09:00 INFO 66431 --- [ionShutdownHook] o.s.b.w.e.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2024-04-11T18:18:34.597+09:00 INFO 66431 --- [tomcat-shutdown] o.s.b.w.e.tomcat.GracefulShutdown : Graceful shutdown complete
2024-04-11T18:18:34.608+09:00 INFO 66431 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2024-04-11T18:18:34.611+09:00 INFO 66431 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2024-04-11T18:18:34.611+09:00 ERROR 66431 --- [ scheduling-1] o.s.s.s.TaskUtils$LoggingErrorHandler : Unexpected error occurred in scheduled task
java.lang.InterruptedException: sleep interrupted
graceful shutdown 도입해도 스케줄러가 정상적으로 완료되지 못하고 종료되는 문제가 발생했습니다.
spring:
lifecycle:
timeout-per-shutdown-phase: 60s # default 30s
shutdown 대기 시간이 디폴트로 최대 30초로 설정되어 있어서, shutdown 이후 30초 후에 종료되어서 정상적으로 완료되지 못했습니다. 해당 값을 충분히 늘리면 스케줄러가 정상 동작 후에 종료되도록 할 수 있습니다.
https://github.com/lukas-krecan/ShedLock
'spring' 카테고리의 다른 글
Java 시간 다루기 (1) | 2024.06.19 |
---|---|
어댑터 패턴을 활용기 (0) | 2024.06.16 |
Spring Boot 로그에 바인딩 매개변수가 표시되지 문제 해결 (0) | 2023.10.24 |
FeignClient @FormProperty 트러블 슈팅 (0) | 2023.10.15 |
스프링의 트랜잭션 전파 속성(Transaction propagation) 실제 쿼리 (0) | 2023.10.07 |
- Total
- Today
- Yesterday
- Attribute Converter
- @FormProperty
- defer-datasource-initialization
- entity 검증
- setDateFormat
- CreatedDate
- Spring Boot 3
- @Converter
- feignClient
- dto 검증
- 레이어드 아키텍처
- WebFlux 의존성
- BasicBinder
- java 17
- JPA SQL Injection
- HandlesTypes
- @ElementCollection
- org.springframework:spring-webflux
- CreationTimestamp
- 유저 시나리오
- dto 위치
- User Scenario
- HTTPInterface
- DispatcherServletInitializer
- 구글 소셜로그인
- ServletContainerInitializer
- 유저 스토리
- FormProperty
- 구글 OpenID
- ValidateException
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |