kimjinmyeong / the-survey-revision

간편한 포인트 기반 웹 설문조사 서비스
1 stars 0 forks source link

비관적 락을 적용해도 멀티 스레드 환경에서 동시 데이터 읽기가 가능한 문제 #4

Open kimjinmyeong opened 3 months ago

kimjinmyeong commented 3 months ago

@parkgeonhu

안녕하세요! 현재 동시성 문제를 해결하기 위해 테스트를 하던 과정에서 도저히 이해가 안 되는 현상이 있어 질문드립니다..!

테스트하고 있는 부분은 설문조사를 생성하는 30개의 동시 요청이 들어왔을때 입니다. User의 초기 포인트가 50이고 2포인트를 필요로 하는 설문조사 30개를 생성하는 요청이 들어오면 결과적으로 User의 포인트는 0이 되어야하고, 설문조사는 25개만 생성되어야 하는데요, (User의 포인트가 0 밑으로 가면 Exception을 발생시키도록 구현했습니다.)

테스트 코드는 아래 처럼 구현하였습니다. https://github.com/kimjinmyeong/the-survey-revision/blob/a0d81d19dfd7394a9321777c44757fb16608d940/api/src/test/java/com/thesurvey/api/service/SurveyServiceConcurrencyTest.java#L165-L194

우선 여러 트랜잭션이 동시에 User의 Point를 읽는 것을 막기 위해서 User의 Point를 읽는 Repository 메서드에 비관적 락을 아래 코드처럼 추가하였습니다. https://github.com/kimjinmyeong/the-survey-revision/blob/a0d81d19dfd7394a9321777c44757fb16608d940/api/src/main/java/com/thesurvey/api/repository/PointHistoryRepository.java#L13-L19

그런데 surveyService.createSurvey() 메서드에서 User의 Point를 조회하는 메서드를 실행(170번 line에 getUserTotalPoint)하면 여러 트랜잭션이 동시에 read가 가능했습니다. 즉 "여러 트랜잭션이 동시에 User의 Point를 읽는 것을 막기" 가 의도한대로 작동하지 않았습니다. https://github.com/kimjinmyeong/the-survey-revision/blob/a0d81d19dfd7394a9321777c44757fb16608d940/api/src/main/java/com/thesurvey/api/service/SurveyService.java#L146-L172 실행 결과 image

getUserTotalPoint 는 아래처럼 구현되어 있습니다. https://github.com/kimjinmyeong/the-survey-revision/blob/a0d81d19dfd7394a9321777c44757fb16608d940/api/src/main/java/com/thesurvey/api/service/PointHistoryService.java#L35-L38

그런데 의아한 점은 PointHistoryService.savePointHistory에서 getUserTotalPoint를 호출하면 이번엔 트랜잭션마다 새로 save한 데이터를 차례대로 읽어서 의도한 대로 동작하게 됩니다. https://github.com/kimjinmyeong/the-survey-revision/blob/a0d81d19dfd7394a9321777c44757fb16608d940/api/src/main/java/com/thesurvey/api/service/PointHistoryService.java#L15-L33 (위 코드에서 System.out.println("# 2 " + userTotalPoint); 를 추가했습니다.) image

여기서 이해가 안 되는 현상이 surveyService.createSurvey() 메서드에서 실행했던int userTotalPoint = pointHistoryService.getUserTotalPoint(user.getUserId()); 를 지우면 savePointHistory에서 getUserTotalPoint를 호출했을 때 동작이 # 1번 처럼 동작해서 테스트에 실패하게 됩니다.

또한 savePointHistory에서 getUserTotalPoint 로직을 createSurvey 에 넣어도 정상적으로 동작하지 않습니다. 뭔가 Transaction의 클래스 간 경계와 관련되어 있을 것 같은데, Isolation level을 조정해보고 synchronized 도 이용해보려 했으나 이 현상이 이해가 잘 안 됩니다... 왜 PointHistoryService.savePointHistory에서 다시 조회했을 때는 정상적으로 동작했을까요??

