개발 일지

HikariCP 동작원리

북극곰은콜라 2022. 11. 16. 09:24
반응형


HikariCP 란

출처: https://www.ibm.com/developerworks/data/library/techarticle/dm-1105fivemethods/index.html

JDBC Connection 효율적으로 관리하는 Connection Pool 구현체이다. 또한 일정 버전 Spring Boot 기본적으로 사용하는 connection-pool Framework .


HikariCP 동작원리 (ver 3.4.5)

HikariConfig

public class HikariConfig implements HikariConfigMXBean {
  ...
  private volatile long connectionTimeout;
  private volatile long validationTimeout;
  private volatile long idleTimeout;
  private volatile long leakDetectionThreshold;
  private volatile long maxLifetime;
  private volatile int maxPoolSize;
  private volatile int minIdle;
  private volatile String username;
  private volatile String password;
  private long initializationFailTimeout;
  private String connectionInitSql;
  private String connectionTestQuery;
  ...
}

Hikari로 Pool을 생성할 때 필요한 설정 정보를 가진 vo성 클래스


HikariPool

HiakriCP의 핵심 클래스. Connection Pool의 역할을 한다.

 

HikariConfig를 통해 Pool이 initialize 되었다면

간단하게 getConnection을 통해서 사용 가능한 connection을 가져온다.

 

HikariPool의 핵심인 getConnection() 코드를 살펴보자면

public final class HikariPool extends PoolBase implements HikariPoolMXBean, ConcurrentBag.IBagStateListener {
  ...
  
  public Connection getConnection(long hardTimeout) throws SQLException {
    this.suspendResumeLock.acquire();
    long startTime = ClockSource.currentTime();

    try {
      long timeout = hardTimeout;

      while(true) {
        PoolEntry poolEntry = (PoolEntry)this.connectionBag.borrow(timeout, TimeUnit.MILLISECONDS);
        if (poolEntry != null) {
          long now = ClockSource.currentTime();
          if (!poolEntry.isMarkedEvicted() && (ClockSource.elapsedMillis(poolEntry.lastAccessed, now) <= this.aliveBypassWindowMs || this.isConnectionAlive(poolEntry.connection))) {
            this.metricsTracker.recordBorrowStats(poolEntry, startTime);
            Connection var10 = poolEntry.createProxyConnection(this.leakTaskFactory.schedule(poolEntry), now);
            return var10;
          }

          this.closeConnection(poolEntry, poolEntry.isMarkedEvicted() ? "(connection was evicted)" : "(connection is dead)");
          timeout = hardTimeout - ClockSource.elapsedMillis(startTime);
          if (timeout > 0L) {
            continue;
          }
        }

        this.metricsTracker.recordBorrowTimeoutStats(startTime);
        throw this.createTimeoutException(startTime);
      }
    } catch (InterruptedException var14) {
      Thread.currentThread().interrupt();
      throw new SQLException(this.poolName + " - Interrupted during connection acquisition", var14);
    } finally {
      this.suspendResumeLock.release();
    }
  }
  
  ...
  
  void closeConnection(PoolEntry poolEntry, String closureReason) {
    if (this.connectionBag.remove(poolEntry)) {
      Connection connection = poolEntry.close();
      this.closeConnectionExecutor.execute(() -> {
        this.quietlyCloseConnection(connection, closureReason);
        if (this.poolState == 0) {
          this.fillPool();
        }
      });
    }
  }
}

 

timeout을 계산하기 위한 현재 시간 및 timeout을 지역변수로 갖는다.

전체적인 구조로 보면 timeout이 존재하는 pool의 구조를 가진다

 - 반복문을 돈다

 - 사용 가능한 Connection을 성공적으로 가져오면 return

 - 사용 불가한 Connection을 가져온다면, 해당 Connection을 사용불가 처리한다.

 - timeout 시간이 지났다면 exception을 만들어서 나간다.

 

connectionBag에서 PoolEntry를 borrow 해서 connection을 가지고 오는데

PoolEntry는 Connection을 가지고 있으며, 해당 커넥션의 사용 이력 같은 정보를 가진 객체이다.

PoolEntry를 통해 Connection이 evicted 되었는지, 최종 접속 시간이 resonable 한 시간인지, 실제로 conneciton이 살아있는지를 체크할 수 있다.

 

Connection이 살아있는지 판단하는 로직

여기서 evict의 개념은 HikariCP에서 설정할 수 있는 MaxLifeTime과 연관이 있습니다.

