티스토리 뷰

개요

  • MariaDB가 제공하는 갈레라 클러스터는 동기식의 멀티 마스터 레플리케이션 방식으로 모든 노드가 마스터 자격을 가지기 때문에 비동기식에서 발생할 수 있는 일시적 데이터 불일치 문제가 발생하지 않는다. 따라서 애플리케이션을 작성하는 개발자 입장에서는 비즈니스 로직 자체에 집중할 수 있고 시스템 엔지니어 입장에서는 무중단 장애 복구가 직관적이라는 장점이 있다. 본 글에서는 갈레라 클러스터 환경에서 Spring Boot 1.5.x 기반 애플리케이션에서의 DataSource 빈 생성 방법을 설명하고자 한다.

목표

  • Spring Boot 1.5.x 기반의 프로젝트에 HikariCP 커넥션 풀 라이브러리를 적용한다.
  • mariadb-java-client가 자체 제공하는 쿼리 디버깅 기능을 활성화한다.
  • MariaDB의 갈레라 클러스터(멀티 마스터 레플리케이션)를 지원하는 2개(하나는 read-write 전용, 하나는 read-only 전용)의 DataSource 빈을 생성한다.
  • 생성된 DataSource를 이용하여 MyBatis로 데이터베이스에 대한 CRUD 수행이 가능한 SqlSession 빈을 생성한다.

build.gradle

  • /build.gradle 파일에 아래 내용을 추가한다. 데이터베이스 연결에 사용할 라이브러리 정보를 작성한다.
dependencies {
    compile group: 'org.springframework.boot', name: 'spring-boot-starter-jdbc'
    compile group: 'com.zaxxer', name: 'HikariCP', version: '3.2.0'
    compile group: 'org.mariadb.jdbc', name: 'mariadb-java-client', version: '2.2.5'
    compile group: 'org.mybatis.spring.boot', name: 'mybatis-spring-boot-starter', version: '1.3.2'
}
  • HikariCPJava 진영에서 가장 성능이 뛰어난 커넥션 풀 라이브러리이다. Spring Boot 2.x.x 부터는 정식으로 채택되어 별도의 설정이 필요없지만 Spring Boot 1.5.x 이하 환경에서는 본 글을 통해 직접 설정해 주어야 정상적으로 작동한다.

application.yml

  • /src/main/resources/application.yml 파일에 아래 내용을 추가한다. 데이터베이스 연결 정보를 작성한다.
mariadb:
    datasource:
        jdbc-url:
            read-write: jdbc:mariadb://{mariadb_host}:{mariadb_port}/{database}
            read-only: jdbc:mariadb://{mariadb_host}:{mariadb_port}/{database}
        driver-class-name: org.mariadb.jdbc.Driver
        username: {mariadb_username}
        password: {mariadb_password}
        maximum-pool-size: 10
        profile-sql: true
  • 일반적으로 갈레라 클러스터 구성시 3개 노드로 구성하는데 쓰기(read-write) 행위를 오직 한 노드로 통일하면 데드락 발생 확률을 낮출 수 있다. 또한 읽기(read-only) 행위를 나머지 1개 노드에 할당하면 적절한 부하 분산 효과를 누릴 수 있다. (남은 1개 노드는 애플리케이션이 사용하지 않는 백업 전용 목적으로 사용한다.) 이 목적을 달성하기 위해 위와 같이 쓰기와 읽기 2개 연결 정보로 분리했다. 개발 환경이라면 실제 각 노드의 주소를 직접 명시해도 무방하나 운영 환경이라면 MaxScale, HAProxy와 같은 로드 밸런서를 모든 노드의 앞에 배치하고 이를 바라보게 할 것을 권장한다.
  • maximumPoolSize 옵션은 생성할 커넥션 풀의 최대 크기를 설정한다. 설정을 생략할 경우 기본값으로 10이 설정되며 1보다 작을 수 없다.
  • profileSql 옵션을 활성화하면 실행되는 모든 쿼리문과 파라메터, 실행에 걸린 시간(ms)을 로그로 출력해주어 상당히 유용하다. 원래 쿼리문 디버깅을 위해 log4jdbc-log4j2와 같은 별도의 JDBC 드라이버를 사용하는 경우가 일반적이나 mariadb-java-client가 자체적으로 지원하므로 적극적으로 이용하자. 자체 지원이라 디버깅에 따른 오버헤드도 최소화할 수 있어 실제 쿼리 실행 시간도 단축된다. 로그 출력 예는 아래와 같다.
