SSARTEL-10th / JPTS_bookstudy

"개발자가 반드시 알아야 할 자바 성능 튜닝 이야기" 완전 정복
7 stars 0 forks source link

DB Connection Pool 그것이 알고싶다 #12

Open KIMSEI1124 opened 1 year ago

KIMSEI1124 commented 1 year ago

👍 문제

12장에서 DB Connection Pool 이라는 키워드가 나왔습니다. 저는 Spring Framework 를 사용할 때 자주본 키워드인데 HikariCP를 사용하는 것만 알고 있을 뿐 동작 방식에 대해서 자세히 알지 못하고 있습니다! 그래서 DB Connection Pool의 종류와 동작 방식에 대해서 설명해주시면 좋을 것 같습니다!

✈️ 선정 배경

키워드와 간단한 설명만 등장하였는데 자세히 알아보면 좋을 것 같고 Spring Framework에서도 자주 확인할 수 있는 키워드여서 한번 자세히 공부하면 좋을 것 같아서 선정하였습니다!

📺 관련 챕터 및 레퍼런스

🐳 비고

주제가 어렵다고 생각하는데 힘주지 말고 해주셔도 괜찮아요!

ChoiSeEun commented 1 year ago

1. DB Connection

DB Connection Pool 에 대해 알아보기에 앞서, 간단히 DB Connection 에 대해 정리하고자 한다. Connection 은 DB에 연결하는 객체를 의미하며, SQL문을 실행할 수 있는 Statement 객체를 생성하는 기능을 제공한다.

private DBUtil util = DBUtil.getUtil();

    @Override
    public Product select(String num) throws SQLException {
        Product prod = null;
        String sql = "select  * from product where num=?";
        try(Connection con = util.getConnection();
                PreparedStatement ps = con.prepareStatement(sql);){
            ps.setString(1, num);
            try(ResultSet rs = ps.executeQuery()){
                if(rs.next()) {
                    prod = new Product(num,rs.getString(2),rs.getInt("price"));
                }
            }
        }
        return prod;
    }

이 때, 사용자가 요청을 할 때마다 JDBC Driver 를 로드하고 Connection 객체를 받아오는 것은 귀찮을 뿐더러 매우 비효율적이다. Connection을 획득하는 과정은 아래의 순서로 진행되어 비용이 많이 드는 작업이기 때문이다.

  1. 애플리케이션 로직은 DB 드라이버를 통해 커넥션을 조회
  2. DB 드라이버는 DB와 TCP/IP 커넥션을 연결. 이때, 3-way-handshake 같은 TCP/IP 연결을 위한 네트워크 동작이 발생함
  3. TCP/IP 커넥션이 연결되면 DB 드라이버는 ID,PW와 기타 부가정보를 DB에 전달
  4. DB는 ID, PW를 통해 내부 인증을 완료하고 내부에 DB 세션을 생성
  5. DB는 커넥션 생성이 완료되었다는 응답을 보냄
  6. DB 드라이버는 커넥션 객체를 생성해서 클라이언트에 반환

2. DB Connection Pool(DBCP)

이런 비효율적인 과정을 해결하기 위해 나온 개념이 DB Connection Pool 이다.

이전 #7 이슈에서 Thread Pool 이 Thread를 재사용하여 성능과 리소스 관리에 도움을 주는 개념이라고 설명했는데 DB Connection Pool 도 동일한 개념이라고 생각하면 된다.

웹 컨테이너(WAS)가 실행되면서 connection 객체를 미리 pool에 생성해둔다. 클라이언트는 pool에서 Connection 객체를 가져다쓰고 반환하면 된다. connection 을 생성하는데 드는 요청마다 연결 시간이 소비되지 않는다는 점이 가장 큰 장점이다.

