Post

경매 입찰 & 작품 구매 동시성 문제 해결 1 - DB Lock

개요

현재 리팩토링중인 AI 기반 작품 경매 플랫폼 프로젝트: Another Art에서는 다음 2가지 주요 기능이 존재한다

  1. 경매 작품 입찰
  2. 작품 구매

경매 작품 입찰 & 일반 작품 구매에서는 멀티 쓰레드 환경에서 동시성 문제가 발생할 수 있고 이를 반드시 제어해야 한다

입찰 프로세스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@UseCase
@RequiredArgsConstructor
public class BidUseCase {
    private final AuctionReader auctionReader;
    private final ArtReader artReader;
    private final MemberReader memberReader;
    private final BidInspector bidInspector;
    private final BidProcessor bidProcessor;
 
    // TODO need `Concurrency Control`
    @AnotherArtWritableTransactional
    public void invoke(final BidCommand command) {
        final Auction auction = auctionReader.getById(command.auctionId());
        final Art art = artReader.getById(auction.getArtId());
        final Member bidder = memberReader.getById(command.memberId());
 
        bidInspector.checkBidCanBeProceed(auction, art, bidder, command.bidPrice());
        bidProcessor.execute(auction, bidder, command.bidPrice());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Service
@RequiredArgsConstructor
public class BidInspector {
    public void checkBidCanBeProceed(
            final Auction auction,
            final Art art,
            final Member bidder,
            final int newBidPrice
    ) {
        validateBidderIsOwner(art, bidder);
        validateAuctionIsOpen(auction);
        validateBidderIsCurrentHighestBidder(auction, bidder);
        validateNewBidPrice(auction, newBidPrice);
    }
 
    private void validateBidderIsOwner(final Art art, final Member bidder) {
        if (art.isOwner(bidder)) {
            throw new AuctionException(ART_OWNER_CANNOT_BID);
        }
    }
 
    private void validateAuctionIsOpen(final Auction auction) {
        if (!auction.isInProgress()) {
            throw new AuctionException(AUCTION_IS_NOT_IN_PROGRESS);
        }
    }
 
    private void validateBidderIsCurrentHighestBidder(final Auction auction, final Member bidder) {
        if (auction.isHighestBidder(bidder)) {
            throw new AuctionException(HIGHEST_BIDDER_CANNOT_BID_AGAIN);
        }
    }
 
    private void validateNewBidPrice(final Auction auction, final int newBidPrice) {
        if (!auction.isNewBidPriceAcceptable(newBidPrice)) {
            throw new AuctionException(BID_PRICE_IS_NOT_ENOUGH);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Service
@RequiredArgsConstructor
public class BidProcessor {
    private final MemberReader memberReader;
    private final AuctionWriter auctionWriter;
 
    @AnotherArtWritableTransactional
    public void execute(
            final Auction auction,
            final Member newBidder,
            final int newBidPrice
    ) {
        final Long previousBidderId = auction.getHighestBidderId();
        final int previousBidPrice = auction.getHighestBidPrice();
 
        // 1. 최고 입찰자 정보 갱신
        auction.updateHighestBid(newBidder, newBidPrice);
        auctionWriter.saveRecord(auction, newBidder, newBidPrice);
 
        // 2. 이전 입찰자 <-> 새로운 입찰자 포인트 트랜잭션
        doPointTransaction(previousBidderId, newBidder, previousBidPrice, newBidPrice);
    }
 
    private void doPointTransaction(
            final Long previousBidderId,
            final Member newBidder,
            final int previousBidPrice,
            final int newBidPrice
    ) {
        newBidder.decreaseAvailablePoint(newBidPrice);
 
        if (previousBidderId != null) {
            final Member previousBidder = memberReader.getById(previousBidderId);
            previousBidder.increaseAvailablePoint(previousBidPrice);
        }
    }
}

간략하게 정리해보면 다음과 같다

  1. 경매 정보(Auction), 작품 정보(Art), 입찰자 정보(Member) 가져오기
  2. 입찰 가능 여부 확인
    • 입찰자가 작품 소유자인지 - validateBidderIsOwner
    • 경매가 열렸는지 - validateAuctionIsOpen
    • 입찰자가 현재 최고 입찰자랑 동일한지 - validateBidderIsCurrentHighestBidder
    • 입찰가가 받아들일만한지 - validateNewBidPrice
  3. 입찰 진행
    • 최고 입찰자 정보 갱신 - Auction Update
    • 입찰 기록 저장 - AuctionRecord Update
    • 이전 최고 입찰자 & 현재 최고 입찰자간의 사용 가능한 포인트 트랜잭션 처리 - Member Update


테스트 코드를 통해서 동시성 문제가 발생하는지 확인해보자

img
img
  • 1) 동일 가격에 대해서 2개의 경매 기록이 Insert된 상황
img
  • 2) 서로 다른 가격 입찰에 대해서 최고 입찰가가 아닌 입찰이 선택된 상황

데드락

img

그런데 로그를 살펴보니 데드락이 발생한 것을 확인할 수 있다
DB 트랜잭션 로그를 살펴보자

img

show engine innodb status를 통해서 상태를 체크해보니 특정 Record에 대한 S-Lock을 보유한 상태에서 서로 다른 Transaction에서 동일한 Record에 대한 X-Lock을 요구하고 있고 공존할 수 없는 S-Lock/X-Lock이기 때문에 데드락이 발생한거라고 판단된다

현재 로직에는 어떠한 DB Lock도 걸지 않았는데 왜 Transaction마다 S-Lock을 잡고 있는걸까?


img

로그를 보니 Auction & Member에 대한 Update Query 이전에 AuctionRecord에 대한 Insert Query가 먼저 진행되고 있다

  • AuctionRecord는 AuctionWriter에 의해서 Data JPA save를 통한 영속화가 진행된다
    • 키 전략은 IDENTITY이고 영속성 컨텍스트에서 관리되기 위해서 ID 값이 필요하므로 즉시 Insert Query
  • Auction, Member에 대한 수정은 JPA Transaction Dirty Checking에 의해서 진행된다
    • 트랜잭션에 대한 flush 시점에 동작

이러한 이유로 인해 AuctionRecord에 대한 Insert가 Auction & Member에 대한 Update보다 먼저 발생한다


이 과정에서 auction_record 테이블은 auction, member 테이블의 PK를 FK(외래키)로 잡고 있다 그에 따라서 auction_record insert 과정에서 X-Lock을 얻음과 동시에 외래키로 잡혀있는 auction, member 테이블에 대한 S-Lock이 전파되는 것이다

외래키 잠금 전파


해결 시도

1. Optimistic Lock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
@Table(name = "auction")
public class Auction extends BaseEntity<Auction> {
    @Column(name = "art_id", nullable = false, updatable = false, unique = true)
    private Long artId;
 
    @Embedded
    private Period period;
 
    @Column(name = "highest_bidder_id")
    private Long highestBidderId;
 
    @Column(name = "highest_bid_price", nullable = false)
    private int highestBidPrice;
 
    @Version
    private long version;
 
    @OneToMany(mappedBy = "auction", cascade = CascadeType.PERSIST)
    private final List<AuctionRecord> auctionRecords = new ArrayList<>();
    
    ...
}
1
ALTER TABLE auction ADD COLUMN version BIGINT NOT NULL DEFAULT 0;
1
2
3
4
5
6
7
8
9
10
11
12
public interface AuctionRepository extends JpaRepository<Auction, Long> {
    // @Query
    @Query("""
            SELECT ac
            FROM Auction ac
            WHERE ac.id = :id
            """)
    @Lock(LockModeType.OPTIMISTIC)
    Optional<Auction> findByIdWithLock(@Param("id") Long id);
    
    ...
}
img

Optimistic Lock은 위의 상황과 마찬가지로 여전히 데드락이 발생하고 있다

  1. auction_record Insert로 인한 X-Lock 획득 + 연관 테이블(auction, member)에 S-Lock 전파
  2. auction & member 갱신을 위해서 Update X-Lock 요구
  3. 데드락…

2. Pessimistic Lock

1) S-Lock: PESSIMISTIC_READ

1
2
3
4
5
6
7
8
9
10
11
12
public interface AuctionRepository extends JpaRepository<Auction, Long> {
    // @Query
    @Query("""
            SELECT ac
            FROM Auction ac
            WHERE ac.id = :id
            """)
    @Lock(LockModeType.PESSIMISTIC_READ)
    Optional<Auction> findByIdWithLock(@Param("id") Long id);
    
    ...
}
img
img
  • 위와 동일한 이유로 데드락이 발생해서 로직을 정상적으로 처리할 수 없는 구조

2) X-Lock: PESSIMISTIC_WRITE

1
2
3
4
5
6
7
8
9
10
11
12
public interface AuctionRepository extends JpaRepository<Auction, Long> {
    // @Query
    @Query("""
            SELECT ac
            FROM Auction ac
            WHERE ac.id = :id
            """)
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Auction> findByIdWithLock(@Param("id") Long id);
    