MaxLifeTime 옵션은 Connection이 살아있을 수 있는 최대 시간으로 보면 됩니다.

 - 경험상 이 옵션은 DB에서 세팅되어있는 Connection의 최대 유효시간 옵션들보다 어느 정도 작게 설정되어야 한다. DB와 설정이 어긋나서 사용 불가한 Conneciton이라는 Exception을 볼 수 있다. (connection was evicted)

내부적으로 Connection의 MaxLifeTime을 관리하는 스케쥴러가 동작하는데, 해당 스케쥴러에 등록되는 Task는 옵션에 따라서 Connection을 끊어야 할 때 사용 중인 Connection이라면 끊지 않고 evict를 true로 바꿉니다. 추후 재사용할 때 evict 값이 true면 사용불가한 Connection으로 판단합니다. (당연히 사용중인 Connection이 아니라면 바로 끊어버립니다.)

 

또한 isAlive 한 Connection인지 판단하기 위해 Test 합니다. 

isConnectionAlive() 코드를 살펴보면

boolean isConnectionAlive(Connection connection) {
    try {
      try {
        this.setNetworkTimeout(connection, this.validationTimeout);
        int validationSeconds = (int)Math.max(1000L, this.validationTimeout) / 1000;
        if (this.isUseJdbc4Validation) {
          boolean var15 = connection.isValid(validationSeconds);
          return var15;
        }

        Statement statement = connection.createStatement();

        try {
          if (this.isNetworkTimeoutSupported != 1) {
            this.setQueryTimeout(statement, validationSeconds);
          }

          statement.execute(this.config.getConnectionTestQuery());
        } catch (Throwable var12) {
          ...
        }
        ...
      } finally {
        ...
      }
      return true;
    } catch (Exception var14) {
      ...
    }
}

먼저 jdbc4 스펙을 만족하면 conneciton.isValid()를 통해 connection 유효를 판별합니다.

아니라면 설정된 test query로 connection 유효를 판별합니다. (기본 SELECT 1)

 

PoolEntry를 코드로 살펴보면

final class PoolEntry implements ConcurrentBag.IConcurrentBagEntry {
  private static final Logger LOGGER = LoggerFactory.getLogger(PoolEntry.class);
  private static final AtomicIntegerFieldUpdater<PoolEntry> stateUpdater = AtomicIntegerFieldUpdater.newUpdater(PoolEntry.class, "state");
  Connection connection;
  long lastAccessed;
  long lastBorrowed;
  private volatile int state = 0;
  private volatile boolean evict;
  private volatile ScheduledFuture<?> endOfLife;
  private final FastList<Statement> openStatements;
  private final HikariPool hikariPool;
  private final boolean isReadOnly;
  private final boolean isAutoCommit;
  
  ...
  
  void markEvicted() {
    this.evict = true;
  }
  
  ...
  
  long getMillisSinceBorrowed() {
    return ClockSource.elapsedMillis(this.lastBorrowed);
  }
  
  ...
  
  private String stateToString() {
    switch (this.state) {
      case -2:
        return "RESERVED";
      case -1:
        return "REMOVED";
      case 0:
        return "NOT_IN_USE";
      case 1:
        return "IN_USE";
      default:
        return "Invalid";
    }
  }
}

Connection객체 최종 접속 시간, 최종 빌린 시간, 현 상태 (5종), evict 여부 등을 속성으로 가진 클래스인 것을 확인할 수 있다.

이 정보를 바탕으로 HikariPool에서 해당 Connection을 사용할지에 대해서 판단한다.

 

마지막으로 ConnectionBag에서 connection을 borrow 하는 부분으로 코드로 살펴보면

public class ConcurrentBag<T extends IConcurrentBagEntry> implements AutoCloseable {

  ...