추가 장점

  1. DB 접속 설정 객체를 미리 만들어 메모리 상에 등록해 놓기 때문에, 불필요한 작업이 사라지므로 클라이언트가 빠르게 DB에 접속이 가능함
  2. DB Connection 수를 제한할 수 있어서 과도한 접속으로 인한 서버 자원 고갈 방지가 가능
  3. DB 접속 모듈을 공통화하여 DB 서버의 환경이 바뀔 경우 쉬운 유지 보수가 가능
  4. 연결이 끝난 Connection 을 재사용함으로써 새로 객체를 만드는 비용을 줄일 수 있음

하지만 connection 은 한정되어 있기 때문에, 동시 접속자가 많은 경우 클라이언트는 connection 이 반환될 때까지 대기 상태로 기다려야 한다는 단점이 존재한다. 대기 상태에 관해서는 아래에서 설명할 예정이다.

종류

대표적인 DBCP 프레임워크는 아래와 같다.

이 중 가장 많이 사용되는 HikariCP 를 중점적으로 알아보고, 다른 프레임워크는 간단히 언급만 하려고 한다.

3. HikariCP

Fast, simple, reliable, HikariCP is a "zero-overhead" production ready JDBC connection pool. At roughly 130Kb, the library is very light.

HikariCP 는 Spring에 기본적으로 내장되어 있는 JDBC DBCP 이다. Spring 은 HikariCP 를 사용할 수 있는 상황이라면 항상 HikariCP 를 선택한다. 그 이유는, HikariCP 가 바이트코드 수준까지 최적화되어 있고 Collection 프레임워크를 영리하게 사용하여 성능이 압도적으로 우수하기 때문이다.

참고로, HikariCP 를 사용할 수 없는 경우 Tomcat DBCP -> Apache Commons DBCP -> Oracle UCP 순으로 선택해서 사용한다고 한다.

동작 방식

HikariCp 는 내부적으로 ConcurrentBag 이라는 구조체를 이용해 connection 을 관리한다. 이는 여러 스레드가 객체를 저장할 수 있으며, 스레드로부터 안전한 컬렉션 클래스 중 하나이다. getConnection()을 호출하면 내부적으로 ConcurrentBag.borrow() 를 통해 사용 가능한(=idle) connection 을 리턴한다.

또한, HikariCP 는 Thread가 이전에 사용했던 connection 이 존재하는지 확인하고 이를 우선적으로 반환한다는 특징이 있다. (각 DBCP 프레임워크 별 Thread 에게 connection 을 반환하는 방식에는 약간씩 차이가 존재한다. )

만약, 가능한 connection 이 존재하지 않으면 Thread 는 HandOffQueue 에서 대기를 하게 된다. 다른 Thread가 connection 을 반납할 때까지 대기하지만 일정 시간(=TimeOut) 까지 대기하다가 시간이 만료되면 예외를 던진다. 참고로 default TimeOut 시간은 30초이다.

Thread는 connection 을 다 사용한 후 Connection.close() 를 통해 다시 객체를 Pool에 반납한다. (정상적으로 transaction이 commit 되거나 에러 등으로 rollback이 호출되면 Connection.close() 이 호출됨 ) 해당 메서드를 호출하면 내부적으로는 ConcurrentBag.requite() 이 실행되어 connection 이 반납된다. 반납된 connection 은 HandOffQueue에 대기하는 Thread에게 제공되고 대기하는 Thread가 없다면 ConcurrentBag 로 관리된다.

내부 코드

워낙 구현이 복잡하게 되어 있어서, 전체 코드가 궁금한 사람은 GitHub를 참고하면 좋을 것 같고 여기서는 몇가지 눈에 띄는 사항들만 정리하고자 한다. https://github.com/brettwooldridge/HikariCP

public final class HikariPool extends PoolBase implements HikariPoolMXBean, IBagStateListener
{
   private final Logger logger = LoggerFactory.getLogger(HikariPool.class);

   public static final int POOL_NORMAL = 0;
   public static final int POOL_SUSPENDED = 1;
   public static final int POOL_SHUTDOWN = 2;

   public volatile int poolState;

