본문으로 건너뛰기

멀티 스케줄러 환경에서의 데드락 발생과 해결

배포 환경에서 에러가 발생했을 때 바로 디스코드로 오도록 조치해놓았습니다.
그런데 간헐적으로 아래와 같은 메시지가 뜹니다. 위 상황이 왜 발생했고 어떻게 해결할 수 있는지 한번 알아보겠습니다.

위 에러는 CannotAcquireLockException으로 select for update 동안 다른 곳에서 락을 획득하지 못해 에러가 반환되는 것입니다.

img2

그래서 해당 비즈니스 상황에서 언제 발생하는지 알아보겠습니다.

img4
위와 같이 유튜브 스토리라는 서비스를 제공하고 있습니다. 간략히 소개하면 유튜브 채널들을 pubsubhubbub을 이용하여 구독하여 바로 DB에 비디오들이 새로 생길 때마다 들어오도록 하고 있습니다. 유튜브 비디오의 댓글과 좋아요를 동기화하기 위해 최근 비디오는 동기화 주기를 빠르게 하고 점차 시간이 지날수록 동기화를 느리게 하도록 설계하였습니다.
img3
유튜브 영상을 가져오는 동기화 메커니즘이 담긴 @Scheduled 어노테이션 3개를 사용하였습니다. 10분과 1시간 주기는 같은 메서드를 사용하도록 하였고 24시간 주기는 다른 메서드를 사용하도록 설계되어있습니다. 여기서 문제가 2가지가 있었습니다.

  1. 10분 주기 스케줄링과 1시간 주기 스케줄링이 정각에 겹쳐서 데드락 발생 가능성
  2. 24시간 주기 스케줄링도 결국 똑같은 테이블을 다루기 때문에 1번 해결만으로는 데드락이 해결되지 않음

먼저 스케줄러를 여러개 사용하기 위해 스케줄러 스레드 풀을 활용하여 3개 이상 놓았습니다. (다른 곳에서도 사용 중) 이를 하지 않는다면 @Scheduled는 단일 스레드이기에 동시 작업이 작동되지 않고 직렬화 방식으로 작동되기 때문입니다.

Case.1 - 10분 스케줄러와 1시간 스케줄러의 데드락 발생 가능성
#

데드락은 서로 락을 가지고 있는 상태에서 서로의 것을 필요로 할 때 순환고리가 생겨서 발생합니다.
10분 스케줄러와 1시간 스케줄러는 동시에 Video 테이블 로우를 수정하는 경우가 발생합니다. 이때 각자 락을 잡은 상태에서 다음 row를 update하려고 할 때 데드락이 발생할 수 있습니다.

그러면 이러한 데드락 상황을 해결할 수 있는 방안들을 살펴보겠습니다. 일단 이 두 개의 스케줄러를 한 개로 합치는 방안은 제외하겠습니다.

해결책 1 - 정각에 겹치지 않게
#

이 해결책은 간단합니다. 하나의 스케줄러는 정각이 아닌 몇 분 뒤로 미루는 것입니다. 이를 안전하게 하기 위해서는 일정 시간 동안 모니터링 후 가장 오래 걸린 동기화 실행 시간 + 버퍼 시간(1분 정도)로 잡는다면 괜찮을 것 같습니다.

이는 엄밀히 말하면 데드락을 회피하는 거라 현재 로직에 대한 해결책이 아닙니다. 그럼 다음 해결책을 보겠습니다.

해결책 2 - 락 기법 직렬화
#

여러 스케줄러가 동시에 같은 video row 집합을 갱신하려고 할 때 deadlock이 발생할 수 있습니다. 이 경우 스케줄러 레벨에서 하나의 작업만 실행되도록 제한하면, 서로 다른 트랜잭션이 같은 row를 동시에 점유하는 상황 자체를 막을 수 있습니다.

이 방식은 애플리케이션 서버 내부에서 세마포어로 구현할 수도 있고, DB의 named lock을 활용해 구현할 수도 있을 것 같습니다. 다만 이 방식은 본질적으로 작업을 직렬화하는 접근이므로, 기존에 서로 다른 row를 병렬로 처리할 수 있었던 상황까지 함께 제한하게 됩니다.
즉 안정성은 높아지지만 동시성은 떨어지는 trade-off가 존재합니다.

해결책 3 - 처리 순서 고정
#

이는 직렬화 방식이라고 볼 수 있는데요. 데드락은 서로 다른 방향에서 접근할 때 발생한다고 할 수 있습니다. 이를 해결하기 위해 특정 인덱스를 기준으로 정렬을 하면 모든 락이 한 방향으로 향합니다.
데드락이 걸리지 않고 먼저 락을 잡은 트랜잭션이 끝날 때 까지 대기 후 락을 잡습니다.

img6
저는 이 방식으로 해결하고자 하였습니다.

기존 findByPublishedAtAfter 메서드는 정렬 조건이 없었고, published_at 인덱스도 없었습니다. 실제로 EXPLAIN 결과 Table Scan이 발생했고, 당시 기준으로 전체 1428건을 스캔한 뒤 조건에 맞는 row를 필터링하고 있었습니다.

이를 개선하기 위해 우선 처리 순서 고정부터 고민했습니다.
데드락은 같은 row들을 여러 트랜잭션이 서로 다른 순서로 점유할 때 주로 발생하기 때문에, 먼저 모든 작업이 가능한 한 동일한 순서로 video를 갱신하도록 맞추는 것이 중요하다고 판단했습니다.

