241128 TIL

2024년 11월 28일 작성

HikiriCPReview

기록용이라 틀린 내용 있을 수 있음

Spring 에서 HikariCP 선택한 이유

HikariCP 가 유명한 이유

  • 나는 HikariCP 를 사용했었는데, spring jdbc 기본 커넥션풀이라 첨엔 아무 생각없이 그냥 아 이걸 쓰는 구나 했었다. 여기까지 들여다보진 않았었다.

성능 최적화

하나하나 다 알아야할 필요까진 없을 것 같고, 요약해서 알아두면 될듯 Statement 보관 자료구조 최적화, lock-free design, 바이트코드 최적화

  • HikariCP 는 짜잘하게 여러 기능들의 성능을 개선해서 전체 성능을 올렸다고한다. (티끌모아 태산)

  • 특히 스레드가 커넥션을 반환하고 나면 사용했던 Statement를 삭제하는 연산의 작은 변화가 인상적이었다.

    • 반환할 커넥션에 해당하는 Statement를 찾기 위해, 기존 커넥션풀 구현체들은 get() 연산으로 모든 요소를 조회해서 삭제할 statement 의 (인덱스?) 범위를 찾아내는 방식으로 구현돼있었다고 한다. 그런데 이 연산이 필요 없어서 (왜인지 더 찾아봐야할듯) 이걸 생략하고 성능을 높였다.
    • Statement 를 원래 ArrayList 에 저장하고 있었는데, 뒤쪽에 있는 Statement가 먼저 삭제되는 성격이 강한 데이터임에도 기본 remove(Statement s) 메서드 사용시 순차 탐색으로 삭제를 했었음. HikariCP는 ArrayList 가 아닌 FastList 라는 커스텀 구현체를 사용해서 뒤쪽부터 탐색해서 삭제할 객체를 찾도록 구현해서 성능을 높였다.
  • ConcurrentBag

    • ConcurrentBag은 HikariCP에서 커스터마이징한 컬렉션으로, 락을 쓰지 않고 동시성을 제어한다.

    • ConcurrentBag이 제공하는 것

      • Lock-free design

      • ThreadLocal 캐싱

        캐싱을 공유하지 않게 함 → false sharing

      • Queue-stealing

      • Direct hand-off optimizations

      이 네 가지를 통해 높은 동시성, 낮은 지연시간 그리고 false-sharing 빈도 최소화를 제공하고 있따.

    • False Sharing이란 ? 실제로 스레드 간 공유되지 않은 데이터지만 캐시 구조의 특성으로 마치 공유되는 것으로 인식돼 불필요한 성능 저하 현상이 일어나는 것.

    각 소캣 또는 코어가 동일한 캐시 라인 내의 서로 다른 위치의 데이터를 지역 캐시를 통해 참조할 때 False sharing 이 발생한다. 실제 같은 데이터를 참조하지 않은 상황에도 불구하고 캐시 일관성 유지 프로토콜 통신(버스 트래픽)과 공유 캐시, 메모리로의 write-back 으로 인한 오버헤드가 발생한다.

    • False Sharing 이 발생하는 이유 멀티코어에서 캐시는 모든 코어가 항상 최신 내용을 볼 수 있도록 처리함. 이를 캐시 일관성 프로토콜로 구현하는데, 어떤 코어가 한 캐시 라인을 쓰려면 다른 코어가 가진 사본을 모두 무효화하는 신호를 보낸다. 스레드가 서로 캐시의 데이터를 논리적으로는 전혀 공유하지 않는데도, 물리적으로 같은 캐시 라인에 있다는 이유로 한 스레드가 갱신하는 경우 다른 스레드의 캐시 데이터가 무효화되는 것이다. (갱신되는 캐시라인은 한번에 무효화하도록 구현돼있으므로)

    • False Sharing이 성능을 저하시키는 이유 갱신하려는 데이터가 속한 캐시 라인을 무효화시키는 행동이 스레드 간 반복되면서, 끊임없이 서로의 캐시 라인을 무효화하고 그에 따라 캐시미스가 계속 발생한다.

  • invokevirtual → invokestatic

    • preparestatement 메서드를 최적화했땅.

      • Connection, Statement, ResultSet 인스턴스 프록시를 만들기 위해서 hikariCP는 처음에 PROXY_FACTORY 라는 싱글톤 팩토리를 static field에 두고 사용했었다.
    • before

      public final PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException {
      		// static 변수 (인스턴스) 로 싱글톤 구현
          return PROXY_FACTORY.getProxyPreparedStatement(this, delegate.prepareStatement(sql, columnNames));
      }
    • after: 필드의 싱글톤 팩토리를 없애고 static method를 갖는 final class ProxyFactory 로 대신함.

      public final PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException{
      		// static method 로 변경
          return ProxyFactory.getProxyPreparedStatement(this, delegate.prepareStatement(sql, columnNames));
      }

    이렇게 하면 바이트코드에서

    1. getstatic 을 호출하지 않게 되고

    2. invokevirtual 대신 invokestatic 을 사용하게 된다. → jvm 은 invokestatic 을 더 쉽게 최적화함.

    3. stack 사이즈가 5에서 4로 줄어든다. (글쿤..)

      Lastly, possibly not noticed at first glance is that the stack size is reduced from 5 elements to 4 elements. This is because in the case of invokevirtual there is an implicit passing of the instance of ProxyFactory on the stack (i.e this), and there is an additional (unseen) pop of that value from the stack when getProxyPreparedStatement() was called.

    어쨌든 요약하면 static 필드 액세스, 한번의 push & pop 과정을 제거하고 callsite가 변경되지 않도록 보장해서 JIT가 바이트코드를 최적화하기 쉽게 만들었다.

풀 사이즈 튜닝하는 이유

  • 나의 경우 레벨4에서 톰캣 스레드풀이랑 hikaricp 커넥션풀 사이즈 바꿔가면서 부하테스트 했었는데, 사실 엄청난 소득은 없었다.

  • 기존에 제공하는 기본 값도 훌륭한 성능을 보여주긴 했었음.

  • 다만 커넥션풀은 스레드들이 서로 기다리고 점유하고 이런 경쟁 상황이 일어나는 근원이 될 수 있어서 대규모트래픽 서비스의 경우나, 한 요청 당 별도 트랜잭션이 많은 경우에는 데드락을 방지하기 위해서 풀 사이즈를 튜닝한다고 한다.

  • 기본적으로 데이터베이스 커넥션 풀 사이즈를 “DB 스펙” 관점에서 최적화하는 공식이 유명하다.

    • connections = ((core_count * 2) + effective_spindle_count)
  • 데드락을 방지하기위해서는, 스펙 관점에서가 아닌 서비스에서 한 스레드가 동시에 가져갈 수 있는 커넥션 개수를 생각해보고 결정해야한다.

    • 보통 이 경우는 애플리케이션 관점에서의 이슈
    • 스레드풀을 늘리면 되는 거긴하지만, 계산을 해보는 것이 바람직하다.
    • 데드락을 방지하는 최소 커넥션 개수는 다음과 같음
      • pool size = Tn x (Cm - 1) + 1
    • 관련해서 유명한 우형 기술블로그 글이 하나 있는데 우형에서는 위 공식을 변형해서 쓴다고 한다.