[my-klas] 낙관적 락 vs 비관적 락, 데드락 해결

woodyn·2021년 4월 29일
0

실험 배경

사실 생각해볼 필요도 없이 비관적 락이 적절한 상황이다.
일반적으로 자원에 대한 경쟁이 심할 때에는 비관적 락을 사용하는게 맞다. 낙관적 락은 Update에 실패하면 했던 행동을 처음부터 다시 수행해야 하기 때문에, 락을 얻기 위해 대기하는 방식보다 더 오랜 시간이 걸릴 것이다.
그러나 그 차이가 얼만할지 궁금했다. 그래서 두 방법 모두 시도보고 비교해보기로 했다.

수강신청 소스 코드는 다음과 같다:

    @Transactional
    fun register(
        studentId: Long,
        dto: LectureRegistrationDto.Register
    ): LectureRegistrationDto.Result {
        // 수강할 학생과 강의 조회
        val student = studentRepository.findByIdForUpdate(studentId).orElseThrow {
            errorHelper.notFound("Student $studentId")
        }
        val lecture = lectureRepository.findByIdForUpdate(dto.lectureId).orElseThrow {
            errorHelper.notFound("Lecture ${dto.lectureId}")
        }

        // 수강 조건 확인
        for (constraint in registerConstraints) {
            if (!constraint.comply(student, lecture)) {
                val constraintName = constraint::class.simpleName
                throw errorHelper.badRequest(
                    "Student couldn't register the lecture: violates $constraintName"
                )
            }
        }

        // 여석 확인
        if (lecture.numAvailable <= 0) {
            throw errorHelper.badRequest(
                "Student couldn't register the lecture: no available seats"
            )
        }
        lecture.numAvailable--

        // 강의 수강 (학생-강의 간 링크 엔티티 생성)
        val registration = LectureRegistration(
            student = student,
            lecture = lecture
        )
        registrationRepository.save(registration)

        // DTO(Response Body)로 결과 반환
        return dtoMapper.mapToDto(registration)
    }

위 코드를 요약하자면,

  1. Student와 Lecture 레코드를 조회한다. (Consistent read)
  2. 수강 조건을 확인한다. 이때 수강 이력 확인을 위해 LectureRegistration 레코드들을 조회한다. (Consistent read)
  3. Lecture의 여석을 확인한다.
  4. 수강이 가능하면 여석을 1 줄인다. (Update)
  5. Student와 Lecture를 참조하는 LectureRegistration을 생성한다. (Insert)

낙관적 락

Hibernate를 사용할 때 낙관적 락을 구현하는 일은 정말 간단하다. Entity에 @Version 필드를 추가하면 된다.

@Entity
class Lecture( /* fields.. */ ) {
    /* fields.. */
    
    @Version
    var version: Long? = null
}

그러나 낙관적 락 방식은 다음과 같이 데드락을 발생시켰다.

com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction

분명 낙관적 락은 DB에서 락을 걸지 않는 방식 아닌가? 낙관적 락에서 데드락이 발생한다는 사실이 모순적이다.
의도치않게 Hibernate와 InnoDB 중 한 쪽에서 락을 걸고 있음을 추측하며, SHOW ENGINE으로 데드락 문제를 진단했다.

------------------------
LATEST DETECTED DEADLOCK
------------------------
2021-04-29 15:09:21 0x7fcf7c354700
*** (1) TRANSACTION:
TRANSACTION 3999492, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1136, 3 row lock(s), undo log entries 1
MySQL thread id 119, OS thread handle 140529055885056, query id 41218 localhost 127.0.0.1 root updating
update lecture set capacity=10, credit=3, lecture_number='5000-83-3941-02', level=83, liberal_art_area=null, name='test-83', num_available=0, subject='test-83', term='2021-1', version=19 where id=158484 and version=18

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 9 page no 12 n bits 240 index PRIMARY of table `myklas`.`lecture` trx id 3999492 lock mode S locks rec but not gap
Record lock, heap no 133 PHYSICAL RECORD: n_fields 13; compact format; info bits 128
...

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 9 page no 12 n bits 240 index PRIMARY of table `myklas`.`lecture` trx id 3999492 lock_mode X locks rec but not gap waiting
Record lock, heap no 133 PHYSICAL RECORD: n_fields 13; compact format; info bits 128
...

