좌석예매를 통한 동시성 문제 알아보기

Hyunho·2023년 7월 11일
0

공연 좌석 예약 서비스프로젝트를 진행하면서 동시성 문제를 해결 해야했습니다.
동시성 문제를 해결하기 위한 여러 방법중 가정 먼저 RDB(mysql)의 LOCK에 대해 정리 하였습니다.

  • 좌석 중복 선택 방지 정책 (동시성 제어)
    • 공연 등록 시점
      • 공연에 예매할 수 있는 좌석들을 Seat 테이블에 먼저 등록
    • 좌석 선택 시점
      • 1안) DB Lock (for update) 활용 - 비관적 동시성 제어
        • 한 사용자가 데이터를 읽는 시점에 Lock을 걸고, 조회/갱신 처리가 완료될때까지 유지
      • 2안) RedisLock 활용
        • key를 가지고 Redis에 등록하면서 Lock을 건다.
        • 다른 트랜잭션에서는 해당 key가 Lock이 걸려있으면 Exception
        • Lock을 건 트랜잭션에서 동작이 끝나면 Lock 해제
        • 이후에는 해당 좌석 정보가 이미 예약되었는지를 확인

좌석 예매(동시성 문제 발생 코드)

@Transactional
public void createReservation(Long performanceId, ReservationCreateValue requestValue) {
    Performance performance = performanceRepository.findById(performanceId)
            .orElseThrow(PerformanceNotFoundException::new);

    Seat seat = seatRepository.findByPerformanceIdAndLocationAndNumber(
            performanceId, requestValue.seatLocation(), requestValue.seatNumber()
    ).orElseThrow(SeatNotFoundException::new);

    if (seat.isReserved()) {
        throw new AlreadyReservedSeatException();
    }

    User user = userRepository.findById(requestValue.userId())
            .orElseThrow(UserNotFoundException::new);

    Reservation newReservation = reservationRepository.save(
            new Reservation(
                    user,
                    performance,
                    seat,
                    LocalDateTime.now()
            )
    );
    seat.reserve();
}

예약 정보는 한 개만 저장되었습니다. 그 이유는 코드상 좌석 정보를 업데이트하는 로직이 좌석 예약과 하나의 트렌잭션에 묶여 작업이 좌석정보를 선점하여 lock걸렸고 mysql의 격리 레벨 설정이 Repeatable read(row에 대한 공유 잠금이 트랜잭션이 종료할 때까지)이기 때문에 동시 요청한 다른 트렌잭션 작업들이 Deadlock걸려 insert한 예매 정보가 rollback되어 의도치 않게 중복 예매가 발생하지 않았습니다…

만약 좌석 정보 업데이트 로직이 없고 예약 데이터만 insert했다면 중복 예약 문제가 발생했을 것입니다.

데이터베이스(mysql)를 이용한 동시성 해결

공연 좌석 예약 서비스를 진행 하면서, 한개 좌석에 사용자가 동시에 예약을 할 수 있는 문제 상황 막기 위한 다양한 방법이 있지만 이번에는 데이터베이스에서 동시성 제어를 하는 방법을 알아보겠습니다.

1.Pessimistic lock

하나의 자원(좌석)데이터에 Lock을 거는 방법입니다. mysql에서 for update 키워드로 조회하려는 row에 대해 Lock을 겁니다.

MySQL for update
for udpate 경우 update뿐만 아니라 select또한 할 수 없습니다.

만약. A작업이 select … for update 쿼리를 실행하면 조회된 예매 좌석정보 데이터에(row) lock가 걸려 다른 작업(transaction)이 접근 할 수 없습니다. 이때 B작업이 해당 row 데이터를 조회 하려고 할때 A가 lock을 가지고 있어 lock가 풀릴때까지 B는 대기를 하게 되고 자원을 얻을때 작업을 수행하게 됩니다.

아래 로그는 세명의 유저가 한개의 좌석을 동시에 예매를 가정으로 테스트를 진행 한것입니다.
로그를 살펴보면 세명의 유저가 동시에 동일한 좌석을 조회하고 2번 유저가 Lock을 획득해 예약 정보를 등록하고, 좌석정보를 수정 하였습니다. 이후 1번,3번 유저는 변경된 좌석 정보를 조회 하게 되어 이미 예약된 좌석이라는 응답을 받게 됩니다.
⇒ 데이터에 작업이 끝나기 전까지 lock을 걸기때문에 성능저하가 발생할 수 있습니다.(읽기가 많이 이루어지는 데이터일 경우 성능 이슈 고려 해야함)

2023-07-06T16:46:39.972+09:00 DEBUG 57891 --- [pool-2-thread-1] org.hibernate.SQL                        : 
    select
        s1_0.id,
        s1_0.created_at,
        s1_0.is_reserved,
        s1_0.location,
        s1_0.number,
        s1_0.performance_id,
        s1_0.updated_at 
    from
        seat s1_0 
    where
        s1_0.performance_id=? 
        and s1_0.location=? 
        and s1_0.number=? for update