2018-06-19 14:38:27,718 67590 [taskScheduler-5] INFO  o.m.j.i.l.ProtocolLoggingProxy - conn=47164(M) - 6.606 ms - Query: /* ping */ SELECT 1
2018-06-19 14:38:27,731 67603 [taskScheduler-2] INFO  o.m.j.i.l.ProtocolLoggingProxy - conn=47164(M) - 10.388 ms - Query: DELETE FROM users WHERE user_id = ?, parameters ['somebadguy']

DataSourceConfig

  • /src/main/java/com.jsonobject.galeratest.config.java 파일에 아래 내용을 작성한다. 앞서 작성한 데이터베이스 연결 정보를 사용하여 실제 DataSource 오브젝트 빈의 생성을 담당하게 된다.
package com.jsonobject.galeratest.config;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import kr.pe.kwonnam.replicationdatasource.LazyReplicationConnectionDataSourceProxy;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;
import java.sql.SQLException;

@Configuration
@EnableTransactionManagement
public class DataSourceConfig {

    @Value("${mariadb.datasource.jdbc-url.read-write}")
    private String JDBC_URL_READ_WRITE;

    @Value("${mariadb.datasource.jdbc-url.read-only}")
    private String JDBC_URL_READ_ONLY;

    @Value("${mariadb.datasource.driver-class-name}")
    private String DRIVER_CLASS_NAME;

    @Value("${mariadb.datasource.username}")
    private String USERNAME;

    @Value("${mariadb.datasource.password}")
    private String PASSWORD;

    @Value("${mariadb.datasource.maximum-pool-size}")
    private String MAXIMUM_POOL_SIZE;

    @Value("${mariadb.datasource.profile-sql}")
    private String PROFILE_SQL;

    @Primary
    @Bean(name = "readWriteDataSource")
    public DataSource readWriteDataSource() throws SQLException {

        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(JDBC_URL_READ_WRITE);
        config.setDriverClassName(DRIVER_CLASS_NAME);
        config.setUsername(USERNAME);
        config.setPassword(PASSWORD);
        config.setPoolName("read-write");
        config.setMaximumPoolSize(MAXIMUM_POOL_SIZE);
        config.addDataSourceProperty("profileSql", PROFILE_SQL);

        HikariDataSource dataSource = new HikariDataSource(config);

        return dataSource;
    }

    @Bean(name = "readOnlyDataSource")
    public DataSource slaveDataSource() {

        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(JDBC_URL_READ_ONLY);
        config.setDriverClassName(DRIVER_CLASS_NAME);
        config.setUsername(USERNAME);
        config.setPassword(PASSWORD);
        config.setPoolName("read-only");
        config.setMaximumPoolSize(MAXIMUM_POOL_SIZE);
        config.addDataSourceProperty("profileSql", PROFILE_SQL);

        HikariDataSource dataSource = new HikariDataSource(config);

        return dataSource;
    }

    @Bean(name = "dataSource")
    public DataSource dataSource(@Qualifier("readWriteDataSource") DataSource readWriteDataSource, @Qualifier("readOnlyDataSource") DataSource readOnlyDataSource) {

        return new LazyReplicationConnectionDataSourceProxy(readWriteDataSource, readOnlyDataSource);
    }

    @Bean(name = "transactionManager")
    public PlatformTransactionManager transactionManager(@Qualifier("dataSource") DataSource dataSource) {

        return new DataSourceTransactionManager(dataSource);
    }

