[Spring] HikariCP 2) Connection을 다루는 메커니즘

개요

 

[Spring] HikariCP 1) DBCP란?

DB Connection Web Application Server(WAS) ↔ DB Server간에 네트워크 통신을 위해서는 TCP/IP 기반으로 Connection을 맺어야 한다 WAS에서는 적절한 DB Driver를 통해서 DBMS에 Connection 요청을 진행한다 DB Driver는 WAS

sjiwon-dev.tistory.com

위의 포스팅을 통해서 기본적인 DBCP에 대한 개념과 HikariCP의 핵심 컴포넌트에 대한 기본적인 개념을 알아보았다

본 포스팅에서는 HikariCP에서 실질적으로 DB Connection을 다루는 메커니즘에 대해서 알아볼 것이다

 

HikariCP 주요 필드

필드 설명
poolState HikariCP의 상태
→ POOL_NORMAL / POOL_SUSPENDED / POOL_SHUTDOWN
poolEntryCreator poolEntry에 새로운 Connection을 생성하기 위한 컴포넌트
postFillPoolEntryCreator PoolEntry의 Idle Connection이 모자랄 경우 채우기 위한 컴포넌
addConnectionExecutor
closeConnectionExecutor
Connection을 add/close 하기 위한 Task를 관리
connectionBag Connection을 관리하는 가방 개념
suspendResumeLock HikariCP에서 Connection을 다루기 위해서 일시적으로 중단/재개하기 위한 Lock
houseKeepingExecutorService
houseKeeperTask
HikariCP의 Idle Connection에 대한 timeout, maximum, ..등을 관리하고 minimum connection을 유지

 

 

HikariCP 메커니즘

1. HikariPool 초기화

(1) HouseKeeper 관련 ExecutorService 초기화, 테스트 연결 시도

HikariPool 생성자

먼저 HIkariCP 생성자를 통해서 초기화를 진행하게 되는데 핵심은 다음과 같다

  1. initializeHouseKeepingExecutorService()
  2. checkFailFast()

 

initializeHouseKeepingExecutorService()

HikariPool#initializeHouseKeepingExecutorService()

DBCP의 Idle/Minimum Connection들을 관리하는 HouseKeeper를 주기적으로 동작시킬 ScheduledExecutorService를 초기화한다

  • corePoolSize = 1개인 ThreadPool
  • RejectedExecutionPolicy = DiscardPolicy
    • Queue, maximumPoolSize가 over되면 다음 Task는 버리기

 

checkFailFast()

HikariPool -> checkFailFast()

그 이후 checkFailFast를 통해서 설정한 DBMS와 연결이 가능한지 먼저 확인을 진행한다

  • 이전 포스팅에서 알아봤듯이 PoolEntry는 Connection을 Wrapping하고 추가적인 메타 정보를 트래킹하는 컴포넌트이다
  • checkFailFast에서 실제 DB Connection을 하나 얻는다는 것을 알 수 있다

HikariPool#createPoolEntry()
PoolBase#newPoolEntry()
PoolBase#newConnection()
HikariPool#checkFailFast()

위의 일련의 과정을 통해서 실제로 DB Connection을 가져오는데 이 결과에 따라 다음과 같은 로직으로 분기된다

 

※ PoolEntry 확보 O = Connection 획득 O

① minimumIdle 값이 0보다 큰지

  • DB 연결 테스트를 위해서 가져온 Connection을 ConnectionBag에 넣어준다
  • 테스트라고 하더라도 실제 DB Connection을 얻은거고 minimumIdle 또한 0보다 크기 때문에 유지할 가치가 있다고 판단

② minimumIdle 값이 0보다 작다면

  • 유지해야 할 최소 Connection이 없다는 의미이므로 얻은 DB Connection을 닫아준다

 

※ PoolEntry 확보 X = Connection 획득 X

Connection을 얻는 과정에서 ConnectionSetupException이 발생한지 확인한다

  • 발생 O = 즉시 테스트 종료 및 예외 발생
  • 발생 X = initializationTimeout이 다 소모될때까지 지속적으로 DB Connection을 얻으려는 시도 진행

 

(2) 여러 메트릭 초기화 및 Executor & HouseKeeper 설정