   private final long aliveBypassWindowMs = Long.getLong("com.zaxxer.hikari.aliveBypassWindowMs", MILLISECONDS.toMillis(500));
   private final long housekeepingPeriodMs = Long.getLong("com.zaxxer.hikari.housekeeping.periodMs", SECONDS.toMillis(30));

   private static final String EVICTED_CONNECTION_MESSAGE = "(connection was evicted)";
   private static final String DEAD_CONNECTION_MESSAGE = "(connection is dead)";

   private final PoolEntryCreator poolEntryCreator = new PoolEntryCreator();
   private final PoolEntryCreator postFillPoolEntryCreator = new PoolEntryCreator("After adding ");

   // Thread 가 pool을 이용하는 주체이므로 ThreadPool을 사용 
   private final ThreadPoolExecutor addConnectionExecutor;
   private final ThreadPoolExecutor closeConnectionExecutor;

    // 내부적으로 ConcurrentBag 사용
   private final ConcurrentBag<PoolEntry> connectionBag;

   private final ProxyLeakTaskFactory leakTaskFactory;
   private final SuspendResumeLock suspendResumeLock;

   private final ScheduledExecutorService houseKeepingExecutorService;
   private ScheduledFuture<?> houseKeeperTask;

   /**
    * Construct a HikariPool with the specified configuration.
    *
    * @param config a HikariConfig instance
    */
   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();

      if (config.getMetricsTrackerFactory() != null) {
         setMetricsTrackerFactory(config.getMetricsTrackerFactory());
      }
      else {
         setMetricRegistry(config.getMetricRegistry());
      }

      setHealthCheckRegistry(config.getHealthCheckRegistry());

      handleMBeans(this, true);

      ThreadFactory threadFactory = config.getThreadFactory();

      final int maxPoolSize = config.getMaximumPoolSize();

    // BlockingQueue를 사용하여 관리 
      LinkedBlockingQueue<Runnable> addConnectionQueue = new LinkedBlockingQueue<>(maxPoolSize);
      this.addConnectionExecutor = createThreadPoolExecutor(addConnectionQueue, poolName + " connection adder", threadFactory, new CustomDiscardPolicy());
      this.closeConnectionExecutor = createThreadPoolExecutor(maxPoolSize, poolName + " connection closer", threadFactory, new ThreadPoolExecutor.CallerRunsPolicy());

      this.leakTaskFactory = new ProxyLeakTaskFactory(config.getLeakDetectionThreshold(), houseKeepingExecutorService);

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

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

   /**
    * Get a connection from the pool, or timeout after connectionTimeout milliseconds.
    *
    * @return a java.sql.Connection instance
    * @throws SQLException thrown if a timeout occurs trying to obtain a connection
    */
   public Connection getConnection() throws SQLException
   {
      return getConnection(connectionTimeout);
   }