    @Bean(name = "sqlSessionFactory")
    public SqlSessionFactory sqlSessionFactory(@Qualifier("dataSource") DataSource dataSource, ApplicationContext applicationContext) throws Exception {

        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSource);
        sqlSessionFactoryBean.setMapperLocations(applicationContext.getResources("classpath:mapper/*.xml"));

        return sqlSessionFactoryBean.getObject();
    }

    @Bean(name = "sqlSessionTemplate")
    public SqlSessionTemplate sqlSessionTemplate(@Qualifier("sqlSessionFactory") SqlSessionFactory sqlSessionFactory) throws Exception {

        return new SqlSessionTemplate(sqlSessionFactory);
    }
}
  • 쓰기와 읽기를 분리한 2개의 DataSource 빈을 생성한 후에 필요한 것은 실제 어떤 상황에 각 DataSource를 사용할지 결정해줄 로직을 작성해야 한다. 직접 작성해도 무방하나 이미 만들어진 훌륭한 클래스를 사용하면 시간을 절약할 수 있다. 위 예제의 경우 한국인 개발자인 권남 님이 개발한 LazyReplicationConnectionDataSourceProxy를 사용하여 자동 분기가 가능한 하나의 DataSource 빈을 생성하였다. [권남 님 GitHub 링크]
  • LazyReplicationConnectionDataSourceProxy의 분기 원칙은 간단하다. @Service 클래스에 명시된 트랜잭션 정보가 @Transactional(readOnly = true)이면 read-only 데이터소스로, @Transactional(readOnly = false)이면 read-write 데이터소스로 분기한다. 분기되는 정보는 아래와 같이 로그에 출력된다.
2018-06-19 15:11:24,393 239666 [XNIO-2 task-4] DEBUG k.p.k.r.LazyReplicationConnectionDataSourceProxy - current readOnly : false
2018-06-19 15:13:55,840 39634 [XNIO-2 task-2] DEBUG k.p.k.r.LazyReplicationConnectionDataSourceProxy - current readOnly : true
  • 최종적으로 애플리케이션 바로 사용 가능한 MyBatisSqlSessionTemplate 빈을 생성하면서 본 클래스의 역할은 끝난다.

트러블슈팅