  public T borrow(long timeout, TimeUnit timeUnit) throws InterruptedException {
    List<Object> list = (List)this.threadList.get();
    ...
    
    for(waiting = list.size() - 1; waiting >= 0; --waiting) {
      entry = list.remove(waiting);
      bagEntry = this.weakThreadLocals ? (IConcurrentBagEntry)((WeakReference)entry).get() : (IConcurrentBagEntry)entry;
      if (bagEntry != null && bagEntry.compareAndSet(0, 1)) {
        return bagEntry;
      }
    }
    
    ...
    
    try {
      label165: {
        Iterator var13 = this.sharedList.iterator();

        while(var13.hasNext()) {
          bagEntry = (IConcurrentBagEntry)var13.next();
          if (bagEntry.compareAndSet(0, 1)) {
            if (waiting > 1) {
              this.listener.addBagItem(waiting - 1);
            }
            break label165;
          }
        }

        this.listener.addBagItem(waiting);
        timeout = timeUnit.toNanos(timeout);

        do {
          long start = ClockSource.currentTime();
          bagEntry = (IConcurrentBagEntry)this.handoffQueue.poll(timeout, TimeUnit.NANOSECONDS);
          if (bagEntry == null || bagEntry.compareAndSet(0, 1)) {
            IConcurrentBagEntry var9 = bagEntry;
            return var9;
          }

          timeout -= ClockSource.elapsedNanos(start);
        } while(timeout > 10000L);

        entry = null;
        return (IConcurrentBagEntry)entry;
      }

      bagEntry = bagEntry;
    } finally {
      this.waiters.decrementAndGet();
    }

    return bagEntry;
  }
  
  ...
  
}

설정된 timeout 기간 동안 내부에서 사용 가능한 bagEntry(PoolEntry의 interface)를 return 한다.

threadList, sharedList와 handoffQueue에서 차례로 사용가능한 PoolEntry를 찾는다.

 

각 리스트에 담기는 Connection을 짧게 설명하자면

threadList: ThreadLocal에 있는 Connection. 이전에 사용하고 대기가 없었던 재사용 가능한 connection

sharedList: 새로 생성된 (idle상태) Connection 들

 - 여기 Connection들은 idleConnection을 생성할 때 채워집니다. fillpool() 메서드

 - Connection을 가져가면서 waiting이 있는지 체크를 하는데, 대기자가 있으면 내가 하나 가져가면서 하나 더 만들어달라고 요청합니다.

handoffQueue : 놀고 있는 Connection이 하나도 없는 상태이기에, Connection timeout이 지나기 전에 다 쓰고 반납되는 Connection을 받아갈 수 있는 Queue입니다.

 

가져오는 과정에서 사용 가능한 판단은 PoolEntry의 state를 확인한다

 - bagEntry.compareAndSet(0,1)은 현재 상태가 0 (NOT_IN_USE)이며, 1 (IN_USE)로 바꾸는 메서드이다.

 

 

Connection을 Hikari옵션에 따라서 lifeCycle을 관리해주는 스케쥴러와 Task에 대해서 잠깐 보겠습니다.

등록된 시간에 의해서 동작하는 HouseKeeper의 코드를 살펴보면

private final class HouseKeeper implements Runnable {
    private volatile long previous;

    private HouseKeeper() {
      this.previous = ClockSource.plusMillis(ClockSource.currentTime(), -HikariPool.this.housekeepingPeriodMs);
    }

    public void run() {
      try {
        HikariPool.this.connectionTimeout = HikariPool.this.config.getConnectionTimeout();
        HikariPool.this.validationTimeout = HikariPool.this.config.getValidationTimeout();
        HikariPool.this.leakTaskFactory.updateLeakDetectionThreshold(HikariPool.this.config.getLeakDetectionThreshold());
        HikariPool.this.catalog = HikariPool.this.config.getCatalog() != null && !HikariPool.this.config.getCatalog().equals(HikariPool.this.catalog) ? HikariPool.this.config.getCatalog() : HikariPool.this.catalog;
        long idleTimeout = HikariPool.this.config.getIdleTimeout();
        long now = ClockSource.currentTime();
        if (ClockSource.plusMillis(now, 128L) < ClockSource.plusMillis(this.previous, HikariPool.this.housekeepingPeriodMs)) {
          HikariPool.this.logger.warn("{} - Retrograde clock change detected (housekeeper delta={}), soft-evicting connections from pool.", HikariPool.this.poolName, ClockSource.elapsedDisplayString(this.previous, now));
          this.previous = now;
          HikariPool.this.softEvictConnections();
          return;
        }

        if (now > ClockSource.plusMillis(this.previous, 3L * HikariPool.this.housekeepingPeriodMs / 2L)) {
          HikariPool.this.logger.warn("{} - Thread starvation or clock leap detected (housekeeper delta={}).", HikariPool.this.poolName, ClockSource.elapsedDisplayString(this.previous, now));
        }

        this.previous = now;
        String afterPrefix = "Pool ";
        if (idleTimeout > 0L && HikariPool.this.config.getMinimumIdle() < HikariPool.this.config.getMaximumPoolSize()) {
          HikariPool.this.logPoolState("Before cleanup ");
          afterPrefix = "After cleanup  ";
          List<PoolEntry> notInUse = HikariPool.this.connectionBag.values(0);
          int toRemove = notInUse.size() - HikariPool.this.config.getMinimumIdle();
          Iterator var8 = notInUse.iterator();

          while(var8.hasNext()) {
            PoolEntry entry = (PoolEntry)var8.next();
            if (toRemove > 0 && ClockSource.elapsedMillis(entry.lastAccessed, now) > idleTimeout && HikariPool.this.connectionBag.reserve(entry)) {
              HikariPool.this.closeConnection(entry, "(connection has passed idleTimeout)");
              --toRemove;
            }
          }
        }

        HikariPool.this.logPoolState(afterPrefix);
        HikariPool.this.fillPool();
      } catch (Exception var10) {
        HikariPool.this.logger.error("Unexpected exception in housekeeping task", var10);
      }

    }
  }

 

 

 

