Posts 동시성 문제 해결 방안 성능 실험
Post
Cancel

동시성 문제 해결 방안 성능 실험

동시성 문제란?

  • 여러 프로세스나 스레드가 동시에 실행되는 상황에서 자원(데이터 등)을
    제대로 공유하거나 수정하지 못해 발생하는 문제

구체적인 상황

  • id, numLikes 필드로 이뤄진 POST 테이블이 있다.
  • likePost 함수는 postId를 인자로 받고,
    post을 조회한 뒤 기존 numLikes를 읽고 그 값에 +1을 한 값을 저장한다.
  • 이때 두 트랜잭션이 동시에 likePost 함수를 호출하면
    둘 중 하나의 트랜잭션은 덮어쓰기 되어 갱신이 소실된다.

해결 방법

  • 비관적 락(pessimistic lock)
    • DB에서 대상이 되는 POST.id 행을 배타적 잠금한다.
    • 락을 확보한 트랜잭션이 작업을 완료할때까지
      다른 트랜잭션들은 대기한다.
    • 대기가 길어서 트랜잭션 타임아웃 시간을 초과하면
      트랜잭션은 실패하고 롤백 처리된다.
    • 배타적 잠금이기 때문에 조회도 대기하게 된다.
  • 낙천적 락(optimistic lock)
    • version 필드를 두어서 이를 애플리케이션에서 처리하여
      동시성 문제를 해결한다.
    • likePost 함수의 경우 어떻게 동작하는지 설명한다.
    • 조회 단계
      • SELECT id, numLikes, version FROM POST WHERE id = 1
        실행한 결과 numLikes = 1, version = 1을 받는다.
    • 수정 작업
      • numLikes를 2로 업데이트 한다.
        version도 2로 업데이트 한다.
        이때 where 절에 version = 1을 추가한다.
      • UPDATE POST SET numLikes = 2, version = 2 WHERE id = 1 AND version = 1
    • Optimistic Locking 동작 (버전 비교)
      • 정상적인 경우
        • version = 1로 읽어온 객체가 수정되어 저장될때,
          version = 1UPDATE 쿼리의 조건에 포함되므로,
          version = 1인 데이터가 존재하면 UPDATE
          정상적으로 실행된다.
      • 경쟁 상태
        • 만약 첫 번째 스레드가 version = 1로 numLikes를 읽은 후,
          두 번째 스레드에서 numLikes를 수정하여 version = 2로 갱신했다면,
          첫 번째 스레드는 더 이상 version = 1을 가진 데이터를
          수정할 수 없다.
        • 이 경우, UPDATE 쿼리가 영향을 미치지 않게 되며
          애플리케이션(예를 들면 스프링)에서
          예외(OptimisticLockingFailureException)를 발생시킨다.
  • 단일 스레드, 비동기 배치 처리
    • 동시성 문제는 여러 프로세스나 스레드에서 동시에
      특정 자원을 점유할때 발생한다.
    • 단일스레드만 사용하여 자원을 점유하게 하면
      동시성 문제를 막을 수 있다.
    • 배치 작업을 위하여 요청과 처리를 분리한다.
    • 요청
      • 레디스와 같은 단일스레드 메세지 큐를 두고
        요청은 레디스 메세지 큐에 쌓는다.
    • 처리
      • 메세지 큐에서 꺼내어 작업뭉치를 꺼낸뒤
        비동기로 배치 작업을 처리하는 프로그램을 단일 스레드로 운영한다.
      • 예를들면 큐에서 3개의 작업을 꺼낸다.
        • likePost(postId = 1)
        • likePost(postId = 2)
        • likePost(postId = 3)
      • autoCommit = false로 전환한뒤
        3개의 작업을 진행한다.
      • 문제가 없다면 commit하고 문제가 있으면 롤백한다.
        이때 롤백이 되면 3개 작업 모두 롤백된다.

실험 환경

  • 웹 프레임워크: 스프링부트, 코틀린
  • DB: 마리아디비
  • 메세지 큐: 레디스 리스트
  • 테스트 툴: k6

테스트 환경

  • k6 설정
    • 가상사용자 수(vus): 100개
    • 총 요청 수 (iterations): 3000개 또는 6000개
    • 시간 (duration): 5분
  • k6 설정 설명
    • 100 명의 유저가 3000개 요청을 5분 동안 동시에 진행한다.
    • 5분이 되기 전에 총 요청 수가 3000에 도달하면 중지한다.
  • pessimistic lock 설정
    • timeout: 5초
    • 격리수준(isolation level): REPEATABLE_READ
  • optimistic lock 설정
    • timeout: 20초
    • 격리수준: REPEATABLE_READ
    • OptimisticLockingFailureException 발생 시 최대 허용 재시도 수: 30
    • OptimisticLockingFailureException 발생 시 대기시간: 0.1초
  • 단일 스레드, 비동기 배치 처리 설정
    • DB timeout: 5초
    • DB 격리수준: REPEATABLE_READ
    • 비동기 배치 함수 배치 크기: 20개
    • 비동기 배치 함수가 담긴 스프링 스케쥴러 fixedDelay: 0.1초
      • fixedDelay는 이전 작업이 완료된 후 재시도 하기 전까지 대기 시간

