데이터를 read/write할 때 머신이 여러개인 경우는 어떻게 해야할까? 데이터를 여러 머신에 나눠서 저장하는 이유는 아래와 같다.
로드 증가로 인해 스케일링 해야한다면, 가장 간단한 방법은 더 좋은 머신을 구입하는 것이다. 흔히들 스케일 업이나 버티컬 스케일링이라고 부르는 방식이다. CPU, RAM, Disk등을 추가로 구입해서 하나의 운영체제 안에 두는 것이다. 여러 CPU가 모든 디스크와 메모리에 접근할 수 있게 해서, 하나의 머신처럼 운영할 수 있다.
이를 shared-memory
아키텍처라고 부르는데, 비용이 exponential하게 증가할 수 있다는 단점이 있다. CPU를 두배로 늘린다고 해서 두배의 로드를 감당할 수 잇는 것은 아니기 때문이다. 또한 여전히 하나의 머신처럼 작동하기 때문에 fault tolerant하지 못하다.
또 다른 접근 방식은 shared-disk
아키텍처이다. 별도의 CPU와 메모리를 가지고 있는 여러 머신들이, 디스크를 공유하고, 그 디스크 안에 데이터를 저장하는 방식이다. 빠른 네트워크 스피드가 보장된다면 좋은 방식이지만, locking limit이 오버헤드가 되는 경우가 있다.
이와 반대로 horizontal scaling, 스케일 아웃이라고 불리는 shared-nothing
아키텍쳐를 많이 사용하기 시작했다. 각각의 머신이나 가상머신이 node
라고 불리는 데이터베이스 소프트웨어를 운영한다. 각각의 노드들은 CPU, 메모리, 디스크를 갖게되고, 노드들간의 코디네이션은 하드웨어가 아닌 소프트웨어 단에서 이루어진다.
shared-nothing 아키텍쳐를 도입하는 경우, 엄청 뛰어난 하드웨어를 필요로 하지 않는다. 그냥 적당히 가성비 좋은 기기를 사용하면 된다. 잠재적으로 넓은 지역에 데이터를 나눠서 저장할 수 있기 때문에, 사용자들이 경험하는 Latency를 줄일 수있고, 데이터센터 하나가 통채로 날아가는 것도 대비할 수 있다. 가상머신을 클라우드 환경에서 배포한다면, 작은규모의 회사도 데이터를 나눠서 저장할 수 있는 것이다.
책은 shared-nothing 아키텍쳐에 대해 집중해서 설명할 예정인데, 이게 제일 좋은 선택지라서가 아니라, 개발자들이 신경쓸 부분이 많기 때문이다. 데이터를 여러 노드에 나눠서 저장한다면, 장단점과 제한사항에 대해서 개발자들이 알고 있어야한다. 어떤 아키텍쳐를 사용할지는 서비스 성향에 따라 다르고, 분산저장이 무조건 좋은 선택지는 아니다. 100개가 넘는 CPU 코어를 하나의 클러스터에 두고 운영하는 것이 더 좋을 때도 있다.
데이터를 여러 노드에 나눠서 저장하는 방식은 아래 두가지가 가장 많이 사용된다.
데이터 복제에는 아래와 같은 장점이 있다.
만약 복제하는 데이터가 변경되지 않는다면, write할 때 복제해서 저장하면 끝이기 때문에 전혀 어렵지 않다. 하지만 복제해서 저장된 데이터를 수정하는 경우, 복제되어 뿌려진 데이터를 모두 수정해야하기 때문에 문제가 된다. 예를들면 데이터를 5개의 머신에 복제해서 저장했는데, 3번째 까지 복제가 끝난 상황에서 4번재 노드로 read요청이 들어오면, 사용자는 최신 데이터를 불러오지 못하는 문제가 있다. 이를 위해 single-leader
, multi-leader
, leaderless
라는 방식을 택해서 데이터를 복제해서 저장한다.
데이터를 복제해서 저장할 때는 고려할 점들이 많다. 동기/비동기 중 어떤 방식을 택할지, 복제에 실패하면 해당 데이터를 어떻게 처리할지 등등이다.
데이터베이스의 복제본을 저장하는 노드를 replica
라고 부른다. replica가 여러개인 경우에는 어떻게 데이터가 모든 replica에 적용되었는지를 확신할 수 있을까?
write는 모든 Replica에 실행되어야 한다. 그렇지 않다면 replica들은 다른 데이터를 갖고있게 된다. 이를 해결하는 가장 간단한 방법이 leader-based
어플리케이션이다.
replication log
나 change stream
의 형태로 자신의 팔로워들에게 전달한다. 각각의 팔로워는 리더에서 받은 정보를 활용하여 자신의 로컬에 write를 실시한다.사용자가 프로필 사진을 업데이트 한다고 하자. 사용자는 리더에게 write 요청을 보내고, 리더는 요청을 받는다. 리더는 받은 요청을 로컬에서 처리하고, 팔로워들에게 전달한다. 그리고 리더는 팔로워에게 요청이 성공했다고 응답한다.
리더는 사용자의 요청의 수락과 응답은 동기로 처리하지만, 팔로워들에게 복제 요청은 비동기로 처리한다. 따라서 사용자는 데이터 복제의 성공 여부와 관련없이 응답을 받는다. 복제는 일반적으로 빠른 편이지만, 얼마나 걸릴지 모르기 때문에, 데이터 복제는 비동기로 처리하는 것이다. 리더-팔로워 간의 latency나 오버헤드 문제로 불필요하게 시간이 더 걸릴수도 있고, 리더-팔로워 간의 네트워크 에러로 사용자가 오랜시간 기다려야 할수도 있다.
동기식으로 데이터를 복제하면, 사용자가 요청한 데이터가 성공적으로 복제되었는지 확실히 알 수 있다는 장점이 있다. 그리고 복제된 데이터의 consistency를 보장할 수 있다. 갑자리 리더가 fail하게 되면, 사용자가 다시 요청을 보낼것이기 때문이다. 하지만 동기방식의 문제는 팔로워가 응답하지 않는 경우 사용자가 계속해서 기다려야한다는 문제가 있다. 팔로워의 무응답으로 write가 지연되는 동안, 다른 사용자들의 write request가 모두 block되는 문제도 있다.
이로인해 모든 팔로워들의 작업을 동기로 처리하는 것은 비현실적이다. 하나의 노드가 죽어도, 전체 시스템이 멈춰버릴수도 있다. 만약 동기방식을 사용한다면, 하나의 팔로워의 복제만 동기로 처리하는 것이 일반적이다. 만약 동기로 처리할 팔로워가 응답하지 않는다면, 비동기로 데이터를 복제하는 팔로워들 중 하나가 동기식으로 응답을 보낸다. 이를 통해 적어도 두개의 노드간 데이터 consistency를 보장하는 것이다. 이 방식을 semi-synchronous
라고 부른다.
일반적으로 리더-팔로워 패턴의 데이터 복제는 완전히 비동기식으로 이루어진다. 리더가 fail해서 회복될 수 없다면, 팔로워들의 write도 모두 실패하게 된다. write가 성공했다고 사용자에게 응답이 돌아가더라도, 실제로 write가 안되는 경우도 있다. 하지만 이러한 방식은 모든 팔로워가 실패한다고 하더라도 리더가 계속해서 write를 처리할 수 있다는 장점이 있다.
데이터 복제를 늘린다거나 죽은 노드를 대체하기 위해 팔로워를 추가해야하는 경우가 있다. 이럴 경우 리더의 데이터를 팔로워가 정확하게 복제할 수 있도록 하려면 어떻게 해야할까?
간단히 데이터 파일을 하나의 노드로부터 복사하는 것은 충분하지 않다. 클라이언트는 지속적으로 데이터베이스에 write요청을 하고, data는 flux패턴으로 단방향으로 써지기 때문이다. 따라서 단순한 파일 복사를 한다면, 업데이트된 데이터를 완벽하게 처리할 수 없다. 만약 lock을 활용해서 데이터베이스 내 consistency를 지킨다면, 이는 high availability의 원칙에 위배된다.
하지만 다행히 팔로워를 추가하는 것은 별도의 다운타임 없이도 간단히 처리할 수 있다.
다운타임 없이 노드를 재부팅 하는 것은, 운영 및 관리 측면에서 유리하다. 따라서 시스템을 하나의 노드가 죽더라도 서비스가 정상적으로 운영될 수 있도록 설계해야 한다.
팔로워는 로컬 디스크에 리더로부터 받은 replication log들을 가지고 있다. 만약 팔로워가 죽어서 재부팅되거나, 리더와의 네트워크 통신이 끊어졌다면, 팔로워는 해당 로그를 사용해서 비교적 쉽게 회복할 수 있다. 팔로워는 가장 마지막에 처리된 트랜잭션을 기반으로 리더와 다시 연결해서, 리더에게 전달받은 요청들을 처리하는 것이다.
리더가 죽으면 좀 골치아프다. 팔로워들 중 하나가 새로운 리더로 선택되어야 한다. 클라이언트는 write를 보낼 새로운 리더 정보를 활용해서 다시 설정해야 하고, 다른 팔로워들은 새로운 리더로부터 데이터를 받아와야 한다. 이를 failover
라고 한다.
자동 failover는 아래와 같은 순서로 이루어진다.
failover를 처리할 때는 아래와 같은 사항들을 고려해야 한다.
split brain
이라고 부른다. 두 리더가 모두 write 요청을 처리하고, 이들의 conflict를 해결할 방법이 없다. 데이터는 손실되거나 오염된다. 몇몇 시스템은 여러명의 리더가 파악되면, 리더들중 하나만 남기고 다른 노드들을 죽인다.leader-based 어플리케이션은 어떻게 동작할까? 여러 방식들이 있다.
리더는 본인이 처리한 모든 write 요청(statement)을 기록하고, 해당 기록을 팔로워들에게 보낸다. 관계형 데이터베이스의 경우 모든 INSERT, UPDATE, DELETE가 팔로워들에게 전달되고 각각의 팔로워가 클라이언트로부터 받은 요청을 parsing해서 처리하는 것이다. 합리적인 것 같지만
NOW()
, RAND()
와 같은 함수의 호출은 특정 노드의 해당 명령어 시점에 따라 다른 값을 리턴한다.이를 해결하기 위해서 리더가 해당 함수들의 결과를 먼저 처리해서 팔로워들에게 넘겨줄 수 있다. 하지만 고려할 점이 너무 많이 때문에 다른 방식들을 많이 사용한다.
로그는 데이터베이스의 write 요청 정보를 가진 append-only sequence이다. 우리는 이 로그를 사용해서 다른 노드에서 Replica를 만들어낼 수 있다. log를 디스크에 write할 뿐만 아니라, 모든 팔로워들에게 전송한다. 팔로워들이 이 로그를 처리하면, 리더와 똑같은 자료구조를 만들 수 있다.
이 방식의 문제는 로그가 low level에서 데이터를 설명한다는 것이다. WAL 은 어떤 디스크 블록에서 어떤 바이트가 수정되었는지를 기록한다. 데이터베이스가 만약 스토리지 엔진을 변경한다면, 이 로그는 사용될 수 없다. 별 거 아닌 것 같지만, 만약 리더와 팔로워의 데이터베이스 버전이 다르다면, 이는 큰 문제를 야기할 수 있다.
로그를 스토리지엔진과 관련 없이 관리하는 방식이다. row에 추가된 값들을 기록한다.
MySQL의 binlog가 이런 방식을 사용하는데, 여러개의 row를 변경하는 트랜잭션의 경우 많은 로그들을 생성한다. 그리고 트랜잭션이 끝나면, 트랜잭션이 commit되었다는 로그가 추가된다. 스토리지와 관련이 없기 때문에, backward compatible하고, 리더와 팔로워가 다른 버전을 사용하더라도 문제없이 데이터를 복제할 수 있다. 또한 외부 어플리케이션에서 parsing하기 쉽다는 장점이 있다.
지금까지 언급한 복제방식은 어플리케이션 코드와 관계 없이 데이터베이스가 구현하는 것이다. 일반적으로 이런 방식을만 사용해도 충분하지만, 가끔 flexibility를 필요로 하는 경우가 있다. 만약 특정 데이터의 일부만 복제하고 싶거나, 다른 데이터베이스로 데이터를 복제하고 싶다면 어플리케이션 단에서 데이터 복제를 처리해야 한다.
trigger
는 사용자가 커스텀한 어플리케이션 코드를 데이터가 변경되면 자동적으로 실행시키는 방식이다. trigger는 이 변동사항을 별도의 테이블에 기록할 수 있고, 어플리케이션은 이 별도 테이블에 작성된 기록을 바탕으로 데이터를 복제할 수 있다.
노드가 죽는 것을 대비하는 것은 데이터 복제의 한가지 이유일 뿐이다. 확장성과, 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
라고 부르는데, 이게 얼마나 걸릴지는 시스템에 따라 다르다. 만약 엄청 오래걸린다면, 어플리케이션 운영에 문제가 생길 수 있는데, 이를 어떻게 해결할 수 있는지 살펴보자.
많은 어플리케이션들이 사용자가 데이터를 submit하고, 그들이 submit한 데이터를 확인시킨다. 데이터 write를 시도하면, 리더에게 먼저 전송되지만, 사용자가 그 데이터를 read할 때는 팔로워에게서 가져올 수도 있다. 만약 read가 write보다 훨씬 더 빈도가 높은 경우 이런 방식을 사용하는 것이 적합하다.
하지만 비동기 복제의 경우 문제가 있다. 만약 사용자가 write요청을 하고 얼마 지나지 않아 read를 시도했는데, read 요청을 받은 팔로워가 데이터를 봊게한 상태가 아니라면, 사용자는 데이터가 write를 제대로 하지 못한 것이라고 생각할 수 있다.
이런 경우 read-after-write consistency
또는 read-your-writes-consistency
가 보장되어야 한다. 만약 사용자가 페이지를 새로고침 하더라도, 본인이 작성한 업데이트를 확인할 수 있어야 한다.
만약 사용자가 다양한 기기에서 서비스를 사용한다면, cross-device read-after-write consistency
를 보장해야 한다.
만약 사용자가 동시에 여러번의 read를 시도한다면, 복제가 성공한 팔로워로부터 read를 했다가, 아직 복제하지 않은 팔로워에게 read 요청을 보낸 경우, 업데이트가 이루어지지 않고 원복된 것처럼 보일 수 있다. 이를 방지하기 위해 read를 할 때 하나의 replica에만 read요청을 보내도록 하는 것이다. 그렇다면 해당 사용자가 새로운 데이터를 read한 기록이 있다면, 아직 복제되지 않은 예전 데이터를 read하지 않도록 할 수 있다.
파티션들이 독립적으로 운영되는 경우, write된 순서를 보장하지 않을 수 있고, 마치 질문을 하기도 전에 답을 한 것처럼 read가 발생하는 문제가 있다. 이를 위해 write sequence가 지정된 순서로 일어날 수 있게 하는 것이다.
만약 비동기 복제 방식으로 서비스를 운영하면서 eventual consistency를 활용한다면, replication lag가 길어질 경우 사용자 경험에 어떤 영향을 미치는지를 고려해야 한다. 만약 read-after-write
consistency를 보장해야한다면, 리더로부터 read하게 한다던지 등으로 문제를 해결해야 한다.
개발자들이 이러한 복제 문제들을 생각하지 않도록 하기 위해서 트랜잭션이 존재한다. 하나의 노드에서 트랜잭션을 사용하는 것은 일반적이지만, 데이터를 분산저장하는 경우 비용문제로 트랜잭션을 사용하지 않는 경우도 많다. 이에 대해서는 뒤에 더 자세히 다룰 예정이다.