본문 바로가기

개발 프로젝트/2024 제로베이스 프로젝트

제로베이스 개인프로젝트 진행록5 - 성능개선 리팩터링/멀티스레드

멀티스레드 도입

 

도입검토기준

1. I/O 대기시간이 큰 것으로 예상되고 I/O대기를 병렬적으로 처리할 수 있는 작업은 멀티스레드로 처리한다.

2. Ram 안에 이루어지는 작업의 경우에 특히, Critical Section의 경우 가급적이면 싱글스레드로 처리한다.

 

멀티스레드 도입검토

위와 같은 관점에서, 멀티스레드 도입전 코드는 두가지 파트로 나눌 수 있다. 

1번  : 각 For loop마다  HeritagePoint List를 접근하고 이를 활용하여 외부 API를 사용하여 결과를 받는 파트

2번 : 1번파트에서 받은 값을 사칙연산을 통해서 원하는 값으로 조정하고 비교하여 최종적으로 최대값을 return하는 연산

  2번의 경우 단순한 연산이며 비교를 통해 return값을 결정하는 변수들 Critical Section이 존재하여 multithread보다는 Single Tread가 적합하다. 병렬작업 구간을 최소화하여서 Critical Section을 다투는 사태가 발생하지 않도록 만들자.

 

개선코드

첫번째 파트는 Callable로 정의해서 멀티쓰레딩을하고 두번째 파트는 싱글스레드로 처리하여 경합이 없도록 구현함.

모든 후보지를 for loop로 API 정보를 획득한 후에 greedy하게 다음 경로를 결정할때 동기화처리하여 결과값을 계산할때는 싱글스레드로 전환하여 계산함.

\

public class RouteFindThreadService {

  private final PathFindApi pathFindApi;
  @Qualifier("ExternalApiTaskExecutor")
  private final ThreadPoolTaskExecutor taskExecutor;


  public Future<PathFindApiResultDtos> submitPathFindTask(
      PointCollection routePoints, HeritagePoint nextDestinationCandidate,
      CustomPoint clientPoint) {

    log.info(
        "RouteFindThreadService Submitting task for {} Active Threads: {}, Queue Size: {}",
        nextDestinationCandidate.toString(),
        taskExecutor.getActiveCount(),
        taskExecutor.getThreadPoolExecutor().getQueue());

    return taskExecutor.submit(

        new Callable<>() {

          @Override
          public PathFindApiResultDtos call()  {

            // Route의 마지막점 ~ Candidate 사이의 Path 정보
            PathFindApiResultDto pathToHeritageCandidate = pathFindApi.getPathInfoBetweenPoints(
                routePoints.getPoints().getLast(), nextDestinationCandidate);
            // Candidate ~ Client Location 사이의 Path 정보
            PathFindApiResultDto pathToReturn = pathFindApi.getPathInfoBetweenPoints(
                nextDestinationCandidate, clientPoint);

            return PathFindApiResultDtos
                .builder()
                .nextDestinationCandidate(nextDestinationCandidate)
                .pathToHeritageCandidate(pathToHeritageCandidate)
                .pathToReturn(pathToReturn)
                .build();

          }
        });

  }

 

Init Data기능 멀티스레드 도입

  InitData의 경우 작업을 세부작업으로 나누어보면 1) 외부에서 API를 호출하는 작업 2) 호출된 작업을 저장하는 작업 3) 호출된 데이터가 비어있는지 확인하는 작업

 

 

설계방식

외부 API를 통해서 Save하는 방식이기때문에 return한 결과값으로 어떤 동작을 하는 것이 아니기때문에 비동기적으로 처리할 수 있다. 다만, 외부 API 데이터가 페이지의 끝에 도달하여 값이 비어있으면 null을 return하도록 되어있기때문에 10page를 비동기적으로 처리하고 future.get()을 통해서 결과값이 null인지 while문 중간에 확인을 한다. 

 

트러블 슈팅 (2) - DeadLock발생

 

(1) 외부API 내 중복레코드 존재하여 DuplicateError발생 : InsertIgnore() 사용

 

InitiateHeritageData()는 외부 API로부터 데이터를 불러와 Entity로 만들고 Repository에 저장하는 방식으로 동작한다. 호출은 API 내 페이지 단위로 동작하며  문제는 API 구조적 문제로 API 내에서 다수의 중복 레코드가 존재한다. 때문에 SaveAll()을 사용하면 SQL Duplicate Error로 저장자체가 취소가되어 InsertIgnore을 사용하여 한꺼번에 저장하여 문제를 해결하였다. 

 

(2)  InsertIgnore()로 멀티스레드 적용시 DeadLock 발생 

 

 

