HikariCP의 작동방식

고승원·2024년 1월 12일
0

Spring

목록 보기
12/14

오늘은 Hikari CP에 대해 알아보겠습니다. (이 글은 3.4.1 버전 기준으로 작성되었습니다.)

Hikari CP를 사용하며 커넥션을 어떻게 주고 받는지 궁금하여 알아봤습니다.

1. 소개

1.1 CP (Connection Pool)

CP는 DB와 연결을 미리 생성하여 풀에 보관하고, 필요할 때마다 이 연결을 주고 받는 기술입니다.

DB와 연결은 비용이 크기 때문에, 매번 새로운 연결을 초기화 하는 것은 비효율적 입니다. CP를 사용해 DB 연결의 생성 및 해제를 최소화하고, 효율적으로 자원을 관리할 수 있습니다.

CP는 다음과 같은 동작을 합니다

  1. 커넥션 생성 및 초기화 : 미리 설정된 최소 연결 수만큼의 DB 연결을 미리 초기화하고, 풀에 저장합니다.
  2. 커넥션 제공 : 애플리케이션이 DB에 연결이 필요한 경우, 미리 생성된 CP에서 사용 가능한 연결을 제공합니다.
  3. 연결 사용: 빌려온 연결을 사용하여 데이터베이스 작업을 수행합니다.
  4. 연결 반납: DB 작업이 끝난 후, 사용한 연결을 풀에 반납합니다.
  5. CP 관리: CP는 최소 연결 수를 유지하며, 필요에 따라 동적으로 연결을 추가하거나 해제하여 최적의 성능을 유지합니다.


1.2 Hikari CP

Hikari CP는 JDBC 연결 풀링을 위한 높은 성능을 제공하는 경량화된 CP 라이브러리입니다.

스프링 부트 2.0부터 default CP가 되었습니다.

  • 성능 : Hikari CP는 높은 성능과 낮은 레이턴시를 제공하여 빠른 데이터베이스 연결을 지원합니다.
  • 설정 용이 : 간단한 설정으로 빠르게 도입이 가능하며, 자동으로 최적의 설정을 찾아주어 별도의 튜닝이 필요하지 않습니다.
  • 경량성 : 라이브러리 자체의 용량이 작아, 애플리케이션 시작 시간을 최소화합니다.




2. 동작 방법

먼저 전체적인 동작을 그려봤습니다.

전체적인 그림을 보기엔 좋으나, 코드와 자세한 내용은 더 밑에 정리해두었으니 참고하시면 이해하는데 도움이 될거라고 생각합니다.


2.1 커넥션 가져오기

일반적인 CP와 동작하는 방법은 같습니다. 다만 FastPool이 존재합니다.

  1. 커넥션을 요청합니다.
  2. FastPool에 커넥션이 있다면 반환합니다.
  3. FastPool에 커넥션이 없다면 Pool에서 가져옵니다.

2.2 커넥션 가져오기 (Pool 내부)

그렇다면 Pool 내부는 어떻게 동작하고 있을까요?

  1. 커넥션을 요청합니다.
  2. 커넥션을 획득할 CP에 락을 겁니다.
  3. ConcurrentBag으로부터 커넥션 대여 요청합니다.
  4. SharedList로부터 커넥션을 찾아 반환합니다. 없다면 5로 넘어갑니다.
  5. HandoffQueue로부터 커넥션을 찾아 반환합니다. 없다면 6으로 넘어갑니다.
  6. timeout이 끝날 때 까지 커넥션을 찾아 반환합니다. 시간이 지나도 마땅한 커넥션이 없다면 null을 반환합니다.

2.3 커넥션 반납하기

  1. 커넥션을 사용한 뒤 관련 statement(SQL)을 닫고, rollback 또는 commit 합니다.
  2. 커낵션 내부에 lastAccessed와, 커넥션 통계를 기록합니다.
  3. ConcurrentBag에 커넥션을 반환합니다.
  4. 커넥션을 “사용하지 않음” 상태로 변경하고, handoffQueue에 저장합니다.




3. 자세한 동작 방법


3.1 트랜잭션 실행

