All Articles

데이터 복제 시 리더-팔로워의 역할

데이터를 read/write할 때 머신이 여러개인 경우는 어떻게 해야할까? 데이터를 여러 머신에 나눠서 저장하는 이유는 아래와 같다.

  1. scalability - 데이터 볼륨이 커지면 read와 write할 때의 Load가 한개의 머신이 감당하기엔 너무 클 수도 있다. 여러 머신에 나눠서 저장하면서 로드를 분산시키는 것이다.
  2. Fault tolerance/high availability - 머신 하나가 고장나더라도 어플리케이션이 정상적으로 작동해야한다면, 여러 머신들에 데이터를 저장하는 방식을 택할 수 있다. 하나의 머신이 고장나면, 다른 머신이 그 자리를 대체하는 것이다.
  3. Latency - 사용자가 전 세계에 흩어져있다면, 서버를 다양한 위치에 뿌려두고, 실제 사용자의 위치와 가까운 데이터센터에서 데이터를 가져갈 수 있도록 설계하는 것이다. 패킷 전송시간이 오래걸리는 것을 방지할 수 있다.

Scaling to Higher Load

로드 증가로 인해 스케일링 해야한다면, 가장 간단한 방법은 더 좋은 머신을 구입하는 것이다. 흔히들 스케일 업이나 버티컬 스케일링이라고 부르는 방식이다. CPU, RAM, Disk등을 추가로 구입해서 하나의 운영체제 안에 두는 것이다. 여러 CPU가 모든 디스크와 메모리에 접근할 수 있게 해서, 하나의 머신처럼 운영할 수 있다.

이를 shared-memory 아키텍처라고 부르는데, 비용이 exponential하게 증가할 수 있다는 단점이 있다. CPU를 두배로 늘린다고 해서 두배의 로드를 감당할 수 잇는 것은 아니기 때문이다. 또한 여전히 하나의 머신처럼 작동하기 때문에 fault tolerant하지 못하다.

또 다른 접근 방식은 shared-disk 아키텍처이다. 별도의 CPU와 메모리를 가지고 있는 여러 머신들이, 디스크를 공유하고, 그 디스크 안에 데이터를 저장하는 방식이다. 빠른 네트워크 스피드가 보장된다면 좋은 방식이지만, locking limit이 오버헤드가 되는 경우가 있다.

Shared-Nothing Architectures

이와 반대로 horizontal scaling, 스케일 아웃이라고 불리는 shared-nothing 아키텍쳐를 많이 사용하기 시작했다. 각각의 머신이나 가상머신이 node라고 불리는 데이터베이스 소프트웨어를 운영한다. 각각의 노드들은 CPU, 메모리, 디스크를 갖게되고, 노드들간의 코디네이션은 하드웨어가 아닌 소프트웨어 단에서 이루어진다.

shared-nothing 아키텍쳐를 도입하는 경우, 엄청 뛰어난 하드웨어를 필요로 하지 않는다. 그냥 적당히 가성비 좋은 기기를 사용하면 된다. 잠재적으로 넓은 지역에 데이터를 나눠서 저장할 수 있기 때문에, 사용자들이 경험하는 Latency를 줄일 수있고, 데이터센터 하나가 통채로 날아가는 것도 대비할 수 있다. 가상머신을 클라우드 환경에서 배포한다면, 작은규모의 회사도 데이터를 나눠서 저장할 수 있는 것이다.

책은 shared-nothing 아키텍쳐에 대해 집중해서 설명할 예정인데, 이게 제일 좋은 선택지라서가 아니라, 개발자들이 신경쓸 부분이 많기 때문이다. 데이터를 여러 노드에 나눠서 저장한다면, 장단점과 제한사항에 대해서 개발자들이 알고 있어야한다. 어떤 아키텍쳐를 사용할지는 서비스 성향에 따라 다르고, 분산저장이 무조건 좋은 선택지는 아니다. 100개가 넘는 CPU 코어를 하나의 클러스터에 두고 운영하는 것이 더 좋을 때도 있다.

Replication vs Partitioning

