Hikari CP 커넥션 누수 with.멀티테넌시

KwonKusang·2022년 8월 3일
1

데이터베이스, 스키마, 테넌트 동의어로 생각하고 작성한다.

1. 문제 발생☹

  • Hikari CP를 사용하면서 원인불명의 타임아웃이 지속적으로 발생해왔다. 꾸준하게 Sentry를 확인해오면서 눈에 들어온 점은 스케줄러로부터 카프카 메시지를 소비하는 과정(많은 테넌트를 동시에 다루는 작업)에서 타임아웃이 많이 발생한다는 점이었다.
  • Prod 환경의 첫 번째 커넥션 타임아웃 직전에 존재하지 않는 데이터베이스에 연결하려 했다는 점을 확인할 수 있었다.

  • 존재하지 않는 데이터베이스에 접근 시 EntityManager를 통한 트랜잭션을 열지도 못했다는 못했다는 예외이다.
  • 멀티테넌시를 구현하며 어떻게 커넥션을 가져오는 지 확인해보자.

  • 해당 구현체는 dataSource로부터 JDBC 커넥션을 가져온 후 사용할 스키마를 지정한다.
  • 우리의 프로젝트는 Hikari CP를 사용중이기 때문에 설정된 Hikari Pool로부터 커넥션을 가져온다. 코드를 타고 들어가보면 Hikari CP 라이브러리의 HikariDataSource로 연결됨을 확인할 수 있었다.
// MultiTenantConnectionProviderImpl
...
@Override
public Connection getAnyConnection() throws SQLException {
    return dataSource.getConnection();
}

@Override
public Connection getConnection(String tenantIdentifier) throws SQLException {
    String tenantId = TenantContext.getCurrentTenantId();
    Connection connection = getAnyConnection();
    connection.setSchema(tenantId);
    connection.createStatement()
            .execute(String.format("use `%s`;", tenantId));
    return connection;
}
...
  • Sentry의 이슈 현상 처럼 USE database SQL문을 실행하는 과정에서 예외가 발생한다면 커넥션풀의 상태는 어떻게 될까?

  • 예외 발생 후에도 여전히 ACTIVE 상태의 커넥션이 하나 존재한다.

  • 어플리케이션을 종료하더라도 idle 상태인 9개의 커넥션만 close 되었다는 로그를 확인할 수 있다. (종료됨에 따라 ACTIVE 상태의 커넥션은 강제 반환됨으로 추정)

2. 원인 파악

Hikari Pool에 커넥션 상태는 4가지로 표기된다.

  • Total : 전체 커넥션의 수, 활성화 상태와 유휴 상태를 커넥션 수
  • Active : 활성화 상태, 어플리케이션에서 커넥션풀로부터 해당 커넥션을 가져감.
  • Idle : 유휴 상태, DB로부터 maximum-pool-size 만큼의 커넥션을 연결하고 커넥션풀에 담아둠.
    • DB로부터 close 되지 않도록 유지함.
  • Waiting : 대기 상태, 모든 커넥션이 Active 상태로 커넥션이 커넥션풀로 반환되기를 기다림.
    • connectionTimeout (default : 30000 (30 seconds)) 설정한 최대 대기 시간을 초과하면 SQLException을 발생시킴.

현재 상태

  • 커넥션을 가져오는 코드를 다시 살펴보자. Total Connection = 10으로 가정한다.
  • Connection connection = getAnyConnection();실행되면 커넥션풀은 커넥션은 반환하고 active 상태로 변경한다.
@Override
public Connection getConnection(String tenantIdentifier) throws SQLException {
    String tenantId = TenantContext.getCurrentTenantId();
    Connection connection = getAnyConnection();  // 1. 커넥션풀로 커넥션을 가져옴(active=1, idle=9)
    connection.setSchema(tenantId);
    connection.createStatement()
            .execute(String.format("use `%s`;", tenantId)); // SQLException 발생

		// 해당 커넥션은 release 되지 못하고 커넥션 누수가 발생!!
		...
}
  • 이번 스프린트에서는 데이터 마이그레이션 이슈가 자주 발생했다. 위와 같은 예외 상황이 많이 발생했을 가능성이 있다.
  • 타임아웃이 지속적으로 발생해서 maximum-pool-size 를 크게 잡았더라도 마이그레이션이 제대로 되어있지 않다면 사용 가능한 커넥션은 지속적으로 감소했을 것이다.

3. 개선 방안🌻

// MultiTenantConnectionProviderImpl
...
@Override
public Connection getConnection(String tenantIdentifier) throws SQLException {
    String tenantId = TenantContext.getCurrentTenantId();
    Connection connection = getAnyConnection();
    try {
        connection.setSchema(tenantId);
        connection.createStatement()
                .execute(String.format("use `%s`;", tenantId));
    } catch (Exception e) {
        releaseAnyConnection(connection);
        throw e;
    }
    return connection;
}
...
  • 처리는 간단하다. 예외 발생 시 해당 커넥션은 release 하고 커넥션풀에게 반환한다.
  • 예외가 발생하더라도 정상적으로 커넥션이 반환되는 모습을 확인할 수 있었다.

  • 사실 데이터 마이그레이션 문제는 개발자의 실수와 같다. 하지만 놓칠 수 있는 가능성이 매우 높다.
  • 개발자가 실수의 비해 사이드 이펙트와 영향력이 매우 크기 때문에 방어 코드를 구축할 필요성이 높다고 생각한다. 실수를 줄이는 것은 당연!