정렬 기준은 publised_at 다음 두 번째로 video_id를 기준으로 두었습니다.
그 이유는 video_id가 NOT NULL이면서 UNIQUE한 값이기 때문에 모든 row에 대해 일관된 순서를 만들 수 있기 때문입니다. 만약 published_at만으로 정렬하면 동일한 시각의 데이터가 존재할 수 있어 최종 순서가 완전히 고정되지 않을 수 있습니다.
따라서 실제 정렬은 published_at ASC, video_id ASC 형태로 적용하여, published_at으로 시간 흐름을 유지하면서도 video_id를 두 번째 정렬 조건으로 사용해 순서를 확정하도록 했습니다.

그다음으로는 이 정렬과 조회 조건을 어떤 인덱스로 받쳐줄지를 고민했습니다.
기존 쿼리는 published_at을 기준으로 최근 3시간, 최근 24시간 범위를 조회하는 구조였기 때문에, 먼저 WHERE published_at > ? 조건을 효율적으로 처리할 수 있어야 했습니다. 이 때문에 복합 인덱스의 선두 컬럼은 published_at으로 두는 것이 맞다고 판단했습니다.

WHERE published_at > ?
ORDER BY published_at ASC, video_id ASC

이 패턴에서는 published_at으로 범위 탐색을 시작하고, 그 이후 video_id로 정렬 순서를 안정적으로 이어가는 구조가 자연스럽습니다. 그래서 복합 인덱스는 (published_at, video_id) 순서로 적용했습니다.

Case.2 - 24시간 주기 스케줄링으로 인한 데드락 발생 가능성
#

img5
24시간 주기 스케줄러의 메서드는 위의 10분, 1시간짜리와 다른 흐름으로 진행됩니다.
24시간 주기 스케줄러의 역할은 좋아요/댓글 내용 최신화 뿐 아니라 해당 채널의 playlist(영상 목록)을 가져와 제목/썸네일/URL까지 다시 한번 검토합니다. 즉 기존 10분, 1시간 잡은 빠르게 최신 통계만 반영하려는 것이고 이 24시간 잡은 놓친 영상이나 변경된 메타데이터를 검토하며 나중에라도 복구하려는 것입니다.

이로 인해 Case.1과 다른 메서드에서 같은 테이블 로우를 건드리는 데드락 상황이 존재했습니다. 이 부분은 별도의 처리 로직이 필요했습니다. 먼저 새로운 Video 와 기존 Video를 구분하여 처리하고자 하였습니다.

  1. 새로운 Video를 Insert 하는 것은 현재 lock과 관련이 없으니 따로 걱정 안해도 될 것 같습니다.
  2. 기존 Video Update는 Case.1처럼 정렬을 활용하여 데드락을 해결하고자 하였습니다.

그러면 새로운/기존 Video를 구분하는 로직이 필요했습니다.
레거시 코드에서는 for loop로 외부 응답 N건 -> DB 조회 N번로 하고 있었습니다. 이 부분을 in 절을 사용해서 외부 응답 N건 -> DB 조회 1번으로 개선하였습니다.

IN 절에 사용할 외부 응답 N건은 Map에 먼저 저장하였습니다. key는 youtubeVideoId, value는 외부 응답 객체로 두어 이후 DB 데이터와 빠르게 매핑할 수 있도록 하였습니다.

이후 외부 응답의 youtubeVideoId 목록으로 IN 조회를 수행하였고, 반환된 List는 기존 Video로 판단할 수 있었습니다.
기존 Video는 Case.1과 동일하게 published_at ASC, video_id ASC 순서로 update 하여, 가능한 한 동일한 방향으로 lock을 획득하도록 맞추었습니다.

반면 외부 응답에는 존재하지만 IN 조회 결과에 없는 데이터는 신규 Video로 판단하여 별도로 insert 하였습니다. 즉, 외부 응답 전체를 먼저 수집한 뒤 IN 조회를 통해 기존/신규 데이터를 구분하고, 기존 Video만 정렬된 순서로 update 하도록 리팩토링하였습니다.


+) 추가
아직 해결이 제대로 안된 부분이 있었습니다..

img7
하나의 트랜잭션이 최상위 메서드 전체에 걸려 있었습니다. 이로 인해 각 유튜브 채널 내부에서는 정렬을 적용하더라도, 채널 A에서 획득한 여러 row lock이 해제되지 않은 상태로 채널 B 처리까지 이어졌습니다. 즉 채널 내부 순서는 맞췄지만, 전체 트랜잭션 범위가 너무 커서 락이 누적 보유되었고, 역순의 가능성이 남아있어서 다른 스케줄러와 충돌할 가능성이 있었습니다.
img8
여기서 해결 방법은 트랜잭션을 하나의 큰 단위로 묶지 않고 채널별로 분리하는 것이었습니다.
그러면 각 채널에 대해서 정렬된 로우 락이 걸리므로 데드락 상황을 피할 수 있었습니다.


느낀점
#

이번 트러블슈팅을 통해 스케줄러를 쓸 때에도 DB Lock 관점에서 고려가 필요하다는 것을 느꼈습니다. 특히 무심코 좋은게 좋은거라는 생각(여러번 검사하면 최신화도 되고 좋은거겠지)을 경계하며 앞으로 주의해야될 것 같습니다.
마지막으로 이론적으로 배운 인덱스와 정렬을 어떤식으로 사용할 지, 어떻게 더 성능을 올릴 수 있는지 적용할 수 있는 기회가 되었습니다.

읽어주셔서 감사합니다.