데이터를 여러 노드에 나눠서 저장하는 방식은 아래 두가지가 가장 많이 사용된다.

  1. Replication - 같은 데이터의 복제본을 여러 지역에 나누어 여러 노드에 저장하는 방식이다. 복제를 통해서 redundancy가 보장되기 때문에, 특정 노드가 죽는다면, 해당 데이터는 다른 노드에서 read할 수 있다. 복제는 성능에서도 장점이 있다.
  2. Patitioning - 큰 데이터를 파티션이라는 작은 단위로 나누어서 저장하는 것이다. 각 파티션별로 다른 노드에 배정된다.

Advantages of Replication

데이터 복제에는 아래와 같은 장점이 있다.

  1. 사용자와 가까운 geographic 위치에 데이터를 저장해서 latency를 줄이는 것
  2. 시스템의 일부가 죽더라도 정상적으로 작동할 수 있게 하는 것
  3. read query를 하는 머신들의 로드를 분산할 수 있는 것

만약 복제하는 데이터가 변경되지 않는다면, write할 때 복제해서 저장하면 끝이기 때문에 전혀 어렵지 않다. 하지만 복제해서 저장된 데이터를 수정하는 경우, 복제되어 뿌려진 데이터를 모두 수정해야하기 때문에 문제가 된다. 예를들면 데이터를 5개의 머신에 복제해서 저장했는데, 3번째 까지 복제가 끝난 상황에서 4번재 노드로 read요청이 들어오면, 사용자는 최신 데이터를 불러오지 못하는 문제가 있다. 이를 위해 single-leader, multi-leader, leaderless 라는 방식을 택해서 데이터를 복제해서 저장한다.

데이터를 복제해서 저장할 때는 고려할 점들이 많다. 동기/비동기 중 어떤 방식을 택할지, 복제에 실패하면 해당 데이터를 어떻게 처리할지 등등이다.

Leaders and Followers

데이터베이스의 복제본을 저장하는 노드를 replica라고 부른다. replica가 여러개인 경우에는 어떻게 데이터가 모든 replica에 적용되었는지를 확신할 수 있을까?

write는 모든 Replica에 실행되어야 한다. 그렇지 않다면 replica들은 다른 데이터를 갖고있게 된다. 이를 해결하는 가장 간단한 방법이 leader-based 어플리케이션이다.

  1. replica들 중 하나를 리더(master나 primary라고도 불림)로 정한다. 클라이언트는 write를 시도할 때 요청을 리더에게 보내야한다. 요청을 받은 리더는 자신의 로컬 스토리지에 write를 실시한다.
  2. 다른 replica들은 팔로워라고 부른다. 리더가 새로운 데이터를 write하면, 리더는 이 데이터를 replication logchange stream의 형태로 자신의 팔로워들에게 전달한다. 각각의 팔로워는 리더에서 받은 정보를 활용하여 자신의 로컬에 write를 실시한다.
  3. read는 굳이 리더를 통하지 않고도 팔로워를 통해서도 데이터를 읽어올 수 있다.

Synchronous vs Asynchronous Replication

사용자가 프로필 사진을 업데이트 한다고 하자. 사용자는 리더에게 write 요청을 보내고, 리더는 요청을 받는다. 리더는 받은 요청을 로컬에서 처리하고, 팔로워들에게 전달한다. 그리고 리더는 팔로워에게 요청이 성공했다고 응답한다.

리더는 사용자의 요청의 수락과 응답은 동기로 처리하지만, 팔로워들에게 복제 요청은 비동기로 처리한다. 따라서 사용자는 데이터 복제의 성공 여부와 관련없이 응답을 받는다. 복제는 일반적으로 빠른 편이지만, 얼마나 걸릴지 모르기 때문에, 데이터 복제는 비동기로 처리하는 것이다. 리더-팔로워 간의 latency나 오버헤드 문제로 불필요하게 시간이 더 걸릴수도 있고, 리더-팔로워 간의 네트워크 에러로 사용자가 오랜시간 기다려야 할수도 있다.