테스트 시나리오

  • 1번 시나리오
    • 단순 문자열만 리턴하는 컨트롤러 조회
    • 실험의 대조군으로 사용하기 위해 조회
    • naive 요청 3000번 조회
  • 2번 시나리오
    • 한 요청에서 ‘likePost(postId = someId)’만 실행
      즉, 한 트랜잭션에서 조회 및 갱신 요청만 실행
    • 요청 3000번 실행
  • 3번 시나리오
    • 한 요청에서 ‘모든 post 목록 조회’와
      ‘likePost(postId = someId)’를 묶어서 실행
      즉, 한 트랜잭션에서 조회 한 번, 한 트랜잭션에서 조회 및 갱신 요청 한 번
    • 모든 post 목록 조회 3000번,
      likePost(postId = someId) 3000번 실행
  • 4번 시나리오
    • 한 요청에서 ‘모든 post 목록 조회’ 또는 ‘likePost(postId = someId)’를 실행
      이때 ‘모든 post 목록 조회’를 전체 요청 수의 90%를 진행하고 ‘likePost(postId = someId)’는 전체 요청 수의 10%만 진행
    • 모든 post 목록 조회 5400번,
      likePost(postId = someId) 600번 실행

결과

  • 1번 시나리오 - naive 요청
    • 총 요청 수: 3000
    • 총 실패 수: 0
    • 테스트 완료 시간(ms): 3363.41
  • 2번 시나리오 - likePost 만 진행
    • pessimistic lock
      • 총 요청 수: 3000
      • 총 실패 수: 0
      • 테스트 완료 시간(ms): 20111.89
    • optimistic lock
      • 총 요청 수: 3000
      • 총 실패 수: 0
      • 테스트 완료 시간(ms): 31873.77
    • 단일 스레드, 비동기 배치 처리
      • 총 요청 수: 3000
      • 총 실패 수: 0
      • 테스트 완료 시간(ms): 17954.14
  • 3번 시나리오 - 모든 post 조회와 likePost 1대1 처리
    • pessimistic lock
      • 총 요청 수: 6000
      • 총 실패 수: 0
      • 테스트 완료 시간(ms): 27453.60
    • optimistic lock
      • 총 요청 수: 6000
      • 총 실패 수: 0
      • 테스트 완료 시간(ms): 37709.28
    • 단일 스레드, 비동기 배치 처리
      • 총 요청 수: 6000
      • 총 실패 수: 0
      • 테스트 완료 시간(ms): 23220.97
  • 4번 시나리오 - 모든 post 조회와 likePost 1대9 처리
    • pessimistic lock
      • 총 요청 수: 6000
      • 총 실패 수: 0
      • 테스트 완료 시간(ms): 16909.48
    • optimistic lock
      • 총 요청 수: 6000
      • 총 실패 수: 0
      • 테스트 완료 시간(ms): 24357.44
    • 단일 스레드, 비동기 배치 처리
      • 총 요청 수: 6000
      • 총 실패 수: 0
      • 테스트 완료 시간(ms): 20432.77

의의

  • 모든 경우에서 optimistic lock 보다 pessimistic lock의 처리속도가 더 빨랐다.
  • optimistic lock의 경우, 실패 후 재시도 시간을 너무 길게 잡으면
    처리 속도가 엄청나게 느려지며, 재시도 시간이 너무 긴 경우
    DB 타임아웃이 발생하여 트랜잭션이 실패한다.
  • 읽기 비중이 높은 환경에서는 모든 케이스에서 처리시간이 단축되었으며,
    특히 Optimistic lock은 Pessimistic lock보다 더 큰 성능 개선 효과를 보였다.
  • pessimistic lock과 optimistic lock 모두에서
    시나리오 3번(쓰기:읽기 = 1:1)보다 4번(쓰기:읽기=1:9)이 테스트 종료 시간이 더 짧았다.
    또한 시나리오 3과 4 간의 성능 향상 폭은 Optimistic lock에서 더 크게 나타났다.
  • 단일 스레드, 비동기 배치 처리는 모든 시나리오에서 상대적으로 일정한 처리 속도를 보인다.
  • 단일 스레드, 비동기 배치 처리는 처리가 비동기로 이뤄지기 때문에
    테스트가 종료된 후 일정 시간이 지나야 처리가 완료된다.
  • 단일 스레드, 비동기 배치 처리는 스케쥴러 대기 시간을 길게 설정하거나 배치 크기를 줄일 경우, 처리가 완료되는 시간이 엄청나게 늘어난다.

결론

  • 여러 스레드에서 동시에 WRITE가 발생할 상황이 조금이라도 생긴다면
    pessimistic lock을 쓰는게 더 유리한 것으로 보인다.
  • optimistic lock의 경우 timeout 시간과 재시도 대기 시간을 잘 설정해야만
    성능이 좋아진다.
  • 예를 들어 현재 테스트 시나리오에서 optimistic lock의 timeout을
    5로 했을때보다 20으로 했을때가 처리시간이 더 짧다.
    아마도 timeout 시간이 짧으면 재시도 대기를 하는 요청도
    늘어나기 때문에 전반적인 처리 시간이 늘어나는 것으로 추정한다.
  • 3번 시나리오에 비해 4번 시나리오의 처리 속도는
    optimistic lock이 pessimistic lock 보다 더 많이 향상되었다.
    이를 통해 충돌이 거의 없는 환경에서는 optimistic lock이
    pessimistic lock보다 더 처리속도가 빠를 것으로 추정한다.
  • 단일 스레드, 비동기 배치 처리 는 다른 방법에 비해
    상대적으로 균일한 처리 속도를 보여준다.
    하지만 비동기이기 때문에 실시간으로 조회가 이뤄져야 하는 경우
    사용하기 어렵다.
    비동기 스케줄러 시간을 줄이면 전반적인 처리 속도는 상승한다.

예시 github repository

This post is licensed under CC BY 4.0 by the author.

블루 - 그린 배포 실험

인증방식 고민