지난 글에 이어 목표 TPS로 가기 위한 여정을 떠나보자.
DB 복제
현재 question 조회에 대한 tps는 17.5인 상황이다. db 서버의 cpu가 매우 많이 오르는 것으로 보아 여전히 db가 병목이다. 이에 DB 서버를 한대 더 늘려 tps를 올려보도록 하자.
RDS를 이용하고 있다면 읽기 전용 복제본을 생성하면 된다.
복제된 DB는 마스터 DB의 바이너리 로그를 읽어서 동기화 작업을 하는데, 방법에는 쿼리 기반으로 복제, 행 기반으로 복제, 두 방법을 혼합해서 복제 하는 방법이 있는데, 나는 RDS의 기본 값인 혼합하는 방식을 사용했다. 다른 방법이나 더 세밀한 설정을 하고 싶다면 파라미터 그룹의 설정을 변경하도록 하자.
코드
코드는 아래와 같다. 어플리케이션의 입장에서는 마스터, 슬레이브의 개념은 없고 서로 다른 DataSource로 부터 읽기를 하게 할 수 있다. Master, Slave 각각의 DataSource 빈을 등록하고, 이들을 라우팅 하기 위한 DataSource를 등록한다. 이후 라우팅 하기 위해 등록한 DataSource 빈을 트랜잭션이 커넥션을 얻을 때 이용할 수 있도록 LazyConnectionDataSourceProxy를 반환하는 DataSource를 등록해주면 된다.
정리하자면, 총 n개의 db가 있다면 n개의 datasource, routing할 datasource, lazy connection을 반환할 datasource 총 n + 2개의 DataSource 빈이 있으면 된다.
@Configuration
public class DataSourceConfig {
//등록해야 하는 DataSource 클래스의 bean이 너무 많아서 각기 다른 bean 이름을 지정하고 재사용
private static final String MASTER_DATASOURCE = "masterDataSource";
private static final String SLAVE_DATASOURCE = "slaveDataSource";
private static final String ROUTING_DATASOURCE = "routingDataSouce";
@Bean(MASTER_DATASOURCE)
@ConfigurationProperties(prefix = "spring.datasource.master.hikari")
public DataSource masterDataSource() {
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.build();
}
// slave database DataSource
@Bean(SLAVE_DATASOURCE)
@ConfigurationProperties(prefix = "spring.datasource.slave.hikari")
public DataSource slaveDataSource() {
return DataSourceBuilder.create()
.type(HikariDataSource.class)
.build();
}
// routing dataSource Bean
@Bean(ROUTING_DATASOURCE)
@DependsOn({MASTER_DATASOURCE, SLAVE_DATASOURCE})
public DataSource routingDataSource(
@Qualifier(MASTER_DATASOURCE) DataSource masterDataSource,
@Qualifier(SLAVE_DATASOURCE) DataSource slaveDataSource) {
RoutingDataSource routingDatasource = new RoutingDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put(DatabaseType.MASTER, masterDataSource);
dataSourceMap.put(DatabaseType.SLAVE, slaveDataSource);
// dataSource Map 설정
routingDatasource.setTargetDataSources(dataSourceMap);
// default DataSource는 master로 설정
routingDatasource.setDefaultTargetDataSource(masterDataSource);
return routingDatasource;
}
/** @Transactional 어노테이션이 붙으면 아래의 과정으로 트랜잭션이 실행됨
* 1. CglibAopProxy.DynamicAdvisedInterceptor.intercept( )
* 2. TransactionInterceptor.invoke( )
* 3. TransactionAspectSupport.invokeWithinTransaction( )
* 4. TransactionAspectSupport.createTransactionIfNecessary( )
* 5. AbstractPlatformTransactionManager.getTransaction( )
* 6. AbstractPlatformTransactionManager.startTransaction( )
* 7. AbstractPlatformTransactionManager.begin( )
* 8. AbstractPlatformTransactionManager.prepareSynchronization( )
* 9. TransactionAspectSupport.invokeWithinTransaction( )
* 10. InvocationCallback.proceedWithInvocation( )
* 11. @Transactional이 적용된 실제 타깃이 실행
*
* 위에 등록한 bean 만으로는 내가 의도한대로 db 커넥션을 얻지 않음.(그냥 랜덤으로 db 커넥션을 @Transactional 시작과 동시에 얻음) 하지만, 이렇게 LazyConnection을 열어줌으로써 db에 접근하는 순간 커넥션을 얻게 함. 이 때 얻는 커넥션은 내가 설정한 routingDataSource에서 의도한대로 커넥션을 얻을 수 있도록 설정했음.
* 참고로 master-slave 구성을 하지 않더라도 이런 식으로 커넥션을 얻는 시간을 늦춰서 약간의 최적화가 가능하기도 함.
* 한줄요약: 트랜잭션 메소드가 호출 될 때 커넥션을 얻냐 db에 접근하는 순간 커넥션을 얻냐 차이
*/
@Bean
@Primary
@DependsOn(ROUTING_DATASOURCE)
public DataSource dataSource(@Qualifier(ROUTING_DATASOURCE) DataSource routingDataSource) {
return new LazyConnectionDataSourceProxy(routingDataSource);
}
}
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
return LoadBalancer.weightRoundRobin();
}
return DatabaseType.MASTER;
}
}
RountingDataSource는 어떤 DataSource를 결정할지 determineCurrentLookupKey()에서 결정한다.
해당 메소드에서 반환하는 key를 앞서 작성해준 dataSourceMap의 키로 갖는 DataSource를 반환하는 셈이다.
Map<Object, Object> dataSourceMap = new HashMap<>();
dataSourceMap.put(DatabaseType.MASTER, masterDataSource);
dataSourceMap.put(DatabaseType.SLAVE, slaveDataSource);
로드밸런서
앞서 RountingDataSource 클래스에 읽기 작업인 경우 LoadBalencer에서 반환하는 키를 반환하는데, 나의 경우 읽기 작업이더라도 마스터 디비에서도 읽기 부하가 갈 수 있도록 로드밸런싱 알고리즘을 작성했다. 아래의 코드는 weight의 비율에 따라 1:2 의 비율로 마스터와 슬레이브의 부하가 가도록 로드를 밸런싱 했다. 이 비율은 성능테스트를 꼭 해보며 각각의 db cpu가 적절하게 이용될 수 있도록 조절해줘야한다.
public enum DatabaseType {
MASTER(1),
SLAVE(2);
private final int weight;
DatabaseType(int weight) {
this.weight = weight;
}
public int getWeight() {
return this.weight;
}
}
public class LoadBalancer {
public static DatabaseType weightRoundRobin() {
Random random = new SecureRandom();
int totalWeight = 0;
DatabaseType[] types = DatabaseType.values();
for (DatabaseType type : types) {
totalWeight += type.getWeight();
}//총 가중치 계산
int randomWeight = random.nextInt(totalWeight);
int weightSum = 0;
for (DatabaseType type : types) {
weightSum += type.getWeight();
if (randomWeight < weightSum) {
return type;
}
}
return DatabaseType.MASTER;
}
public static DatabaseType roundRobin() {
DatabaseType[] dbTypes = DatabaseType.values();
int index = (int) (System.currentTimeMillis() % dbTypes.length);
return dbTypes[index];
}
}
결과
이전에 비해 TPS가 20정도 오른 것이 확인 가능하다. 아래의 cpu 사용량을 보면 로드밸런싱도 내가 조절한 비율대로 적절히 밸런싱 됐다. 쓰기 작업이 없는 부하테스트 임을 감안 했을 때 쓰기 작업까지 부하가 걸린다면 이 비율은 적절할 것이다.
비용
이번 성능 개선에는 서버를 한대 늘렸기 때문에 한달 기준 약 18달러 정도의 비용이 발생했다. 쓰기작업까지 감안한다면 난이도는 조금 있지만, 스케일 업을 하는 것 보다 성능 개선이 많이 됐으며, 가용성 또한 더 높아졌으니 웬만해서는 스케일 아웃이 더 좋은 선택지라고 생각한다.