동기식으로 데이터를 복제하면, 사용자가 요청한 데이터가 성공적으로 복제되었는지 확실히 알 수 있다는 장점이 있다. 그리고 복제된 데이터의 consistency를 보장할 수 있다. 갑자리 리더가 fail하게 되면, 사용자가 다시 요청을 보낼것이기 때문이다. 하지만 동기방식의 문제는 팔로워가 응답하지 않는 경우 사용자가 계속해서 기다려야한다는 문제가 있다. 팔로워의 무응답으로 write가 지연되는 동안, 다른 사용자들의 write request가 모두 block되는 문제도 있다.

이로인해 모든 팔로워들의 작업을 동기로 처리하는 것은 비현실적이다. 하나의 노드가 죽어도, 전체 시스템이 멈춰버릴수도 있다. 만약 동기방식을 사용한다면, 하나의 팔로워의 복제만 동기로 처리하는 것이 일반적이다. 만약 동기로 처리할 팔로워가 응답하지 않는다면, 비동기로 데이터를 복제하는 팔로워들 중 하나가 동기식으로 응답을 보낸다. 이를 통해 적어도 두개의 노드간 데이터 consistency를 보장하는 것이다. 이 방식을 semi-synchronous라고 부른다.

일반적으로 리더-팔로워 패턴의 데이터 복제는 완전히 비동기식으로 이루어진다. 리더가 fail해서 회복될 수 없다면, 팔로워들의 write도 모두 실패하게 된다. write가 성공했다고 사용자에게 응답이 돌아가더라도, 실제로 write가 안되는 경우도 있다. 하지만 이러한 방식은 모든 팔로워가 실패한다고 하더라도 리더가 계속해서 write를 처리할 수 있다는 장점이 있다.

Setting Up New Followers

데이터 복제를 늘린다거나 죽은 노드를 대체하기 위해 팔로워를 추가해야하는 경우가 있다. 이럴 경우 리더의 데이터를 팔로워가 정확하게 복제할 수 있도록 하려면 어떻게 해야할까?

간단히 데이터 파일을 하나의 노드로부터 복사하는 것은 충분하지 않다. 클라이언트는 지속적으로 데이터베이스에 write요청을 하고, data는 flux패턴으로 단방향으로 써지기 때문이다. 따라서 단순한 파일 복사를 한다면, 업데이트된 데이터를 완벽하게 처리할 수 없다. 만약 lock을 활용해서 데이터베이스 내 consistency를 지킨다면, 이는 high availability의 원칙에 위배된다.

하지만 다행히 팔로워를 추가하는 것은 별도의 다운타임 없이도 간단히 처리할 수 있다.

  1. lock을 걸지 않고 리더 데이터베이스의 스냅샷을 뜬다.
  2. 스냅샷을 새로운 팔로워 노드에 복제한다
  3. 팔로워가 스냅샷 이후로부터 리더에게 들어온 write요청들을 replication log를 활용해서 처리한다.
  4. 팔로워가 모든 데이터 백로그를 처리하면 팔로워 추가가 완료된다.

Handling Node Outages

다운타임 없이 노드를 재부팅 하는 것은, 운영 및 관리 측면에서 유리하다. 따라서 시스템을 하나의 노드가 죽더라도 서비스가 정상적으로 운영될 수 있도록 설계해야 한다.

Follower failure: Catch-up recovery

팔로워는 로컬 디스크에 리더로부터 받은 replication log들을 가지고 있다. 만약 팔로워가 죽어서 재부팅되거나, 리더와의 네트워크 통신이 끊어졌다면, 팔로워는 해당 로그를 사용해서 비교적 쉽게 회복할 수 있다. 팔로워는 가장 마지막에 처리된 트랜잭션을 기반으로 리더와 다시 연결해서, 리더에게 전달받은 요청들을 처리하는 것이다.

Leader failure: Failover

리더가 죽으면 좀 골치아프다. 팔로워들 중 하나가 새로운 리더로 선택되어야 한다. 클라이언트는 write를 보낼 새로운 리더 정보를 활용해서 다시 설정해야 하고, 다른 팔로워들은 새로운 리더로부터 데이터를 받아와야 한다. 이를 failover라고 한다.

