티스토리 뷰
개요
- 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'
}
- HikariCP는 Java 진영에서 가장 성능이 뛰어난 커넥션 풀 라이브러리이다. 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
- 최종적으로 애플리케이션 바로 사용 가능한 MyBatis의
SqlSessionTemplate
빈을 생성하면서 본 클래스의 역할은 끝난다.
트러블슈팅
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");
}
추가로 참고할만한 글
댓글
최근에 올라온 글
최근에 달린 댓글
- Total
- Today
- Yesterday
링크
TAG
- 로드 바이크
- MySQL
- 태그를 입력해 주세요.
- 자전거
- Kendo UI Web Grid
- JavaScript
- Eclipse
- kotlin
- jstl
- JHipster
- spring
- chrome
- 평속
- 로드바이크
- bootstrap
- Kendo UI
- jpa
- 구동계
- DynamoDB
- jsp
- Spring Boot
- java
- graylog
- 알뜰폰
- Spring MVC 3
- Tomcat
- CentOS
- maven
- Docker
- node.js
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
글 보관함