HikariPool 생성자

checkFailFast()를 통해서 테스트 DB 연결을 성공했다면 그 이후는 여러 메트릭 설정과 Executor & HouseKeeper 설정을 진행한다

  • addConnectionQueue는 Connection을 새로 만들기 위해서 관련된 Thread들을 대기시키는 Queue

 

(3) HouseKeeper minimumIdleConnection 채우기

HikariPool 생성자

HikariPool 초기화 마지막 단계로써 minimumIdle까지 Connection을 채우는 작업을 진행하게 된다

그런데 의문이 들 것이다

  • 그 어디에도 Connection을 채우는 코드가 보이지 않음
  • 그대신 getTotalConnections() < config.getMinimumIdle()을 통해서 minimumIdle이 다 채워졌는지 확인하는 코드는 보임
이 과정은 이전에 초기화 및 설정을 진행한 HouseKeeper가 담당하게 된다

HouseKeeper#run()
HikariPool#fillPool

  1. minimumIdle을 초과했다면 STATE_NOT_IN_USE나 유효하지 않은 Connection들 정리
  2. minimumIdle이 모자라면 fillPool(true)를 통해서 채워주기

 

이러한 일련의 과정을 통해서 HikariPool이 초기화된다
여기서 핵심은 minimumIdle까지 Connection을 미리 생성하는 부분이다

 

 

 

2. Connection 획득

(1) HikariDataSource#getConnection()

  • HikariDataSource는 HikariConfig의 여러 설정값에 대해서 이 값들이 제대로된 값인지 검증한다 (URL, Driver, …)
  • 그 이후 pool & fastPathPool을 초기화한다

HikariDataSource -> getConnection()

  1. HikariDataSource차 close 상태인지 확인
    • close면 Connection 획득 불가능
  2. fastPathPool이 존재하면 fastPathPool을 통해서 getConnection()
  3. fastPathPool이 없다면 그냥 pool을 통해서 getConnection()
  4. fastPathPool & pool 둘다 없다면 HikariPool 새로 생성해서 getConnection()

 

(2) HikariPool#getConnection()

HikariPool#getConnection

  • HikariPool은 connectionTimeout 이내에 DB Connection을 얻으려고 시도한다

 

(2-1) ConcurrentBag#borrow()

HikariPool#getConnection

  • connectionBag(ConcurrentBag)의 borrow를 통해서 DBCP에 존재하는 Connection을 가져오려는 시도를 진행한다

 

① threadList 확인

ConcurrentBag#borrow()

1. ThreadLocal에서 해당 Thread가 이전에 사용했던 Connection들 중에서 사용 가능한 Connection이 있다면 해당 Connection을 사용
  • compareAndSet을 통해서 STATE_NOT_IN_USE → STATE_IN_USE로 사용하겠다는 상태로 변경

 

② sharedList 확인

이전에 자신이 사용했던 Connection을 캐싱하는 threadList에 사용 가능한 Connection이 없다면

  • 이제는 모든 Thread가 공유하는 HikariCP의 전체 Connection을 관리하는 sharedList에서 찾아본다
  • 만약 sharedList에 사용 가능한 Connection이 존재한다고 하더라도 나보다 이전에 Connection을 기다리고 있던 waiter가 존재한다면 waiter들을 위해서 listener에 Connection 생성 요청을 넣어두고 대기한다

HikariPool#addBagItem

2. 전체 Connection을 보관하는 sharedList에서 사용 가능한 Connection을 찾아보고 만약 있다면 대기자들도 체크하고 가져온다
→ waiter가 본인말고 추가로 존재한다면 Connection 생성을 위해 addConnectionExecutor에 Task(Connection 생성 - poolEntryCreator)를 Submit한다

 

 

③ handoffQueue에서 대기 (with Polling)

threadList, sharedList 둘다 사용 가능한 Connection이 없다면

  • handoffQueue를 지속적으로 polling해서 사용 가능한 Connection이 생겼는지 감지하는 단계로 들어가게 된다
  • timeout이 10ms보다 적게 남아있으면 polling을 중단하고 return null

