[HikariCP] Connection Pool 획득 과정에서 발생하는 지연시간 개선하기

Hocaron·2025년 1월 12일
0

Spring

목록 보기
45/45

서비스 시작 시점에 HikariCP의 커넥션 획득 시간이 2.58초로 급격히 증가하는 현상이 발생했다. 지연 발생 원인과 해결방법을 알아보자.

커넥션 풀 초기화와 포트 오픈 타이밍으로 인한 지연

HikariCP는 기본적으로 커넥션 풀을 비동기로 채운다. 이로 인해 애플리케이션이 시작되고 포트가 열린 후에도 커넥션 풀이 완전히 채워지지 않은 상태일 수 있다. 이때 요청이 들어오면 커넥션 생성으로 인한 지연이 발생할 수 있다.

커넥션 풀이 모두 채워지기 전에 포트가 열리는 것을 확인할 수 있다.

커넥션 풀 초기화 완료 후 트래픽 수용하기

HikariCP는 커넥션 풀이 완전히 준비된 후에 트래픽을 수용할 수 있도록 하는 설정을 제공한다.

# application.yml
spring:
  datasource:
    hikari:
      initialization-fail-timeout: 8000

# JVM 옵션
-Dcom.zaxxer.hikari.blockUntilFilled=true

동작 원리

      if (Boolean.getBoolean("com.zaxxer.hikari.blockUntilFilled") && config.getInitializationFailTimeout() > 1) {
         addConnectionExecutor.setMaximumPoolSize(Math.min(16, Runtime.getRuntime().availableProcessors()));
         addConnectionExecutor.setCorePoolSize(Math.min(16, Runtime.getRuntime().availableProcessors()));

         final long startTime = currentTime();
         while (elapsedMillis(startTime) < config.getInitializationFailTimeout() && getTotalConnections() < config.getMinimumIdle()) {
            quietlySleep(MILLISECONDS.toMillis(100));
         }

         addConnectionExecutor.setCorePoolSize(1);
         addConnectionExecutor.setMaximumPoolSize(1);
      }
   }
  1. 최대 16개(CPU 코어 수 제한) 스레드로 커넥션 풀을 병렬 초기화
  2. 다음 두 조건 중 하나를 만족할 때까지 대기
  • minimum-idle 개수만큼 커넥션이 채워짐
  • initialization-fail-timeout가 경과
  1. 초기화 완료 후 스레드 풀 원복

fyi; HikariPool Construct

🔥주의사항: 여러 서비스가 동시에 구동될 때 DB 부하 고려 필요

각 서비스마다 수십 개의 커넥션을 동시에 생성하려고 시도하여, DB 서버에 갑작스러운 부하가 발생할 수 있다.
특히 Kubernetes와 같은 환경에서 여러 Pod이 동시에 재시작되는 경우 주의하자.

위 설정 적용하고 두근거리는 마음으로 배포했다. 하지만 지연은 여전히 발생하고 있었다.

커넥션 획득 시 발생하는 경합으로 인한 지연

메트릭을 확인하던 중 흥미로운 점을 발견했다. 지연이 커넥션 생성(connection.create)이 아닌 커넥션 획득(connection.acquire) 과정에서 발생하고 있었다. HikariCP의 소스 코드를 확인해보니 metricsTracker.recordBorrowStats(poolEntry, startTime)를 통해 getConnection() 메서드에서 커넥션 획득 시간을 측정하고 있었다.
이는 처음 예상했던 커넥션 생성 지연이 아닌, 이미 생성된 커넥션을 가져오는 과정에서 문제가 발생한다는 것을 의미한다.

커넥션 획득 과정에서의 지연 원인

HikariCP의 커넥션 획득 과정에서 두 가지 지점에서 지연이 발생한다.

1. suspendResumeLock 경합

public Connection getConnection(final long hardTimeout) throws SQLException {
   suspendResumeLock.acquire();
   try {
      var poolEntry = connectionBag.borrow(timeout, MILLISECONDS);
      ...
   }
}

모든 커넥션 획득 요청은 이 lock을 먼저 획득해야 한다. 동시에 많은 요청이 들어올 경우, lock 획득을 위한 대기 시간이 발생한다.

2. ConcurrentBag의 커넥션 획득 구조로 인한 경합

HikariCP는 ConcurrentBag이라는 자료구조를 사용해 커넥션을 관리한다.

public class ConcurrentBag<T extends IConcurrentBagEntry> implements AutoCloseable
{
   private static final Logger LOGGER = LoggerFactory.getLogger(ConcurrentBag.class);

   private final CopyOnWriteArrayList<T> sharedList;
   private final boolean weakThreadLocals;

   private final ThreadLocal<List<Object>> threadList;
   
   public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException
   {
      // Try the thread-local list first
      final var list = threadList.get();
      for (int i = list.size() - 1; i >= 0; i--) {
         final var entry = list.remove(i);
         @SuppressWarnings("unchecked")
         final T bagEntry = weakThreadLocals ? ((WeakReference<T>) entry).get() : (T) entry;
         if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
            return bagEntry;
         }
      }

      // Otherwise, scan the shared list ... then poll the handoff queue
      final int waiting = waiters.incrementAndGet();
      try {
         for (T bagEntry : sharedList) {
            if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
...
         }

         listener.addBagItem(waiting);

         timeout = timeUnit.toNanos(timeout);
         do {
            final var start = currentTime();
            final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);
...
	}