*** (2) TRANSACTION:
TRANSACTION 3999493, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1136, 3 row lock(s), undo log entries 1
MySQL thread id 115, OS thread handle 140529453315840, query id 41222 localhost 127.0.0.1 root updating
update lecture set capacity=10, credit=3, lecture_number='5000-83-3941-02', level=83, liberal_art_area=null, name='test-83', num_available=0, subject='test-83', term='2021-1', version=19 where id=158484 and version=18

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 9 page no 12 n bits 240 index PRIMARY of table `myklas`.`lecture` trx id 3999493 lock mode S locks rec but not gap
Record lock, heap no 133 PHYSICAL RECORD: n_fields 13; compact format; info bits 128
...

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 9 page no 12 n bits 240 index PRIMARY of table `myklas`.`lecture` trx id 3999493 lock_mode X locks rec but not gap waiting
Record lock, heap no 133 PHYSICAL RECORD: n_fields 13; compact format; info bits 128
...

lock mode S locks rec but not gap
lock_mode X locks rec but not gap waiting

동일한 Lecture 레코드에 대해 s-lock과 x-lock을 시도하고 있음을 확인했다.
s-lock 이후 x-lock을 걸면 다음과 같은 상황에서 데드락이 발생한다:

  1. 트랜잭션 A가 s-lock 획득
  2. 트랜잭션 B가 s-lock 획득 (s-lock은 양립 가능)
  3. 트랜잭션 B가 x-lock 획득을 위해 대기 (x-lock은 s-lock과 양립 불가, 트랜잭션 A를 기다려야 함)
  4. 트랜잭션 A가 x-lock 획득을 위해 대기 (x-lock은 s-lock과 양립 불가, 트랜잭션 B를 기다려야 함)

문제는 s-lock과 x-lock 모두 의도한 적이 없었다는 것이다.
Hibernate의 로그를 간단히 확인해봐도 이와 관련된 SQL은 생성되지 않았다.
따라서 InnoDB가 자체적으로 락을 걸었음을 추측하며, MySQL 공식 문서에서 동시성과 관련된 부분을 읽어보았다.

가장 먼저 x-lock이 작동한 이유를 알 수 있었다:

For locking reads (SELECT with FOR UPDATE or FOR SHARE), UPDATE, and DELETE statements, the locks that are taken depend on whether the statement uses a unique index with a unique search condition, or a range-type search condition.

  • For a unique index with a unique search condition, InnoDB locks only the index record found, not the gap before it.

레코드를 수정할 때에는 InnoDB가 항상 x-lock을 건다고 한다.
따라서 UPDATE lecture SET ... WHERE id=? AND version=? 구문에서 x-lock이 걸렸음을 확인할 수 있었다.

그리고 s-lock이 작동한 이유는 다음과 같다:

If a FOREIGN KEY constraint is defined on a table, any insert, update, or delete that requires the constraint condition to be checked sets shared record-level locks on the records that it looks at to check the constraint. InnoDB also sets these locks in the case where the constraint fails.

다른 레코드를 참조하는 새 레코드를 생성할 때, 참조하는 레코드에 s-lock을 건다고 한다.
따라서 INSERT INTO lecture_registration VALUES (...) 구문에서 s-lock이 걸렸음을 확인할 수 있었다.

InnoDB에서는 위 잠금들을 트랜잭션 격리 수준과 관계 없이 항상 수행한다. (READ UNCOMITTED에서도 잠금을 수행한다!)
그러므로 Foreign key 제약 조건이 있는 테이블에는 Optimistic Lock을 활용할 수 없다. 이는 DB에서 Lock을 걸지 않도록 만들 방법이 없기 때문인데, Lock이 없으면 데이터의 일관성을 지키기 어렵기 때문이다.

LectureRegistration(학생-강의 간 링크 엔티티)은 Lecture를 참조하는 필드를 가지므로, 현 상황에서는 낙관적 락을 구현할 수 없는 것으로 정리된다.

비관적 락

Hibernate와 Spring Data JPA를 사용하면 비관적 락을 구현하는 일도 크게 어렵지 않다.
Repository에 @Lock 애너테이션을 적용한 조회 메소드를 추가하고, 트랜잭션이 시작될 때 이를 사용하여 레코드를 조회하면, Lecture에 x-lock을 걸어둠으로써 트랜잭션을 원자적으로 수행하기 때문에 데드락이 발생하지 않을 것이다.

@Repository
interface LectureRepository : JpaRepository<Lecture, Long>, ... {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select lec from Lecture lec where lec.id = ?1")
    fun findByIdForUpdate(id: Long): Optional<Lecture>
    

데드락 없이 동시적으로 잘 수행되었음을 확인했다.

profile
🦈

0개의 댓글