JPA

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

Developer RyanKim 2020. 7. 3. 18:51

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

 


"객체관계형 매핑 프레임워크를 사용하면 뜻하지않게 데이터베이스가 제공하는 원자적 연산을 사용하는 대신 불안전한 read-modify-write 주기를 실행하는 코드를 작성하기 쉽다."


오류 로그를 보던 중 동시에 같은 파라메터로 온 request들을 처리하다가 발생한 오류를 보았습니다.

Duplicate entry for unique key

오류가 발생한 api는 특정업체 상품에 접근한 횟수를 카운트하는 기능을 하였고,
업체마다 토큰을 지정하여 해당 토큰이 파라메터로 들어오는 구조였습니다.

 

업무시간 외에 여러 방법으로 테스트한 기록을 작성합니다.

 

예시코드:

    @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);
        }
    }

 

에러 로그 일부:

ERROR Duplicate entry '884-2020-7' for key 'outlink_access_count_uk'
org.springframework.dao.DataIntegrityViolationException

코드 동작순서는
1. 오늘날짜의 접근기록 데이터(유니크) 확인
2. 존재하지 않으면 ( == null) insert
3. 존재하면 count 값 + 1

동시에 호출되어 두 쓰레드의 트랜잭션 모두 select 시 존재하지 않는다고 판단하여
insert를 실행하다가 한쪽 트랜잭션에서 유니크 제약에 걸려 오류가 발생한것으로 파악됩니다.

이러한 문제를 쓰기 스큐(write skew), 팬텀(phantom) 읽기 라고 합니다.

 

쓰기 스큐: 트랜잭션이 무언가를 읽고, 읽은 값을 기반으로 어떤 결정을 내린 후 그 결정을 데이터 베이스에 쓴다.
그러나 쓰기를 실행하는 시점에는 읽은시점에 결정을 내렸던 상태와 달라 참이 아니다. 직렬성 격리만 이런 현상을 막을 수 있다.

팬텀 읽기: 트랜잭션이 어떤 검색 조건에 부합하는 객체를 읽는다. 다른 클라이언트가 그 검색결과에 영향을 주는 쓰기를 실행한다.
스냅숏 격리는 간단한 팬텀 읽기는 막아주지만 쓰기 스큐맥락에서 발생하는 팬텀은 색인 범위 잠금처럼 특별한 처리가 필요하다.

 

위 경우에 쓰기 스큐와 팬텀 읽기 중 어떤 현상인지 고민하다 팬텀 읽기에 조금더 가깝다고 결론을 내렸습니다.


먼저 문제 상황을 재현하기 위해 테스트코드를 작성하였습니다.

    @Test
    @Rollback(false)
    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); // 테스트 될 메소드
                    System.out.println("Tid : " + finalI);
                } catch(Exception e) {
                    e.printStackTrace();
                }
                latch.countDown();
            });
        }

        latch.await();

        AccessCount countRes = accessCountRepository
                .findByToken(token);

        assertNotNull(countRes);

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

 

쓰레드를 여러개 생성하여 호출되도록 테스트 코드를 구성하였고, 호출된 횟수 만큼 카운트가 증가되어야 되기 때문에
assert 조건을 쓰레드 갯수와 카운트 결과 갯수로 하였습니다.

실행결과 문제상황 이었던 ERROR Duplicate entry '884-2020-7' for key 'outlink_access_count_uk'
-> 유니크 키 중복 쓰기 시도 오류가 재현되는 것을 확인하였습니다.

예상대로 다수 쓰레드에서 동시에 (1. 오늘날짜 접근기록을 확인)을 실행하였을 때 접근 기록이 없다고 판단하여
(2. 존재하지 않은경우 insert)가 실행되었는데 한 쓰레드는 성공하고, 나머지 쓰레드는 성공하지 못한 경우였습니다.

정상적으로 동작하려면 한 쓰레드에서만 insert를 실행하고,
나머지 쓰레드는 그 insert 된 객체를 읽어서 카운트가 증가되도록 해야합니다.

그렇다면 어떻게 정상적으로 동작하게 해야할까요?

 

해결하기 앞서 몇가지 방법을 생각해보면


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

3. 트랜잭션 수행 과정에서 사용되는 데이터에 무조건 lock을 걸도록 비관적 락을 사용한다.
4. 트랜잭션 격리 수준을 serializable로 하여 무조건 트랜잭션이 순차적으로 실행되도록 한다.

 

위 4가지로 요약하였습니다.

* 추가
5. 검색 대상이 되는 객체 조건에 색인(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/


 

다음 포스팅에서 문제 해결 과정과 결과를 포스팅 하도록 하겠습니다!

감사합니다.

다음 포스팅:
https://lion-king.tistory.com/71