본문으로 건너뛰기
pjh-climb-dev

MySQL Skip Locked로 데이터를 멋있게 다루기

개요

가끔 스케줄러를 실행하는데, 너무 성능이 느려 인스턴스를 여러 대 늘릴 수도 있다. 예를 들어, 쿠폰 발급이나 알림을 전송하는 속도가 너무 느리다는 것이 그 이유가 될 수 있다. 인스턴스가 1대에서 N대로 늘었기 때문에 처리 속도는 빨라질 수 있지만, 데이터베이스 자원에 접근하는 프로세스도 여러 개가 되기 때문에, 같은 자원을 중복 처리한다던지, 아니면 락 점유 시간이 길어져 데드락이 발생할 수도 있다.

내가 프로젝트로 진행한 와글와글(WaggleWaggle)은 실시간 인구 혼잡도 API를 통해 혼잡도 정보를 가져오고, 데이터를 가공하여 DB에 저장한다. 약 5분 간격으로 혼잡도 정보를 갱신한다.

여기서, 인스턴스 수를 늘렸을 때 MySQL에서 제공하는 Skip Locked로 중복 처리(동시성) 문제와, 데드락 발생을 없애보도록 한다.


1. 반드시 Skip Locked이어야만 할까?

아니다. 다른 방법도 많다.
아래처럼 위의 문제를 해결하는 방법은 여러가지가 있다.

  • Message Queue
    아키텍처적으로 가장 깔끔하다. Producer가 이벤트를 발행하여 큐에 넣고, Consumer가 큐에서 하나씩 꺼내와서 처리한다. 하지만, 서비스에서 사용중인 인프라가 아니라면 관리해야 할 포인트가 늘어나는 것이 단점이다.
  • Modulo 연산
    락을 아예 사용하지 않고, 각 프로세스가 처리할 구역을 미리 정한다. id를 기준으로 나누는 것이 일반적이지만 서버가 늘어나면 코드를 수정해야하는 유연성이 부족하고, 서버 A가 죽으면 A가 처리할 데이터는 아무도 처리하지 않게 된다.
    • 서버 A : id % 3 == 0 인 것만 처리
    • 서버 B : id % 3 == 1 인 것만 처리
    • 서버 C : id % 3 == 2 인 것만 처리

그렇다고 Skip Locked가 무조건 좋은 건 아니라, 모두 전략이기 때문에 상황에 맞게 사용하면 좋을 것 같다.


2. 테이블 정의

와글와글 프로젝트는 인구 혼잡도를 실시간으로 보여줘야하기 때문에, 지역별 인구 혼잡도를 DB에 저장할 수 있도록 한다. 아래와 같이 정의했다.

테이블 정의.png

  • Place: 데이터를 수집할 주요 장소 테이블
  • Population: 인구 혼잡도 데이터를 관리하는 테이블. Place 테이블과 1:1 매핑되어 있다.
  • JobLog: 로그를 저장하는 테이블

Place를 기준으로 혼잡도 데이터를 수집하고, Population 테이블에 값을 갱신시킨다. 만약, 존재하지 않는 데이터라면 Insert 한다. (Upsert 사용)

갱신이 완료됐다면, Place의 processedDate를 갱신시켜준다.


3. 테스트 방식 및 기대 효과

우선, 전체 흐름은 다음과 같다. PM2를 사용해서 스케줄러를 Fork 모드로 4개 띄운다.

전체 흐름.png

여기서 중요한 부분은 DB에서 처리되지 않는 데이터를 Select할 때, Skip Locked를 적용하냐 안하냐의 차이를 확인한다.
적용하고 안하고의 차이를 확인할 때 다음과 같은 결과를 예상할 수 있다.

  • Deadlock이 발생하지 않는다.
    같은 자원에 대해 Lock을 차지하려고 시도하지 않기 때문이다.
  • Job Log에는 각 Worker가 데이터를 하나씩 처리했다는 것을 알 수 있다. (동시성 발생 ❌)
    이미 Lock이 걸린 Row를 넘겼기 때문이다.
  • 처리량이 눈에 띄게 높아진 것을 확인할 수 있다.

참고로 테스트 데이터의 갯수는 120개로 매우 적다.
로직이 거대하지도 않아 Lock 점유 시간이 매우 작을 수 있기 때문에,
확실한 변화를 확인하기 위해서 300ms 지연을 두었다.


4. Skip Locked를 설정하지 않고 실행했을 경우

테스트를 위한 실행코드는 아래와 같다. (실제 로직은 이것과는 다르다.)

async run() {
  while (true) {
    // 매 루프마다 새로운 트랜잭션 시작
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // 처리할 데이터 1개를 가져옴
      const place = await queryRunner.manager.getRepository(Place)
        .createQueryBuilder('place')
        .where('place.processedDate IS NULL')
        .take(1)
        .getOne();

      // 더 이상 처리할 데이터가 없으면 종료
      if (!place) {
        await queryRunner.commitTransaction();
        break;
      }

      // 비즈니스 로직 수행 (API 호출 등 시간이 걸리는 작업)
      await this.fetchAndSavePopulationData(place, queryRunner.manager);

      // 처리 완료 표시
      place.processedDate = new Date();
      await queryRunner.manager.save(place);

      // 커밋 (이때 락이 해제됨)
      await queryRunner.commitTransaction();

    } catch (e) {
      await queryRunner.rollbackTransaction();
      this.loggerService.error(`Failed to process ${place?.idx}: ${e.message}`);
    } finally {
      await queryRunner.release();
    }
  }
}

