동시성 제어를 위해 어떤 방법을 쓰시나요?
낙관락
, 비관락
, 분산락
등등등..
해당 글은 동시성 제어 방법들의 장/단점을 알아보기 위해, 실제로 테스트 하는 과정을 담았습니다.
그러기 앞서 기존 구현한 콘서트 예약을 좀 더 깊게 소개해봅니다.
콘서트 예약은 유저와 좌석이 필요합니다.
유저는 "예약되지 않은 좌석" 을 예약할 수 있습니다.
이때, 동시성 제어를 위해 좌석(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에 저장된다.
한 좌석에 여러 예약 요청이 몰려들면 단 한 사람만 좌석 예약에 성공해야 합니다.
콘서트 대기자들이 한 번에 몰려들어 좌석 예약
버튼을 누르면, 가장 첫 번째로 성공한 유저는 좌석 예약에 성공합니다.
하지만, 해당 내용이 아직 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를 이용한 분산락까지 성능을 비교해보겠습니다.
비관락과 비교하기 전 환경부터 설정합니다.
요구사항은 두 가집니다.
그러므로 저렴한 클라우드 인스턴스를 이용하여 테스트합니다.
OCI를 이용하고 테스트 환경은 다음과 같습니다.
망은 아래와 같은 구성입니다. (OCI인점을 제외하면)
선택할 수 있는 성능 지표는 다음과 같습니다.
테스트 환경은 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);
};
테스트는 803개 요청 중 802개가 실패하고, 1개가 성공했다.
k6 성능 지표
VM 성능 지표
DB 성능 지표
테스트는 779개의 요청 중 778개가 실패하고, 1개가 성공했습니다.
테스트는 830개 요청 중 680개가 실패하고, 150개가 성공했습니다. (좌석수 150개)
k6 성능 지표
VM 성능 지표
DB 성능 지표
예약 150개가 성공된 DB 쿼리 결과
테스트는 819개 요청 중 669개가 실패하고, 150개가 성공했습니다. (좌석수 150개)
k6 성능 지표
VM 성능 지표
DB 성능 지표
예약 150개가 성공된 DB 쿼리 결과
(아마도) VM/DB의 성능이 너무 좋아서, 1000개 요청으로는 실질적으로 낙관/비관락 차이를 느끼기가 어려웠습니다.
그래서 이용자수 3만, 좌석 수를 10만개로 바꾸어, 다시 테스트 해봤습니다.
const TARGET_TPS = 30_000; // 목표 TPS 설정
const MAX_SEAT_ID = 100_000; // seatId의 최대값 설정
테스트는 302,771개 요청 중 288,996개가 실패하고, 13,775개가 성공했습니다. (좌석수 100,00개, 요청하는 좌석은 3만 이하로 중복되는 번호입니다.)
k6 성능 지표
VM 성능 지표
DB 성능 지표
테스트는 291,894개 요청 중 275,764개가 실패하고, 16,130개가 성공했습니다. (좌석수 100,00개이다, 요청하는 좌석은 3만 이하로 중복되는 번호이다)
VM 성능 지표
DB 성능 지표
- next key락
Reservation 엔티티 자체에도 생성시 락을 걸 수 있다.
- gradle
OpenAPI Generator 7.6 버전 기준 gradle 7.6 버전 이하 오류, 8.10 버전 오류 발생
ubuntu 기본 gradle 버전은 4.4이므로 sdk를 이용해야 한다.
- redis 접속 prefix
oci Cache 접속시 접두사 rediss://