ecsimsw

Mysql DB Replication 으로 데이터 백업, DB 부하 분산 본문

Mysql DB Replication 으로 데이터 백업, DB 부하 분산

JinHwan Kim 2023. 11. 5. 03:47

Mysql db replication

PicUp 프로젝트에서 DB를 백업하기 위해서 replication 을 사용한다. 당장은 백업을 위함이 가장 컸지만 replication 도입으로 쿼리 부하 분산, 지역화의 이점도 얻을 수 있을 것이다. 

 

이 글에선 아래 네가지 키워드를 다룬다.

- Mysql이 제공하는 Replication 방식과 각 장단점

- 이 프로젝트에서 비동기 복제 방식을 사용한 이유와 정합성 문제 고민

- Mysql 에서 비동기 Statement Based Replication 설정 방법

- Spring 에서 여러 DB source를 정의하고 transaction readOnly 옵션으로 target source 를 분기할 수 있는 설정

 

Replication 종류

Mysql 의 복제 방식은 '동기', '비동기', '반동기'가 대표적이다.

 

동기 방식은 한 노드에 요청된 쿼리를 트랜잭션 내에서 다른 노드들에 전달하고 모든 노드들이 동기화되면 트랜잭션의 결과를 반환한다. 마스터(또는 첫 요청을 받은 노드)가 다른 노드들에 쿼리를 전달하는 식으로 진행된다.

 

모든 노드에서 동일한 요청을 수행한 후 결과를 반환하기에 모든 노드에 데이터 정합성이 보장된다는 장점이 있으나, 그렇기 때문에 애플리케이션 쪽에선 DB 처리 속도가 느려지고 트랜잭션의 생명주기가 길어진다는 단점이 있다. 또 노드 중 하나라도 에러나 다른 상태를 갖게된다면 모든 노드가 결국 요청을 못 다루는 상황이 생기게 된다.

 

비동기 방식은 한 노드에 요청된 쿼리를 다른 노드에서 읽어서 처리한다. Mysql 의 경우 수행된 쿼리나 변경 값을 로그로 (바이너리 로그) 저장하고 master thread 에서 , slave 에선 그 내용을 기록해두었다가 (릴레이로그) slave thread 로 읽어 본인의 data 에도 이를 반영하는 식으로 진행된다. 애플리케이션 입장에선 Master 노드의 처리만 기다리면 되기 때문에 속도에서 이점이 있으나, slave 에서 sync 처리를 반영하는 사이 시간 동안 데이터 정합성이 깨지는 단점이 있다.

 

반동기 방식은 이 둘을 섞는다. Master 가 요청된 요청을 Binary log 에 저장하고 slave 는 이 log 를 읽고 처리가 완료되면 master node 에 ACK 로 알린다.  Master 는 한 개 이상의 ACK 를 확인하면 그제서야 트랜잭션을 커밋한다.

 

이렇게 하면 모든 Node 를 기다리지 않되 한 개의 slave 는 데이터 정합성을 유지한다는 것이 보장되게 된다. 그렇지만 여전히 비동기 방식보다 느리고, slave 의 상태나 네트워크에 영향을 받는다는 단점이 있다.

 

비동기식 Replication

이 문서는 PicUp 프로젝트에서 Album server db replication 을 적용하면서 작성되었다. 이 프로젝트 상황에선 비동기 방식의 데이터 정합성이 생기는 경우는 사진을 삭제했는데 여전히 보인다거나 생성했는데 사진이 안보이는 상황이 가장 대표적일텐데 다시 새로고침을 하지 그렇게 심각한 문제가 아니라고 생각했다. 그보다 동기식으로 처리했을 때의 모든 노드들을 관리 해야 하는 노드 관리 문제, 속도 문제, 노드 예외 문제 등이 더 심각한 문제로 생각했다.

 