    ...
}
img
img

X-Lock을 통해서 Auction을 읽는 진입점부터 다른 Thread들을 대기하게 함으로써 입찰에 대한 동시성 문제를 해결하였다


Pessimistic Write Lock Performance Issue

Pessimistic Write Lock은 다른 Thread들의 읽기/수정/삭제 모든 연산들을 대기시킴으로써 동시성을 제어할 수 있는 기법이다
하지만 Lock을 얻은 트랜잭션의 처리가 지연된다면 지연된만큼 다른 Thread들의 처리 역시 밀리게 되는 문제가 발생할 수 있다

  • 과도한 대기 후 Timeout or Deadlock 발생 가능


따라서 이러한 문제를 해결하기 위해서 Lock 점유를 위해서 일정 시간만 대기하고 해당 시간동안 얻지 못하면 해당 트랜잭션을 실패 또는 다른 방식으로 처리할 필요가 있다

  • 이래야 Cascading으로 다른 Thread들의 처리가 점점 밀리는 현상을 최소화시킬 수 있다

1) @QueryHints

1
2
3
4
5
6
7
8
@Query("""
        SELECT ac
        FROM Auction ac
        WHERE ac.id = :id
        """)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="3000")})
Optional<Auction> findByIdWithLock(@Param("id") Long id);

2) EntityManager

1
2
3
Map<String, Object> properties = new HashMap();
properties.put("javax.persistence.query.timeout", 3000);
final Auction auction = entityManager.find(Auction.class, id, LockModeType.PESSIMISTIC_READ, properties);
This post is licensed under CC BY 4.0 by the author.