brunch

You can make anything
by writing

C.S.Lewis

by 이권수 Feb 09. 2020

HikariCP 뜯어보기 2편

풀장에서 커넥션 얻어오기!!


대체 Pool에서 어떻게 커넥션을 가져다 쓰는 건가요??


이전 편에서 Hikari가 어떻게 커넥션 Pool을 생성하는지 소스코드를 통해서 확인해 보았습니다. 

자바 어플리케이션이 뜨게 된 순간에 이미 Hikari는 minimumIdle 값만큼의 커넥션을 Pool에 담아 놓습니다. 그러면 어플리케이션은 요청을 처리하기 위해서 어떻게 Connection을 받는지 한 번 알아보도록 하겠습니다.


다시 한번 테스트 소스를 살펴보겠습니다. 

# java/com/zaxxer/hikari/db/BasicPoolTest.java

HikariDataSource ds = new HikariDataSource(config);
Connection conn = ds.getConnection();

HikariDataSource 객체를 생성한 후에 커넥션 획득을 위해서 해당 객체에서 getConnection()을 호출합니다. 해당 함수는 com/zaxxer/hikari/HikariDataSource.java 코드에서 확인하실 수 있습니다.


1. 먼저 Datasource가 Close되었는지 확인합니다.

2. fastPathPool이 있으면 fastPathPool에서 커넥션을 가져옵니다. -------- (1)

3. fastPathPool이 없으면 poold에서 커넥션을 가져옵니다.             -------- (2)

( pool도 없는 경우에는 새로 pool을 생성합니다!)


(1)과 (2)는 가져오는 객체는 다르지만 같은 함수를 호출합니다. 바로 getConnection()함수인데, 이 함수가 커넥션을 가져오는 가장 핵심적인 부분입니다.


1. suspendResumeLock.acquire()를 통해서 현재 요청 Thread가 커넥션을 가져올 순서인지 체크해서 Permit을 확보합니다. 이것은 먼저 와서 대기중인 Thread가 먼저 커넥션을 받을 수 있도록 제한하기 위한 Semaphore입니다.


2. ConnectionBag.borrow()를 통해서 존재하는 pool에서 커넥션을 꺼내옵니다. 

final List<Object> list = threadList.get(); <----------- 현재 ThreadList를 가져옵니다
for (int i = list.size() - 1; i >= 0; i--) {     
   final Object 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;
   }
}

먼저 ThreadLocal에서 리스트를 확인합니다. Threadlist은 이전에 사용이력이 있는 Thread가 마지막 Connection을 종료하면서 상태로 STATE_NOT_IN_USE 추가된 리스트입니다. 즉 이전에 사용한 이력이 있고, 반납 과정에서 아무도 대기자가 없어서 threadlist로 등록된 것을 의미합니다. 이 threadlist는 50개까지 유지합니다. ThreadLocal 리스트는 커넥션을 할당받은 Thread가 사용을 마치고 반납하는 과정에서 발생합니다.(ProxyConnection.close())


ProxyConnection.close()를 파고 파고 들어가면 connectionBag.requite()함수가 나오는데, 여기가 중요한 부분이니 한 번 살펴보겠습니다. 

Connection을 반납하는 부분


먼저, 해당 Entry를 STATE_NOT_IN_USE로 상태값을 변경합니다. 이렇게 상태값을 변경하게 되면 순간적으로 사용이 가능한 커넥션이 되어 버리기 때문에, 다른 thread가 connectionBag.borrow()하는 과정에서 해당 커넥션을 가로챌 수 있습니다. ThreadLocal을 뒤지는 과정에서 가로챌 수도 있고, handoffQueue에서 30초간 열심히 기다리는 과정에서 가로챌 수도 있습니다. 그래서 반납되는 커넥션이 바로 다른 Thread로 연결되는 순간 requite()함수를 종료합니다. 만약 기다리는 Thread가 없고 현재 threadLocal의 리스트가 50개 이하인 경우에는 threadlocalList에 추가해서 다음 thread가 connnectionBag.borrow()를 하는 과정에서 더 빨리 받을 수 있도록 합니다.


다시 Back~!!

다시 borrow()과정으로 돌아가겠습니다. ThreadLocal에 있는 커넥션 리스트를 다 뒤져서 STATE_NOT_IN_USE인 커넥션을 찾지 못한 경우에는 다음 후보군을 찾아보기 시작합니다. 그 후보군이 바로 SharedList입니다. SharedList는 새로 생성된 커넥션이 등록되는 리스트입니다. 


threadlist는 sharedList의 cache같은 느낌이랄까..!


먼저 자신을 wating 리스트에 포함시키기 위해 waiting 값을 1 증가시킵니다. 그리고 아래의 코드에서 보시는 것처럼 shareList를 다 뒤지기 시작합니다.  for문을 돌면서 하나씩 꺼내보고 STATE_NOT_IN_USE 상태인지를 확인합니다. 만약 waiting 값이 1보다 크면(즉, 자기자신 말고 다른 waiting이 있는 경우), 다른 waiter를 위해서 새로 하나를 추가해달라고 요청합니다. [ listener.addBagItem(waiting - 1) ]