그리고 WAS 가 많거나, 요청이 많은 상황이 아니라 데이터 백업의 목적이 당장은 제일 컸기 때문에 Master, Slave 를 각각 하나씩만 두려고 했고, 그렇기에 반동기식 방식은 동기식 방식과 다를게 없어 의미를 갖지 못한다고 생각했다. 그래서 비동기식 복제 방식으로 결정하게 되었다.

 

비동기식 Replication 의 정합성 문제

비동기식의 결정 근거에서 데이터 정합성이 가끔 생겨도 심각한 상황이 안나올 것 같다고 했는데, 구체적으로 어떤 상황에서 비동기식 replication 이 데이터 정합성 문제가 생기는지 고민해보았다. 결국 master db 의 처리와 그 내용이 slave db 에서 sync 완료되는 시간 사이에 slave db 에서 읽기가 되는 경우일 것 같다. 그리고 그 경우들은 아래와 같을 것이다.

 

1. 읽기 요청 과다 : slave db에 읽기 요청이 워낙 많아 처리 성능 자체가 떨어지고 sync 도 밀리는 경우이다.

-> 이런 경우 slave db 를 scale up 하거나, slave db 자체를 증설하여 요청을 분산 할 수 있겠다.

 

2. Sync 속도를 앞지른 많은 쓰기 : mater db 의 쓰기량에 비해 slave db sync 속도가 낮은 경우이다.  
-> Mysql 의 기본 옵션은 replication 을 위해 싱글 스레드만을 사용했다. master db 는 여러 세션에서 처리 결과를 만들고 쿼리 내용 또는 결과를 log 에 기록하는데 복제를 위한 thread 는 단 한개 뿐이라 쓰기가 많아진다면, slave db 가 많아진다면 정합성 문제는 불가피했다.
-> Mysql 5.6 부터 추가된 Multi-Threaded Replication Slaves 을 사용해서 복제를 위한 스레드 (SQL thread, worker)를 증설하는 것이 도움이 된다. [mysql-mysql-5-6-ga-replication-enhancements]

 

3. 너무 느린 Sync : slave db 에서 Sync 를 처리하는 시간 자체가 느린 경우를 말한다. 그 시간 동안 정합성 문제가 생긴다.

-> Mysql 의 binary log 을 이용한 동기화엔 여러 방식이 있다. (Binary Format) 그 중 Statement Based Replication 는 master 에서 처리한 쿼리를 그래도 slave 에서 실행하는 방식이고 그 경우 쿼리 시간 이상은 반드시 정합성 문제가 생긴다.

-> 쿼리 속도 자체를 낮추거나 Row Based Replication 으로 바꿔 master 에서 쿼리 실행으로 변경된 row 를 sync 하는 것으로 쿼리 시간 대신 변경 반영 시간으로 정합성 문제의 시간을 바꿀 수 있다.

 

TIP :: Multi source replication

반대로 여러 master 에서 한 slave 에 복제가 요구되는 경우도 있을 것이다. 예를 들어 DB 를 분리해둬서 DB source 가 여러 개인데 모니터링이나 로깅를 이유로 한번에 여러 db 의 처리 내역를 하나의 db로 모으고 싶은 경우이다. 그럴 땐 Multi source replication 을 키워드로 적용할 수 있을 것 같다.

 

Mysql replication 설정

Mysql 서버 2대를 준비한다.

 

1. /etc/mysql/conf.d/my.cnf

 

아래 설정 파일을 Master 와 Slave 각 mysql 서버의 '/etc/mysql/conf.d/my.cnf' 에 적용한다.

#### master
[mysqld]
log-bin=mysql-bin
server-id=1
#### slave
[mysqld]
log-bin=mysql-bin
server-id=2
log_slave_updates = 1
read_only = 1

 

- log-bin : log file 의 prefix 를 지정한다.

- server-id : replication group 에서 식별될 id 이다.

- log_slave_updates : slave server 의 로그도 기록한다. 기본 값은 false 로 기록하지 않는다.

- read_only : 읽기 전용으로 한다.

 

2. MASTER - log file 이름 확인

 

