더 진한 동시성 제어 - 1

Uicheon·2024년 10월 31일
0

들어가기전

동시성 제어를 위해 어떤 방법을 쓰시나요?

낙관락, 비관락, 분산락 등등등..

해당 글은 동시성 제어 방법들의 장/단점을 알아보기 위해, 실제로 테스트 하는 과정을 담았습니다.

그러기 앞서 기존 구현한 콘서트 예약을 좀 더 깊게 소개해봅니다.

0. 콘서트 예약에 필요한 것

콘서트 예약은 유저좌석이 필요합니다.

유저는 "예약되지 않은 좌석" 을 예약할 수 있습니다.

이때, 동시성 제어를 위해 좌석(Seat)에 낙관락을 겁니다.

    @Transactional
    public MakeReservationResult reserve(MakeReservationCommand command) {
        Long seatId = command.seatId();
        Long userId = command.userId();

        Seat lockedSeat = seatRepository.findWithLockById(seatId).orElseThrow(() ->
                new EntityNotFoundException("해당 좌석은 존재하지 않습니다. seatId = " + seatId)
        );

        User user = userRepository.findById(userId).orElseThrow(() ->
                new EntityNotFoundException("해당 유저는 존재하지 않습니다. userId = " + userId)
        );

        // TODO in new Reservation() -> Events.raise(new SeatReservationEvent) with Transaction
        lockedSeat.reserveSeat();
        Reservation reservation = new Reservation(ReservationStatus.RESERVED, lockedSeat.getId(), user.getId());
        Reservation saved = reservationRepository.save(reservation);
        return new MakeReservationResult(saved.getId());
    }

위 코드에서 확인할 수 있듯이, 예약 은 새로운 데이터 저장하는 것이므로 락을 걸지 않습니다.

다음과 같이 좌석에 낙관락을 걸어 동시성을 제어합니다.

public class Seat {
	...
	@Version
    private Integer version;
}

예약에 성공하면, 예약 정보가 DB에 저장된다.

1. 낙관락 선택 이유

한 좌석에 여러 예약 요청이 몰려들면 단 한 사람만 좌석 예약에 성공해야 합니다.
콘서트 대기자들이 한 번에 몰려들어 좌석 예약 버튼을 누르면, 가장 첫 번째로 성공한 유저는 좌석 예약에 성공합니다.

하지만, 해당 내용이 아직 DB에 반영되기 전에 요청한 이용자는 그 사실을 모르고 "이선좌"가 아닌, 비어있는 좌석이라 판단하여 예약 요청을 보내게 됩니다.

여기서 낙관락이 도와줍니다.
낙관락을 건다면, 트랜잭션이 끝나는 시점의 update 쿼리에 다음과 같이 처음 select해왔었던 version 정보를 확인하면서, version을 변경합니다.

그러므로, 처음 예약에 성공한(version update 쿼리를 성공시킨) 그 이외 사람들은 실패합니다.

실제로 확인해보겠습니다.

다른 유저가 해당 좌석이 점유됐다는 사실을 모른채(트랜잭션이 끝나기 전에) 똑같이 요청을 보내면 어떻게 될까요?

해당 서비스에 2개의 요청을 동시에 보내서 로그를 찍어보면 쓰레드 [pool-1-thread-1][pool-1-thread-2] 둘 모두 새로운 예약 요청 insert, 그리고 좌석 선점 요청 update를 보내고 있습니다.

여기서 두 개의 update쿼리가 나가면, 아래 그림처럼 진행됩니다.

조금 더 늦게 시작한 [pool-thread-2]의 트랜잭션이 ObjectOptimisticLockingFailureException이 발생하며, 롤백됩니다.

언두 로그에 락을 걸 수 없기에 락을 거는 동작은 모두 언두 로그가 아닌 실제 테이블에 적용한다. 이 경우에도 MySQL이 Repeatable Read 격리 수준을 갖고 있어도 update가 되는 것을 확인할 수 있다.

