본문 바로가기
JPA

(Spring/JPA/Transaction) 쓰기 스큐, 팬텀. Write skew, Phantom (하)

by Developer RyanKim 2020. 7. 7.

(Spring/JPA/Transaction) 쓰기 스큐, 팬텀. Write skew, Phantom


https://lion-king.tistory.com/70

 

(Spring/JPA/Transaction) 쓰기 스큐, 팬텀. Write skew, Phantom

(Spring/JPA/Transaction) 쓰기 스큐, 팬텀. Write skew, Phantom "객체관계형 매핑 프레임워크를 사용하면 뜻하지않게 데이터베이스가 제공하는 원자적 연산을 사용하는 대신 불안전한 read-modify-write..

lion-king.tistory.com

위 포스트에 이어 테스트 코드 결과와 해결 과정을 공유합니다.

 

테스트 대상 메소드

@Transactional
    public void count(final String outlinkToken){
        OutlinkToken outlinkTokenEntity = outlinkTokenRepository.findByToken(outlinkToken);
        
        // 월별 outlink 카운트 증가
        int year = LocalDate.now().getYear();
        int month = LocalDate.now().getMonthValue();

        OutlinkAccessCount outlinkAccessCount = outlinkAccessCountRepository
                .find(outlinkTokenEntity.getId(), year, month);
        if(outlinkAccessCount == null) {
            outlinkAccessCount = new OutlinkAccessCount();
            // init count ...
            outlinkAccessCountRepository.save(outlinkAccessCount);
        } else {
            // count++
            outlinkAccessCount.setAccessCnt(outlinkAccessCount.getAccessCnt() + 1);
            outlinkAccessCountRepository.save(outlinkAccessCount);
        }
    }

해당 토큰을 가진 업체의 접근 기록이 없으면 insert
있으면 +1

 


테스트 코드

  @Test
    public void test_count {

        String token = "example";

        int numberOfThreads = 10;
        ExecutorService service = Executors.newFixedThreadPool(10);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);

        for(int i = 0; i < numberOfThreads; i++) {
            int finalI = i;
            service.execute(() -> {
                try {
                    service.count(token); // 테스트 될 메소드
                } catch(Exception e) {
                    e.printStackTrace();
                }
                latch.countDown();
            });
        }

        latch.await();

        AccessCount countRes = accessCountRepository
                .findByToken(token);

        assertNotNull(countRes);

        int resCount = countRes.getAccessCnt();
        assertEquals(numberOfThreads, resCount);
    }

다중 쓰레드에서 테스트 메소드 호출


시도 방법 목록


1. 데이터를 insert하는 부분에서 Exception 발생시, catch 하여 다시 한번 처음부터 메소드를 실행시킨다.
2. 위 1번과 유사하게 하되, catch 블록에서 count를 증가시키도록 한다.

3. 트랜잭션 수행 과정에서 사용되는 데이터에 무조건 lock을 걸도록 비관적 락을 사용한다.
4. 트랜잭션 격리 수준을 serializable로 하여 무조건 트랜잭션이 순차적으로 실행되도록 한다.
5. 검색 대상이 되는 객체 조건에 색인(index) 추가




1,2번 

1. 데이터를 insert하는 부분에서 Exception 발생시, catch 하여 다시 한번 처음부터 메소드를 실행시킨다.
2. 위 1번과 유사하게 하되, catch 블록에서 count를 증가시키도록 한다.

            try {
                outlinkAccessCountRepository.save(outlinkAccessCount);
            } catch(Exception e) {
                OutlinkAccessCount outlinkAccessCountAfterException = outlinkAccessCountRepository
                        .findAllByOutlinkTokenIdxAndYearAndMonth(outlinkTokenEntity.getId(), year,month);
                outlinkAccessCountAfterException
                        .setAccessCnt(outlinkAccessCount.getAccessCnt() + 1);
                outlinkAccessCountRepository.save(outlinkAccessCount);
            }

테스트 결과: 

: 서로 다른 쓰레드에서 insert 시도 및 예외발생 확인 - 이 예외를 catch 하여 처리 시도.
catch block의 outlinkAccessCountRepository.findAllByOutlinkTokenIdxAndYearAndMonth 수행과정에서 오류.