place를 조회할 때, 아무런 설정 없이 처리할 데이터 1건을 가져온다.

PM2 Fork 모드로 스케줄러 4대를 띄운다.

pm2 fork 4대 띄운거.png


4-1. 결과

4개의 스케줄러가 시작되고, 데이터를 처리하던 도중 다음과 같은 에러가 발생했다.

img.png

img_1.png

107번 데이터인 ‘여의도한강공원’의 혼잡도 데이터를 처리하다가, Deadlock이 발생한 것이다. 왼쪽의 process id를 보면 0~3번의 스케줄러 모두가 Deadlock 에러가 발생했고, ‘update failed’ 에러가 발생한 것을 알 수 있다.

조금 더 로그를 추적해보면, 0, 3 스케줄러가 해당 데이터를 처리했다고 로그가 남겨져있다. 하지만, 스케줄러 2개가 데이터를 중복 처리했음을 알 수 있다.

img_2.png

아래 Job Log의 결과를 보면 107번 ‘여의도한강공원’ 뿐만 아니라, 데이터를 성공적으로 처리했다는 로그가 2개씩 남겨져있다.

img.png

실행 시간을 확인하려고 하니, 스케줄러 1, 2의 로그는 찾을 수 없었고 0, 3 스케줄러만 남겨져있었다. 심지어, 처리 시간이 약 45s가 넘는다.

img.png


4-2. 정리

정리하면 다음과 같은 현상이 발생했다.

  • 모든 스케줄러에서 Deadlock 에러가 발생했다.
  • 데이터를 중복 처리했다. (동시성 문제 발생)
  • 실행 시간이 길다. 심지어 실행 로그도 제대로 남지 않은 것을 보아, 스케줄러가 비정상적으로 종료되었음을 알 수 있다.

5. Skip Locked를 설정하고 실행했을 경우

로직은 단순하다. 아래와 같이 Place를 Select하는 부분에서 Skip Locked를 설정하면 된다.

const place = await queryRunner.manager.getRepository(Place)
  .createQueryBuilder('place')
  .setLock('pessimistic_write') // 배타 락(Write Lock) 설정
  .setOnLocked('skip_locked')   // 잠긴 행은 대기하지 않고 건너뜀
  .where('place.processedDate IS NULL')
  .take(1)
  .getOne();

위와 같은 방식으로 똑같이 스케줄러 4대를 띄우고 결과를 확인해본다.


5-1. 결과

4개의 스케줄러가 실행되고, 로그를 지켜본 결과 실패 에러가 발생하지 않았다. 혹시나해서 Job Log를 확인해보니, 데이터를 성공적으로 처리했다는 로그가 모두 1개씩 찍혀있었다.

img.png

실행 시간도 상당히 단축되었다. 모든 스케줄러가 20s 이내로 단축됨을 알 수 있었다.

img_1.png


5-2. 정리

정리하면 다음과 같다.

  • Deadlock 에러가 발생하지 않았다.
  • 데이터를 중복 처리하지 않고, 각 스케줄러가 1개의 데이터를 처리했다.
  • 실행 시간이 45s에서 20s 이내로 상당히 단축되었다.

6. 결론

Deadlock 발생중복 처리실행 시간
Skip Locked 미적용약 45s (심지어 스케줄러가 비정상적으로 종료되어 모든 로그가 남지 않음)
Skip Locked 적용약 20s
  • 여러 스케줄러가 자원에 접근해서 처리해야하는 상황이라면, Skip Locked가 매우 효과적이다.
  • Message Queue와 같이 복잡한 인프라 없이 Queue의 역할을 하게 한다.

6-1. 개인적인 생각

Skip Locked는 기술적으로 봤을 때, 상태 값(Pending -> Processing -> Done)이 있을 때 가장 강력하게 동작하는 것 같다. 예를 들면 쿠폰 발행이라던지, 아니면 좌석 예매 등의 데이터 처리일 것이다. 그 이유는 데이터의 생명주기와 관련있을 것 같다. 지금 위의 예시는 일회성 처리가 아니라 주기적으로 갱신해야하는 성격을 가지고 있다. 일단 예제를 구현하며 느꼈던 것은

  • 정확한 값이 없으니 시간 계산을 해야함
  • 처리된 시간을 계속 갱신할 때, 어떤 기준으로 갱신해야할지 불편함
  • 특히, 시간으로만 확인했을때 처리된 데이터인지 직관적으로 구별이 안됨

주기적으로 갱신해주기 위해 위의 고민을 불필요하게 해야하는 것이다. 외부 스케줄러가 갱신할 대상을 선별하고, 그 대상을 처리하는 프로세스가 있으면 좋을 것 같다는 생각을 했다. Producer/Consumer 구조가 이 문제를 해결하기에 좋은 기술이고, Producer가 Message Queue에 정보를 갱신할 대상을 넣어주고 Consumer가 하나씩 처리하는 구조를 생각했다.

그래서 다음에는 Redis Streams로 MQ 아키텍처를 도입하는 글을 작성하고자 한다.


Reference