그러므로, 가장 첫번째에 업데이트에 성공한 유저만 좌석 예약 선점이 가능하므로, 낙관적 락을 쓰는게 가장 적당해보였습니다.

특히 가장 가까운 대안인 비관락을 사용하면, 블락 대기 시간이 늘어나 성능에 문제가 있다고 판단했습니다.

만약 낙관락 예외가 발생한다면, 무조건 "좌석 예약 경합에 실패한 유저"라고 판단해서, 재시도 로직도 넣지 않았습니다. (이건 잘못된 가정이어서 수정이 필요하다.)

정말 그럴까요?

낙관락, 비관락 나아가서 Redis를 이용한 분산락까지 성능을 비교해보겠습니다.

2. 환경 설정

비관락과 비교하기 전 환경부터 설정합니다.
요구사항은 두 가집니다.

  1. 메트릭 확인이 가능할 것
  2. 제한된 리소스를 가질 것

그러므로 저렴한 클라우드 인스턴스를 이용하여 테스트합니다.
OCI를 이용하고 테스트 환경은 다음과 같습니다.

  1. VM
  • Shape: VM.Standard.A1.Flex
  • OS: Canonical Ubuntu 24.04
  • Network bandwidth (Gbps): 1
  • vCPU: 1
  • Mem: 6GB
  1. Database (PostgreSQL)
  • Shape: VM.Standard3.Flex
  • vCPU: 2
  • Mem: 32GB(?) -- 최소 단위
  • PostgreSQL version: 14.11
  • Performance tier: 75K IOPS
  • Database system type: Single node
  1. Cache (Redis)
  • node: 1
  • mem: 2GB

망은 아래와 같은 구성입니다. (OCI인점을 제외하면)

3. 성능 비교

선택할 수 있는 성능 지표는 다음과 같습니다.

  • CPU Util
  • Mem Util
  • Disk R/W IO
  • Load
  • (DB) CPU Util

1. 한 유저가 한 좌석에 짧은 시간안에 500+번 요청

테스트 환경은 10초안에 한 유저 아이디를 갖는 유저가 많은 양의 예약 요청을 한 좌석에 보냅니다.
"이미 선택된 좌석입니다"가 많이 발생하여 테스트 결과가 크게 재밌지 않을 수 있습니다.

/**
 * 한_유저가_한_좌석을_동시에_500+번_예약시도한다_그러나_예약한사람은_오직한명이다
 */

import http from 'k6/http';
import {sleep} from "k6"

var BASE_URL = `http://${__ENV.TARGET_HOST}:8080/`

const TARGET_TPS = 300; // 목표 TPS 설정

export let options = {
    scenarios: {
        burst_test: {
            executor: 'constant-vus',
            vus: TARGET_TPS, // 동시에 요청을 보낼 VU 수 설정
            duration: '10s', // 지속 시간 (한번의 집중 burst를 위해 짧은 시간 설정)
        }
    },

    thresholds:
        {
            http_req_duration: ['p(95)<100'],
        }
}

function getUri() {
    return "reservations";
}

export default function () {
    let url = BASE_URL + getUri()

    //set authorization Bearer token
    let headers = {
        'Content-Type': 'application/json',
        "Wait-Token": `1`,
    };

    let body = {
        userId: 1,
        seatId: 1
    }

    http.post(url, JSON.stringify(body), {headers});
    sleep(1);
};

1. 낙관락 결과

테스트는 803개 요청 중 802개가 실패하고, 1개가 성공했다.

  • k6 성능 지표

  • VM 성능 지표

  • DB 성능 지표

  • 예약 1개가 성공된 DB 쿼리 결과

2. 비관락 결과

테스트는 779개의 요청 중 778개가 실패하고, 1개가 성공했습니다.

  • k6 성능 지표
  • VM 성능 지표

  • DB 성능 지표

  • 예약 1개가 성공된 DB 쿼리 결과

