서비스 시작 시점에 HikariCP의 커넥션 획득 시간이 2.58초로 급격히 증가하는 현상이 발생했다. 지연 발생 원인과 해결방법을 알아보자.
HikariCP는 기본적으로 커넥션 풀을 비동기로 채운다. 이로 인해 애플리케이션이 시작되고 포트가 열린 후에도 커넥션 풀이 완전히 채워지지 않은 상태일 수 있다. 이때 요청이 들어오면 커넥션 생성으로 인한 지연이 발생할 수 있다.
커넥션 풀이 모두 채워지기 전에 포트가 열리는 것을 확인할 수 있다.
HikariCP는 커넥션 풀이 완전히 준비된 후에 트래픽을 수용할 수 있도록 하는 설정을 제공한다.
# application.yml
spring:
datasource:
hikari:
initialization-fail-timeout: 8000
# JVM 옵션
-Dcom.zaxxer.hikari.blockUntilFilled=true
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);
}
}
fyi; HikariPool Construct
각 서비스마다 수십 개의 커넥션을 동시에 생성하려고 시도하여, DB 서버에 갑작스러운 부하가 발생할 수 있다.
특히 Kubernetes와 같은 환경에서 여러 Pod이 동시에 재시작되는 경우 주의하자.
위 설정 적용하고 두근거리는 마음으로 배포했다. 하지만 지연은 여전히 발생하고 있었다.
메트릭을 확인하던 중 흥미로운 점을 발견했다. 지연이 커넥션 생성(connection.create)이 아닌 커넥션 획득(connection.acquire) 과정에서 발생하고 있었다. HikariCP의 소스 코드를 확인해보니 metricsTracker.recordBorrowStats(poolEntry, startTime)를 통해 getConnection() 메서드에서 커넥션 획득 시간을 측정하고 있었다.
이는 처음 예상했던 커넥션 생성 지연이 아닌, 이미 생성된 커넥션을 가져오는 과정에서 문제가 발생한다는 것을 의미한다.
HikariCP의 커넥션 획득 과정에서 두 가지 지점에서 지연이 발생한다.
public Connection getConnection(final long hardTimeout) throws SQLException {
suspendResumeLock.acquire();
try {
var poolEntry = connectionBag.borrow(timeout, MILLISECONDS);
...
}
}
모든 커넥션 획득 요청은 이 lock을 먼저 획득해야 한다. 동시에 많은 요청이 들어올 경우, lock 획득을 위한 대기 시간이 발생한다.
HikariCP는 ConcurrentBag이라는 자료구조를 사용해 커넥션을 관리한다.
public class ConcurrentBag<T extends IConcurrentBagEntry> implements AutoCloseable
{
private static final Logger LOGGER = LoggerFactory.getLogger(ConcurrentBag.class);
private final CopyOnWriteArrayList<T> sharedList;
private final boolean weakThreadLocals;
private final ThreadLocal<List<Object>> threadList;
public T borrow(long timeout, final TimeUnit timeUnit) throws InterruptedException
{
// Try the thread-local list first
final var list = threadList.get();
for (int i = list.size() - 1; i >= 0; i--) {
final var 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;
}
}
// Otherwise, scan the shared list ... then poll the handoff queue
final int waiting = waiters.incrementAndGet();
try {
for (T bagEntry : sharedList) {
if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
...
}
listener.addBagItem(waiting);
timeout = timeUnit.toNanos(timeout);
do {
final var start = currentTime();
final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);
...
}
public void requite(final T bagEntry)
{
bagEntry.setState(STATE_NOT_IN_USE);
...
final var threadLocalList = threadList.get();
if (threadLocalList.size() < 50) {
threadLocalList.add(weakThreadLocals ? new WeakReference<>(bagEntry) : bagEntry);
}
}
이러한 구조로 인해 초기에는 모든 커넥션이 sharedList에만 있어 경합이 심하지만, 시간이 지나면서 각 스레드의 threadList에 분산되어 성능이 안정화된다.
fyi; HikariPool.getConnection()
ThreadLocal에서 커넥션을 관리할 때 두 가지 방식을 비교해보자.
threadLocalList.add(bagEntry); // 일반 참조
threadLocalList.add(new WeakReference<>(bagEntry));
WeakReference는 ThreadLocal에 저장된 커넥션이 메모리 누수를 일으키지 않도록 보장하는 안전장치 역할을 한다.
borrow()
에서 ThreadLocal 리스트를 검색할 때, 유효하지 않는 커넥션(WeakReference가 null인 항목)은 자연스럽게 제거된다.
for (int i = list.size() - 1; i >= 0; i--) {
// 1. ThreadLocal 리스트에서 항목 제거
final var entry = list.remove(i);
// 2. WeakReference인 경우 실제 참조 객체 조회
final T bagEntry = weakThreadLocals ? ((WeakReference<T>) entry).get() : (T) entry;
// 3. 유효성 체크 및 상태 변경
if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
}
get()
으로 실제 커넥션 객체 조회null
이 아님)GC된 WeakReference는 자연스럽게 정리되고, 다음 requite() 호출 시 새로운 커넥션을 저장할 공간 확보할 수 있다.
✅ ALB Slow Start를 통한 점진적 트래픽 증가
초기에 모든 커넥션이 sharedList에만 존재하여 경합이 발생하는 문제를 해결하기 위해, ALB Slow Start를 적용하여 트래픽을 점진적으로 증가시킨다.
❎ 애플리케이션 웜업을 통한 성능 최적화
서비스 시작 시 더미 요청을 통해 커넥션 풀을 웜업하여 초기 성능을 개선한다.
위 두 가지 방안을 통해 초기 트래픽 처리 시의 커넥션 획득 경합을 효과적으로 감소시키고, 안정적인 성능을 제공할 수 있다. 특히 ALB Slow Start는 애플리케이션 코드 변경 없이 빠르게 적용할 수 있다.