Hikari Connection Pool 에서 Connection 획득할 때, 발생했던 DeadLock 해결
문제
Spring Batch에서 Partitoner를 통해 Step들을 병렬 처리하려고 했었다. 그런데 처음에는 정상적으로 수행이 되다가 어느 시점에 아래와 같은 에러가 발생했다.
병렬 처리를 위한 TaskExecutorPool의 Size는 50으로 설정했었고, DB Connection Pool의 Size는 10으로 설정했었다. 일반적인 상황이라면, 충분히 수행되어야 하는 상황인데도 위와 같은 에러가 발생하게 되었다.
원인
하나의 스레드에서 2개의 DB Connection을 획득하려고 하다 보니 Connection이 부족하게 되었고, 이로 인해 Timout이 발생한 상황이었다. Spring Batch에서 Chunk 지향 처리는 Reader, Processor, Writer라는 3가지 구성 요소를 기반으로 동작한다. 그리고 Chunk 지향 처리는 트랜잭션 단위로 진행된다. 이때 Reader에서 JpaPagingItemReader를 사용하는 경우, pageSize만큼 데이터를 조회하기 위해서 트랜잭션을 획득하는데, 추가적인 DB Connection이 필요하다. 즉, 하나의 Chunk 작업에서 2개의 Connection이 필요한 셈이다. 아래의 doReadPage 메서드를 간략히 요약하자면, 다음과 같다.
- entityManager.getTransaction()에 의한 트랜잭션 시작
(상위 트랜잭션에서 하나의 Connection을 획득했는데, 또 다른 Connection 하나를 획득한다.) - entityManager.flush(), entityManager.clear() 처리
- pageSize만큼의 데이터를 가져오기 위한 쿼리 수행
- results 변수에 쿼리 결과 저장
- tx.commit()에 의한 트랜잭션 커밋
(Connection을 Pool에 반환한다.)
테스트
위의 상황과 비슷하게 코드를 구성해서 다시 테스트해봤다.
DeadLockRepository
@Slf4j
@Repository
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class DeadLockRepository {
private final EntityManager entityManager;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveTxRequiresNew(DeadLockEntity deadLockEntity) {
log.info("Repository - saveTxRequiresNew 호출");
entityManager.persist(deadLockEntity);
log.info("Repository - saveTxRequiresNew 종료");
}
}
위의 코드에서 중요한 점은 save 메서드가 아래의 옵션으로 설정되어 있다는 것이다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
상위 트랜잭션과 상관없이 새 트랜잭션을 시작한다. 즉, 새롭게 Connection을 획득한다는 의미이다.
DeadLockService
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class DeadLockService {
private final DeadLockRepository deadLockRepository;
@Transactional
public void save(String situation) {
log.info("Service - save 호출");
DeadLockEntity deadLockEntity = DeadLockEntity.create(situation);
deadLockRepository.saveTxRequiresNew(deadLockEntity);
log.info("Service - save 종료");
}
}
DeadLockService 클래스의 save 메서드에는 @Transactional으로 인해 트랜잭션을 시작하게 된다.
application.properties
# Tomcat
server.tomcat.threads.min-spare=1 # 테스트를 위해서 1로 설정
server.tomcat.threads.max=1 # 테스트를 위해서 1로 설정
# Hikari CP
spring.datasource.hikari.jdbc-url=jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.hikari.minimum-idle=1 # 테스트를 위해서 1로 설정
spring.datasource.hikari.maximum-pool-size=1 # 테스트를 위해서 1로 설정
Thread의 Size는 1개로 고정하고, DB Pool의 Size도 1개로 고정했다.
Test
@Slf4j
@SpringBootTest
public class SpringTxDeadLockTest {
@Autowired
DeadLockService deadLockService;
@Test
void deadLock() {
// given
String situation = "데드락을 발생시킨다.";
// when
assertThatThrownBy(() -> deadLockService.save(situation))
.isInstanceOf(CannotCreateTransactionException.class);
// then
assertThat(deadLockRepository.findBySituation(situation).isPresent()).isFalse();
}
}
위의 코드를 실행하면, Spring Batch에서 얻었던 결과와 동일하게 에러가 발생한다.
그리고 해당 흐름을 도식화해보면, 다음과 같다.
해결했던 방법
HikariCP에서는 Pool Locking과 관련된 대안을 제시하고 있으며, 추가적으로 우아한 형제들 기술 블로그를 참고했는데, 다음과 같이 DB Pool Size를 지정하여 문제를 해결할 수 있었다.
// Tn은 Thread의 수를 의미하며, Cm은 Connection의 수를 의미한다.
pool size = Tn x (Cm - 1) + (Tn / 2)
정리
하나의 요청(혹은 스레드)에서 2개 이상의 DB Connection을 획득하는 상황이 있다면, DeadLock이 발생할 가능성이 높다는 것을 알게 되었다.
참고