결론

  • 낙관락
    • http 요청 대기(http_req_waiting)가 예상보다 시간이 길었습니다.
    • Load(Cpu 대기)때문으로 보입니다다. (2vCPU면 다른 결과일 듯으로 보임)
      현재 테스트로는 큰 차이를 느끼기가 어려운 것 같습니다.
      특히, DB 메트릭은 거의 차의가 없습니다.(DB 성능이 좋기도 합니다.)

2. 한 유저가 여러 좌석에 짧은 시간안에 500+번 요청

1. 낙관락 결과

테스트는 830개 요청 중 680개가 실패하고, 150개가 성공했습니다. (좌석수 150개)

  • k6 성능 지표

  • VM 성능 지표

  • DB 성능 지표

  • 예약 150개가 성공된 DB 쿼리 결과

2. 비관락 결과

테스트는 819개 요청 중 669개가 실패하고, 150개가 성공했습니다. (좌석수 150개)

  • k6 성능 지표

  • VM 성능 지표

  • DB 성능 지표

  • 예약 150개가 성공된 DB 쿼리 결과

결론

(아마도) VM/DB의 성능이 너무 좋아서, 1000개 요청으로는 실질적으로 낙관/비관락 차이를 느끼기가 어려웠습니다.

그래서 이용자수 3만, 좌석 수를 10만개로 바꾸어, 다시 테스트 해봤습니다.

3. 한 유저가 여러 좌석에 짧은 시간(3분)안에 ~=300,000번 요청

const TARGET_TPS = 30_000; // 목표 TPS 설정
const MAX_SEAT_ID = 100_000; // seatId의 최대값 설정

1. 낙관락 결과

테스트는 302,771개 요청 중 288,996개가 실패하고, 13,775개가 성공했습니다. (좌석수 100,00개, 요청하는 좌석은 3만 이하로 중복되는 번호입니다.)

  • k6 성능 지표

  • VM 성능 지표

    • 소리치는 CPU
  • DB 성능 지표

  • 예약 20,022개가 성공된 DB 쿼리 결과

2. 비관락 결과

테스트는 291,894개 요청 중 275,764개가 실패하고, 16,130개가 성공했습니다. (좌석수 100,00개이다, 요청하는 좌석은 3만 이하로 중복되는 번호이다)

  • k6 성능 지표
  • VM 성능 지표

  • DB 성능 지표

  • 예약 19,885개가 성공된 DB 쿼리 결과

결론

  • VM 성능이 너무 낮기에 정확한 결과를 얻을 수 없었습니다.

개인 생각

  • 낙관락은 정상적으로 요청을 마쳤음에도 http 요청 결과의 실패 비율이 왜 더 높을까?
    • (낙) http 성공/실제: 13,775 / 20,222 = 68%
    • (비) http 성공/실제: 16,130 / 19,885 = 81%
  • 비관락은 생각보다 텍스트짧게 잡나..? 라는 추측을 해봤다.
  • (아쉬운점) DB 성능이 너무 좋아서 메트릭을 보고 판단 내리는것이 (나는) 불가능하다. 100만건 데이터도 이렇게 쉽게 적재하는 줄 몰랐다.
  • (테스트 부정합) 테스트는 1~100,000까지의 수를 랜덤으로 돌리는것이 아니라 순차적으로 올라간다. 이 부분에서 실제 예약 패턴과는 달라 테스트의 실질성은 조금 떨어진다.
  • (유량 제어) 실험하며 대기열의 필요성 더 잘 느꼈다.
  • DB는 비싼 자원이다. (하루 안되서 4천원 나옴)

알게된 점

  1. next key락
    Reservation 엔티티 자체에도 생성시 락을 걸 수 있다.
  1. gradle
    OpenAPI Generator 7.6 버전 기준 gradle 7.6 버전 이하 오류, 8.10 버전 오류 발생
    ubuntu 기본 gradle 버전은 4.4이므로 sdk를 이용해야 한다.
  1. redis 접속 prefix
    oci Cache 접속시 접두사 rediss://
profile
컨셉입니다~

0개의 댓글