AWS RDS Aurora Mysql (1 writer +1 reader) 을 사용하는데 writer instance에서 cpu 사용률이 99% 를 보이며 매우 느리게 (죽지는 않음) 처리되는 현상에 대한 해결책을 검토하였다.
일단 ReadOnly 인스턴스의 경우에는 Writer가 99%인 상황에서도 0에 수렴하는 사용률(실제로 사용이 안되고 있으나 내부적으로 replication 은 동작)이 보였기 때문에 서비스단에서 CUD에 대한 처리는 Writer, R은 Reader에서 처리하는 부하분산에 대한 검토 요청이 있었다.
검토에 앞서 RDS Aurora MySQL 클러스터의 아키텍처를 보면 (1 writer + 1 reader 기준)
서로 다른 가용영역에 writer 와 reader 인스턴스가 생성이 되고 writer에서 reader로 데이터복제가 지속되고 있다가, writer가 down 되었을 경우 60~120초 사이에 기존의 reader가 writer로 승격이 되고 down 되었던 기존 writer는 다시 올라올때 reader로 변경이되는 구조이다.
즉 부하의 로드밸런싱 보다는 failover에 초점이 맞춰져 있는 아키라 볼수 있다.
하지만 AWS에 비싼 비용을 지불하고 있기도 하고, (동일 스펙에서) 최대 5배의 성능을 보일수 있다는 RDS 소개 문구가 현재 기준에서의 최적화에 대한 검토로 이어지게 된것 이었다.
이에 우선 검토한 대상은 아래 블로그를 토대로 한 Mariadb connector/J와 AWS MySQL JDBC의 사용가능여부였는데
AWS Aurora Endpoint and WAS Connection Pool - Voyager of Linux
AWS Aurora Endpoint 와 WAS Connection Pool 를 위한 JDBC 드라이버, FailOver 시 고려해야할 사항에 대해서 포스팅 해보았습니다. Aurora를 위한 드라이버로 MariaDB Connector/j 를 사용하시면 기본설정만으로도 충
linux.systemv.pe.kr
https://hoing.io/archives/1285
AWS JDBC Driver for Amazon Aurora MySQL
안녕하세요 이번 포스팅에서는 Amazon AWS 에서 새롭게 출시한 Java의 MySQL 접속 드라이버인 AWS JDBC Driver 에 대해서 확인 해보려고 합니다. 드라이버의 특징AWS JDBC Driver 는 MySQL 8.0.28 community driver 를
hoing.io
위 블로그에도 나와있지만 두 개 모두 어떠한 문제들이 있어 적용이 불가한 것으로 결론 지었다.
1. mariadb 는 공식 드라이버가 아니고 3.x 버전부터는 aurora 에 대한지원이 되지 않는다.
오픈소스라서 db 패치등을 꼭 해야되는 상황( AWS는 기본적으로 수동패치이나 간혹 이러한 패치들이 쌓이면 지정된 날짜까지 패치를 진행하도록 하고 안할 경우 AWS에서 자체적으로 패치를 해버리는 상황)에서 문제가 발생하면 오픈 소스 드라이버에서는 대응방안을 마련하기가 어려운 부분이 있다.
2. aws-mysql-jdbc 에서는 현재는 read/write split을 공식 지원하고 있지 않다.
pull request로 해당 plugin이 올라와 있는 상태이나 반영 시점은 알수없으니 적합하지 않았다.
https://github.com/awslabs/aws-mysql-jdbc/pull/272
Add Read-Write Splitting Plugin by congoamz · Pull Request #272 · awslabs/aws-mysql-jdbc
Summary Implement Read-Write Splitting Plugin Additional Reviewers @sergiyv-bitquill @karenc-bq
github.com
이에 제 3안으로 hikari master와 slave를 통한 dynamic routing datasource 를 구현하여 테스트 하였는데
(구현은 아래 블로그 참조)
https://cheese10yun.github.io/spring-transaction/
Spring 레플리케이션 트랜잭션 처리 방식 - Yun Blog | 기술 블로그
Spring 레플리케이션 트랜잭션 처리 방식 - Yun Blog | 기술 블로그
cheese10yun.github.io
분산은 되었으나 또 예상치 못한 문제가 발견되었다.
1. @Transactional 패키지
https://interconnection.tistory.com/123
어떤 @Transactional을 사용해야 할까?
애플리케이션을 개발하다 보면, 보통 @Transactional을 사용해서 Transaction을 사용합니다. 관습적으로 사용하다 보니, 내부적으로 어떻게 돌아가는지 원리에 대해서만 관심을 가졌습니다. 하지만 여
interconnection.tistory.com
위 블로그에서도 중요한 부분은 이부분이다.
- @javax.transaction.Transactional은 TransactionType, rollback 정도로 구현되어있습니다.
- @org.springframework.transaction.annotation.Transactional은propagation, isolation, timeout, readOnly, rollback으로 구성되어있습니다.
분산처리를 위해서는 readOnly 어트리뷰트를 사용해야 하는데 javax.transaction.Transactional에서는 사용이 되지 않아
전혀 분산처리가 되지 않고 에러도 나지 않아서 찾는데 오래걸렸다.
변경이후 분산은 되었지만 또 다시
JPA Listener에서 @PostPersist 등의 처리를 하고 있었는데 함께 사용할때 NullPointer가 발생했다.
(원인을 찾는 중에 처리 방향이 바뀌어 확실친 않지만 javax 와 혼용 사용으로 인한것이지 않을까 생각만 해본다)
read와 writer가 명확하게 구분이 되는 application이 아닐경우 나오는 문제가 여기서 보인다.
1. 수정/개발 공수증가
aop를 구현하거나 @Transactional(readOnly=true) 에 대한 추가 및 config 구성은 기본이고
이를 적용한 이후 발생하는 문제들에 대해 또다시 원인파악을 해서 수정해야하는 공수가 들어간다는것
2. 부하분산은 되었지만 안정성이 보장이 되지도 않는다.
만약 read 또는 writer 가 down되었을 경우 정상적인 동작이 될까?
결론
reader인스턴스의 replica를 추가하여 3중화를 구성더라도 writer 1개, reader 1~2개의 환경에서 최악의상황을 가정하여 1개 인스턴스만 남더라도 시스템이 안정적으로 구동될 수 있는 환경을 만드는 것으로 방향이 선회 되었다.
scale up을 통해 writer/reader 의 리소스를 두배로 늘리고 다시 검토하는 것으로 이번 검토를 마치게 되었다.
++ 이하는 hikari master/slave config
// DataSourceConfig.java
package com.split.common.context;
import java.util.HashMap;
import java.util.Map;
import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy;
import org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Primary;
import org.springframework.core.env.Environment;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import com.zaxxer.hikari.HikariDataSource;
import lombok.RequiredArgsConstructor;
@Configuration
@RequiredArgsConstructor
public class DataSourceConfig {
private final Environment env;
public static final String MASTER_DATASOURCE = "masterDataSource";
public static final String SLAVE_DATASOURCE = "slaveDataSource";
@Bean(MASTER_DATASOURCE)
@ConfigurationProperties(prefix = "spring.datasource.master.hikari") // (1)
public DataSource masterDataSource() {
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.build();
}
@Bean(SLAVE_DATASOURCE)
@ConfigurationProperties(prefix = "spring.datasource.slave.hikari")
public DataSource slaveDataSource() {
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.build();
}
@DependsOn({MASTER_DATASOURCE, SLAVE_DATASOURCE})
@Bean
public DataSource routingDataSource(
@Qualifier(MASTER_DATASOURCE) DataSource master,
@Qualifier(SLAVE_DATASOURCE) DataSource slave) {
DynamicRoutingDataSource routingDataSource = new DynamicRoutingDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put("master", master);
dataSourceMap.put("slave", slave);
routingDataSource.setTargetDataSources(dataSourceMap);
routingDataSource.setDefaultTargetDataSource(master);
return routingDataSource;
}
@DependsOn({"routingDataSource"})
@Bean
public DataSource dataSource(DataSource routingDataSource) {
return new LazyConnectionDataSourceProxy(routingDataSource);
}
@Primary
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
em.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
em.setDataSource(dataSource);
em.setPackagesToScan("com.split");
Map<String, Object> properties = new HashMap<>();
properties.put("hibernate.physical_naming_strategy",
SpringPhysicalNamingStrategy.class.getName());
properties.put("hibernate.implicit_naming_strategy",
SpringImplicitNamingStrategy.class.getName());
properties.put("hibernate.hbm2ddl.auto", env.getProperty("spring.jpa.hibernate.ddl-auto"));
em.setJpaPropertyMap(properties);
return em;
}
@Primary
@Bean
public PlatformTransactionManager transactionManager(
EntityManagerFactory entityManagerFactory) {
JpaTransactionManager transactionManager = new JpaTransactionManager();
transactionManager.setEntityManagerFactory(entityManagerFactory);
return transactionManager;
}
}
//DynamicRoutingDataSource.java
package com.split.common.context;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.support.TransactionSynchronizationManager;
public class DynamicRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() { // (1)
return (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) ? "slave" : "master"; //(2)
}
}
// application.yml
spring:
profiles: default
main:
allow-circular-references: true
allow-bean-definition-overriding: true
datasource:
master:
hikari:
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:33060/split?serverTimezone=Asia/Seoul&characterEncoding=UTF-8&enabledTLSProtocols=TLSv1.2
username: split
password: splitPwd99!
read-only: false
maximum-pool-size: 100
connection-timeout: 50000
connection-init-sql: SELECT 1
validation-timeout: 5000
minimum-idle: 50
idle-timeout: 600000
max-lifetime: 1800000 #30min
slave:
hikari:
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:33061/split?serverTimezone=Asia/Seoul&characterEncoding=UTF-8&enabledTLSProtocols=TLSv1.2
username: split
password: splitPwd99!
read-only: true
maximum-pool-size: 100
connection-timeout: 50000
connection-init-sql: SELECT 1
validation-timeout: 5000
minimum-idle: 50
idle-timeout: 600000
max-lifetime: 1800000 #30min
jpa:
database: mysql
generate-ddl: true
show-sql: true
properties:
hibernate:
show_sql: false
format_sql: true
dialect: org.hibernate.dialect.MySQL8Dialect
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
hibernate:
ddl-auto: none
use-new-id-generator-mappings: false
추가
aws-mysql-jdbc를 사용하게 되면서 failover 시간(정확하게는 failover 이후 DB 복구까지의 시간)에 대한 검증을 해보았다.
@Scheduled로 1초마다 db에 시간을 기록하는 잡을 돌린 상태에서 writer인스턴스를 재기동하여 측정하였더니
어느 블로그에서 10초이내에 대한이야기도 하는것 같았는데, 직접 테스트 해보니 약 20초정도의 시간이 걸렸다.
이것도 실제 운영환경에서는 어떨런지 모르겠으나, 일단 공식적으로 60~120초 이내 정상복구이니 (저번 테스트에서는 100초) 방어적으로 이것을 기준으로 하는것이 좋겠다.
//pom.xml
<dependency>
<groupId>software.aws.rds</groupId>
<artifactId>aws-mysql-jdbc</artifactId>
<version>1.1.1</version>
</dependency>
//application.yml
spring:
profiles: prd
main:
allow-circular-references: true
allow-bean-definition-overriding: true
datasource:
driver-class-name: software.aws.rds.jdbc.mysql.Driver
url: jdbc:mysql:aws://${MYSQL_URL}/test?serverTimezone=Asia/Seoul&characterEncoding=UTF-8&enabledTLSProtocols=TLSv1.2&allowMultiQueries=true
username: ${MYSQL_USERNAME}
password: ${MYSQL_PASSWORD}
jpa:
database: mysql
generate-ddl: true
show-sql: true
properties:
hibernate:
show_sql: false
format_sql: true
dialect: org.hibernate.dialect.MySQL8Dialect
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
hibernate:
ddl-auto: update
use-new-id-generator-mappings: false
mvc:
hiddenmethod:
filter:
enabled: true'AWS' 카테고리의 다른 글
| DOP-C02 다이어그램3 (0) | 2025.02.24 |
|---|---|
| DOP-C02 다이어그램 2 (0) | 2025.02.23 |
| DOP-C02 다이어그램1 (0) | 2025.02.23 |
| AWS TechCamp - 1 일차 오전 (0) | 2024.11.27 |
| AWS EKS 에 CloudWatchAgent 배포 (3) | 2022.09.21 |