카테고리 없음

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

joyfulviper 2023. 12. 2. 18:17

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