이 Task의 목적은 최소 idleConnection 설정과 "사용 가능한" idleConnection 수를 비교해서 부족하면 fillpool() 합니다. 다만 비교를 진행하기 전에 실제로 가용 idleConnection이 몇 개인지 파악하기 위해 cleanup 작업을 진행합니다.

cleanup 작업은

 - 우선 evict 된 connection 들을 정리합니다.

 - Connection이 idleTimeout 시간을 지났으면 정리합니다.

 - idleConnection이 필요 이상으로 많으면 정리합니다.

입니다.

idleConnection은 위에 잠깐 설명했듯이 sharedList에 진입해있는 Connection들입니다.

 

 

위에 잠깐 언급되었던 MaxLifeTime 부분은 PoolEntry가 생성되는 시점인데 fillpool() 내부적으로 실행됩니다.

createPoolEntry()의 코드를 살펴보면

private PoolEntry createPoolEntry() {
    try {
      PoolEntry poolEntry = this.newPoolEntry();
      long maxLifetime = this.config.getMaxLifetime();
      if (maxLifetime > 0L) {
        long variance = maxLifetime > 10000L ? ThreadLocalRandom.current().nextLong(maxLifetime / 40L) : 0L;
        long lifetime = maxLifetime - variance;
        poolEntry.setFutureEol(this.houseKeepingExecutorService.schedule(() -> {
          if (this.softEvictConnection(poolEntry, "(connection has passed maxLifetime)", false)) {
            this.addBagItem(this.connectionBag.getWaitingThreadCount());
          }

        }, lifetime, TimeUnit.MILLISECONDS));
      }

      return poolEntry;
    } catch (PoolBase.ConnectionSetupException var8) {
      if (this.poolState == 0) {
        this.logger.error("{} - Error thrown while acquiring connection from data source", this.poolName, var8.getCause());
        this.lastConnectionFailure.set(var8);
      }
    } catch (Exception var9) {
      if (this.poolState == 0) {
        this.logger.debug("{} - Cannot acquire connection from data source", this.poolName, var9);
      }
    }

    return null;
}

HouseKeeper가 등록되는 그 executorService에 익명 클래스로 들어갑니다. MaxLifeTime이 지나면 실행되며, 이때 해당 Connection을 evict 상태로 바꿉니다.


Conclusion

 - HikariPool에서 모든 Connection을 제공하는 interface를 제공한다.

 - hikariConfig를 통해서 pool을 관리하는 설정을 할 수 있다.

 - Connection의 제공은 ConnectionBag에서 한다

    - ThreadLocal에서 등록 / 사용되는 재사용이 많이 되는 Connection이 있다.

    - sharedList에서 관리되는 idle Connection들이 있다.

    - 모든 Connection이 사용 중이라면, 반환되는 Connection을 기다린다.

 - HouseKeeper는 Connection들을 관리한다.

    - 지속적으로 evict 된 Connection을 정리한다.

    - idleTimeout설정으로 idleConnection들을 정리한다.

    - 실제 idleConnection이 적으면 만들고, 많으면 정리한다.

 - 모든 Connection은 생성될 때 스케쥴러에 maxLifeTime이 지나면 evict 되는 task를 등록한다.

반응형

'개발 일지' 카테고리의 다른 글

OIDC 란  (0) 2022.11.23
Logstash 란  (0) 2022.11.23
[외부자료] Java ClassLoader 요약 글  (0) 2022.11.22
JsonPath란  (0) 2022.11.22
JDBC 동작원리  (0) 2022.11.15