문제상황 확인

 

Java Spring 로그

 

MySQL 로그

 

Latest Detected Deadlock

Transaction 1 (249434)

  • State: Active (0 seconds) inserting
  • Query:
  •  
    INSERT IGNORE INTO heritage_entity (heritage_Id, heritage_Name, location, heritage_Grade) VALUES ('3413706990000', '구미 죽장리 이정표석', x'e6100000010100000000000000000000000000000000000000', '문화유산자료');
  •  
  • Locks Held:
    • Table: heritage_entity
    • Space ID: 87, Page Number: 276
    • Index: PRIMARY
    • Lock Mode: X (Exclusive)
  • Locks Requested:
    • Same Table (heritage_entity), Space ID: 87, Page Number: 276
    • Lock Mode: X (Insert Intention)

Transaction 2 (249435)

  • State: Active (0 seconds) inserting
  • Query:
     
    INSERT IGNORE INTO heritage_entity (heritage_Id, heritage_Name, location, heritage_Grade) VALUES ('4413501860000', '김제 구 백구금융조합', x'e61000000101000000b8ee55806cbc5f40592d5d8e80f04140', '국가등록문화유산');
  • Locks Held:
    • Table: heritage_entity
    • Space ID: 87, Page Number: 276
    • Index: PRIMARY
    • Lock Mode: X (Exclusive)
  • Locks Requested:
    • Same Table (heritage_entity), Space ID: 87, Page Number: 276
    • Lock Mode: X (Insert Intention)

 

  위 로그를 토대로 해석해보자. 우선 MySQL Lock은 레코드 레벨로 동작한다. 위의 로그에서 페이지에서 Lock 문제가 발생하였다는 것은 단순히 Lock의 위치를 Page 단위로만 표현하기 때문이다. 이러한 오해를 제외하고 해석해보면 두 트랜잭션이 InsertIntention(GapLock의 일종)을 획득하는  과정에서 서로 갖고있는 X-Lock에 대해서 대기가 발생하였다는 것이다.

  GapLock은 GapLock과만 충돌할 수 있으며 InsertIntention Lock은 다른 트랜잭션과 공존할 수 있는 GapLock이기때문에 NextKeyLock과 충돌했다는 결론이 나온다. NextKeyLock은 Insert Intention Lock 다음으로 오는 GapLock인데 이는 트랜잭션 내내 유지된다. InsertIgnore이든 Insert On Duplicate Update이든 NextKeyLock은 트랜잭션 내내 유지 되기때문에 트랜잭션의 길이가 길어지면 어쩔수 없이 데드락이 발생하게 되는 것이다. 

  InsertIntentionLock 충돌이 발생하지 않게 하기 위해서는 트랜잭션 길이를 줄이거나 격리수준을 낮춰서 NextKeyLock 사용을 없애면 된다.

 

  다만, Repository에 데이터가 존재하는 경우에 InitiateHeritageData()를 동작시키면 데드락이 현재 발생하는데, 이 원인은 InsertIgnore의 동작에 기인한다. Insert는 우선 PrimaryKey/UniqueKey 중복 여부를 체크하기 위해서 Locking Read를 실행하게 되는데 이때 NextKeyLock이 작동하게 된다. 이때 Duplicate Error가 발생하면 GapLock과 Record Lock이 그대로 유지된 채로 Error로 인해 Skip이 발생하게 된다. 

  이때 남아있는 NextKeyLock (X-lock)은 트랜잭션이 끝날때까지 유지되게 되고 이는 경쟁상황을 심화시킨다. 그리고 다른 트랜잭션도 유사하게 NextKeyLock을 걸고 있고 두 트랜잭션이 잠겨있는 Range에 InsertIntention Lock을 걸려고 서로의 NextKeyLock이 풀리기를 대기하면 Deadlock이 걸리게 된다. 

 

 

 

해결방안

 

(1) : NextKeyLock이 동작하지 않도록 Repeatable Read에서 ReadCommitted로 격리수준을 낮춘다.

NextKeyLock은 결국 팬텀리드를 방지하기 위한 것인데, 해당 InitHeirtage에서는 단순한 데이터 삽입이기때문에 팬텀리드로 인해 예상되는 문제는 없다. 

(2) : 페이지 단위로 트랜잭션하던 것을 처리하지 않는다

(3) : InsertDuplicate은 Error 발생시 Skip하지 않고 후속처리를 행하기 때문에 Lock이 트랜잭션 내내 걸려있는 경우는 없다. 

 

 

 

참고할만한 자료

왜 Insert문만으로 deadlock이 걸리는가?

https://blog.naver.com/seuis398/220313514110