ConcurrentBag#requite

  • Connection을 반납하는 과정에서 handoffQueue에 넣어주고 이 때 handoffQueue를 polling하고 있던 Thread는 반납된 Connection을 얻어서 사용하게 된다
3. Timeout 이내동안 handoffQueue를 polling하면서 사용 가능한 Connection이 생겼는지 감지하고 생겼다면 해당 Connection을 얻게 된다

 

 

④ 못얻음 → return null

  • threadList → sharedList → handoffQueue를 거쳐도 못얻었으면 timeout + return null과 함께 예외가 발생한다

 

일단 여기까지 ConcurrentBag의 borrow()를 통해서 Connection을 얻는 프로세스를 정리해보자

  1. threadList(이전에 사용했던 Connection) 탐색하면서 사용 가능한 Connection 조회
  2. sharedList(전체 Connection) 탐색하면서 사용 가능한 Connection 조회
  3. handoffQueue에서 대기 + polling하면서 사용 가능한 Connection 감지

 

(2-2) validation

ConcurrentBag#borrow()를 통해서 사용 가능한 Connection을 얻었으면 바로 사용해도 될까? → No

HikariPool#createPoolEntry()

  • HikariCP에서 Connection을 생성할때는 maxLifeTime & keepAliveTime 설정을 고려해서 진짜 쓸 수 있는지 마킹을 진행하게 된다

HikariPool#MaxLifetimeTask & KeepaliveTask

  • 유효하지 않은 Connection임을 validation했다면 softEvictConnection을 진행한다

HikariPool#softEvictConnetion
ConcurrentBag#reserve
HikariPool#closeConnection
PoolBase#quietlyCloseConnection

  1. 해당 Connection에 Evict 마크를 새긴다
  2. 소유권(owner)이 있거나 Connection의 상대가 STATE_NOT_IN_USE면 Connection을 close한다
    • STATE_RESERVED로 상태를 바꾸는데 이 상태는 해당 Connection이 종료되어야 한다는 것을 의미
  3. try-with-resource를 통해서 Connection을 release

다시 돌아와서 ConcurrentBag#borrow()를 통해서 Connection을 획득하면 위에서 말한 validation을 진행한다

  1. Evict 마크 O
    • 가져온 Connection은 사용할 수 없는 Connection이고 close
  2. Evict 마크 X
    • 현재로부터 마지막 Access가 aliveBypassWindowMs보다 큰지 + Connection이 Dead 상태인지 확인한 후 모두 맞다면 close
    • Dead 상태 판단 Query는 SELECT 1을 통해서 확인한다

 

얻은 Connection에 대한 추가 검증 끝에 poolEntry.createProxyConnection을 통해서 최종적으로 HikariProxyConnection을 획득하게 된다

 

 

3. Connection 반납

HikariProxyConnection을 얻은 후 사용하다가 더이상 쓸 일이 없다면 반납을 하게 된다

 

(1) ProxyConnection#close()

ProxyConnection#close()

 

(2) PoolEntry#recycle()

메소드 네이밍을 통해서 완전한 close가 아니라 재사용을 위해서 DBCP에 반납하는 느낌이라는 것을 유추할 수 있다

PoolEntry#recycle

  • lastAccessed를 기록하고 recycle을 진행한다

 

(3) HikariPool#recycle()

HikariPool#recycle

  • 메트릭 관련 지표를 수집하고 ConcurrentBag의 requite를 호출한다

 

(4) ConcurrentBag#requite()

ConcurrentBag#requite

  1. PoolEntry[Connection]의 상태를 STATE_NOT_IN_USE로 변경한다
  2. handoffQueue에서 polling을 통해서 Connection을 대기하는 waiter들이 존재한다면 반납하는 Connection을 제공
  3. waiter가 없다면 최대 50개까지 보관 가능한 threadList에 반납한 Connection을 넣어준다

 

주요 옵션 값

maximumPoolSize [default = 10]

Idle + InUse를 합친 DBCP에서 관리할 수 있는 최대 Connection 
  • maximumPoolSize에 도달한 후 Idle Connection 없이 전부 InUse 상태라면 Connection을 얻기 위해서 대기해야 한다 (getConnection)
  • 이 때 connectionTimeout[ms] 만큼 대기한다

 

 