자동 failover는 아래와 같은 순서로 이루어진다.

  1. 리더가 죽었다는 것을 판단한다. - crash가 일어나거나, 전원 공급이 중단되거나, 네트워크 오류로 인해 Fail이 발생할 수 있다. 정확한 원인을 찾을 수 있는 방법이 마땅치 않아서, 일반적으로 타임아웃을 활용한다. 노드들은 서로 메세지를 주고 받는데, 만약 정해진 시간내에 답변이 없으면 죽은 것으로 판단한다.
  2. 새로운 리더 선출 - 리더가 fail하면 election을 통해 리더를 선출하거나, 과거 리더가 있을 때 미리 선택된 컨트롤러 노드가 리더가 되기도 한다. 최고의 리더 후보는, 기존 리더와 가장 sync가 맞는 팔로워이다.
  3. 새로운 리더를 사용하기 위한 configuration 수정 - 클라이언트는 이제 새로운 리더에게 write 요청을 보내야 한다. 만약 예전 리더가 되살아나면, 해당 리더가 아직도 리더라고 생각할수도 있기 때문이다. 시스템은 예전 리더가 다시 살아나면 팔로워로서 역할을 할 수 있도록 해야한다.

failover를 처리할 때는 아래와 같은 사항들을 고려해야 한다.

  1. 만약 비동기식으로 복제를 처리했다면, 새로운 리더는 기존 리더가 받았던 write 요청들을 모두 확인하지 못했을 수 있다. 죽었던 과거 리더가 다시 살아나는 경우 새로운 리더가 받은 write 요청들과 conflict가 발생할 수 있다. 가장 간단한 방법은 새로운 리더가 놓친 예전 리더의 write 요청들을 버리는 것이다.
  2. 하지만 write요청을 버리는 것은 데이터베이스 외부의 스토리지와 데이터가 연관되어있는 경우 매우 위험하다. 예를들면 MySQL 리더가 죽어서 auto increment에 문제가 생기면, 이 값을 MySQL 외부의 다른 스토리지에서 사용할 경우 중복되거나, primary key를 잘못 배정하는 문제가 생길 수 있다.
  3. 여러 노드가 동시에 본인이 리더라고 생각할 수도 있다. 이 현상을 split brain이라고 부른다. 두 리더가 모두 write 요청을 처리하고, 이들의 conflict를 해결할 방법이 없다. 데이터는 손실되거나 오염된다. 몇몇 시스템은 여러명의 리더가 파악되면, 리더들중 하나만 남기고 다른 노드들을 죽인다.
  4. 그렇다면 리더가 죽었음을 판단하는 타임아웃은 얼마정도가 적당할까? 너무 길면 시스템 운영에 문제가 생기고, 너무 짧으면 조금만 지나면 괜찮아질 수 있는데, 불필요한 비용이 발생하게 된다. 잘 고려해서 설계해야한다.

Implementation of Replication Logs

leader-based 어플리케이션은 어떻게 동작할까? 여러 방식들이 있다.

Statement-based replication

리더는 본인이 처리한 모든 write 요청(statement)을 기록하고, 해당 기록을 팔로워들에게 보낸다. 관계형 데이터베이스의 경우 모든 INSERT, UPDATE, DELETE가 팔로워들에게 전달되고 각각의 팔로워가 클라이언트로부터 받은 요청을 parsing해서 처리하는 것이다. 합리적인 것 같지만

  1. NOW(), RAND()와 같은 함수의 호출은 특정 노드의 해당 명령어 시점에 따라 다른 값을 리턴한다.
  2. 만약 auto-increment 컬럼과 관련있다면, primary key가 꼬일 수 있고, 트랜잭션에서 문제를 야기할 수 있다.
  3. 사이드이펙트가 있는 statement의 경우 각각의 replica에서 다른 사이드이펙트를 발생시킬 수 있다.

이를 해결하기 위해서 리더가 해당 함수들의 결과를 먼저 처리해서 팔로워들에게 넘겨줄 수 있다. 하지만 고려할 점이 너무 많이 때문에 다른 방식들을 많이 사용한다.

Write-ahead log(WAL) shipping