2023-07-06T16:46:39.972+09:00 DEBUG 57891 --- [pool-2-thread-2] org.hibernate.SQL                        : 
    select
        s1_0.id,
        s1_0.created_at,
        s1_0.is_reserved,
        s1_0.location,
        s1_0.number,
        s1_0.performance_id,
        s1_0.updated_at 
    from
        seat s1_0 
    where
        s1_0.performance_id=? 
        and s1_0.location=? 
        and s1_0.number=? for update
2023-07-06T16:46:39.972+09:00 DEBUG 57891 --- [pool-2-thread-3] org.hibernate.SQL                        : 
    select
        s1_0.id,
        s1_0.created_at,
        s1_0.is_reserved,
        s1_0.location,
        s1_0.number,
        s1_0.performance_id,
        s1_0.updated_at 
    from
        seat s1_0 
    where
        s1_0.performance_id=? 
        and s1_0.location=? 
        and s1_0.number=? for update
2023-07-06T16:46:39.980+09:00 DEBUG 57891 --- [pool-2-thread-2] org.hibernate.SQL                        : 
    select
        u1_0.id,
        u1_0.agw,
        u1_0.created_at,
        u1_0.email,
        u1_0.name,
        u1_0.phone,
        u1_0.role,
        u1_0.updated_at,
        u1_0.user_id,
        u1_0.user_pw 
    from
        user u1_0 
    where
        u1_0.id=?
2023-07-06T16:46:40.026+09:00 DEBUG 57891 --- [pool-2-thread-2] org.hibernate.SQL                        : 
    insert 
    into
        reservation
        (created_at,performance_id,reserved_at,seat_id,updated_at,user_id) 
    values
        (?,?,?,?,?,?)
2023-07-06T16:46:40.063+09:00 DEBUG 57891 --- [pool-2-thread-2] org.hibernate.SQL                        : 
    update
        seat 
    set
        is_reserved=?,
        location=?,
        number=?,
        performance_id=?,
        updated_at=? 
    where
        id=?
====> create reservation second user
이미 선택된 좌석입니다.
이미 선택된 좌석입니다.
16:46:40.093133 All jobs are terminated

jpa에서 pessimistic lock 사용하는 방법

@Lock(value : LockModeType.XXX) value 에 LockModeType.PESSIMISTIC_WRITE 같이 원하는 전략을 명시
@QueryHints(@QueryHint(name = "javax.persistence.lock.timeout",value = "0")) for update 구절에 notwait을 적용

@Repository
public interface SeatRepository extends JpaRepository<Seat, Long> {
    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "javax.persistence.lock.timeout",value = "0"))
    Optional<Seat> findByPerformanceIdAndLocationAndNumber(Long performanceId,
                                                           String location,
                                                           Integer number);
}
select s1_0.id,
       s1_0.created_at,
       s1_0.is_reserved,
       s1_0.location,
       s1_0.number,
       s1_0.performance_id,
       s1_0.updated_at 
from seat s1_0 
where s1_0.performance_id=? 
      and s1_0.location=? 
      and s1_0.number=? for update nowait

2.Optimistic lock

DB에서 제공해주는 특징을 이용하는 것이 아닌 application에서 잡아주는 lock

@Repository
public interface SeatRepository extends JpaRepository<Seat, Long> {
    @Lock(value = LockModeType.OPTIMISTIC)
    Optional<Seat> findByPerformanceIdAndLocationAndNumber(Long performanceId,
                                                           String location,
                                                           Integer number);
}
@Entity
@Getter
public class Seat extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Version
    private Long version;

    @Column(nullable = false)
    private Long performanceId;

    @Column(nullable = false)
    private String location;

    @Column(nullable = false)
    private Integer number;

    @Column(nullable = false)
    private Boolean isReserved;

    protected Seat() {
    }
}

etc

mysql의 lock에 대해 테스트를 진행하다 조건절에 어떤 데이터를 가지고 검색 하는지에 따라 lock 범위가 달라지는 현상을 확인하여 mysql의 lock을 추가로 정리하였습니다.

우선 mysql은 record 단위로 lock을걸며, shared lockexclusive lock를 사용합니다.

Record lock
레코드락은 테이블 레코드를 잠그는 락을 가리킵니다. 하지만 mysql의 레코드락은 테이블 레코드가 아닌 index의 레코드에 lock을 겁니다.

select *
from seat
where id = 1
for update;

만약 index가 걸려있지 않은 데이터를 검색한다면 테이블의 모든 데이터에 락을 걸어 작업이 끝나기 전까지는 해당테이블에 어떠한 작업(for udpate, update, delete)도 할 수 없게 되는 문제가 발생하게 됩니다.

mysql 8.0 lock 조회 쿼리

SELECT STRAIGHT_JOIN dl.THREAD_ID
                   , est.SQL_TEXT
                   , dl.OBJECT_SCHEMA
                   , dl.OBJECT_NAME
                   , dl.INDEX_NAME
                   , dl.LOCK_TYPE
                   , dl.LOCK_MODE
                   , dl.LOCK_STATUS
                   , dl.LOCK_DATA
FROM performance_schema.data_locks dl
         INNER JOIN performance_schema.events_statements_current est
                    ON dl.THREAD_ID = est.THREAD_ID
ORDER BY est.TIMER_START, dl.OBJECT_INSTANCE_BEGIN;
profile
hyunho

0개의 댓글