// 커넥션 생성
try (Connection connection = dataSource.getConnection()) {
// 데이터베이스 작업 수행
...
} catch (SQLException e) {
    e.printStackTrace();
} finally {
    // 커넥션 반납
    dataSource.close();
}

일반적으로 트랜잭션은 다음과 같이 실행됩니다.

여기서 커넥션을 받아오는 getConnection()과, 반납하는 close()를 CP가 처리하게됩니다.



3.2 getConnection

getConnection을 얻는 방법은 다음과 같습니다.

  1. DataSource에서 pool을 확인한뒤, 없다면 생성합니다.
  2. pool로부터 connection을 얻습니다.
  3. pool 내부의 concurrentBag으로부터 poolEntry(connection)을 받습니다.

1, 2, 3번을 클래스별로 뜯어보겠습니다.


3.2.1 HikariDataSource.java

Hikari CP의 데이터 소스입니다.


getConnection

@Override
 public Connection getConnection() throws SQLException
 {
		// 1
    if (isClosed()) {
       throw new SQLException("HikariDataSource " + this + " has been closed.");
    }

		// 2
    if (fastPathPool != null) {
       return fastPathPool.getConnection();
    }

    HikariPool result = pool;
    if (result == null) {
       synchronized (this) {
          result = pool;
          if (result == null) {
             validate();
             try {
                pool = result = new HikariPool(this);
                this.seal();
             }
             catch (PoolInitializationException pie) {
                if (pie.getCause() instanceof SQLException) {
                   throw (SQLException) pie.getCause();
                }
                else {
                   throw pie;
                }
             }
          }
       }
    }
    return result.getConnection();
 }

커넥션 풀로부터 커넥션을 얻기위한 메서드입니다.

  1. 데이터 소스가 닫혀있는지 확인합니다.
  2. *fastPathPool이 있다면 fastPathPool에게 커넥션을 얻습니다.
  3. pool이 없다면 새로 생성합니다.
  4. pool로부터 커넥션을 얻습니다.

*ㅤfastPathPool은 pool과 같은 HikariPool 객체지만, final 멤버여서 생성자에서 초기화되고, 변경이 없습니다. 이는 동기화로 인한 오버헤드를 없에주어 더 빠릅니다.


3.2.2 HikariPool.java

Hikari CP에 대한 기본 풀링 동작을 제공하는 기본 풀 입니다.


constructor

public HikariPool(final HikariConfig config)
   {
      super(config);

      this.connectionBag = new ConcurrentBag<>(this);
      this.suspendResumeLock = config.isAllowPoolSuspension() ? new SuspendResumeLock() : SuspendResumeLock.FAUX_LOCK;
      this.houseKeepingExecutorService = initializeHouseKeepingExecutorService();

      checkFailFast();

			// 기타 설정 초기화

			this.leakTaskFactory = new ProxyLeakTaskFactory(config.getLeakDetectionThreshold(), houseKeepingExecutorService);
			this.houseKeeperTask = houseKeepingExecutorService.scheduleWithFixedDelay(new HouseKeeper(), 100L, housekeepingPeriodMs, MILLISECONDS);

   }
  • connectionBag : 커넥션을 저장하고 관리 합니다.
  • suspendResumeLock : 커넥션 풀 동기화 락을 제공 합니다.
  • houseKeepingExecutorService : 커넥션 풀의 정기적인 유지관리를 합니다.
  • checkFailFast() : connectionBag에 PoolEntry(커넥션)을 넣어줍니다.
  • leakTaskFactory : 커넥션 누수를 감지합니다.
  • houseKeeperTask : 정기적으로 백그라운드 유지 관리 작업 스케줄을 설정합니다.

getConnection