   public void requite(final T bagEntry)
   {
      bagEntry.setState(STATE_NOT_IN_USE);

...

      final var threadLocalList = threadList.get();
      if (threadLocalList.size() < 50) {
         threadLocalList.add(weakThreadLocals ? new WeakReference<>(bagEntry) : bagEntry);
      }
   }
  1. Thread Local 검색
  • 현재 스레드의 threadList에서 커넥션 검색
  • STATE_NOT_IN_USE 상태인 커넥션을 찾으면 즉시 반환
  • threadList는 이전에 해당 스레드가 사용하고 반환(requite)한 커넥션들을 보관
    • 각 스레드는 자신의 ThreadLocal 리스트에 최대 50개까지 커넥션을 보관 가능
  1. Shared List 검색
  • threadList에서 찾지 못한 경우 전체 커넥션 풀 검색
  • 다른 대기자가 있는 경우 추가 커넥션 요청
  1. Handoff Queue 대기
  • sharedList에서도 찾지 못한 경우 다른 스레드가 반환하는 커넥션 대기
  • timeout 동안 대기하며, 시간 초과시 null 반환

이러한 구조로 인해 초기에는 모든 커넥션이 sharedList에만 있어 경합이 심하지만, 시간이 지나면서 각 스레드의 threadList에 분산되어 성능이 안정화된다.

fyi; HikariPool.getConnection()

fyi; ConcurrentBag.borrow()

🤔 오랫동안 사용되지 않은 커넥션은 정리될 수 있는데, ThreadLocal 에서 참조하고 있으면 메모리 누수가 발생하지 않을까?

ThreadLocal에서 커넥션을 관리할 때 두 가지 방식을 비교해보자.

  1. 일반 참조 방식의 문제점
threadLocalList.add(bagEntry);  // 일반 참조
  • ThreadLocal에 커넥션 참조가 계속 남아있어 GC가 되지 않음
  • 커넥션이 실제로는 더 이상 유효하지 않아도 메모리를 계속 점유
  1. WeakReference 사용의 이점 (HikariCP 기본값)
threadLocalList.add(new WeakReference<>(bagEntry));
  • 커넥션이 더 이상 사용되지 않으면 GC가 가능
  • ThreadLocal에만 참조가 남아있는 커넥션은 메모리에서 해제 가능
  • 실제 커넥션 객체의 메모리는 회수되어 메모리 누수 방지
  • WeakReference 객체는 여전히 리스트에 남아있지만, get()이 null을 반환

WeakReference는 ThreadLocal에 저장된 커넥션이 메모리 누수를 일으키지 않도록 보장하는 안전장치 역할을 한다.

🤔 각 스레드는 자신의 ThreadLocal 리스트에 유효하지 않는 커넥션은 어떻게 정리하지? 50개 제한도 있는데.

borrow()에서 ThreadLocal 리스트를 검색할 때, 유효하지 않는 커넥션(WeakReference가 null인 항목)은 자연스럽게 제거된다.

for (int i = list.size() - 1; i >= 0; i--) {
   // 1. ThreadLocal 리스트에서 항목 제거
   final var entry = list.remove(i);
   
   // 2. WeakReference인 경우 실제 참조 객체 조회
   final T bagEntry = weakThreadLocals ? ((WeakReference<T>) entry).get() : (T) entry;
   
   // 3. 유효성 체크 및 상태 변경
   if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
      return bagEntry;
   }
}
  1. ThreadLocal 리스트의 모든 항목을 뒤에서부터 순회하며 제거
  2. WeakReference인 경우
    • get()으로 실제 커넥션 객체 조회
    • GC된 경우 null 반환
  3. 커넥션이 유효한 경우(null이 아님)
    • STATE_NOT_IN_USE 상태면 STATE_IN_USE로 변경
    • 성공하면 해당 커넥션 반환

GC된 WeakReference는 자연스럽게 정리되고, 다음 requite() 호출 시 새로운 커넥션을 저장할 공간 확보할 수 있다.

커넥션 획득 과정에서 발생하는 경합 해결 방안

✅ ALB Slow Start를 통한 점진적 트래픽 증가
초기에 모든 커넥션이 sharedList에만 존재하여 경합이 발생하는 문제를 해결하기 위해, ALB Slow Start를 적용하여 트래픽을 점진적으로 증가시킨다.

  • 초기 커넥션 획득 시의 급격한 경합을 방지
  • 각 스레드의 threadList에 커넥션이 분배될 시간 확보
  • 경합으로 인한 지연 시간 최소화

❎ 애플리케이션 웜업을 통한 성능 최적화
서비스 시작 시 더미 요청을 통해 커넥션 풀을 웜업하여 초기 성능을 개선한다.

  • 각 스레드의 threadList에 커넥션을 미리 분배

위 두 가지 방안을 통해 초기 트래픽 처리 시의 커넥션 획득 경합을 효과적으로 감소시키고, 안정적인 성능을 제공할 수 있다. 특히 ALB Slow Start는 애플리케이션 코드 변경 없이 빠르게 적용할 수 있다.

profile
기록을 통한 성장을

0개의 댓글