이해하기가 어려우실 것 같습니다 ㅠㅠ 요약하자면 이렇습니다.

  1. User가 보유한 Point를 조회하는 메서드를 여러 트랜잭션이 동시에 실행하지 못하도록 하고싶다.
  2. 따라서 User가 소유하고 있는 Point를 조회하는 메서드에 비관적 락을 적용했다.
  3. 하지만 Point를 조회하는 메서드를 처음 실행했을 땐, 모든 트랜잭션이 read가 가능했다.
  4. 그런데 PointHistoryService.savePointHistory에서 다시 조회했을 땐 의도한 대로 동작이 되었다.

다른 대안으로 PointHistory에 point column을 User 엔티티로 옮기고, @Version을 적용해서 Point가 update될 때마다 version을 업데이트 하는 방식을 생각하고 있는데 이게 비관적 락보다 더 나은 해결 방안일까요??

아래는 현재 구현되어 있는 PointHistory 구현입니다! https://github.com/kimjinmyeong/the-survey-revision/blob/a0d81d19dfd7394a9321777c44757fb16608d940/api/src/main/java/com/thesurvey/api/domain/PointHistory.java#L11-L30

parkgeonhu commented 2 months ago

@kimjinmyeong 지난번 구글밋 이후로 혹시나 작성하셨을까 해서 살펴봤는데, 블로그 잘 봤습니다. 잘 써주신 것 같습니다 😀


MySQL을 기술하신 김에 한 가지 내용에 대해 조금 더 알아봐주셨으면 하는 것이 있습니다.


다음은 추가 질문입니다.


MySQL 관련 질문은 슬슬 DBA의 단계로 넘어가고 있는 것 같아서, 어려운 부분들이 많은 것 같습니다. (저 역시 추측하는 부분도 많고, 공식 문서의 어떤 부분을 레퍼런스하는 것도 상당히 어렵네요)

그래서 mysql 책을 하나 제대로 봐야겠다는 계기가 될 것 같아요 😇 화이팅입니다 🔥

kimjinmyeong commented 2 months ago

@parkgeonhu 먼저 MySQL 질문에 대한 답변부터 작성했습니다!

MySQL 테스트에서 tx1, tx2 각각 select ... for update 이후, 먼저 select for update 쿼리를 실행한 tx1에서 insert 문을 실행하면 mysql에서 무조건 데드락으로 인지할까요? 만약 위의 질문이 참이라면, 구글밋에서 진행했던 테스트(mysql 연결한)는 어떻게 통과했을까요? 블로그에 기술하신 것과 구글밋에서 진행했던 테스트에서 쿼리의 차이는 무엇이었을까요? 블로그에 작성하신 테스트 코드의 order by 의 대상이 무엇인지 보시고, order by의 대상을 변경하다보면 알 수 있습니다.

음... 아래처럼 time이라는 column을 추가해서 Order by 대상을 time으로 해보면 잘 동작하는군요..

@Entity
@Getter
@Setter
public class UserTest {

