본문 바로가기

카테고리 없음

성능 최적화 (DB 복제와 로드밸런싱)

지난 글에 이어 목표 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달러 정도의 비용이 발생했다. 쓰기작업까지 감안한다면 난이도는 조금 있지만, 스케일 업을 하는 것 보다 성능 개선이 많이 됐으며, 가용성 또한 더 높아졌으니 웬만해서는 스케일 아웃이 더 좋은 선택지라고 생각한다.