
서비스 모니터링을 하는 도중, 스케줄러 수행 시점에 데드락 예외 트레이스가 반복적으로 발생하는 것을 발견했다.
예외 로그는 다음과 같았다.
exception.stacktrace 
"org.springframework.dao.CannotAcquireLockException: JDBC exception executing SQL [
&{이달의 랭킹 업데이트 쿼리;}
] [Deadlock found when trying to get lock; try restarting transaction] [n/a]; SQL [n/a]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:287)
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:256)
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:244)
at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:560)
at org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:...)
at org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:343)
서비스에서 이달의 랭킹을 10분마다 업데이트한다. 이 작업은 Spring의 @Scheduled 로 수행하고 있었다. monthly_reading 테이블에 대해 UPDATE 하는 쿼리를 실행하는 작업인데, 여기서 계속 Deadlock found when trying to get lock 에러가 발생하고 있었다.
CannotAcquireLockException이 발생한 것으로 보아, 데이터베이스 레벨에서 트랜잭션을 수행하는데 데드락이 발생한 것이다.
Deadlock이란 두 개 이상의 프로세스나 스레드가 서로의 자원을 기다리며 무한히 기다리는 현상이다.
위 작업에서 Deadlock이 발생한 이유는 두 트랜잭션이 서로가 보유한 monthly_reading 락을 무한히 대기하고 있기 때문이다.
❓하나의 애플리케이션에서 스케줄러끼리 스레드 충돌이 발생할 수 있을까?
@Scheduled는 Spring에서 제공하는 스케줄링 전용 스레드 풀에서 스레드를 꺼내 사용하는데,
이 스레드 풀에 대해 추가적으로 설정한 config가 없으면 default 스레드 풀 사이즈가 1이다.즉, 단일 스레드 환경에서 스케줄러가 실행되는 것이다.
따라서 하나의 애플리케이션 안에서 스케줄러 스레드 여러 개가
monthly_reading을 점유하느라 발생한 데드락은 아닌 것이다.
여러 개의 스레드가 monthly_reading에 대한 락을 동시에 점유하려고 시도하는 경우들을 추려보았다.
a. 스케줄러 스레드 vs 클라이언트 요청 스레드 :
스케줄러가 실행되는 정확한 타이밍에 사용자가 아티클을 읽어서monthly_reading업데이트를 시도한다.b. 스케줄러 스레드 vs 스케줄러 스레드 :
분산 환경에서 두 개의 인스턴스가 동시에 스케줄러를 실행해monthly_reading에 대한 락 점유를 시도한다.
스케줄러 스레드는 10분마다 monthly_reading의 전체적인 랭킹을 갱신하는 작업을 진행한다.
이에 대해 클라이언트 요청 스레드는 동시에 monthly_reading에 대해 갱신을 요구할 수 있다.
스케줄러 스레드가 10분마다 이달의 독서왕을 업데이트 하기 위해 monthly_reading 에 대한 update를 시도한다.
UPDATE monthly_reading mr
JOIN (
SELECT member_id,
RANK() OVER (ORDER BY current_count DESC) AS calculated_rank
FROM monthly_reading
) ranks ON mr.member_id = ranks.member_id
LEFT JOIN (
SELECT DISTINCT
mr1.member_id,
COALESCE(MIN(mr2.current_count) - mr1.current_count, 0) AS next_diff
FROM monthly_reading mr1
LEFT JOIN monthly_reading mr2
ON mr2.current_count > mr1.current_count
GROUP BY mr1.member_id, mr1.current_count
) diffs ON mr.member_id = diffs.member_id
SET mr.rank_order = ranks.calculated_rank,
mr.next_rank_difference = diffs.next_diff;
PK 인덱스를 순회하면서 레코드 하나씩 업데이트를 수행하므로 monthly_reading 레코드들에 대해 X record lock을 점유한다.
결과적으로는 스케줄러 트랜잭션동안 monthly_reading 의 모든 레코드에 X record lock이 잡히게 된다.
모두 락을 잡고 있다가 commit 시 한 번에 풀어주게 된다.
이 때 사용자가 아티클을 읽어서 이달의 읽기 개수(current_count)를 갱신하려고 시도한다.
클라이언트 요청 스레드가 monthly_reading 에 대해 update를 시도하는 것이다.
update monthly_reading
set member_id = ?, current_count = ?, created_at = ?, updated_at = ?
where id = ?
트랜잭션은 monthly_reading 테이블에서 해당 id에 대한 업데이트를 실행하므로 이 레코드에 대한 X record lock을 시도한다.
이 경우는 교차적으로 서로의 락을 기다리기보단, 1번 락이 2번 락을 대기하거나 2번 락이 1번 락을 대기하는 단방향 lock-wait만 발생한다.
락을 점유한 한 쪽 트랜잭션이 작업을 완료하고 락을 해제하면, 이후에 나머지 트랜잭션에서 락을 얻고 사용할 수 있으므로 무한 대기는 발생하지 않는다.
따라서 Deadlock의 원인은 아닐 것이라고 판단했다.
현재 서비스는 분산 환경으로, ec2 인스턴스 2개로 서버를 운영 중이다.
즉, 두 개의 애플리케이션에서 완전히 동일한 시간에 스케줄러가 실행 되는 것이다.
따라서 두 개의 스케줄러 스레드가 동시에 monthly_reading 에 대해 update를 시도한다.
UPDATE monthly_reading mr
JOIN (
SELECT member_id,
RANK() OVER (ORDER BY current_count DESC) AS calculated_rank
FROM monthly_reading
) ranks ON mr.member_id = ranks.member_id
LEFT JOIN (
SELECT DISTINCT
mr1.member_id,
COALESCE(MIN(mr2.current_count) - mr1.current_count, 0) AS next_diff
FROM monthly_reading mr1
LEFT JOIN monthly_reading mr2
ON mr2.current_count > mr1.current_count
GROUP BY mr1.member_id, mr1.current_count
) diffs ON mr.member_id = diffs.member_id
SET mr.rank_order = ranks.calculated_rank,
mr.next_rank_difference = diffs.next_diff;
데드락이 발생한 결과 정보를 얻기위해 SHOW ENGINE INNODB STATUS를 사용했다.
❓
SHOW ENGINE INNODB STATUS
SHOW ENGINE INNODB STATUS명령은 현재 InnoDB Monitor의 결과를 보여준다.
결과에는 백그라운드로 실행되는 스레드, 세마포어, 가장 최근에 발생한 FK 에러, 그리고 가장 최근에 감지된 Deadlock 등이 포함된다.실행 결과에서 한 개의 블럭은 PK 하나의 레코드에 대한 락 정보를 보여준다.
*** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 65 page no 4 n bits 192 index PRIMARY of table `bombom`.`monthly_reading` trx id 351844299710552 lock mode S Record lock, heap no 56 PHYSICAL RECORD: n_fields 9; compact format; info bits 64 0: len 8; hex 8000000000000002; asc ;; 1: len 6; hex 000000000006; asc kv;; ... 7: len 5; hex 900000000c; asc l\;; 8: len 5; hex 9000000000; asc ;;
*** (1) HOLDS THE LOCK(S):하위에는 해당 트랜잭션이 얻은 모든 lock의 정보가 나타난다.
- 첫 번째 트랜잭션이 S lock을 점유한다. (lock mode S) (
LOCK(S)에 있는 S는 상관 X)- 락은 primary key에 대한 인덱스, 즉 클러스터드 PK 인덱스에 대해 lock을 점유한다.
monthly_reading테이블의 PK는 id이므로, id에 대한 인덱스이다.0: len 8; hex 8000000000000002; asc ;;
- InnoDB는 인덱스의 key 값을 0번 필드에 기록한다.
hex 8000000000000002: 0번 필드 값을 나타내므로 PK 값인 것이다.
- PK 값이 2인 인덱스임을 추측할 수 있다.
- 나머지 1 ~ 8번 까지는 해당 레코드의 다른 필드 데이터들이다.
1) 첫 번째 애플리케이션의 트랜잭션
첫 번째 트랜잭션이 id = 2인 레코드에 대해 S lock을 점유하고 있다.
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 65 page no 4 n bits 192 index PRIMARY of table `bombom`.`monthly_reading` trx id 351844299710552 lock mode S
Record lock, heap no 56 PHYSICAL RECORD: n_fields 9; compact format; info bits 64
0: len 8; hex 8000000000000002; asc ;;
...
첫 번째 트랜잭션은 id = 9인 레코드에 대해 S lock을 대기하고 있다.
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 65 page no 4 n bits 192 index PRIMARY of table `bombom`.`monthly_reading` trx id 351844299710552 lock mode S waiting
Record lock, heap no 57 PHYSICAL RECORD: n_fields 9; compact format; info bits 64
0: len 8; hex 8000000000000009; asc ;;
...
2) 두 번째 애플리케이션의 트랜잭션
두 번째 트랜잭션은 id = 9인 레코드에 대해 X record lock을 점유하고 있다.
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 65 page no 4 n bits 192 index PRIMARY of table `bombom`.`monthly_reading` trx id 158951 lock_mode X locks rec but not gap
Record lock, heap no 57 PHYSICAL RECORD: n_fields 9; compact format; info bits 64
0: len 8; hex 8000000000000009; asc ;;
...
두 번째 트랜잭션은 id = 2인 레코드에 대해 X record lock을 대기하고 있다.
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 65 page no 4 n bits 192 index PRIMARY of table `bombom`.`monthly_reading` trx id 158951 lock_mode X locks rec but not gap waiting
Record lock, heap no 56 PHYSICAL RECORD: n_fields 9; compact format; info bits 64
0: len 8; hex 8000000000000002; asc ;;
...
T1 : S(2) holding → S(9) request
T2 : X(9) holding → X(2) request
서로의 lock을 요청하고 있었기에 deadlock이 발생하고 있었다.
그리고 이 부분이 원인임을 단정지을 수 있던 가장 명확한 근거는 두 개의 트랜잭션 IP가 달랐다.
따라서 2번의 케이스가 해당 데드락 발생의 명확한 원인임을 알 수 있었다.
❓ UPDATE 쿼리에 S lock은 왜 포함되어 있을까?
쿼리에 UPDATE 대상인 테이블
monthly_reading이 조인/서브쿼리에서도 읽기용으로 참조한다.
MySQL의 의도에 따르면, UPDATE 대상 테이블이 조인/서브쿼리에서 동일하게 사용되면 읽기 과정을 위해 S lock을 점유하도록 설계되어있다고 한다.이를 따른다면 쿼리 실행에 따른 lock 점유 순서는 아래와 같을 것이다.
- 첫 번째 JOIN 서브쿼리에 대해 S lock 획득
→monthly_reading의 모든 레코드가 S lock 점유SELECT member_id, RANK() OVER (ORDER BY current_count DESC) AS calculated_rank FROM monthly_reading;
- 두 번째 LEFT JOIN 서브쿼리에 대해 S lock 획득
→monthly_reading에서 ON 조건에 맞는 레코드들이 S lock 점유SELECT DISTINCT mr1.member_id, COALESCE(MIN(mr2.current_count) - mr1.current_count, 0) AS next_diff FROM monthly_reading mr1 LEFT JOIN monthly_reading mr2 ON mr2.current_count > mr1.current_count GROUP BY mr1.member_id, mr1.current_count
- UPDATE 시점에 대상 레코드들에 대해 X lock으로 승격
→monthly_reading의 모든 레코드가 X lock으로 승격UPDATE monthly_reading mr JOIN (sub_query_1) ranks ON mr.member_id = ranks.member_id LEFT JOIN (sub_query_2) diffs ON mr.member_id = diffs.member_id SET mr.rank_order = ranks.calculated_rank, mr.next_rank_difference = diffs.next_diff;
위 Deadlock을 해결하기 위해선 스케줄러를 두 서버 중 하나에서만 실행하도록 만들면 된다.
데이터베이스 레벨의 락을 직접 제어하기보단, 애플리케이션 레벨에서 제어해서 해결하도록 시도했다.
고려해본 방법으로 3가지가 있었다.
1. Spring 환경변수로 스케줄러 실행 활성화 / 비활성화
2. ShedLock 라이브러리 사용
3. Redis 분산락도 조금 고민했지만, Redis 도입 비용 대비 서비스 내 활용력이 떨어질 것 같아 제외했다.
yml 파일 환경변수로 각 서버 별 scheduler.enabled를 분리하고, Config 파일에서 스케줄러 실행 여부를 제어할 수 있다.
# application.yml
scheduler:
enabled: true // false
SchedulerConfig 파일을 생성하고 @ConditionalOnProperty 애노테이션으로 환경변수에 따른 스케줄링 실행을 On/Off 한다.
@ConditionalOnProperty는 bean을 등록할지 여부에 대해 조건을 거는 애노테이션이다.
@Configuration
@EnableScheduling // ServerApplication에서는 애노테이션 제거
@ConditionalOnProperty(
prefix = "scheduler",
name = "enabled",
havingValue = "true",
matchIfMissing = false // scheduler.enabled 미설정 시 false
)
public class SchedulerConfig {
}
@ConditionalOnProperty 조건이 true가 되면 이 SchedulerConfig가 config로 로딩된다.
그럼 @EnableScheduling이 활성화 되고 @Scheduled가 동작한다.
@ConditionalOnProperty 애노테이션을 개별 Scheduler Class에 적용한다.
@Component
@ConditionalOnProperty(
prefix = "scheduler",
name = "enabled",
havingValue = "true",
matchIfMissing = false
)
public class ReadingScheduler {
...
조건이 true면 Scheduler 클래스가 bean으로 등록되므로 스케줄러가 실행된다.
메서드 수준의 제어를 위해선 @ConditionalOnProperty 애노테이션만으로는 해결할 수 없다.
따라서 @Value로 직접 property 값을 가져와서 early return 해야한다.
@Component
public class RankingScheduler {
@Value("${scheduler.enabled:false}")
private boolean isEnabled;
@Scheduled(cron = "0 0 * * * *")
public void updateMonthlyReading() {
if (!isEnabled) {
return; // 해당 스케줄러 비활성화
}
...
}
@Scheduled(cron = "0 */10 * * * *")
public void updateRanking() {
// 두 서버에서 스케줄러 모두 실행
...
}
}
서버 전체 → 클래스 → 메서드 순으로 세밀하게 제어할 수 있다.
만약 도입한다면 1번 서버 전체 스케줄러 On/Off 수준으로 제어해도 괜찮을 것 같다.
우리 서비스는 단일 DB이기 때문이다.
만약 각 서버가 다른 DB를 보고 있었다면, 스케줄러가 두 번 수행되는 것이 의미있을지도 모른다.
하지만 지금은 두 서버가 하나의 DB를 바라보기 때문에 결국 하나의 DB에 중복 연산을 하는 것이다.
어떤 스케줄러들은 서버 각각 실행해야 하고, 어떤 스케줄러는 한 번만 실행 해야할 필요가 없다.
모든 스케줄러가 두 서버 통들어 딱 한 번만 실행하면 된다.
따라서 서버 전체 스케줄러에 대한 On/Off로 제어할 수 있을 것이다.
ShedLock 라이브러리를 사용하면 간편하게 서버 한 곳에서만 스케줄러를 실행하도록 만들 수 있다.
ShedLock은 스케줄링 실행 시 동시에 최대 한 번만 실행되도록 제어하는 라이브러리이다.
하나의 스레드에서 작업이 실행 중이면 잠금이 걸리고, 다른 스레드에서 동일한 작업이 실행되지 않도록 한다.
한 노드에서 이미 실행 중인 작업이 있을 경우, 다른 스레드는 대기하지 않고 건너뛴다.
MongoDB, JDBC, Redis 등 외부 저장소를 사용해서 테이블에 스케줄러의 실행 정보를 저장해야한다.
일종의 분산락 라이브러리로 이해하면 쉬울 것이다.
⚠️ 데이터베이스 레벨에서 직접 데이터 접근 락을 제어하는게 아닌, 애플리케이션 레벨에서 스케줄러 실행 횟수를 제어하는 것이다.
기존의 스케줄링 메서드는 아래와 같다.
@Scheduled(cron = EVERY_TEN_MINUTES_CRON, zone = TIME_ZONE)
public void tenMinutelyCalculateMemberRank() {
log.info("이달의 독서왕 순위 업데이트");
readingService.updateMonthlyRanking();
log.info("이달의 독서왕 순위 업데이트 완료");
}
ShedLock을 적용하기 위해선 ShedLock 라이브러리를 import하고,
Application 클래스에 @EnableSchedulerLock 애노테이션을 붙여준다.
...
@EnableSchedulerLock
@SpringBootApplication
public class BomBomServerApplication {
...
이제 shedlock 전용 테이블이 필요하다.
작업에 대해 락을 잡고 처리하는데 필요한 정보가 shedlock 테이블에 저장되기 때문이다.
shedlock을 생성하는 테이블 스키마는 아래와 같다.
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)
);
name : 작업 이름이다. (PK)lock_until : 이 시간까지 락이 유효하다.locked_at : 락을 획득한 시간이다.locked_by : 락을 가진 인스턴스이다.ShedLock을 얻는데 사용하는 ShedLockProvider bean도 등록한다.
@Configuration
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(dataSource);
}
}
그리고 ShedLock을 적용하려는 스케줄러에 아래처럼 @SchedulerLock을 적용하면 된다.
@Scheduled(cron = EVERY_TEN_MINUTES_CRON, zone = TIME_ZONE)
@SchedulerLock(name = "ten_minutely_calculate_member_rank", lockAtLeastFor = "PT1.5S", lockAtMostFor = "PT3S")
public void tenMinutelyCalculateMemberRank() {
log.info("이달의 독서왕 순위 업데이트");
readingService.updateMonthlyRanking();
log.info("이달의 독서왕 순위 업데이트 완료");
}
name : ShedLock용 테이블에 PK로 들어갈 작업 이름이다.name의 락을 갖는 서버는 딱 한 대이다.lockAtLeastFor : 적어도 락을 유지할 시간을 지정한다.lockAtMostFor : 락을 최대 유지할 시간을 지정한다.@SchedulerLock AOP가 먼저 개입해 LockProvider를 통해 락을 시도한다.lock_until <= now)일 경우에만 업데이트 한다.# pseudo code
UPDATE shedlock
SET lock_until = now + lockAtMostFor,
locked_at = :lockedAt,
locked_by = :lockedBy
WHERE name = :name
AND lock_until <= :now;
lock_until = now + lockAtMostFor인 상태로 작업 수행을 시작한다.now + lockAtMostFor 이전까지는 락 획득에 실패한다. lockAtLeastFor을 고려해 lock_until을 조정한다.lock_until을 업데이트한다.# pseudo code
UPDATE shedlock
SET lock_until = MAX(start_at + lockAtLeastFor, NOW())
WHERE name = :lockName
AND locked_by = :currentInstance;
결론적으로 ShedLock을 선택했다.
단일 DB + 분산 환경 구조에서 효율적으로 사용하기 좋은 라이브러리라고 판단했다.
추가적인 인프라 구축 필요도 없고, 데이터베이스에 shedlock 테이블만 추가하면 된다.
이후로 @SchedulerLock만 붙여주면 스케줄러 실행에 대한 중복 제어는 모두 ShedLock 라이브러리가 해주는 것이다.
만약 Spring 환경 변수를 사용한 스케줄러 활성화를 구현한다고 가정하자.
지금은 2개의 서버 중 한 대만 전체 스케줄러를 실행하면 되므로, 서버 단위로 스케줄러 실행을 제어했다.
하지만 만약 클래스 별, 또는 메서드 별 스케줄러 활성화 여부를 제어 해야할 때가 온다면?
그렇다면 Spring 환경 변수를 통한 제어는 개발자가 직접 실행 조율 과정을 다시 구현해야한다.
Shedlock은 이미 메서드 레벨로 스케줄러 활성화 제어가 가능하다.
@SchedulerLock 애노테이션만 붙이고 떼면 손쉽게 조율할 수 있다.
이렇게 Deadlock 발생 원인에 대해 파악해보고 ShedLock으로 간단하게 해결했다.
Deadlock을 처음 겪어서 해결책이 어려울 것이라고 생각했는데, 해결책보다 원인 파악 과정을 깊게 파는게 더 어려웠다.
공부해보면서 Shedlock은 분산 환경 + 단일 DB 일 때 사용하기 좋은 전략임을 느꼈다.
하지만 규모가 훨씬 큰 서비스에서는 이러한 경우를 어떻게 해결할까?
여러 개의 데이터베이스를 가지게 되면 하나의 DBMS에서만 shedlock을 관리할까?
하지만 대규모 서비스는 애초에 스케줄링이 실행되는 서버를 한 개로만 고정할 것 같다고 추측한다.
참고
https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/show/show-engine-innodb-status
https://bugs.mysql.com/bug.php?id=72005
https://github.com/lukas-krecan/ShedLock
https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/show/show-engine-innodb-status