    @Id
    @GeneratedValue(strategy=GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String username;

    private LocalDateTime time = LocalDateTime.now();

}
@Repository
public interface UserTestRepository extends JpaRepository<UserTest, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    List<UserTest> findAllByOrderByTimeDesc();

id와 time 사이에서 Lock 동작의 차이가 무엇이 있을지 약 3일간 밤새 찾아본 결과 드디어 답을 찾은 것 같습니다...!

Backward Index Scan와 Using filesort의 차이!

ORDER BY ID 기준 정렬

EXPLAIN
SELECT
    usertest0_.id AS id1_9_,
    usertest0_.username AS username2_9_
FROM
    user_test usertest0_
ORDER BY
    usertest0_.id DESC
    FOR UPDATE;

ORDER BY time 기준 정렬

explain
select
    usertest0_.id as id1_9_,
    usertest0_.time as time2_9_,
    usertest0_.username as username3_9_
from
    user_test usertest0_
order by
    usertest0_.time desc for update;

위 쿼리를 각각 실행해보면 image

이런식으로 쿼리의 실행 계획이 나오게 되는데, Extra는 인덱스 사용 방법이나 정렬 수행 방법을 나타냅니다. 여기서 ID 기준 정렬은 Backward Index Scan, time 기준 정렬은 Using filesort 를 사용합니다.

Backward Index Scan

여기서 Backward Index Scan은 MySQL이 인덱스를 역순으로 스캔하는 방식입니다. 인덱스를 역순으로 스캔하여 정렬된 결과를 얻을 수 있는 경우에 사용됩니다.

supremum?

InnoDB에서는 인덱스 스캔 시 “supremum”라 불리는 임시의 레코드를 사용하는데, "supremum" 레코드는 인덱스에서 실제 존재하는 값들 보다 더 큰 값을 가집니다. 이는 인덱스 페이지의 끝을 나타내며, 페이지가 분할될 때 새로운 페이지의 경계를 설정하는 데 사용됩니다.

DeadLock 발생 이유

image

image블로그에 있는 Log에서 supremum 이라는 record의 Lock을 transaction 1이 쥐고 있고 있습니다. 그리고 transaction 2가 insert intention lock을 얻기 위해 transaction 1이 쥐고 있는 supremum 레코드의 대한 Lock을 기다리게 되어 DeadLock이 발생했습니다.

즉, ID를 기준으로 정렬했을 때 인덱스 테이블(Clustered index)을 사용했다는 것이며 -> 먼저 시작한 transaction이 supremum 레코드의 lock을 소유했고 (이 동작은 이후 transaction이 INSERT를 막기 위함 같습니다.) -> 두 transaction이 서로가 쥐고 있는 레코드의 Lock을 기다리고 있는 DeadLock이 발생했다고 이해를 했습니다.

order by time의 경우: Using filesort

Using filesort는 MySQL이 메모리나 디스크 상의 임시 영역을 사용하여 데이터를 정렬하는 방법입니다. 이는 인덱스를 사용하지 않고, 정렬이 필요한 경우에 사용됩니다. 따라서 supremum 레코드를 사용하지 않습니다. 이 때문에 supremum 레코드의 Lock을 기다리는 DeadLock이 발생하지 않았던 것 입니다.

질문 답변

MySQL 테스트에서 tx1, tx2 각각 select ... for update 이후, 먼저 select for update 쿼리를 실행한 tx1에서 insert 문을 실행하면 mysql에서 무조건 데드락으로 인지할까요?

이는 인덱스의 사용 여부, Isolation level에 따라 달라질 수 있습니다. 당근 테크 블로그 - MySQL Gap Lock 다시보기 저는 ORDER BY로 인한 데드락 케이스인데, 여기서는 INSERT Intention Gap Lock 과 관련된 케이스로 데드락을 Isolation level 조정으로 해결합니다.

만약 위의 질문이 참이라면, 구글밋에서 진행했던 테스트(mysql 연결한)는 어떻게 통과했을까요?

구글밋에서 사용한 쿼리는 transactionDate를 기준으로 정렬했기 때문에, 인덱스를 사용하지 않은 이유라 볼 수 있습니다.

블로그에 기술하신 것과 구글밋에서 진행했던 테스트에서 쿼리의 차이는 무엇이었을까요? 블로그에 작성하신 테스트 코드의 order by 의 대상이 무엇인지 보시고, order by의 대상을 변경하다보면 알 수 있습니다.

마찬가지로 인덱스를 사용하느냐, 안 하느냐의 차이입니다. 힌트가 많은 도움이 되었습니다.


확실한 정보보다는 이러이러 하니 이럴 것이다. 라는 추측으로 작성한거라 정확하지 않을 수 있습니다.. 정정할 부분이 있다면 말씀해주세요! SELECT..FOR UPDATE를 하고 INSERT하면 무조건 데드락이 걸리는 것으로 넘어가려 했는데, 생각해보니 구글밋에서는 정상적으로 동작을 했었던 것을 잊고있었군요.. 이 문제를 해결하면서 마치 DBA가 되기 위한 공부를 한 것 같습니다. (PostgreSQL의 MVCC, SnapShot, MySQL은 Locking Read... Next Key Lock, Insert Intention Lock, Clustered Index, Secondary Index 등등등...) 추가 질문도 차차 답변드리겠습니다.

kimjinmyeong commented 2 months ago

@Transactional이 붙은 메소드에서 트랜잭션이 실제로는 언제 commit 될까요?

메서드가 성공적으로 종료될 때 commit이 됩니다.

이는 AOP 동작과 연관이 있는데, Spring AOP 어노테이션이 적용된 클래스나 메서드가 최초로 실행되면 프록시 객체를 생성하여 메서드 호출 흐름을 가로챕니다. 그리고 Intercept 객체가 intercept 메서드를 실행해서 적절한 advice를 찾고 실제 메서드 로직 실행 전, 실행 후에 수행할 코드를 수행합니다.

Transactional의 경우 다음과 같은 순서로 실행됩니다.