   /**
    * Get a connection from the pool, or timeout after the specified number of milliseconds.
    *
    * @param hardTimeout the maximum time to wait for a connection from the pool
    * @return a java.sql.Connection instance
    * @throws SQLException thrown if a timeout occurs trying to obtain a connection
    */
   public Connection getConnection(final long hardTimeout) throws SQLException
   {
      suspendResumeLock.acquire();
      final var startTime = currentTime();

    // TimeOut 이 존재하고, 이를 넘어가면 오류를 발생 
      try {
         var timeout = hardTimeout;
         do {
            var poolEntry = connectionBag.borrow(timeout, MILLISECONDS);
            if (poolEntry == null) {
               break; // We timed out... break and throw exception
            }

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

         metricsTracker.recordBorrowTimeoutStats(startTime);
         throw createTimeoutException(startTime);
      }
      catch (InterruptedException e) {
         Thread.currentThread().interrupt();
         throw new SQLException(poolName + " - Interrupted during connection acquisition", e);
      }
      finally {
         suspendResumeLock.release();
      }
   }

    // Thread의 작업을 종료시키는 shutdown 메소드에서 close() 구현 
   public synchronized void shutdown() throws InterruptedException
   {
      try {
         poolState = POOL_SHUTDOWN;

         if (addConnectionExecutor == null) { // pool never started
            return;
         }

         logPoolState("Before shutdown ");

         if (houseKeeperTask != null) {
            houseKeeperTask.cancel(false);
            houseKeeperTask = null;
         }

         softEvictConnections();

         addConnectionExecutor.shutdown();
         if (!addConnectionExecutor.awaitTermination(getLoginTimeout(), SECONDS)) {
            logger.warn("Timed-out waiting for add connection executor to shutdown");
         }

         destroyHouseKeepingExecutorService();

         connectionBag.close();

         final var assassinExecutor = createThreadPoolExecutor(config.getMaximumPoolSize(), poolName + " connection assassinator",
                                                                           config.getThreadFactory(), new ThreadPoolExecutor.CallerRunsPolicy());
         try {
            final var start = currentTime();
            do {
               abortActiveConnections(assassinExecutor);
               softEvictConnections();
            } while (getTotalConnections() > 0 && elapsedMillis(start) < SECONDS.toMillis(10));
         }
         finally {
            assassinExecutor.shutdown();
            if (!assassinExecutor.awaitTermination(10L, SECONDS)) {
               logger.warn("Timed-out waiting for connection assassin to shutdown");
            }
         }

         shutdownNetworkTimeoutExecutor();
         closeConnectionExecutor.shutdown();
         if (!closeConnectionExecutor.awaitTermination(10L, SECONDS)) {
            logger.warn("Timed-out waiting for close connection executor to shutdown");
         }
      }
      finally {
         logPoolState("After shutdown ");
         handleMBeans(this, false);
         metricsTracker.close();
      }
   }
}

속성

속성명 설명
autoCommit pool에서 반환된 connection의 기본 자동 커밋 동작 제어 (default = true)
connectionTimeout 클라이언트가 pool로 부터 연결을 기다리는 최대 시간 (default = 30000 ms)
idleTimeout Connection Pool에서 쉬고 있는 커넥션을 유지하는 시간 (default = 60000 ms)
keepaliveTime 데이터베이스 또는 네트워크 인프라에 의해 시간 초과되는 것을 방지하기 위해 connection 유지를 위한 연결을 얼마나 자주 시도할 것인지 제어 (default = 0)
maxLifetime Connection Pool에서 살아 있을 수 있는 커넥션의 최대 수명 시간 (default = 1800000 ms)
connectionTestQuery 데이터베이스 연결이 활성 상태인지 확인하기 위해 pool 에서 연결이 제공되기 직전에 실행되는 쿼리 (default = none)
minimumIdle 커넥션이 일을 하지 않아도 해당 사이즈만큼은 커넥션을 유지하도록
maximumPoolSize Connection Pool에 유지시킬 수 있는 최대 커넥션 수 (default = 10)
pollName Connection Pool의 사용자 정의 이름 (default = auto-generated)

Deadlock

Thread 가 서로의 DB Connection 이 반납되기만을 무한정 대기하는 상황

DBCP 를 사용할 때는 교착상태(deadlock) 을 주의해야 한다. Pool Size를 충분하게 설정하지 못하는 경우 Deadlock 이 발생할 수 있다.