About Pool Sizing

光 HikariCP・A solid, high-performance, JDBC connection pool at last. - brettwooldridge/HikariCP

github.com

 

maximumPoolSize가 많다고 무조건 성능이 좋아질까?

 

  • maximumPoolSize가 많다고 무조건 성능이 좋아질까?
  • 이전 포스팅에서도 언급했듯이 ThreadPool이든 DBCP든 내부에서 관리하는 리소스가 많다고 해서 그에 비례적으로 성능도 좋아지는 것은 아니다


Nginx vs Apache를 예로 들어보자

  • Apache = Client Connection당 하나의 쓰레드가 처리하는 구조
  • Nginx = Event-Driven기반으로 Client Connection들을 적은 프로세스/쓰레드를 활용해서 비동기적으로 처리


위와 같은 논리라면 자원이 많다고 비례해서 성능이 좋아지면 Apache가 Nginx보다 성능이 좋아야 하지 않을까?
현실은 그렇지 않다


Apache

  • Client Connection당 하나의 쓰레드가 담당해서 처리하기 때문에 Connection이 많아질수록 쓰레드 생성, CPU + 메모리 낭비, ..가 심해진다
    • C10K Problem
  • 서버의 프로세스가 Blocking되면 처리가 완료될때까지 대기해야 한다

Nginx

  • 적은 양의 쓰레드 + Event-Driven 기반 비동기로 동작하기 때문에 적은 양의 쓰레드만 사용되고 Context Switching, CPU, 메모리를 효율적으로 사용한다
  • 모든 I/O를 Event Listener에게 Delegating하고 그에 따라서 흐름이 끊기지 않고 응답이 빠르게 진행된다

 

그리고 HikariCP 팀에서 자체적으로 Performance Test를 진행한 결과 오히려 PoolSize가 줄어들었을 때 응답 속도가 개선됨을 확인하였다

You can see from the video that reducing the connection pool size alone, in the absence of any other change, decreased the response times of the application from ~100ms to ~2ms – over 50x improvement.

 

Pool Locking

하나의 Thread에서 Connection을 획득한 후 추가적인 Connection을 획득하려고 하는 순간 Pool의 Idle Connection이 고갈되어서 획득하지 못하고 대기하는 현상

HikariCP에서 이러한 Pool Locking 현상을 방지하기 위해서 제안하는 공식은 다음과 같다

  • Tn =  최대 쓰레드 수
  • Cm = 단일 쓰레드에서 점유하고 있는 최대 동시 Connection 

 

minimumIdle [default = maximumPoolSize]

DBCP에서 최소로 유지해야 할 Idle Connection 수
  • HikariCP Docs에서는 성능 & 급증하는 요청에 대한 응답성을 확보하기 위해서 이 값을 건드리지 말고 maximumPoolSize와 동일하게 유지하라고 권장한다

 

connectionTimeout [default = 30000ms = 30s]

Client가 DB Connection을 획득하려고 기다릴 수 있는 최대 시간
  • 이 시간을 초과해서 기다린다면 SQLException이 발생하게 된다

 

idleTimeout [default = 600000ms = 10m || min = 10000ms = 10s]

Pool에서 Idle 상태로 유지될 수 있는 최대 시간
  • minimumIdle만큼의 Connection은 제외하고 나머지 Idle Connection에 대해서 적용되는 Timeout

idleTimeout은 minimumIdle < maximumPoolSize의 경우에 한해서 의미있는 옵션인데 이 부분은 당연해보인다

  • Pool 입장에서는 minimumIdle만큼의 Connection은 반드시 유지해야 된다
    • 그런데 minimumIdle == maximumPoolSize라면? idleTimeout이 지나간다고 하더라도 없애버리면 어차피 다시 채워야한다
    • 이럴거면 애초에 idleTimeout이 지나서 Connection을 close한다고 하더라도 다시 Connection을 만들어야 하는데 이 리소스를 소모할바에 그냥 유지하는게 당연히 낫다

 

maxLifeTime [default = 1800000ms = 30m || min = 30000ms = 30s]

Connection의 최대 수명 시간
  • InUse Connection은 절대로 제거되지 않고 Idle 상태로 close되어야 제거된다