public Connection getConnection(final long hardTimeout) throws SQLException
   {
			// 1
      suspendResumeLock.acquire();
      final long startTime = currentTime();

      try {
         long timeout = hardTimeout;
         do {
						// 2
            PoolEntry poolEntry = connectionBag.borrow(timeout, MILLISECONDS);
            if (poolEntry == null) {
               break; // We timed out... break and throw exception
            }

            final long now = currentTime();
						// 3
            if (poolEntry.isMarkedEvicted() || (elapsedMillis(poolEntry.lastAccessed, now) > aliveBypassWindowMs && !isConnectionAlive(poolEntry.connection))) {
               closeConnection(poolEntry, poolEntry.isMarkedEvicted() ? EVICTED_CONNECTION_MESSAGE : DEAD_CONNECTION_MESSAGE);
               timeout = hardTimeout - elapsedMillis(startTime);
            }
            else {
							 // 4
               metricsTracker.recordBorrowStats(poolEntry, startTime);
							 // 5
               return poolEntry.createProxyConnection(leakTaskFactory.schedule(poolEntry), now);
            }
         } while (timeout > 0L);

				 //6
         metricsTracker.recordBorrowTimeoutStats(startTime);
         throw createTimeoutException(startTime);
      }
      catch (InterruptedException e) {
         Thread.currentThread().interrupt();
         throw new SQLException(poolName + " - Interrupted during connection acquisition", e);
      }
      finally {
         suspendResumeLock.release();
      }
   }
  1. suspendResumeLock을 사용해 커넥션을 획득할 동안 커넥션 풀에 락을 겁니다.
  2. 생성자에서 커넥션을 생성하고 담아둔 connectionBag에서 timeout만큼 커넥션을 borrow합니다.
  3. while문을 돌면서 커넥션의 상태를 보고 커넥션을 닫습니다. 그게 아니라면 timeout 값을 뺍니다.
    a. 마킹(폐기)된 경우
    b. 수명이 다한 경우
    c. 활성화 되지 않은 경우
  4. 성공적으로 커넥션을 획득한 경우 통계 정보를 기록합니다.
  5. 커넥션에 대한 프록시를 생성하고 반환합니다. 이는 커넥션 사용을 추적하고 누수 검사 작업을 스케줄링 하기 위함입니다. (close할때도 사용됩니다)
  6. 커넥션을 획득을 시간 초과로 실패한 경우 통계 정보를 기록합니다.

3.2.3 ConcurrentBag

커넥션이 담기는 가방(리스트) 역할을 합니다.

borrow

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

			// 2
      final int waiting = waiters.incrementAndGet();
      try {
         for (T bagEntry : sharedList) {
            if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
               // If we may have stolen another waiter's connection, request another bag add.
               if (waiting > 1) {
                  listener.addBagItem(waiting - 1);
               }
               return bagEntry;
            }
         }

				 // 3
         listener.addBagItem(waiting);

				 // 4
         timeout = timeUnit.toNanos(timeout);
         do {
            final long start = currentTime();
            final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);
            if (bagEntry == null || bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
               return bagEntry;
            }

            timeout -= elapsedNanos(start);
         } while (timeout > 10_000);

         return null;
      }
      finally {
         waiters.decrementAndGet();
      }
   }
  1. 이전에 사용했던 커넥션중 재사용 가능한 것을 찾아 반환합니다.

    1. 가장 최근에 사용한 커넥션이 가장 뒤에 있기 때문에 역순으로 탐색합니다.
    2. 약한참조 유무를 확인하고 객체를 반환 하거나 약한 참조 객체로 형변환 합니다. (약한 참조는 스레드에서 더이상 필요하지 않을때 GC대상이 되도록 합니다.)
    3. null검사와 함께 해당 커넥션이 사용중이 아닌경우, 사용중으로 상태를 변경합니다.
  2. *sharedList에서 사용 가능한 커넥션을 찾아 반환합니다.
    a. 대기중인 스레드 수를 1 증가시킵니다.
    b. 해당 커넥션이 사용중이 아닌경우, 사용중으로 상태를 변경합니다.
    c. 대기중인 다른 스레드에 의해 빼앗긴 커넥션이 있다면 **listener에게 커넥션을 요청합니다.

  3. sharedList에 사용가능한 커넥션이 없다면 listener에게 커넥션을 요청합니다.

  4. ***handoffQueue에서 timeout 시간만큼 커넥션을 찾습니다. 주어진 시간동안 커넥션을 반환하지 못하면 null을 반환합니다.