로그는 데이터베이스의 write 요청 정보를 가진 append-only sequence이다. 우리는 이 로그를 사용해서 다른 노드에서 Replica를 만들어낼 수 있다. log를 디스크에 write할 뿐만 아니라, 모든 팔로워들에게 전송한다. 팔로워들이 이 로그를 처리하면, 리더와 똑같은 자료구조를 만들 수 있다.

이 방식의 문제는 로그가 low level에서 데이터를 설명한다는 것이다. WAL 은 어떤 디스크 블록에서 어떤 바이트가 수정되었는지를 기록한다. 데이터베이스가 만약 스토리지 엔진을 변경한다면, 이 로그는 사용될 수 없다. 별 거 아닌 것 같지만, 만약 리더와 팔로워의 데이터베이스 버전이 다르다면, 이는 큰 문제를 야기할 수 있다.

Logical(row-based) log replication

로그를 스토리지엔진과 관련 없이 관리하는 방식이다. row에 추가된 값들을 기록한다.

  1. insert 된 row는 모든 컬럼에 대해 새로운 값을 기록한다.
  2. delete 된 row는 삭제된 row를 구분할 수 있는 unique한 값을 기록한다.
  3. update 된 row는 해당 row의 unique한 값과, 모든 컬럼에 대해 새로운 값을 기록한다.

MySQL의 binlog가 이런 방식을 사용하는데, 여러개의 row를 변경하는 트랜잭션의 경우 많은 로그들을 생성한다. 그리고 트랜잭션이 끝나면, 트랜잭션이 commit되었다는 로그가 추가된다. 스토리지와 관련이 없기 때문에, backward compatible하고, 리더와 팔로워가 다른 버전을 사용하더라도 문제없이 데이터를 복제할 수 있다. 또한 외부 어플리케이션에서 parsing하기 쉽다는 장점이 있다.

Trigger-based replication

지금까지 언급한 복제방식은 어플리케이션 코드와 관계 없이 데이터베이스가 구현하는 것이다. 일반적으로 이런 방식을만 사용해도 충분하지만, 가끔 flexibility를 필요로 하는 경우가 있다. 만약 특정 데이터의 일부만 복제하고 싶거나, 다른 데이터베이스로 데이터를 복제하고 싶다면 어플리케이션 단에서 데이터 복제를 처리해야 한다.

trigger는 사용자가 커스텀한 어플리케이션 코드를 데이터가 변경되면 자동적으로 실행시키는 방식이다. trigger는 이 변동사항을 별도의 테이블에 기록할 수 있고, 어플리케이션은 이 별도 테이블에 작성된 기록을 바탕으로 데이터를 복제할 수 있다.

Problems with Replication Lag

노드가 죽는 것을 대비하는 것은 데이터 복제의 한가지 이유일 뿐이다. 확장성과, latency도 같이 고려해야 한다. leader-based 복제는 모든 write를 하나의 node에서 처리하지만, read는 여러 replica들에서 처리할 수 있다. read가 많고 write가 적은 어플리케이션에는 적합한 아키텍쳐이다. 팔로워를 많이 만들고 read query를 팔로워들에게 분산한다면 리더의 load를 줄일 수 있다.

read-only request를 분산처리 할 수 있지만, 이는 비동기 방식으로 데이터를 복제할 때만 가능하다. 만약 모든 팔로워들에게 동기로 데이터를 복제한다면, 하나의 노드가 죽거나, 네트워크 에러가 발생하면 시스템은 바로 write를 할 수 없는 문제가 생긴다. 그리고 노드가 많아질수록, 죽는 노드가 발생할 가능성이 높아지고, 결국 동기식으로 복제하는 시스템은 불안정해진다.

하지만 비동기식으로 데이터를 복제한다면, replica에서 read를 할 경우 consistency를 보장할 수 없다. 따라서 비동기식으로 데이터를 복제한다면, consistency를 보장하기 위해 일정 시간동안 write를 block하기도 하는데, 이를 eventual consistency라고 부른다.eventual이라는 단어가 매우 모호한데, 얼마나 기달려야 consistency를 보장할 수 있는지 알 수 없기 때문이다. 리더가 write한 데이터를 팔로워들이 복제하는데 걸리는 시간을 replication lag라고 부르는데, 이게 얼마나 걸릴지는 시스템에 따라 다르다. 만약 엄청 오래걸린다면, 어플리케이션 운영에 문제가 생길 수 있는데, 이를 어떻게 해결할 수 있는지 살펴보자.