ConnectionBag의 borrow함수 (1)


SharedList는 fillPool()함수를 통해서 idleConnection을 생성할 때 채워집니다. 정확히 말하면 connectionBag.add(bagEntry) 함수를 통해서 connectionBag에 새로 생성한 커넥션을 connectionBag에 넣을 때 sharedList에 넣습니다. 

connectionBag.add(bagEntry) 함수


이렇게 SharedList를 찾아봤음에도 불구하고 없으면 그 다음 단계를 진행합니다. 일단, 새로운 커넥션을 추가해달라고 요청합니다. 그리고 timeout이 10(micro second)보다 작아지는 순간까지 계속해서 반복문을 돕니다. 여기서 timeout은 connectionTimeout값(기본값 30초)입니다. 여기서는 handoffQueue에서 커넥션이 생길 때까지 polling합니다. handoffQueue은 사용을 마친 커넥션을 requite()하는 과정에서 추가됩니다. 즉, 지금 사용을 마친 커넥션이 handoffQueue에 들어갈 때 지금 기다리고 있던 커넥션이 해당 커넥션을 낚아채버립니다. 그런데 그 사이에 handoffQueue에 아무런 커넥션이 들어오지 않으면 30초간 쭈욱 기다리다가 Timeout 에러가 납니다.


ConnectionBag의 borrow함수 (2)


지금까지 connectionBag.borrow() 한 줄 알아봤습니다... 


위에서 커넥션을 받지 못하면 바로 do~while문을 빠져나오고, 타임아웃 에러를 기록하며 끝이 납니다.

PoolEntry poolEntry = connectionBag.borrow(timeout, MILLISECONDS);
if (poolEntry == null) {
   break; // We timed out... break and throw exception   <----  return null인 경우입니다!
}
... 
metricsTracker.recordBorrowTimeoutStats(startTime);
throw createTimeoutException(startTime);




그런데 커넥션을 받았다고 바로 반환해주는 것이 아닙니다


모든 커넥션은 MaxLifetime이 존재합니다. 이 MaxLifetime은 PoolEntry가 생성되는 createPoolEntry()에서 Scheduler에 등록시킬 때 적용됩니다. 

createPoolEntry() 함수를 통해 Pool에 새로운 Entry를 추가합니다.


Scheduler에 등록된 함수를 보면 softEvictConnection() 함수를 호출합니다. 계산한 maxLifetime 시간이 지나면 이 함수가 호출됩니다. 여기서 maxLifetime에 variance값으로 시간 차이를 만드는 이유는 동시에 maxLifetime으로 인한 커넥션 종료가 발생하는 것을 방지하기 위함입니다.


softEvictConnection()

먼저 evict 값을 true로 세팅합니다. 그리고 주인이 있거나 해당 Entry가 STATE_NOT_IN_USE인 경우에는 해당 커넥션을 종료합니다.(여기서 reserve란 의미는 이 커넥션을 unavailable 상태로 만들 수 있냐는 뜻입니다. 즉, reserve상태라면 해당 커넥션은 이미 수명을 마쳤기 때문에 종료되어야 하는 커넥션이라고 이해하시면 됩니다.)

여기서 보면 수명이 끝났음(MaxLifetime을 적용하는 시점)에도 해당 커넥션이 사용되고 있으면 해당 커넥션은 종료되지 않습니다. 이대로 가만히 두면 해당 커넥션은 더 이상 종료되지 않고 STATE_IN_USE로 남게 되어 위에서 connectionBag.borrow()할 때 후보로 나올 수 있습니다


그래서 poolEntry.isMarkedEvicted()를 통해 한 번 더 검증을 합니다. 만약 evict 값이 true라면 해당 커넥션은 수명을 다했기 때문에 커넥션을 종료합니다. 

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

위 코드를 보시면 && 조건(AND 조건)으로 붙는 것이 하나 있습니다. 그것은 바로 커넥션이 실제로 살아있는지 확인하는 부분입니다. isConnectionAlive() 함수는 실제 커넥션이 살아있는지를 파악하기 위해 Test Query를 날려봅니다. 기본 Test Query는 "SELECT 1"입니다. 


hibernate.hikari.connectionTestQuery=SELECT 1



이 모든 과정을 거치고 드디어 정상적인 커넥션을 받았습니다!


정상적인 커넥션을 받은 경우에는 해당 시간을 기록한 후에 ProxyConnection을 생성합니다. 이후에는 해당 커넥션을 통해서 Statement를 보내 결과를 받을 수 있습니다.

metricsTracker.recordBorrowStats(poolEntry, startTime);
return poolEntry.createProxyConnection(leakTaskFactory.schedule(poolEntry), now);



쓰는 저도 이해하기 힘드네요..