Q1. maxLifeTime 설정으로 최대 시간 후에 강제로 반환하도록 할 수 있지 않을까?

  • 현재 local 환경의 HikariPool 설정이다.

Test.

maxLifeTime (default : 1800000 (30분)) 으로 테스트를 진행한다.

  • 11:32 : HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)

  • 11:33 : exception 발생 - HikariPool-1 - Pool stats (total=10, active=1, idle=9, waiting=0)

  • 12:31 : 30분 초과된 후 maxLifeTime 발동으로 커넥션 close 후 add 확인 가능
  • 한 시간이 지났음에도 커넥션이 활성화 상태를 유지 (누수)
  • 12:36 : 상태는 이전과 동일 - HikariPool-1 - Pool stats (total=10, active=1, idle=9, waiting=0)

A1. maxLifeTime 은 커넥션풀에 유휴(idle) 상태의 커넥션이 유지되는 시간이다.

connection의 최대 유지 시간을 설정합니다.

connection의 maxLifeTime 지났을 때, 사용중인 connection은 바로 폐기되지 않고 작업이 완료되면 폐기됩니다.

하지만 유휴 커넥션은 바로 폐기됩니다.

  • 즉, 커넥션이 active 상태이기 때문에 적용 대상에서 제외된다. 우리는 커넥션 누수가 발생했기 때문에 해당 커넥션이 언제 작업이 완료되는지 장담할 수 없고 예측 불가하다.
  • maxLifeTime 이 지나고 유휴 상태의 커넥션 9개가 close/add 되는 모습이다.

4. 현재 프로젝트에 적용해볼 만한 HikariCP 설정은 무엇이 있을까?

  • connectionTimeout
    • 클라이언트가 커넥션풀에서 연결을 기다리는 최대 시간이다.
    • 대기시간을 초과하면 SQLException이 발생시킨다.
    • 허용되는 가장 낮은 연결시간은 250ms. 기본값: 30000(30초)
  • maximumPoolSize
    • 유휴(idle), 활성화(active) 상태 모두 포함하여 커넥션풀의 최대 크기이다.
  • minimumIdle
    • 커넥션풀에서 유지하려고 하는 최소 유휴 연결 수를 제어한다.
    • 기본값 maximumPoolSize와 같기 때문에은 성능을 원한다면 이 값을 건드리지 않는것이 좋다.
  • idleTimeout
    • 커넥션풀에서 유휴 상태의 커넥션을 유지하는 시간이다.
    • minimumIdle이 maximumPoolSize보다 작게 정의된 경우에만 적용된다.
    • 최소 허용값은 10000ms(10초). 기본값: 600000(10분)
  • maxLifetime
    • 커넥션풀에 존재하는 커넥션의 최대 수명이다. 유휴(idle) 상태의 커넥션 대상이다.
    • 활성화(active) 상태은 절대 폐기되지 않으며 close 될때 제거됩니다.
    • 최소 허용 값은 30000ms(30초). 기본값: 1800000(30분)

현재 프로젝트의 DB 상태를 확인해보자.

  • MySQL에 설정된 max_connections은 최대 90개이다. show variables like '%max_connect%';
  • wait_timeout은 600s로 설정되어 있다. show variables like '%timeout%';

maxLifetime, idleTimeout 유휴(idle) 상태의 커넥션을 관리한다. DB에 설정된 커넥션 수(90개)를 조금 더 효율적으로 사용하도록 하는 설정이다. 하지만, 활성화(active) 상태의 커넥션을 강제로 끊는 것이 불가하다.

HikariCP는 네트워크 지연 시간을 고려하여maxLifetime 설정은 MySQL의 wait_timeout 설정보다 2~3초 정도 짧게 줄 것을 권고한다.

5. 결론👍

  • 커넥션을 직접 조작한다면 반드시 release 할 수 있도록 유의해야 한다.
  • 즉, 커넥션풀로부터 커넥션을 받아왔다면 반드시 돌려주자!

Tip!

# HikariCP DEBUG Options
logging.level.com.zaxxer.hikari.HikariConfig=DEBUG
logging.level.com.zaxxer.hikari=TRACE
datasource.hikari.leak-detection-threshold=2000

[Hikari CP] 光 살펴보기 - 1

Querydsl 에서 DB connection leak 이슈

HikariCP Dead lock에서 벗어나기 (이론편) | 우아한형제들 기술블로그

profile
안녕하세요! 백엔드 개발자 권구상입니다.

1개의 댓글

comment-user-thumbnail
2022년 8월 24일

잘봤어요

답글 달기