Reading Your Own Writes

많은 어플리케이션들이 사용자가 데이터를 submit하고, 그들이 submit한 데이터를 확인시킨다. 데이터 write를 시도하면, 리더에게 먼저 전송되지만, 사용자가 그 데이터를 read할 때는 팔로워에게서 가져올 수도 있다. 만약 read가 write보다 훨씬 더 빈도가 높은 경우 이런 방식을 사용하는 것이 적합하다.

하지만 비동기 복제의 경우 문제가 있다. 만약 사용자가 write요청을 하고 얼마 지나지 않아 read를 시도했는데, read 요청을 받은 팔로워가 데이터를 봊게한 상태가 아니라면, 사용자는 데이터가 write를 제대로 하지 못한 것이라고 생각할 수 있다.

이런 경우 read-after-write consistency 또는 read-your-writes-consistency가 보장되어야 한다. 만약 사용자가 페이지를 새로고침 하더라도, 본인이 작성한 업데이트를 확인할 수 있어야 한다.

  1. 해당 사용자가 변경한 데이터를 read하는 경우 리더를 사용해서 read한다. 그렇지 않은 경우에만 팔로워로부터 read한다. 이를 구현하려면 사용자가 최근에 write 요청을 보낸 것을 알고있어야 한다.
  2. write가 발생한 시점을 기록하고, 해당 시점으로부터 특정 시간까지는 리더를 통해서 read한다
  3. 클라이언트가 가장 최근 write 요청을 보낸 시점을 기억하고, 리더로 read요청을 할지, 팔로워로 할지 결정한다.
  4. 만약 데이터센터가 여러곳에 있다면, 해당 리더가 write를 한 데이터센터 정보도 고려해야 한다.

만약 사용자가 다양한 기기에서 서비스를 사용한다면, cross-device read-after-write consistency를 보장해야 한다.

  1. 사용자가 write를 시도한 시점의 메타데이터를 별도의 장소에 저장해야 한다.
  2. 만약 복제본이 다양한 데이터 센터로 뿌려져있다면, 다른 기기들이 같은 데이터센터로 요청을 보내도록 고려해야 한다.

Monotonic Reads

만약 사용자가 동시에 여러번의 read를 시도한다면, 복제가 성공한 팔로워로부터 read를 했다가, 아직 복제하지 않은 팔로워에게 read 요청을 보낸 경우, 업데이트가 이루어지지 않고 원복된 것처럼 보일 수 있다. 이를 방지하기 위해 read를 할 때 하나의 replica에만 read요청을 보내도록 하는 것이다. 그렇다면 해당 사용자가 새로운 데이터를 read한 기록이 있다면, 아직 복제되지 않은 예전 데이터를 read하지 않도록 할 수 있다.

Consistent Prefix Reads

파티션들이 독립적으로 운영되는 경우, write된 순서를 보장하지 않을 수 있고, 마치 질문을 하기도 전에 답을 한 것처럼 read가 발생하는 문제가 있다. 이를 위해 write sequence가 지정된 순서로 일어날 수 있게 하는 것이다.

Solutions for Replication Lag

만약 비동기 복제 방식으로 서비스를 운영하면서 eventual consistency를 활용한다면, replication lag가 길어질 경우 사용자 경험에 어떤 영향을 미치는지를 고려해야 한다. 만약 read-after-write consistency를 보장해야한다면, 리더로부터 read하게 한다던지 등으로 문제를 해결해야 한다.

개발자들이 이러한 복제 문제들을 생각하지 않도록 하기 위해서 트랜잭션이 존재한다. 하나의 노드에서 트랜잭션을 사용하는 것은 일반적이지만, 데이터를 분산저장하는 경우 비용문제로 트랜잭션을 사용하지 않는 경우도 많다. 이에 대해서는 뒤에 더 자세히 다룰 예정이다.

Apr 26, 2023

AI Enthusiast and a Software Engineer