About Part 2
저장소와 데이터 검색에 여러 장비가 관여한다면 어떻게 될까? 이번 장에서는 분산 데이터에 대해 다룬다.
여러 장비가 필요한 이유
- 확장성 : 부하를 분산시킬수 있다.
- 내결함성/고가용성 : 장애가 발생해도 시스템이 계속 동작할 수 있다.
- 지연시간 : 사용자 가까이 데이터를 분산시킬 수 있다.
공유 메모리 아키텍처, 공유 디스크 아키텍처
- 많은 CPU, 메모리, 디스크를 하나의 운영체제로 합친다.
- 단순한 구조에 어느정도 내결함성을 가진다.
- 선형적이지 않은 비용증가, 제한적인 내결함성이 단점이다.
비공유 아키텍처
- 각 장비를 노드로 분류하고, 각각의 컴퓨팅 자원을 독립적으로 사용한다.
- 노드간 통신을 위한 네트워크가 필요하며, 소프트웨어 레벨에서 coordination 한다.
- 훨씬 유연한 내결함성과, 나름 일관적인 비용증가가 장점이다.(디스크를 더 달기 위해서, RAM을 더 넣기 위해서 필요한 부가적인 하드웨어가 없을것이다)
- 물론 추가적인 복잡성이라는 단점이 있다.
데이터 분산은 주로 복제
와 파티셔닝
으로 이루어진다.
복제
“복제란 네트워크로 연결된 여러 장비에 동일한 데이터의 복사본을 유지한다는 의미이다.” 복제가 필요한 이유는 다음과 같다. 첫 번째, 지리적으로 사용자와 가깝게 데이터를 유지해 지연 시간을 줄인다. 두 번째, 내결함성을 높인다. 세 번째, 읽기 처리량을 높인다.
참고로 이번장은 데이터셋이 작아서 모든 장비에 복제본 전부를 넣을 수 있는 상황을 가정한다. 이러한 제약조건은 주제를 단순화하고, 해당 개념에 집중할 수 있게 한다.
복제가 진짜 어려운 이유는 데이터가 변경되기 때문이다. 그리고 그러한 어려움을 해결하기 위해 이번장에서는 가장 인기있는 세가지 알고리즘을 다룬다.(single-leader, multi-leader, leaderless)
리더와 팔로워
데이터베이스의 복사본을 저장하는 각 노드를 replica라고 한다. 그리고 다중 복제 서버를 사용할 때, 가장 중요한 문제와 맞닥뜨리게 된다.
“모든 복제 서버에 모든 데이터가 있다는 것을 어떻게 보장할 수 있을까?”
가장 먼저 생각해야 할 것은, 모든 쓰기는 모든 복제 서버에 각각 적용되어야 한다는 것이다. 이걸 달성하기 위해 리더와 팔로워라는 개념을 사용한다. (복제 서버중 하나를 리더로 지정, 리더에만 쓰기를 허용하고, 리더는 팔로워에게 본인의 쓰기 로그 또는 변경 스트림을 전달한다.)
동기식 복제와 비동기식 복제
복제는 동기식과 비동기식으로 나뉘어지고, 대부분의 rdb에서 설정 할 수 있다. 동기식은 리더가 보낸 스트림 또는 쓰기로그의 처리가 완료된걸 확인하고 응답을 보내는 방식이고, 비동기식은 전송을 하고 쓰기 완료는 따로 확인하지 않는 방식이다.
리더 기반의 복제는 보통 반동기식, 혹은 비동기식으로 구성한다. (현실적으로 모든 복제를 동기식으로 처리하는건 불가능하다. 왜냐하면 네트워크 지연이나 장애로 인해 리더가 쓰기를 처리하지 못할 수 있기 때문이다.)
새로운 팔로워 설정
새로운 팔로워를 설정할 때 도 데이터가 지금 변경되고 있는중이라는 것이 중요하다. 데이터베이스를 잠궈서 새로운 팔로워를 설정하는건, 고가용성이라는 목표에 반대되는 행동이다. 그래서 새로운 팔로워설정은 아래와 같은 방식으로 이루어진다.
- 리더의 스냅샷을 가져온다.
- 스냅샷을 복사 완료 한 이후 리더의 변경 스트림을 이어받는다.
- 어디서부터 이어받을지는 로그 일련번호나 이진로그 좌표등으로 결정한다.
- 이어받은 이후 변경 스트림을 적용받는다.
노드 중단처리
팔로워의 중단처리
- 중단 지점의 트랜잭션을 알아내고, 팔로워는 중단 지점 이후의 변경 스트림을 요청한다.
리더의 중단처리
- 리더가 중단되면 중단을 감지한다(대부분 그냥 타임아웃 설정)
- 새로운 리더를 선출한다.(보통은 팔로워중 가장 높은 로그번호를 가진 노드를 선출하는데, 이 경합에 대해서는 9장에서.)
- 새로운 리더 사용을 위해 시스템을 재설정한다.(라우팅, 새로운 리더로부터 변경 스트림을 받기 등)
문제는 다음과 같은 복구과정을 거쳐도 잘못될 수 있는것 투성이라는 것이다.
- 비동기식 복제 상황에서 이전 리더가 실패하기 전에 이전 리더의 쓰기를 일부 수신하지 못할 수 있다. 그리고 만약 이전 리더가 복구될 때 충돌이 발생할 수 있다.
- 만약 특정 구간 쓰기를 폐기한다면 내구성에 심각한 의문이 제기된다
예전에 잘못된 노드가 리더로 승격되었고, db의 기본키를 레디스에서 사용해서, 뒤쳐진 리더가 기본키를 중복으로 사용하고 그대로 일부 정보가 레디스를 통해 노출되었던 사례가 있다.
- 스플릿 브레인 문제가 발생할 수 있다. (리더가 여러개 생기는 문제)
- 타임아웃도 까다로운 문제이다. (네트워크 지연이나 장애로 인해 리더가 쓰기를 처리하지 못할 수 있기 때문이다.)
복제 로그 구현
구문 기반 복제
가장 간단한 방법으로 요청 statement를 기반으로 한다. 팔로워도 저걸 전달받아서 실행하면 된다. 문제는 복제가 깨질 수 있는 여러 사례가 있다는 것이다.
NOW()
,RAND()
같은 함수를 사용하면 리더와 팔로워의 결과가 다를 수 있다.- 자동증가 칼럼, 혹은 데이터베이스의 데이터를 의존하는 경우
- 부수효과 (트리거, 스토어드 프로시저, 사용자 정의 함수)
물론 간편하기 때문에 위의 문제들을 적절히 보완하고 사용하는 경우도 있다.
쓰기 전 로그 배송
3장에서 본 것처럼, 모든 쓰기는 로그에 남는다. 그리고 이 로그를 팔로워에게 전달하면 된다.
문제는 제일 저수준에서 기록된다는 것이고, 특정 바이트를 변경했는지 수준으로 관리된다는 것이다. 그래서 엔진과 밀접하게 엮이고, 버전이 변경되거나 하는 엔진과 관련된 변경이 생길 때 문제가 발생할 수 있다는 것이다. (실제로 리더와 팔로워의 소프트웨어 버전을 다르게 실행 할 수 없다.)
논리적(로우기반) 로그 복제
복제를 위한 별도의 로그를 만들어서 진행한다.
- 삽입된 로우의 로그는 모든 칼럼의 값을 포함한다.
- 삭제된 로우의 로그는 로우를 식별하는 키를 포함한다.(기본키, 없으면 모든값 기록)
- 갱신된 로우는 고유값과 모든 칼럼의 새로운값을 포함한다.
-- users 테이블 생성
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT
);
-- INSERT 예시
-- 원본 쿼리
INSERT INTO users (id, name, age) VALUES (1, 'Alice', 30);
-- 복제 로그
INSERT LOG
Table: users
Columns: id, name, age
Values: 1, 'Alice', 30
-- DELETE 예시
-- 원본 쿼리
DELETE FROM users WHERE id = 1;
-- 복제 로그
DELETE LOG
Table: users
Condition: id = 1
-- UPDATE 예시
-- 원본 쿼리
UPDATE users SET age = 31 WHERE id = 1;
-- 복제 로그
UPDATE LOG
Table: users
Columns: age
Values: 31
Condition: id = 1
트리거 기반 복제
- 어플리케이션에서 변경이 발생하면, 트리거가 발동되어 복제 로그를 생성한다고 한다.
- 요건 설명이 자세하지 않고 잘 사용되지 않는것같아서 생략한다.
복제 지연 문제
지금까지 설명한 내용은 확장성이나, 지연시간 측면에서 매우 유용하게 사용될 수 있다. 특히 읽기 비중이 높은 웹환경에서 매우 유용한 옵션이다. 하지만 대부분은 비동기적인 복제를 사용하고, 이 방식은 복제 지연 문제를 가지고 있다.
자신이 쓴 내용 읽기
리더한테 쓰고, 가까운 팔로워 노드한테 읽는다고 했을 때 발생 할 수 있다.
이것 때문에 쓰기 후 읽기 일관성
을 지키기 위해서 여러가지 방법이 쓰인다.
- 논리적인 규칙을 통해서 (사용자의 프로필은 리더에서 읽고, 다른 사용자의 프로필은 팔로워에서 읽는다)
- 특정한 기준으로 (최근 5분 이내의 데이터는 리더에서 읽는다)
- 클라이언트에 기억된 타임스탬프를 통해서(리더에 쓰고, 팔로워에 읽을 때 타임스탬프를 비교해서 읽는다)
단조 읽기
두번째 이슈는 시간이 거꾸로 흐르는 현상을 클라이언트가 볼 수 있다는 것이다. (더 빠른 팔로워에서 읽고, 다음 요청이 느린 팔로워에서 읽는다면, 특정 데이터가 이전 요청에는 포함이 되었지만, 다음 요청에는 포함이 안될 수 있다.) 단조 읽기는 이러한 문제를 해결하는 것인데, 새로운 데이터를 읽은 이후에는 이전 데이터를 읽지 않는다는 것이다.
가장 쉽게 달성하는 법은 한 사용자가 계속 같은 팔로워를 사용하게 하는 것이다.(다른 사용자는 다른 팔로워를 사용해도 된다.)
일관된 순서로 읽기
사실 이건 뒤에서 더 자세히 나올 것 같은게 파티션에 의해 발생하는 문제이다.
“네” “당신은 미래를 볼 수 있나요?”
다중 리더 복제
다중 리더 복제에 대한 니즈도 당연히 존재한다. 쓰기 요청에 대한 내결함성 등 여러가지 사유가 있을 수 있다.
다중 리더 복제의 사용 사례
다중 데이터 센터 운영
일단 복잡도는 굉장히 많이 올라간다. 하지만 성능(쓰기를 위해 리더에게 가야만 하는 시간), 내결함성(리더가 죽어도 다른 리더가 있어서 쓰기를 계속할 수 있다.), 등의 이점이 있다.
문제는 쓰기에 충돌이 발생할 여지가 많다는 것인데, auto increment key, unique key, trigger 등을 미묘하게 신경쓰며 관리해야 한다.
클라이언트의 오프라인 작업
클라이언트가 오프라인이 된 경우 디바이스의 저장소에 데이터를 보관하는데, 이러한 경우 다중 리더 복제의 ‘로직’을 적용할 수 있다. 다만 클라이언트 만큼 많은 리더와 믿을 수 없는 네트워크가 있는 만큼 매우 어렵고 복잡한 이슈이다.
협업 편집
노션이나 구글닥스를 생각하면 된다. 물론 다른 알고리즘이나 솔루션을 사용하지만, 몇몇부분은 다중 리더 복제와 비슷한 개념을 사용한다. 예를들어 충돌을 없애기 위해 lock을 걸수도 있지만, 사용성을 위해 락을 매우 쪼개고 충돌을 해결해주는 방식을 사용할 수도 있다.
쓰기 충돌 다루기
동기 대 비동기 충돌 감지 동기식으로 할 수 있기는 하지만, 사실 의미가 없다(다중 쓰기가 무효화됨) 비동기식으로 풀 수 있는 여지는 없다
충돌 회피 충돌을 회피하는 가장 간단한 전략이다. 그리고 이건 특히 레코드를 기준으로 쓰기를 나누면 회피를 할 수 있다. 물론 이와 같은 방식은 역할을 나누는 방식이기 때문에 내결함성에는 취약하다. (리더가 변경되는 상황이나, 리더가 고장나는 상황에 취약)
일관된 상태 수렴
모든 복제 서버는 최종적으로 동일하다는 사실을 보장해야 한다
즉 중간에 데이터 반영의 속도차이때문에 다른 경우는 있을 수 있어도
최종적으로는 동일해야한다.
문제는 다중리더에서는 쓰기 순서가 정해지지 않아 최종적으로 동일하다는 것을 보장 할 수 없다.
그래서 아래와 같은 방법을 둔다.
- 각 쓰기에 고유ID를 부여하고, 해당 값이 가장 높은 경우를 반영한다.
- 각 복제 서버에 고유ID를 부여하고 낮은 숫자의 복제서버에서 생긴 쓰기보다 항상 우선적으로 선택한다.
- 명시적인 충돌 해소 로직을 애플리케이션 코드로 작성한다.
사용자 정의 충돌 해소 로직 여기서는 간단한 충돌로직을 언급하는데, 구체적이지 않다.
충돌은 무엇인가?
마찬가지로 12장에서 설명할 내용을 소개하고 간단하게 갈무리한다.
다중 리더 복제 토폴로지
복제 토폴로지는 쓰기를 한 노드에서 다른 노드 전달하는 통신 경로를 설명한다. 이부분에서는 리더의 쓰기 요청이 다른 노드에 전달될 수 있도록 구성한 토폴로지를 설명한다. 그래프로 갈음 할 수 있는 별모양 토폴로지와, 복제의 순서를 지정 할 수 있는 원모양 토폴로지, 마지막으로 전체 연결 토폴로지이다.
물론 여기서도 다양한 위험에 빠지게 된다(읽기 요청의 경우, 읽기는 직전 삽입에 종속적이다. 다만 그러한 것들이 충돌로 인식되지 못하고 진행되는 경우가 있고, 다양한 충돌이 발생한다.)
리더 없는 복제
리더 없는 복제에서는 모두가 리더이다. 모든 노드가 쓰기 요청을 허락받는다 물론 그많은 충돌을 해결하는 것은 아니고, 클라이언트가 여러 복제서버에 쓰기를 직접 전송하거나 이러한일을 담당하는 코디네이터 노드를 두기도 한다.
리더 없는 복제에서 노드가 다운됐을 때 데이터베이스에 쓰기 이러한 방식에서는, 노드 하나가 죽어도 상관이 없다. 클라이언트는 쓰기요청과 읽기 요청을 병렬로 보내기 때문이다. 죽어있던 기간에 생긴 누락된 데이터는 더 정확한 노드를 판별하여 알아서 사용한다.
읽기 복구와 안티 엔트로피 물론 클라이언트는 문제가 없지만, 노드 입장에서는 다운타임동안 발생한 데이터를 따라잡아야 한다. 읽기 복구 : 클라이언트가 오래된 버전의 데이터를 감지하면, 해당 노드의 데이터를 직접 업데이트한다.(읽기 요청이 많아야 기능함) 안티 엔트로피 처리 : 별도의 백그라운드 프로세스가 계속 감시해준다..(짐작이 가능하듯 감지되어 쓰기가 되기 까지 매우 오래걸릴 수 있다.)
개인적으로 여기도 ‘최종적으로 동일하다는 사실을 보장한다’고 봐야하는지 의문이 든다.
읽기와 쓰기를 위한 정족수
어느 범위까지 유효한 쓰기이고, 유효한 읽기로 간주 할 수 있을까?
n
: 복제 노드의 수
w
: 쓰기 성공 카운트
r
: 질의하는 노드의 수
w + r > n 이면 보통은 최신 데이터를 읽을 것 이라고 간주한다.
위의 값들은 파라미터로 각각 설정이 가능한데, 일반적인 선택은 w = r = (n + 1)/2 이다.
동시 쓰기 감지
일단 다이나모 스타일 데이터베이스도 여러 클라이언트가 동시에 같은 키를 쓰는것을 허용하기에 정족수를 엄격하게 조절해도 충돌이 발생한다.
당장 네트워크 상황만 생각해봐도 클라이언트 A, B가 동시에 세개의 노드로 X키를 기록하는 상황을 생각해볼 수 있다.
노드1, 2 번이 각각 다른 순서로 A,B의 요청을 받을 수 있기에 충돌을 감지해야 한다.
최종 쓰기 승리(동시 쓰기 버리기) 첫번째 접근 방식은 각 복제본이 가진 예전 값을 버리가 가장 최신 값으로 덮어쓰는 것이다. 어떤 쓰기가 최신인지 명확하게 결정할 수 있어야 한다. 문제는 클라이언트의 상태가 없는 노드의 입장에서 어떠한 값이 최신인지 식별할 방법은 사실상 없고, 이 순서가 정해지지 않았다고 생각해야 하니까 결국 동시 쓰리가로 해야한다. 결국 타임스탬프같은 값들을 기준으로 최종 승리 쓰기를 기술한다.
(이건 지속성을 침해하므로 사실 키로 UUID를 사용해서 모든 쓰기 작업에 고유키를 부여하는 방식이 일반적이다)
이전 발생 관계와 동시성 이건 충돌을 완벽하게 해결하는 방식은 아니고, 이전 발생을 식별하는 경우 처리를 단순화 하는 것에 가깝다. 예를들어 A라는 값을 생성하는 쓰기와, 해당 값을 변경하는 쓰기처럼 인과성이 있는 경우 이전 작업을 식별 할 수 있어 별도의 충돌 해결이 필요 없다.
이전 발생 관계 파악하기