  1. Thread-1 이 Repository.save() 의 insert query를 실행하기 위해 Transaction 시작
  2. Root Transaction 용 Connection 을 가져옴
  3. 하지만, Repository.save() 를 위해서 Connection 이 더 필요하다면 ?
    • Pool 에 유휴 상태인 Connection 이 없으므로 handOffQueue에서 TimeOut 동안 기다림
    • 1개 있던 Connection 은 본인이 사용하고 있으므로, 본인이 반납하지 않는 이상 사용 가능한 Connection 이 없음
    • TimeOut 시간이 지나서 hikari-pool-1 - Connection is not available, request timed out after 30000ms 에러 발생
  4. SQLTransientConnectionException 으로 인해 Sub Transaction이 Rollback
  5. Sub Transaction이 Rollback 되며 Root Transaction 이 rollbackOnly=true 가 되며 Root Transaction 또한 Rollback
  6. Rollback 되었으므로 Root Transaction 의 Connection 은 다시 pool 에 반납됨

적정 사이즈

위와 같은 상태를 방지하기 위해서는 적절한 Pool Size를 지정해야 한다. HikariCP 공식 문서에서 제공하는 적절한 커넥션 풀 사이즈 공식은 아래와 같다.

connection=((core_count∗2)+effective_spindle_count)

core_count : cpu 의 코어 수 effective_spindle_count : DB 서버가 동시 관리할 수 있는 IO 개수

또한, deadlock 이 발생하지 않을 DBCP 최소 사이즈도 아래와 같이 제안하고 있다.

Tn​ ∗(Cm​ −1)+1

Tn : WAS 의 전체 thread 개수 Cm : thread가 작업을 수행하기 위해 동시에 필요한 connection의 개수

thread 최대 개수가 10개고, 동시에 필요한 connection 개수가 3개라고 가정하고 위의 공식을 적용해보자. 위의 공식에 대입하면 10 * (3-1) + 1 = 21 의 결과가 나온다. 만약, 10개의 thread가 2개씩 connection 을 할당받으면 1개의 connection pool에 남게 된다. 따라서 최소 하나의 thread는 무조건 connection을 3개 확보하고 작업을 처리한 뒤 connection을 반납할 수 있게 되어 deadlock 이 발생하지 않는다.

위의 공식은 단순히 참고용일 뿐, 애플리케이션에 따라 추가적인 기준이 필요하다. 이때, connection 또한 객체이기 때문에 메모리 공간을 차지하므로 적절한 크기를 찾아 설정해야 한다.

4. 기타 DBCP

Apache Commons DBCP

Apache 에서 제공하는 대표적인 DBCP 프레임워크이다. 아래와 같이 4개의 속성이 대표적으로 존재한다.

속성명 설명
initialSize BasicDataSource 클래스 생성 후 최초로 getConnection() 메서드를 호출할 때 커넥션 풀에 채워 넣을 커넥션 개수
maxActive 동시에 사용할 수 있는 최대 커넥션 개수 (default : 8)
maxIdle 커넥션 풀에 반납할 때 최대로 유지할 수 있는 커넥션 개수 (default : 8)
minIdle 최소한으로 유지할 커넥션 개수 (default : 0)

가장 중요한 성능 요소는 maxActive 이다. 이 값은 DBMS의 설정과 애플리케이션 서버의 개수, Apache, Tomcat 에서 동시에 처리할 수 있는 사용자 수등을 고려해서 설정해야 한다. 또한, Commons DBCP 에서는 DBMS에 로그인을 시도하고 있는 커넥션도 사용 중인 것으로 간주한다. 따라서 로그인을 시도하고 있는 상태에서 무한으로 대기한다면 새로운 요청을 처리하지 못하게 된다. 이를 막기 위해 loginTimeOut 속성과 같은 JDBC 드라이버별 타임아웃 속성을 설정하는 것이 좋다.

Tomcat DBCP

tomcat 에 내장되어 사용되고 있는 프레임워크이다. Apache Commons DBCP를 바탕으로 만들어져 있으며, Spring 2.0 하위 버전의 기본 DBCP이다.

참고자료

https://velog.io/@mooh2jj/%EC%BB%A4%EB%84%A5%EC%85%98-%ED%92%80Connection-pool%EC%9D%80-%EC%99%9C-%EC%93%B0%EB%8A%94%EA%B0%80 https://steady-coding.tistory.com/564 https://hudi.blog/dbcp-and-hikaricp/ https://data-make.tistory.com/764 https://techblog.woowahan.com/2664/