실시간 선착순 쿠폰 발급 서비스 #2

전영호·2024년 5월 3일

코드: https://github.com/youngho9999/Coupon/tree/master/single_rdb/src/main/java/com/test/single_rdb/coupon/serializable

사례 1 - 1WAS, 1RDB

사례 1 - 방법 1 Serializable

가장 먼저, Transaction Isolation Level 을 Serializable 로 적용할 경우 가장 쉽게 해결 할 수 있겠다는 생각을 했습니다.

간단하게, 비즈니스 로직을 다음과 같이 짰습니다.

    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void getCoupon(Long id) {
        Coupon coupon = couponRepository.findById(id).orElseThrow(RuntimeException::new);

        if(coupon.getQuantity() <= 0) {
            throw new RuntimeException();
        }

        coupon.getCoupon();
    }
    
    
    
    public class Coupon {

    @Id
    private Long id;
    private int quantity;

    public void getCoupon() {
        	this.quantity--;
    	}
	}

그리고 ExecutorService 를 이용해 멀티쓰레딩 환경을 조성하여 테스트를 진행해 보았습니다.

	@BeforeEach
    void setUp() {
        coupon = new Coupon(5L, 50000);
        couponRepository.save(coupon);
    }
    
    
    @Test
    void getCoupon() {

        int threadCount = 3;
        int customerCount = 50;
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(customerCount);

        for(int i = 0; i < customerCount; i++) {
            executorService.execute(() -> {
                try {
                    couponController.getCoupon(coupon.getId());
                } finally {
                    latch.countDown();
                }
            });
        }

        try {
            latch.await();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        Coupon c = couponRepository.findById(coupon.getId()).get();
        
        Assertions.assertThat(c.getQuantity()).isEqualTo(coupon.getQuantity() - customerCount);
    }

위의 경우 쓰레드 3개, 사용자 10명인 상황을 설정 후 테스트를 돌려보았습니다.
예상대로 라면, 쿠폰 50000개 에서 10개가 사라진 49990개가 db에 저장되어 있어야 합니다.

하지만, 예상과 달리 49994 라는 결과가 나왔습니다.

Exception in thread "pool-2-thread-17" org.springframework.dao.CannotAcquireLockException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [update coupon set quantity=? where id=?]; SQL [update coupon set quantity=? where id=?]

위와 같은 데드락 상황이 여러번 발생한걸 확인할 수 있었습니다.

show engine innodb status;
명령어를 통해 데드락 상황을 분석해 보았습니다.

------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-05-02 19:56:05 140056665884416
*** (1) TRANSACTION:
TRANSACTION 34164, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 21, OS thread handle 140056603965184, query id 602 172.18.0.1 root updating
update coupon set quantity=49995 where id=5

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 103 page no 4 n bits 72 index PRIMARY of table `coupon`.`coupon` trx id 34164 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 8; hex 8000000000000005; asc         ;;
 1: len 6; hex 000000008572; asc      r;;
 2: len 7; hex 01000001c020b8; asc        ;;
 3: len 4; hex 8000c34c; asc    L;;


*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 103 page no 4 n bits 72 index PRIMARY of table `coupon`.`coupon` trx id 34164 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 8; hex 8000000000000005; asc         ;;
 1: len 6; hex 000000008572; asc      r;;
 2: len 7; hex 01000001c020b8; asc        ;;
 3: len 4; hex 8000c34c; asc    L;;


*** (2) TRANSACTION:
TRANSACTION 34165, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 20, OS thread handle 140056605021952, query id 604 172.18.0.1 root updating
update coupon set quantity=49995 where id=5

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 103 page no 4 n bits 72 index PRIMARY of table `coupon`.`coupon` trx id 34165 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 8; hex 8000000000000005; asc         ;;
 1: len 6; hex 000000008572; asc      r;;
 2: len 7; hex 01000001c020b8; asc        ;;
 3: len 4; hex 8000c34c; asc    L;;


*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 103 page no 4 n bits 72 index PRIMARY of table `coupon`.`coupon` trx id 34165 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 8; hex 8000000000000005; asc         ;;
 1: len 6; hex 000000008572; asc      r;;
 2: len 7; hex 01000001c020b8; asc        ;;
 3: len 4; hex 8000c34c; asc    L;;

*** WE ROLL BACK TRANSACTION (2)

2개의 쓰레드가 서로 record lock - s lock 을 지니고 x lock 을 요청하는 형태 였기에 데드락에 걸린 것 이었습니다.

다만, 여기서 s lock 이 어디서 걸렸는지 궁금해 졌습니다.

제 서비스는 현재 2개의 쿼리가 나가고 있습니다.

select id, quantity from coupon where id=5;
update coupon set quantity=49995 where id=5;

먼저 첫번째 select 문이 실행될때 s-lock 이 걸리나 의심했습니다.
https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html#isolevel_serializable
하지만 위 공식문서에 따르면, autocommit 이 on 일경우 단순 select 문에는 s-lock 이 걸리지 않는다고 합니다.

claude ai가 대답한 바로는, innodb 는 update 문을 실행 할때, 먼저 s lock 을 걸고 데이터를 읽고 x-lock 을 건다고 합니다.

만약 그렇다면, select 문을 날릴때 먼저 x-lock (select ~ for update) 을 걸면 데드락 문제는 해결된다고 예상했습니다.

	@Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select c from Coupon c where c.id = :id")
    Optional<Coupon> findByIdForUpdate(Long id);
    
    -----------------------------------------------------
    
    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void getCoupon(Long id) {
        Coupon coupon = couponRepository.findByIdForUpdate(id).orElseThrow(RuntimeException::new);

        if(coupon.getQuantity() <= 0) {
            throw new RuntimeException();
        }

        coupon.getCoupon();
    }

위와 같이 쿼리문을 설정해주어서, select 문을 날릴때부터 x-lock 을 걸어주었더니, 더이상 데드락 현상이 발생하지 않았고 테스트 결과가 예상대로 잘 나왔습니다.

결론

트랜잭션 격리수준을 Serializable 로 설정해주고, select ~ for update 문을 이용해주면 동시성 문제를 해결 할 수 있다.

0개의 댓글