개요
- spring JPA에서 MySQL skip locked를 활용한 성능 최적화 방법 설명
- 선착순 쿠폰 발급 상황에서의 동시성 이슈를 고려하여 최적화
ERD

- 하나의 쿠폰 테이블에서 모든 쿠폰 정보를 관리
- 새로운 쿠폰이 추가되면 새로운 쿠폰명/쿠폰타입으로 테이블에 데이터를 append하는 방식
- 유저ID가 NULL인 필드는 아직 발급되지 않은 쿠폰을 의미
JPA Entity
User Entity
@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long userId;
@Column(name = "user_name", length = 255)
private String name;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<Coupon> couponList;
}
Coupon Entity
@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Coupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "coupon_id")
private Long couponID;
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "user_id")
private User user;
@Column(name = "coupon_name")
private String name;
@Enumerated(value = EnumType.STRING)
@Column(name = "coupon_type")
private CouponType type;
}
Service
@Service
@RequiredArgsConstructor
@Slf4j
public class CouponService {
private final CouponRepository couponRepository;
private final UserRepository userRepository;
...
@Transactional
public boolean simpleIssueCoupon(long userId, String couponName, CouponType couponType) {
PageRequest pageRequest = PageRequest.of(0, 1);
List<Coupon> remainCouponList = couponRepository.getSimpleRemainCoupon(couponName, couponType, pageRequest);
if (remainCouponList.size() == 0) {
log.error("남은 쿠폰이 존재하지 않습니다.");
return false;
}
Coupon remainCoupon = remainCouponList.get(0);
User user = userRepository.findById(userId).get();
remainCoupon.setUser(user);
return true;
}
@Transactional
public boolean pessimisticLockIssueCoupon(long userId, String couponName, CouponType couponType) {
PageRequest pageRequest = PageRequest.of(0, 1);
List<Coupon> remainCouponList = couponRepository.getPessimisticLockRemainCoupon(couponName, couponType, pageRequest);
if (remainCouponList.size() == 0) {
log.error("남은 쿠폰이 존재하지 않습니다.");
return false;
}
Coupon remainCoupon = remainCouponList.get(0);
User user = userRepository.findById(userId).get();
remainCoupon.setUser(user);
return true;
}
@Transactional
public boolean skipLockedIssueCoupon(long userId, String couponName, CouponType couponType) {
Coupon remainCoupon = couponRepository.getSkipLockedRemainCoupon(couponName, couponType.name());
if (remainCoupon == null) {
log.error("남은 쿠폰이 존재하지 않습니다.");
return false;
}
User user = userRepository.findById(userId).get();
remainCoupon.setUser(user);
return true;
}
}
- userID가 nullable인 쿠폰을 조회
- 조회된 쿠폰을 유저에게 발급
Repository
public interface CouponRepository extends JpaRepository<Coupon, Long>, CouponRepositoryCustom {
...
@Query("select c from Coupon c where c.name = :name AND c.type = :type AND c.user IS NULL")
List<Coupon> getSimpleRemainCoupon(@Param("name") String couponName, @Param("type") CouponType couponType, Pageable pageable);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Coupon c where c.name = :name AND c.type = :type AND c.user IS NULL")
List<Coupon> getPessimisticLockRemainCoupon(@Param("name") String couponName, @Param("type") CouponType couponType, Pageable pageable);
@Query(nativeQuery = true,
value = "select * " +
"from coupon c " +
"where c.coupon_name = :name AND c.coupon_type = :type AND c.user_id IS NULL LIMIT 1 for update skip locked")
Coupon getSkipLockedRemainCoupon(@Param("name") String couponName, @Param("type") String couponType);
}
- getSimpleRemainCoupon : 아무런 lock없이 남아있는 쿠폰을 조회
- getPessimisticLockRemainCoupon : 비관적 락을 사용해 남아있는 쿠폰을 조회
- getSkipLockedRemainCoupon : mySQL skip locked을 사용해 남아있는 쿠폰을 조회
Test
- 조건 100명의 유저가 1000개의 쿠폰을 연속적으로 요청해서 발급받음
@Test
void TestSimpleIssueCoupon() throws InterruptedException {
final ExecutorService executor = Executors.newFixedThreadPool(executorNum);
final CountDownLatch countDownLatch = new CountDownLatch(executorNum);
final AtomicInteger totalIssued = new AtomicInteger();
int totalCoupon = couponService.findTotalCoupon("test", CouponType.TYPE_A).size();
int remainCoupon = couponService.findRemainCoupon("test", CouponType.TYPE_A).size();
System.out.println("start issuing..");
System.out.println("totalCoupon = " + totalCoupon);
System.out.println("remainCoupon = " + remainCoupon);
System.out.println("totalIssued = " + totalIssued);
long startTimeMillis = System.currentTimeMillis();
for (int i = 0; i < executorNum; i++) {
executor.execute(() -> {
while (true) {
long userId = getRandomUserId();
boolean isIssued = couponService.simpleIssueCoupon(userId, "test", CouponType.TYPE_A);
if (isIssued) {
totalIssued.incrementAndGet();
} else {
break;
}
}
countDownLatch.countDown();
});
}
countDownLatch.await();
long endTimeMillis = System.currentTimeMillis();
totalCoupon = couponService.findTotalCoupon("test", CouponType.TYPE_A).size();
remainCoupon = couponService.findRemainCoupon("test", CouponType.TYPE_A).size();
System.out.println("end issuing..");
System.out.println("totalCoupon = " + totalCoupon);
System.out.println("remainCoupon = " + remainCoupon);
System.out.println("totalIssued = " + totalIssued);
System.out.println("elapsed time millis = " + (endTimeMillis - startTimeMillis));
}
Simple
sql
SELECT c1_0.coupon_id,c1_0.coupon_name,c1_0.coupon_type,c1_0.user_id
FROM coupon c1_0
WHERE c1_0.coupon_name=? and c1_0.coupon_type=? and c1_0.user_id is null
LIMIT ?,?
update coupon set coupon_name=?,coupon_type=?,user_id=? where coupon_id=?
output
start issuing..
totalCoupon = 1000
remainCoupon = 1000
totalIssued = 0
end issuing..
totalCoupon = 1000
remainCoupon = 0
totalIssued = 4993
elapsed time millis = 5963
- 동시성 문제 때문에 1000개의 쿠폰을 발급했지만 최종적으로 4993개의 쿠폰이 사용자에게 발급됨
- 최종적으로 5963ms 소요
Pessimistic Lock
sql
SELECT c1_0.coupon_id,c1_0.coupon_name,c1_0.coupon_type,c1_0.user_id
FROM coupon c1_0
WHERE c1_0.coupon_name=? and c1_0.coupon_type=? and c1_0.user_id is null
LIMIT ?,? for update
update coupon set coupon_name=?,coupon_type=?,user_id=? where coupon_id=?
output
start issuing..
totalCoupon = 1000
remainCoupon = 1000
totalIssued = 0
end issuing..
totalCoupon = 1000
remainCoupon = 0
totalIssued = 1000
elapsed time millis = 3277
- 비관적 락을 통해 해당 테이블의 데이터를 읽을 때 락을 수행
- 정확히 1000건의 쿠폰이 발급됨
- 최종적으로 3277ms 소요
- 락을 걸지 않았을 때에는 동시성 이슈 때문에 최종적으로 4993건의 쿠폰이 발급되었기 때문에, 락을 걸지 않았을 때보다 소요시간이 감소
- 다음 사용자가 쿠폰을 발급 받기 위해서는 남아있는 쿠폰이 있음에도 불구하고 현재 사용자의 쿠폰 발급이 끝날 때까지 대기해야 함
Skip Locked
sql
SELECT *
FROM coupon c
WHERE c.coupon_name = ? AND c.coupon_type = ? AND c.user_id IS NULL
LIMIT 1 for update skip locked
update coupon set coupon_name=?,coupon_type=?,user_id=? where coupon_id=?
output
start issuing..
totalCoupon = 1000
remainCoupon = 1000
totalIssued = 0
end issuing..
totalCoupon = 1000
remainCoupon = 0
totalIssued = 1000
elapsed time millis = 1032
- 비관적 락을 통해 해당 테이블의 데이터를 읽을 때 락을 수행
- 정확히 1000건의 쿠폰이 발급됨
- 최종적으로 1032ms 소요
- mysql의 skip locked 옵션을 통해서 lock이 걸려있는 레코드는 제외하고 쿼리를 수행
- 현재 사용자가 쿠폰 발급이 끝나지 않았더라도 쿠폰이 남아있다면 다음 사용자의 쿼리를 수행할 수 있음
정리
전체 쿠폰 수 : 1000
동시 요청 사용자 수 : 100
| simple query | pessimistic lock query | skip locked query | versioning | |
| 발급 쿠폰 수 | ≥ total | = total | = total | ≤ total |
| 전체 소요시간(ms) | 5963 | 3277 | 1032 | x |
| 쿠폰 당 소요시간(ms) | 1.19 | 3.27 | 1.03 | x |
- simple query에서 측정된 소요시간에는 동시성 문제에 의한 update lock에 의한 대기 시간도 포함되기 때문에 쿠폰 당 소요시간이 skip locked query보다 높은 것처럼 나옴
- skip locked를 사용하면 lock을 사용하지 않은 query와 비슷한 성능을 낼 수 있음
skip locked의 단점
- JPA에서 native query를 사용하기 때문에 DB에 종속되는 코드를 수행
- 상황에 맞춰 적절히 사용하는 것을 권장
'MySQL' 카테고리의 다른 글
| [Real MySQL] 쿼리 작성 및 최적화 2 (0) | 2024.05.06 |
|---|---|
| [Real MySQL] 쿼리 작성 및 최적화 1 (1) | 2024.05.06 |
| [Real MySQL] 인덱스 (0) | 2024.04.11 |
| 트랜잭션 격리 수준과 락 톺아보기 (1) | 2024.01.14 |
| [Real MySQL] 트랜잭션과 잠금 (0) | 2023.12.29 |