show master status\G;

 

위 명령어를 master에서 각자 입력하고 binary log file 의 이름을 확인한다. 설정한 prefix 와 일치하는지 확인하고 file 이름을 기록한다.

 

*************************** 1. row ***************************
             File: mysql-bin.000001
         Position: 881
     Binlog_Do_DB:
 Binlog_Ignore_DB:
Executed_Gtid_Set:

 

 

3. MASTER - Replication slave 를 위한 권한 생성

 

master server 에 REPLICATION SLAVE 권한을 갖은 User 계정을 생성한다. 후에 slave 는 이 계정에 연결하여 master 에서의 사용 권한을 얻을 것이다.

CREATE USER '{USER_NAME}'@'%' IDENTIFIED BY '{PASSWORD}';
GRANT REPLICATION SLAVE ON *.* TO '{USER_NAME}'@'%';

 

 

4. SLAVE - 연결 시작

CHANGE MASTER TO MASTER_HOST='${HOST_IP_ADDRESS}', \
                 MASTER_USER='${HOST_USER_TO_CONNECT}', \
                 MASTER_PASSWORD='${HOST_PASSWORD_TO_CONNECT}', \
                 MASTER_LOG_FILE='${MASTER_LOG_FILE_TO_READ}', \
                 MASTER_LOG_POS=0, \
                 GET_MASTER_PUBLIC_KEY=1;

 

위 명령어로 Master node 를 설정하고, 'start slave' 로 slave 로써 동기화를 시작한다.

start slave;

 

아래 명령어로 slave 모드의 상태를 확인할 수 있다.

show slave status\G;

 

 

5. Mysql replication 설정 이슈

 

1. ISSUE : start slave 시

ERROR : `ERROR 1872 (HY000): Replica failed to initialize applier metadata structure from the repository`

SOL : SLAVE DB에서 아래 세 커맨드를 수행한다.

1. stop slave;
2. reset slave;
3. start slave;

 

그래도 안된다면 slave 연결 `CHANGE MASTER TO ~` 부분 재실행한다.

 

2. ISSUE : 'show slave status\G;' 확인에 아래와 같은 Error 가 표시된다면

```

Coordinator stopped because there were error(s) in the worker(s). The most recent failure being: Worker 1 failed executing transaction 'ANONYMOUS' at source log mysql-album-master-bin.000003, end_log_pos 443. See error log and/or performance_schema.replication_applier_status_by_worker table for more details about this failure or others, if any.

```

SOL : Repliaction 계정 이름을 Master와 Slave이 같은지 확인해본다. 예를 들어 Master에 Replication 으로 등록한 User 이름이 Slave에도 그대로 있는 경우 이 문제가 발생했다.

 

Repliaction User 이름을 변경하고 재등록하여 해결

 

Spring multi db source 설정과 ReadOnly 옵션으로 target source 분기

Spring 에서 앞서 설정한 두 개의 db 를 db source 로 설정하고 transactional 의 readOnly 옵션으로 쿼리가 수행될 target source 를 분기한다. 읽기 작업은 SLAVE DB 에서, 쓰기 작업은 MASTER DB 에서 처리한다.

 

1. DB source 를 정의한다.

 

연결할 DB 정보를 설정한다.

 

# application.properties

spring.datasource.master.username={USER_NAME}
spring.datasource.master.password={PASSWORD}
spring.datasource.master.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.master.jdbc-url= jdbc:mysql://{SOURCE_URL}/{DB}

spring.datasource.slave.username={USER_NAME}
spring.datasource.slave.password={PASSWORD}
spring.datasource.slave.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.slave.jdbc-url= jdbc:mysql://{SOURCE_URL}/{DB}

 

# DataSourceConfig

@Bean(value = DB_SOURCE_BEAN_ALIAS_MASTER)
@ConfigurationProperties(prefix = "spring.datasource.master")
public DataSource masterDataSource() {
    return DataSourceBuilder.create().build();
}

