티스토리 뷰

spring

ShedLock 도입기

songjb 2024. 4. 11. 10:29

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

https://kok202.tistory.com/119

https://developer-been.tistory.com/34