전체 코드 링크: https://github.com/ji-jjang/Learning/commit/9b080a5e2cdaea4fbad2cf6cbc2c1559ebc47052
특정 경쟁 조건을 만족시키기 위해 sleep으로 조정한 코드가 있습니다. 운영체제, 컴퓨터 사양, 실행 상태 등에 따라 쓰레드 스케줄링이 달라 다른 결과가 나올 수 있는 점 참고해 주세요.
public class TimeSlot {
private Long id;
private LocalDateTime startTime;
private LocalDateTime endTime;
private Boolean isReserved;
private Integer price;
}
public class Reservation {
private Long id;
private LocalDateTime startTime;
private LocalDateTime endTime;
private Integer price;
}
// 예약 서비스, 예약 생성 메서드
@Transactional
public Reservation createReservationRaceCondition(List<Long> timeSlotIds) {
LocalDateTime startTime = null;
LocalDateTime endTime = null;
for (var timeSlotId : timeSlotIds) {
TimeSlot timeSlot = getTimeSlotRaceCondition(timeSlotId);
startTime = getStartTime(startTime, timeSlot.getStartTime());
endTime = getEndTime(endTime, timeSlot.getEndTime());
timeSlot.setIsReserved(true);
timeSlotRepository.updateIsReservedTrue(timeSlot);
}
Reservation reservation = new Reservation(null, startTime, endTime, 10000);
reservationRepository.save(reservation);
return reservation;
}
private TimeSlot getTimeSlotRaceCondition(Long timeSlotId) {
TimeSlot timeSlot = timeSlotRepository
.findById(timeSlotId)
.orElseThrow(
() -> new RuntimeException(String.format("TimeSlot %d not found", timeSlotId)));
if (timeSlot.getIsReserved()) {
throw new RuntimeException("TimeSlot is reserved");
}
return timeSlot;
}
@Test
@DisplayName("예약 생성 경쟁 조건이 발생하여 같은 슬롯에 2개의 예약이 생성된다.")
void createReservationRaceCondition() throws InterruptedException {
List<Long> timeSlotIds = List.of(1L, 2L, 3L, 4L);
ExecutorService executor = Executors.newFixedThreadPool(2);
List<Reservation> reservations = Collections.synchronizedList(new ArrayList<>());
Runnable task1 = () -> {
Reservation reservation = reservationService.createReservationRaceCondition(timeSlotIds);
reservations.add(reservation);
};
Runnable task2 = () -> {
Reservation reservation = reservationService.createReservationRaceCondition(timeSlotIds);
reservations.add(reservation);
};
executor.execute(task1);
executor.execute(task2);
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
Assertions.assertThat(reservations.size()).isEqualTo(2);
}
1. thread2 슬롯 조회 (슬롯 예약되지 않은 상태)
2. thread1 슬롯 조회 (슬롯 예약되지 않은 상태)
3. thread1 슬롯 1, 2, 3, 4 UPDATE 및 예약 생성
4. thread2 슬롯 1, 2, 3, 4 UPDATE 및 예약 생성
<update id="updateIsReservedTrue">
UPDATE time_slots_versioning
SET is_reserved = TRUE,
version = version + 1
WHERE id = #{id}
AND version = #{version}
</update>
public class TimeSlotVersioning {
private Long id;
private LocalDateTime startTime;
private LocalDateTime endTime;
private Boolean isReserved;
private Integer price;
private Integer version;
}
@Transactional
public Reservation createReservationOptimistic(List<Long> timeSlotIds) {
List<TimeSlotVersioning> timeSlots = new ArrayList<>();
for (var timeSlotId : timeSlotIds) {
TimeSlotVersioning timeSlot = timeSlotVersioningRepository
.findById(timeSlotId)
.orElseThrow(
() -> new RuntimeException(String.format("TimeSlot %d not found", timeSlotId)));
timeSlots.add(timeSlot);
}
LocalDateTime startTime = null;
LocalDateTime endTime = null;
for (var timeSlot : timeSlots) {
startTime = getStartTime(startTime, timeSlot.getStartTime());
endTime = getEndTime(endTime, timeSlot.getEndTime());
timeSlot.setIsReserved(true);
int rows = timeSlotVersioningRepository.updateIsReservedTrue(timeSlot);
if (rows == 0) {
throw new RuntimeException("Optimistic reservation failed");
}
}
Reservation reservation = new Reservation(null, startTime, endTime, 10000);
reservationRepository.save(reservation);
return reservation;
}
@Test
@DisplayName("낙관적 락을 사용할 때 조회한 슬롯들을 한 번에 수정하지 않으면 병행 제어가 되지 않는다.")
void createReservationOptimisticFailed() throws InterruptedException {
List<Long> timeSlotIds = List.of(1L, 2L, 3L, 4L);
ExecutorService executor = Executors.newFixedThreadPool(100);
List<Reservation> reservations = Collections.synchronizedList(new ArrayList<>());
for (int i = 0; i < 100; ++i) {
executor.execute(
() -> {
try {
Reservation reservation = reservationService.createReservationOptimistic(timeSlotIds);
reservations.add(reservation);
System.out.println("ThreadInfo: " + Thread.currentThread());
} catch (Exception e) {
System.err.println(Thread.currentThread().getName() + " failed: " + e.getMessage());
}
});
}
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("reservations = " + reservations);
Assertions.assertThat(reservations.size()).isEqualTo(1);
}
<update id="bulkUpdateIsReservedTrue">
UPDATE time_slots_versioning
SET is_reserved = TRUE, version = version + 1
WHERE id IN
<foreach item="id" collection="list" open="(" separator="," close=")">
#{id}
</foreach>
AND is_reserved = FALSE
</update>
@Transactional
public Reservation createReservationOptimisticBulk(List<Long> timeSlotIds) {
List<TimeSlotVersioning> timeSlots = new ArrayList<>();
LocalDateTime startTime = null;
LocalDateTime endTime = null;
for (var timeSlotId : timeSlotIds) {
TimeSlotVersioning timeSlot = timeSlotVersioningRepository
.findById(timeSlotId)
.orElseThrow(
() -> new RuntimeException(String.format("TimeSlot %d not found", timeSlotId)));
timeSlots.add(timeSlot);
startTime = getStartTime(startTime, timeSlot.getStartTime());
endTime = getEndTime(endTime, timeSlot.getEndTime());
}
int rowsUpdated = timeSlotVersioningRepository.bulkUpdateIsReservedTrue(timeSlotIds);
if (rowsUpdated < timeSlotIds.size()) {
throw new RuntimeException("Some TimeSlots failed to reserve due to optimistic locking");
}
Reservation reservation = new Reservation(null, startTime, endTime, 10000);
reservationRepository.save(reservation);
return reservation;
}
이후 설명 할 비관적 쓰기 락 (Select For Update)에서는 조회와 동시에 락을 걸기 때문에 다른 쓰레드에서 조회 및 수정이 불가능하다. 이 경우 하나씩 업데이트해도 경쟁 조건이 발생하지 않는다.
Select For Update
명령어를 통해 조회 시 비관적 쓰기 락을 건다. 다른 트랜잭션은 해당 데이터에 대해 읽기 및 수정을 하지 못한다. 강력한 잠금 방식이며, 충돌이 빈번하지 않은 상황에서는 성능상 오버헤드가 크다. 4개의 슬롯을 조회하며 비관적 쓰기 락을 걸어보자.<select id="findByIdsForUpdate" resultType="com.juny.locktest.TimeSlot">
SELECT
id,
start_time AS startTime,
end_time AS endTime,
is_reserved AS isReserved,
price
FROM time_slots
WHERE id IN
<foreach item="id" collection="list" open="(" separator="," close=")">
#{id}
</foreach>
FOR UPDATE
</select>
@Transactional
public Reservation createReservationPessimisticWriteLock(List<Long> timeSlotIds)
throws InterruptedException {
List<TimeSlot> timeSlots = timeSlotRepository.findByIdsForUpdate(timeSlotIds);
for (var timeSlot : timeSlots) {
if (timeSlot.getIsReserved()) {
throw new RuntimeException("TimeSlot is reserved");
}
}
LocalDateTime startTime = null;
LocalDateTime endTime = null;
for (TimeSlot timeSlot : timeSlots) {
startTime = getStartTime(startTime, timeSlot.getStartTime());
endTime = getEndTime(endTime, timeSlot.getEndTime());
timeSlot.setIsReserved(true);
timeSlotRepository.updateIsReservedTrue(timeSlot);
}
Reservation reservation = new Reservation(null, startTime, endTime, 10000);
reservationRepository.save(reservation);
return reservation;
}
@Test
void createReservationPessimisticWriteLock() throws InterruptedException {
List<Long> timeSlotIds = List.of(1L, 2L, 3L, 4L);
ExecutorService executor = Executors.newFixedThreadPool(100);
List<Reservation> reservations = Collections.synchronizedList(new ArrayList<>());
for (int i = 0 ; i < 100; ++i) {
executor.execute(
() -> {
try {
Reservation reservation =
reservationService.createReservationPessimisticWriteLock(timeSlotIds);
reservations.add(reservation);
} catch (Exception e) {
System.err.println(Thread.currentThread().getName() + " failed: " + e.getMessage());
}
});
}
executor.shutdown();
executor.awaitTermination(5, TimeUnit.SECONDS);
Assertions.assertThat(reservations.size()).isEqualTo(1);
}
@Transactional
public Reservation createReservationPessimisticWriteLock(List<Long> timeSlotIds)
throws InterruptedException {
System.out.println("비관적 쓰기 락 조회 전" + Thread.currentThread().getName());
List<TimeSlot> timeSlots = timeSlotRepository.findByIdsForUpdate(timeSlotIds);
System.out.println("비관적 쓰기 락 조회 후" + Thread.currentThread().getName());
Thread.sleep(3000);
... 생략
}
1. 비관적 쓰기 락 조회 전pool-1-thread-1
2. 비관적 쓰기 락 조회 전pool-1-thread-2
3. thread-1 SELECT FOR UPDATE (쓰레드 2는 대기 중)
4. thread-1 개별 UPDATE 쿼리
5. thread-2 SELECT FOR UPDATE
Select For Share
, 비관적 읽기 락은 데이터를 읽는 동안 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 락을 거는 방식이다. <select id="findByIdsForShare" resultType="com.juny.locktest.TimeSlot">
SELECT
id,
start_time AS startTime,
end_time AS endTime,
is_reserved AS isReserved,
price
FROM time_slots
WHERE id IN
<foreach item="id" collection="list" open="(" separator="," close=")">
#{id}
</foreach>
FOR SHARE
</select>
@Transactional
public Reservation createReservationPessimisticReadLock(List<Long> timeSlotIds)
throws InterruptedException {
List<TimeSlot> timeSlots = timeSlotRepository.findByIdsForShare(timeSlotIds);
for (var timeSlot : timeSlots) {
if (timeSlot.getIsReserved()) {
throw new RuntimeException("TimeSlot is reserved");
}
}
LocalDateTime startTime = null;
LocalDateTime endTime = null;
for (TimeSlot timeSlot : timeSlots) {
startTime = getStartTime(startTime, timeSlot.getStartTime());
endTime = getEndTime(endTime, timeSlot.getEndTime());
timeSlot.setIsReserved(true);
timeSlotRepository.updateIsReservedTrue(timeSlot);
}
Reservation reservation = new Reservation(null, startTime, endTime, 10000);
reservationRepository.save(reservation);
return reservation;
}
Exception in thread "pool-1-thread-2" org.springframework.dao.DeadlockLoserDataAccessException:
### Error updating database. Cause: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
1. Thread-1 슬롯 1,2,3,4 비관적 읽기 락
2. Thread-2 슬롯 1,2,3,4 비관적 읽기 락
3. Thread-1 슬롯 1,2,3,4 수정 (쓰기 락)
4. Thread-2 슬롯 1 수정하려고 할 때 DeadLock
이게 왜 데드락일까? Thread-1과 Thread2는 슬롯 1,2,3,4에 대해 공유 락(S)을 설정하고, 쓰레드 1이 수정할 때 쓰기 락(EX)은 쓰레드 2의 공유 락(S)을 해제하길 원하고, Thread-2가 슬롯 1을 수정(EX)하려고 할 때 Thread-1의 읽기 락(s)을 해제하길 원하니, 자원을 가진채 서로의 자원을 원하는 상황(Hold And Wait)이라 데드락이 발생한다.
이처럼 비관적 읽기 락을 사용하면, 데이터를 읽는 시점에 다른 트랜잭션이 해당 데이터를 수정하는 것을 막아주는 효과가 있다.