  1. Transactional은 CGLIBAopProxy 객체를 생성하여 intercept를 호출하고, DynamicAdvisedInterceptor가 호출됩니다.
  2. DynamicAdvisedInterceptor에서 invokeWithinTransaction를 호출하면 Transaction의 advice를 담당하는 TransactionInterceptor의 invoke를 호출합니다.
  3. 여기서 invocation.proceed();를 메서드를 실행하여 실제 수행해야 하는 메서드를 진행하고 메서드가 끝나면 다시 TransactionInterceptor가 invokeWithinTransaction를 호출하여 TransactionAspectSupport의 invokeWithTransaction에서 commitTransactionAfterReturning 또는 completeTransactionAfterThrowing 을 수행합니다.

추가적으로 @Transactional의 self-invocation(자기 호출)에 대해서도 알아보았습니다.

self-invocation 현상은 같은 클래스 내의 메서드 호출이 프록시를 거치지 않고 직접 호출될 때 발생합니다. AOP 프록시는 클래스 내부에서 자기 자신의 메서드를 호출할 때는 프록시 객체를 거치지 않고, 실제 대상 객체의 메서드를 직접 호출하게 됩니다. 이는 프록시가 자기 자신을 알지 못하기 때문에 발생합니다.

@Service
public class UserTestService {

    @Transactional
    public void thread1() {
        thread2();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void thread2() {

    }

따라서 여기서 thread2를 호출해도, self invocation 현상 때문에 새로운 transaction을 시작하지 않습니다.

즉, thread2 메서드가 끝나더라도 실제로는 commit 되지 않습니다.

kimjinmyeong commented 2 months ago

ReentrantLock 을 대안책으로 기술하셨는데, 만약 해당 로직(select for update 후 insert)을 처리하는 서버가 여러 대인 경우, 어떤 일이 발생할까요?

이 질문을 보고나서 분산 락이 떠올랐습니다!

ReentrantLock은 JVM 내에서만 유효하므로, 여러 서버 간에는 Lock이 공유되지 않습니다. 따라서 여러 서버가 동시에 동일한 데이터에 접근하는 것을 막을 수 없습니다.

그렇다면 DB 수준에서 동시성을 해결하느냐.. (Isolation level 조정?)라 했을 때 마찬가지로여러 DB 인스턴스를 사용한다면? 이라는 질문에 답이 될 수 없기 때문에..

구글밋에서 보여주셨던 Redis를 사용한 Lock이 분산 Lock을 위해서도 사용하는 것으로 알고있습니다.

블로그에서는 ReentrantLock 임계 영역으로 만든다 로 기술했는데 지금 이 revision 프로젝트에서는 redis를 적극적으로 적용해보고자 합니다.