2018-06-27 16:33:21,471 488370 [taskScheduler-3] WARN  com.zaxxer.hikari.pool.PoolBase - read-only - Failed to validate connection org.mariadb.jdbc.MariaDbConnection@2dfe0cce (Connection.setNetworkTimeout cannot be called on a closed connection). Possibly consider using a shorter maxLifetime value.
2018-06-27 16:33:21,471 488370 [read-only connection closer] DEBUG com.zaxxer.hikari.pool.PoolBase - read-only - Closing connection org.mariadb.jdbc.MariaDbConnection@2dfe0cce: (connection is dead)
2018-06-27 16:48:15,688 248355 [read-only connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - read-only - Added connection org.mariadb.jdbc.MariaDbConnection@78b0915b
  • HikariCP는 커넥션 풀로 관리 중인 각 연결이, 설정된 maxLifetime(기본값: 1800000ms = 30m)에 도달하면 해당 연결을 종료하고 새 연결을 생성한다. 만약 데이터베이스 서버에 설정된 maxLifetime(MariaDB의 경우 wait_timeout 파라메터로 기본값은 28800s = 8h)보다 작을 경우 이미 서버에서 해당 연결을 종료하여 위와 같은 오류가 발생할 수 있다. 제작자는 이 값을 서버보다 최소 1분 작은 값으로 설정할 것을 권장한다. 관련 링크
  • 한편, 해당 연결이 아무런 작업 없이 놀기 시작한지 idleTimeout(기본값: 600000ms = 10m)에 도달하면 30초마다 작동하는 하우스키퍼 쓰레드에 의해 연결이 종료된다.
  • maxLifetime이 데이터베이스 서버에 설정된 wait_timeout보다 짧게 설정되었는데 위 경고가 계속 발생할 경우 애플리케이션이 아닌 다른 레이어를 의심해봐야 한다. MariaDB 서버의 에러 로그를 살펴보고 아래와 같은 경고 메시지가 계속 발생한다면 로드 밸런서를 의심해봐야 한다.
2018-06-26 18:07:53 140311952269056 [Warning] Aborted connection 14877717 to db: 'somedb' user: 'someuser' host: '192.168.0.1' (Got an error reading communication packets)
  • 로드 밸런서로 HAProxy를 사용할 경우 HikariCP에서 했던 것과 동일하게 timeout client, timeout server 파라메터를 데이터베이스 서버의 wait_timeout과 동일하게 설정해야 한다.
# nano /etc/haproxy/haproxy.cfg
# 애플리케이션 서버로부터 들어오는 구간 설정
timeout client {wait_timeout}

# 데이터베이스 서버로 라우팅하는 구간 설정
timeout server {wait_timeout}

커넥션 풀 상태 측정

  • 앞서 각 데이터소스에 지정한 Dropwizrd Metrics를 이용하여 원하는 시점에 커넥션 풀의 현재 상태를 상세하게 측정할 수 있다. 측정 가능한 파라메터는 아래와 같다.
read-only.pool.MinConnections=1
read-only.pool.MaxConnections=1
read-only.pool.IdleConnections=1
read-only.pool.PendingConnections=0
read-only.pool.ActiveConnections=0
read-only.pool.TotalConnections=1
read-only.pool.ConnectionCreation.count=0
read-only.pool.ConnectionCreation.snapshot.75thPercentile=0.0
read-only.pool.ConnectionCreation.snapshot.95thPercentile=0.0
read-only.pool.ConnectionCreation.snapshot.98thPercentile=0.0
read-only.pool.ConnectionCreation.snapshot.99thPercentile=0.0
read-only.pool.ConnectionCreation.snapshot.999thPercentile=0.0
read-only.pool.ConnectionCreation.snapshot.max=0
read-only.pool.ConnectionCreation.snapshot.mean=0.0
read-only.pool.ConnectionCreation.snapshot.median=0.0
read-only.pool.ConnectionCreation.snapshot.min=0
read-only.pool.ConnectionCreation.snapshot.stdDev=0.0
read-only.pool.ConnectionTimeoutRate.count=0
read-only.pool.ConnectionTimeoutRate.meanRate=0.0
read-only.pool.ConnectionTimeoutRate.oneMinuteRate=0.0
read-only.pool.ConnectionTimeoutRate.fiveMinuteRate=0.0
read-only.pool.ConnectionTimeoutRate.fifteenMinuteRate=0.0
read-only.pool.Usage.count=0
read-only.pool.Usage.snapshot.75thPercentile=0.0
read-only.pool.Usage.snapshot.95thPercentile=0.0
read-only.pool.Usage.snapshot.98thPercentile=0.0
read-only.pool.Usage.snapshot.99thPercentile=0.0
read-only.pool.Usage.snapshot.999thPercentile=0.0
read-only.pool.Usage.snapshot.max=0
read-only.pool.Usage.snapshot.mean=0.0
read-only.pool.Usage.snapshot.median=0.0
read-only.pool.Usage.snapshot.min=0
read-only.pool.Usage.snapshot.stdDev=0.0
read-only.pool.Wait.count=0
read-only.pool.Wait.oneMinuteRate=0.0
read-only.pool.Wait.fiveMinuteRate=0.0
read-only.pool.Wait.fifteenMinuteRate=0.0
read-only.pool.Wait.meanRate=0.0
read-only.pool.Wait.snapshot.75thPercentile=0
read-only.pool.Wait.snapshot.95thPercentile=0
read-only.pool.Wait.snapshot.98thPercentile=0
read-only.pool.Wait.snapshot.99thPercentile=0
read-only.pool.Wait.snapshot.999thPercentile=0
read-only.pool.Wait.snapshot.max=0
read-only.pool.Wait.snapshot.mean=0
read-only.pool.Wait.snapshot.median=0
read-only.pool.Wait.snapshot.min=0
read-only.pool.Wait.snapshot.stdDev=0

read-write.pool.MinConnections=1
read-write.pool.MaxConnections=1
read-write.pool.IdleConnections=1 
read-write.pool.PendingConnections=0
read-write.pool.ActiveConnections=0
read-write.pool.TotalConnections=1
read-write.pool.ConnectionCreation.count=0
read-write.pool.ConnectionCreation.snapshot.75thPercentile=0.0
read-write.pool.ConnectionCreation.snapshot.95thPercentile=0.0
read-write.pool.ConnectionCreation.snapshot.98thPercentile=0.0
read-write.pool.ConnectionCreation.snapshot.99thPercentile=0.0
read-write.pool.ConnectionCreation.snapshot.999thPercentile=0.0
read-write.pool.ConnectionCreation.snapshot.max=0
read-write.pool.ConnectionCreation.snapshot.mean=0.0
read-write.pool.ConnectionCreation.snapshot.median=0.0
read-write.pool.ConnectionCreation.snapshot.min=0
read-write.pool.ConnectionCreation.snapshot.stdDev=0.0
read-write.pool.ConnectionTimeoutRate.count=0
read-write.pool.ConnectionTimeoutRate.fifteenMinuteRate=0.0
read-write.pool.ConnectionTimeoutRate.fiveMinuteRate=0.0
read-write.pool.ConnectionTimeoutRate.meanRate=0.0
read-write.pool.ConnectionTimeoutRate.oneMinuteRate=0.0
read-write.pool.Usage.count=1
read-write.pool.Usage.snapshot.75thPercentile=12.0
read-write.pool.Usage.snapshot.95thPercentile=12.0
read-write.pool.Usage.snapshot.98thPercentile=12.0
read-write.pool.Usage.snapshot.99thPercentile=12.0
read-write.pool.Usage.snapshot.999thPercentile=12.0
read-write.pool.Usage.snapshot.max=12
read-write.pool.Usage.snapshot.mean=12.0
read-write.pool.Usage.snapshot.median=12.0
read-write.pool.Usage.snapshot.min=12
read-write.pool.Usage.snapshot.stdDev=0.0
read-write.pool.Wait.count=1
read-write.pool.Wait.oneMinuteRate=0.07996993086896952
read-write.pool.Wait.fiveMinuteRate=0.16649812252232055
read-write.pool.Wait.fifteenMinuteRate=0.18814374193356292
read-write.pool.Wait.meanRate=0.01573430493254545
read-write.pool.Wait.snapshot.75thPercentile=0
read-write.pool.Wait.snapshot.95thPercentile=0
read-write.pool.Wait.snapshot.98thPercentile=0
read-write.pool.Wait.snapshot.99thPercentile=0
read-write.pool.Wait.snapshot.999thPercentile=0
read-write.pool.Wait.snapshot.max=0
read-write.pool.Wait.snapshot.mean=0
read-write.pool.Wait.snapshot.median=0
read-write.pool.Wait.snapshot.min=0
read-write.pool.Wait.snapshot.stdDev=0
  • Spring Boot 1.5.x 기반에서는 아래와 같이 MetricsEndpoint 빈의 invoke() 메써드를 실행하여 바로 확인할 수 있다.
@Service
public class SomeService {

    @Autowired
    private MetricsEndpoint metricsEndpoint;

    public void healthCheck() {

        Map<String, Object> metrics = metricsEndpoint.invoke();
        int readOnlyTotalConnectionCount = (int) metrics.get("read-only.pool.TotalConnections");
        int readOnlyIdleConnectionCount = (int) metrics.get("read-only.pool.IdleConnections");
    }

추가로 참고할만한 글

댓글
댓글쓰기 폼