@Bean(value = DB_SOURCE_BEAN_ALIAS_SLAVE)
@ConfigurationProperties(prefix = "spring.datasource.slave")
public DataSource slaveDataSource() {
    return DataSourceBuilder.create().build();
}

 

 

2. DB 타입을 정의하고 ReadOnly 여부에 따라 사용할 DB source 를 반환한다.

enum DBType {
    MASTER,
    SLAVE
}

 

determineCurrentLookupKey() 를 재정의하여 쿼리를 전송할 DB source 를 나타낼 lookup key 를 반환할 수 있다.

class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        var isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        if (isReadOnly) {
            return SLAVE;
        }
        return MASTER;
    }
}

 

TransactionSynchronizationManager.isCurrentTransactionReadOnly() 를 사용하면 현재 transaction 의 readOnly 옵션 여부를 확인할 수 있다. readOnly 면 SLAVE type 을 키로, 그렇지 않으면 MASTER type 을 키로 반환할 것이다.

 

3. RoutingDataSource 를 정의하여 DB type 과 DB source 를 mapping 한다.

 

lookup 키로 반환 받은 DB type 만으론 어떤 type 에 어떤 DB source 가 매핑 되었는지 알 수 없다. RoutingDataSource 를 정의하고 { DB type : DB source } 를 mapping 하여 type 별 target DB source 를 지정한다.

 

@Bean
public DataSource routingDataSource(
    @Qualifier(DB_SOURCE_BEAN_ALIAS_MASTER) DataSource masterDataSource,
    @Qualifier(DB_SOURCE_BEAN_ALIAS_SLAVE) DataSource slaveDataSource
) {
    var routingDataSource = new RoutingDataSource();
    routingDataSource.setDefaultTargetDataSource(masterDataSource);
    routingDataSource.setTargetDataSources(
        Map.of(
            MASTER, masterDataSource,
            SLAVE, slaveDataSource
        )
    );
    return routingDataSource;
}

 

 

4. LazyConnectionDataSourceProxy 를 앞서 설정한 routingDataSource 와 함께 생성한다.

 

Spring은 default 로 Transactional 이 시작되는 시점부터 DataSource 를 미리 정해둔다. 지금 상황처럼 Master, Slave 로 나누거나, 쿼리 동작에 따라 DB1, DB2 로 나누는 등 transaction 진입 이후에 DB source 분기가 필요한 상황에서 이미 transaction 이 시작된 상황에서 DB source 가 결정 되었기에 이후 분기가 불가능하다.

 

@Bean
@Primary
public DataSource dataSource() {
    var determinedDataSource = routingDataSource(
        masterDataSource(),
        slaveDataSource()
    );
    return new LazyConnectionDataSourceProxy(determinedDataSource);
}

 

LazyConnectionDataSourceProxy 를 사용하여 DB source 결정을 transaction 진입 이후로 미뤄 쿼리가 실행될 때 DB source 를 결정할 수 있도록 한다.

 

5. Test code - Routing data source 의 db source 분기를 확인한다.

@TestPropertySource(locations = "/application-dev.properties")
@SpringBootTest
class DataSourceConfigTest {

    private final RoutingDataSource routingDataSource;

    public DataSourceConfigTest(@Autowired RoutingDataSource routingDataSource) {
        this.routingDataSource = routingDataSource;
    }

    @Transactional
    @DisplayName("Transactional readonly = false 인 경우 master db에 쿼리된다.")
    @Test
    void masterDataSource() {
        Object currentLookupKey = routingDataSource.determineCurrentLookupKey();
        assertEquals(DBType.MASTER, currentLookupKey);
    }

    @Transactional(readOnly = true)
    @DisplayName("Transactional readonly = true 인 경우 slave db에 쿼리된다.")
    @Test
    void slaveDataSource() {
        Object currentLookupKey = routingDataSource.determineCurrentLookupKey();
        assertEquals(DBType.SLAVE, currentLookupKey);
    }
}
Comments