org.hibernate.AssertionFailure: null id in OutlinkAccessCountentry (don't flush the Session after an exception occurs)
https://stackoverflow.com/questions/10855542/org-hibernate-assertionfailure-null-id-in-entry-dont-flush-the-session-after

- catch block 시작에 em.clear() 추가

: null Pointer Exception 발생. insert를 성공한 쓰레드(1)의 트랜잭션 결과를 실패한 쓰레드(2)의 트랜잭션에서 읽을수 없음. 따라서 업데이트도 불가.

따라서 엔티티 매니저 문제 뿐만 아니라, 쓰레드1, 쓰레드2가 동시에 트랜잭션을 시작한경우
1이 저장한 데이터를 2에서 읽을 수 없음.
( 트랜잭션 마다 쿼리수행시 스냅숏 버전이 있고, 트랜잭션 안에서는 해당 버전의 스냅숏을 보기 때문 )

* 추가: 예외 발생시 트랜잭션에 roollback-only 마킹이 수행됨 (marking existing transaction as rollback-only)
마킹된 해당 트랜잭션은 커밋이 불가함. 따라서 위 문제가 다 해결되었다고 해도 catch 블럭에서 새로운 트랜잭션을 시작하지 않으면
count update를 진행할 수 없음.


3,4,5번
3. 트랜잭션 수행 과정에서 사용되는 데이터에 무조건 lock을 걸도록 비관적 락을 사용한다.
4. 트랜잭션 격리 수준을 serializable로 하여 무조건 트랜잭션이 순차적으로 실행되도록 한다.
5. 검색 대상이 되는 객체 조건에 색인(index) 추가

 

Service Method에 

@Transactional(isolation = Isolation.SERIALIZABLE) 사용

Repository Method에
@Lock(LockModeType.PESSIMISTIC_READ)
@Lock(LockModeType.PESSIMISTIC_WRITE) 사용

번갈아가며 모든 테스트를 수행

결과 : 

모든 테스트에서 DeadLock 예외 발생.

- 트랜잭션을 무조건 순서대로 수행시키면 오류가 없을것이라고 생각하였지만 Lock 사용과정에서 문제가 발생함.
S Lock, E Lock 에 대한 더 깊은 이해가 필요한듯하다.

- 검색대상이 되는 객체 조건에 index 추가
https://www.it-swarm.dev/ko/java/hibernate%EA%B0%80-%EC%99%9C-orghibernateexceptionlockacquisitionexception%EC%9D%84-%EB%8D%98%EC%A7%80%EB%82%98%EC%9A%94/1048228873/


: 검색 대상 테이블에 index를 추가하고 mysql explain 을 사용하여 수행계획까지 확인한 결과 index 사용을 확인하였다.
Isolation level SERIALIZABLE, Lock 설정을 하고 다시한번 수행.
하지만 코드 실행시 위와 같은 오류 발생함.


문제 해결

1. org.springframework.retry:spring-retry 사용

@Transactional
@Retryable(value = {SQLException.class}, maxAttempts = 2, backoff = @Backoff(delay = 5000))
    public void count(final String outlinkToken)

Spring 에서 제공하는  retry 라이브러리 사용.
해당 메소드 수행시 SQLException이 발생하면 다시 시도하도록 설정.
예외 발생한 트랜잭션은 어보트 시키고, 새로 트랜잭션을 수행하는 것으로 판단됨.
쓰레드 10개로 테스트한 결과 정상동작 확인. ( 예외발생시, 다시 수행하여 count 값과 호출 횟수가 일치 )

2. 새 트랜잭션 생성으로 보상 트랜잭션 수행

@Service
@RequiredArgsConstructor
public class CountRecover {

    private final OutlinkAccessCountRepository outlinkAccessCountRepository;
    private final OutlinkTokenRepository outlinkTokenRepository;


    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recover(String token, int year, int month) {
        OutlinkToken outlinkTokenEntity = outlinkTokenRepository.findByToken(token);

        OutlinkAccessCount outlinkAccessCount = outlinkAccessCountRepository
                .findAllByOutlinkTokenIdxAndYearAndMonth(outlinkTokenEntity.getId(), year, month);

        outlinkAccessCount.setAccessCnt(outlinkAccessCount.getAccessCnt() + 1);
        outlinkAccessCountRepository.save(outlinkAccessCount);
    }

}

새로운 트랜잭션을 생성하는 클래스를 만들고, catch block에서 새 트랜잭션 수행으로 count 갯수를 맞추도록 하였다.

try {
                outlinkAccessCountRepository.save(outlinkAccessCount);
            } catch(DataIntegrityViolationException e) {
                countRecover.recover(outlinkToken, year, month);
            }

Retry와 동일하게 정상 동작 확인.

해결방법 1,2 중 2를 택하였다.
Spring retry 사용 경험도 없고 동작원리도 모르는 상태에서 적용하는 것은 좋지 않다고 판단하였다.

* 추가
JPA Repository에서는 SQLException을 throw 하지 않아서 catch 할 수 없었습니다.

모든 예외는 DataAccessException 으로 다시 던져진다고 합니다.
저는 DataAccessException를 상속한 예외를 catch하여 처리하였습니다.

 


결론

일단, 문제를 해결하기는 하였지만 많은 아쉬움이 남습니다.
Runtime Exception을 catch 하여 처리하는 것도 그렇고, 근본적으로
중복으로 insert 시도 되는 부분 자체를 막고싶었지만 그러지 못하였습니다.
책에서 읽은 이론대로라면 트랜잭션을 직렬화하여 수행하면 쓰기스큐, 팬텀읽기의 문제를 해결할수 있다고 하였는데
구현을 해보니 쉽지않네요. 혹시 위와 비슷한 문제 처리경험이 있으신 개발자분의 답글 부탁드립니다!
읽어주셔서 감사합니다.

댓글