*ㅤsharedList는 커넥션을 공유하는 리스트로, 현재 사용중이지 않은 커넥션이 저장되어 있습니다.
** listener는 커넥션 풀 이벤트를 처리하는 인터페이스를 나타냅니다. listener.addBagItem은 대기중인 스레드를 전달하여 커넥션 요청을 하도록 만듭니다.

*** handoffQueue는 대기중인 스레드가 기다리고 있는 대기열 입니다.


3.3 close

커넥션을 close 하는 방법은 다음과 같습니다.

  1. ProxyConnection에서 관련 리소스를 해제합니다.
  2. PoolEntry에 lastAccess 시간을 기록하고, 커넥션 사용을 기록합니다.
  3. ConcurrentBag에 커넥션을 반환합니다.

1, 2, 3번을 클래스별로 뜯어보겠습니다.

3.3.1 ProxyConnection

Connection 객체의 Proxy입니다.


close

public final void close() throws SQLException {
			// 1
      closeStatements();

      if (delegate != ClosedConnection.CLOSED_CONNECTION) {
				 // 2
         leakTask.cancel();

         try {
						// 3
            if (isCommitStateDirty && !isAutoCommit) {
               delegate.rollback();
               lastAccess = currentTime();
            }

            if (dirtyBits != 0) {
               poolEntry.resetConnectionState(this, dirtyBits);
               lastAccess = currentTime();
            }
         }
         catch (SQLException e) {
            if (!poolEntry.isMarkedEvicted()) {
               throw checkException(e);
            }
         }
         finally {
						// 4
            delegate = ClosedConnection.CLOSED_CONNECTION;
            poolEntry.recycle(lastAccess);
         }
      }
   }
  1. 관련된 모든 statement를 닫습니다. (statement는 커넥션에 실행중인 SQL문을 뜻합니다.)
  2. leakTask를 취소합니다. (리소스 누수 감지용)
  3. 커넥션 상태를 확인한 뒤 롤백 처리합니다.
  4. 커넥션을 닫고 커넥션 풀에 recycle(반환)합니다.

3.3.2 PoolEntry

ConcurrentBag에 담겨져 있는 커넥션 인스턴스 입니다.

void recycle(final long lastAccessed) {
		// 1
    if (connection != null) {
       this.lastAccessed = lastAccessed;
       hikariPool.recycle(this);
    }
 }
  1. 커넥션이 존재한다면, lastAccessed를 기록하고 hikariPool에 반환합니다.

3.3.3 HikariPool

HikariCP에 대한 기본 풀링 동작을 제공하는 기본 풀 입니다.

 void recycle(final PoolEntry poolEntry) {
		// 1
    metricsTracker.recordConnectionUsage(poolEntry);

		// 2
    connectionBag.requite(poolEntry);
 }
  1. 커넥션에 대한 통계 기록을 기록합니다.
  2. connectionBag(커넥션 풀)에 poolEntry(커넥션)를 반환합니다.

3.3.4 ConcurrentBag

커넥션이 담기는 가방(리스트) 역할을 합니다.

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

      for (int i = 0; waiters.get() > 0; i++) {
				 // 2
         if (bagEntry.getState() != STATE_NOT_IN_USE || handoffQueue.offer(bagEntry)) {
            return;
         }
         else if ((i & 0xff) == 0xff) {
            parkNanos(MICROSECONDS.toNanos(10));
         }
         else {
            yield();
         }
      }

			// 3
      final List<Object> threadLocalList = threadList.get();
      threadLocalList.add(weakThreadLocals ? new WeakReference<>(bagEntry) : bagEntry);
   }
  1. 커넥션 상태를 사용하지 않음으로 변경합니다.
  2. 대기중인 스레드가 커넥션 사용중이 아니면 커넥션을 handoffQueue에 저장합니다.
  3. ThreadLocalList에 추가하여, 사용이 안되면 GC의 대상이 되도록 합니다.






참고자료


https://github.com/brettwooldridge/HikariCP

https://techblog.woowahan.com/2664/

profile
봄은 영어로 스프링

0개의 댓글