HikariCP를 뜯어보니 이것저것 너무 많이 얽히고 섥여서 이해하기가 쉽지는 않네요.(혹시나 수정할 내용이 있다면 언제든 따끔한 피드백 부탁드립니다.) 커넥션을 맺고 반납하는 과정에 대한 Flow는 얼마전 우아한 형제들 블로그에 자세히 올라온 글이 있어서 이 글을 참조하시면 좋을 것 같습니다.


이렇게 복잡하게 얽힌 Hikari Connection pool 관리에서 중요한 내용만 다시 한 번 집고 넘어가도록 하겠습니다.


| 공통사항 

1. 모든 커넥션은 sharedList에 들어갑니다. 

2. Threadlocal은 한 번 사용된 커넥션을 캐시하는 느낌으로 보관을 해 더 빠르게 커넥션을 반납해줍니다.

3. Multi-Thread로 작동하기 때문에 아래의 ㅋ순서 중간중간마다 다른 놈들이 끼어들 수 있습니다!!!


| Pool 생성

1. HikariPool을 생성할 때는 CheckFailFast를 통해서 1개의 샘플 커넥션을 생성합니다.

2. MinimumIdle 커넥션까지 어플리케이션은 hikari pool을 사용하지 않습니다.


| 커넥션 가져오기

1. connetionBag.borrow()함수를 통해서 커넥션을 가져옵니다.

2. 먼저, 사용 이력이 있는 threadlist중에서 idle 커넥션을 찾습니다. 

3. 2번에서 찾지못하면, sharedList 중에서 idle 커넥션을 찾습니다. 

4. 3번에서 찾지못하면, handoffQueue에서 계속해서 pooling합니다(기본값 30초 동안). 반납되는 커넥션이 handoffQueue에 들어오게 되면 그 때 커넥션을 가져갑니다.

5. 2,3,4번이 발생하는 도중에도 HouseKeeper는 idleConnection Timeout을 체크하여 closeConnection을 진행합니다. 이때 커넥션의 상태값을 STATE_RESERVED로 변경합니다. 

6. 2,3,4번이 발생하는 도중에 HouseKeeper는 각 커넥션에 할당된 maxLifeTime이 지나면 closeConnection을 진행합니다. 이때 커넥션의 상태값을 STATE_RESERVED로 변경합니다. 만약 종료되어야 하는 커넥션이 사용중이면 evict값을 true로 변경하고 넘어갑니다.

7. 5,6번에서 closeConnection을 할 때마다 fillPool함수를 호출해서 idleConnection만큼 채워줍니다. 

8. 커넥션을 확보했다고 할지라도, 종료되어야하는 커넥션인 경우(evict=true)이거나, 샘플 쿼리를 날렸을 때 에러가 발생하면 커넥션을 종료하고 다시 찾아봅니다.


| 커넥션 반납하기

1. connectionBag.requite()함수를 통해서 커넥션을 반납합니다.

2. 반납하는 순간 커넥션 상태는 STATE_NOT_IN_USE가 됩니다. 

3. 커넥션을 기다리는 Thread가 handoffQueue를 polling하고 있는 경우, 종료된 커넥션을 바로 매칭시켜줍니다. 

4. 2번과 3번 사이에 다른 Thread가 idle커넥션으로 인식해서 낚아챌수도 있습니다.

5. 아무도 대기자가 없으면 이미 사용한 커넥션이므로 ThreadLocalList에 추가합니다.(최대 50개까지)


** 만약에...!!

# maxLifeTime이 지나서 종료하는 함수를 실행하는 순간에 갑자기 요청이 들이닥친다면...?

- 먼저 커넥션의 상태값이 STATE_IN_USE로 변경되기 때문에 단순히 evict=true만 설정해놓고 함수를 종료합니다. 동시에 STATE_NOT_IN_USE 상태인 커넥션이 부족하니까 fillpool은 그 만큼을 채우기 위해서 계속 커넥션을 추가합니다. 이때 maxConnection 값이 idleConnection보다 크면, 마치 idle인 커넥션이 있음에도 추가로 생성하는 것처럼 보일 수 있습니다. 또 처리를 마친 커넥션들이 다시 STATE_NOT_IN_USE로 돌아오게 되면, houseKeeper가 minimumIdle을 조정하거나 다음 요청이 들어올 때 evict가 true임으로 해당 커넥션들을 종료합니다.



마치며...


솔직히 제대로 적었는지 잘 모르겠습니다.. 워낙 얽혀있는게 많고 소스 자체도 중간중간 업데이트가 많이 진행된 것 같아서 참조하기가 쉽지 않았습니다. 뛰어나신 분들의 따끔한 피드백을 기다리고 있습니다. 조금이나라 HikariCP를 이해하는데 도움이 되기를 희망하면서 글을 마치겠습니다~



## 잘못된 내용은 피드백주시면 더 좋은 글로 보답하겠습니다.

매거진의 이전글 HikariCP 뜯어보기 1편
브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari