designing-data-intensive-applications

9장. 일관성과 합의

일관성 보장

선형성

시스템에 선형성을 부여하는 것은 무엇인가?

선형성에 기대기

선형성 시스템 구현하기

선형성과 정족수

선형성의 비용

CAP 정리

선형성과 네트워크 지연

순서화 보장

순서화와 인과성

인과적 순서가 전체 순서는 아니다

선형성은 인과적 일관성보다 강하다

인과적 의존성 담기

일련번호 순서화

인과성을 추적하기 위해 일련번호나 타임스탬프를 통해 이벤트의 순서를 정할 수 있다.

이 때 타임스탬프는 물리적 시계(신뢰성 없는 시계 문제가 있음)가 아니라 논리적 시계(일련번호 생성 알고리즘, 보통 카운터)에서 얻어도 된다.

이렇게 생성된 일련번호는 전체 순서를 보장하기 때문에 인과성에 일관적인 전체 순서대로 일련번호 생성이 가능하다. 단일리더 복제에서는 연산마다 카운터를 증가시켜 복제 로그 각 연산에 단조 증가하는 일련번호를 할당하기만 하면 됨.

비인과적 일련번호 생성기

다중 리더, 리더 없는 데이터베이스, 혹은 파티셔닝이 되어 있다면 3가지 방법이 고려될 수 있다.

  1. 각 노드마다 자신만의 일련번호 집합을 생성
    1. 예를 들어 한 노드는 짝수, 다른 노드는 홀수만 생성
  2. 각 연산에 일 기준 시계(물리적 시계)에서 얻은 타임스탬프를 붙인다
    1. 이런 타임스탬프는 순차적이지 않지만 해상도가 충분히 높다면 연산의 전체 순서를 정하는데 충분할 수 있음
  3. 일련번호 블럭을 미리 할당한다
    1. 노드 A는 1~1000까지, 노드 B는 1001~2000까지의 블럭을 차지할 수 있다.

이 방법들은 단일 리더에서 모든 연산을 처리하는 것보다 확장성이 좋다. 하지만 이러한 일련번호는 인과성에 일관적이지 못한 문제가 있다.

즉 위에 3가지 방법은 인과적이지 못하다.

램포트 타임스탬프

인과성에 일관적인 일련번호를 생성하는 방법.

타임스탬프 순서화로 충분하지 않다

램포트 타임스탬프가 인과성에 일관적 연산의 전체 순서를 정의하지만, 분산 시스템의 여러 공통 문제 해결에는 아주 충분하지는 않다.

램포트 타임스탬프는 사후에 성공하는 쪽을 결정하는데에 효과적이지만, 당장 결정해야 하는 경우에는 부족하기 떄문이다.

다른 어떤 노드도 동시에 더 낮은 타임스탬프를 갖고 동일한 사용자명으로 계정 생성을 처리하는 중이 아니라고 확신하기 위해서는 다른 모든 노드가 무슨 작업을 하고 있는지 확인해야 한다. 이는 노드 중 하나에 장애가 발생할 경우 시스템이 멈추게 되어 내결함성을 지키지 못하게 된다.

여기서 문제는 연산의 전체 순서는 모든 연산을 모은 후에 드러난 다는 것(= 연산이 수행되는 도중에는 순서를 알 수 없을 수 있음)

= 언제 전체 순서가 확정되는지 알아야 한다는 아이디어를 뒤에 다룸

전체 순서 브로드 캐스트

프로그램이 단일 CPU 코어에서만 실행되면 CPU에서 실행된 순서가 전체 순서이기 때문에 연산의 전체 순서를 정하기가 매우 쉽다.

하지만 내결함성을 위해서는 분산 시스템을 이용해야 하기 떄문에 전체 순서가 동일하도록 합의하기 까다롭다.

단일 리더 복제에서는 한 노드를 리더로 선정하고 리더의 단일 CPU 코어에서 모든 연산을 차례대로 배열함으로써 연산의 전체 순서를 정한다. 여기서 문제는 처리량이 단일 리더가 처리할 수준을 넘어설 때 시스템을 어떻게 확장할 것인가 and 리더에 장애가 발생했을 때 어떻게 장애 복구를 할 것인가이다.

= 분산 시스템 분야에서 이 문제는 전체 순서 브로드캐스트, 원자적 브로드캐스트로 알려져 있음.

전체 순서 브로드캐스트는 보통 노드 사이에 메시지 교환하는 프로토콜로 기술되며 비공식적으로 2가지 안전성 속성을 항상 만족해야 한다.

전체 순서 브로드캐스트 사용하기

모든 메시지가 데이터베이스에 쓰기를 나타내고, 모든 복제 서버가 같은 쓰기 연산을 같은 순서로 처리하면 복제 서버들은 서로 일관성 있는 상태를 유지한다.(일시적 복제 지연 제외) 이 원리를 상태 기계 복제라고 한다.

여기서 중요한 포인트는 전체 순서 브로드캐스트는 메시지가 전달되는 시점에 순서가 고정된다는 것에 있다.

전체 순서 브로드캐스트를 사용해 선형성 저장소 구현하기

선형성 시스템(연산에 전체 순서 있음)과 전체 순서 브로드캐스트는 동일하지 않지만 밀접한 관계가 있다.

전체 순서 브로드캐스트는 비동기식이기 때문에 언제 메시지가 전달될지 보장되지 않지만, 선형성은 최신성 보장이기 떄문에 읽기가 최근에 쓰여진 값을 보는 것이 보장된다.

하지만 전체 순서 브로드캐스트 구현을 기반으로 선형성 저장소를 만들 수 있다.

예를 들어 사용자 계정을 유일하게 식별하도록 보장할 수 있는데, 이를 전체 순서 브로드캐스트를 추가 전용 로그로 사용해 선형성 compare-and-set 연산을 구현할 수 있다.

  1. 메시지를 로그에 추가해 점유하기 원하는 사용자명을 가리킨다.
  2. 로그를 읽고, 추가한 메시지가 되돌아오길 기다린다.
  3. 원하는 사용자명을 점유하려는 메시지가 있는지 확인한다.
    1. 이 때 첫번째 메시지가 자신의 메시지라면 성공, 아니라면 어보트 시킨다.

로그는 모든 노드에게 같은 순서로 전달되므로 쓰기 충돌이 일어날 경우 첫번째 쓰기를 승자로 택하고 나머지를 어보트 시킨다. (다중 객체 트랜잭션에도 비슷한 방법을 쓸 수 있음)

이 절차는 선형성 쓰기는 보장하지만, 선형성 읽기는 보장되지 않는다. (로그는 비동기적으로 갱신되기 떄문에 오래된 값이 읽힐 가능성이 있다, =타임라인 일관성, 순차적 일관성)

읽기를 선형적으로 만들기 위해선 몇 가지 선택지가 존재한다.

선형성 저장소를 사용해 전체 순서 브로드캐스트 구현하기

선형성 저장소를 기반으로 전체 순서 브로드캐스트 구현도 가능하다.

가장 쉬운 방법은 정수를 저장하고 원자적 increment-and-get 연산이 지원되는 선형성 레지스터가 있다고 가정하는 것. 대신 원자적 compare-and-set 연산이 있어도 된다.

  1. 전체 순서 브로드캐스트를 통해 보내고 싶은 모든 메시지를 준비한다.
  2. 선형성 정수로 increment-and-get 연산을 수행하고 레지스터에서 얻은 값을 일련번호로 메시지에 붙인다.
  3. 메시지를 모든 노드에 보낸다.(손실되면 재전송)
  4. 수신하는 노드들은 일련번호 순서대로 메시지를 전달한다. (동기화, 쓰기)

램포트 타임스탬프와 달리 선형성 레지스터를 증가시켜 얻은 숫자들은 틈이 없는 순열을 형성한다.

램포트는 그렇지 않다. 이것이 전체 순서 브로드캐스트와 타임스탬프 순서화의 핵심 차이다.

원자적 increment-and-get 연산이 지원되는 선형성 정수를 만드는 것은 단일 노드에 변수 하나로 처리하면 쉽다. 하지만 내결함성을 위해서는 이 방식을 사용할 수 없다.

= 합의 알고리즘을 사용할 수 있다.

분산 트랜잭션과 합의

여러 노드들이 뭔가에 동의하게 만드는 것.

원자적 커밋과 2단계 커밋(2PC)

원자성은 실패한 트랜잭션이 절반만 완료되거나 절반만 갱신된 상태로 데이터베이스를 어지럽히는 것을 막아준다.

단일 노드에서 분산 원자적 커밋으로

단일 데이터베이스 노드에서 실행되는 원자성은 흔히 저장소 엔진에서 구현된다. 커밋 레코드를 디스크 로그에 추가하기 때문에 만약 트랜잭션 도중에 데이터베이스가 죽더라도 재시작 시 로그를 통해 복구가 가능하다. (죽기 전에 레코드가 디스크에 쓰였다면 커밋으로 간주하고, 아니라면 롤백)

즉, 단일 노드에서 트랜잭션 커밋은 순서에 결정적으로 의존한다. 트랜잭션이 커밋되거나 어보트되는 시점은 데이터를 먼저 쓰고, 커밋 레코드 쓰기를 마치는 시점이다.


하지만 트랜잭션에 여러 노드가 관여한다면 더 복잡하다. 여러 노드에 커밋 요청을 보내고 각 노드에서 독립적으로 커밋하는 것으론 충분하지 않다.

트랜잭션 커밋은 되돌릴 수 없어야 한다. 왜냐하면 데이터가 커밋되면 다른 트랜잭션에 데이터가 보이게 되고, 다른 클라이언트들은 그 데이터에 의존하기 시작할지 모르기 때문이다.

= 되돌릴 수 있어야 한다면 그 데이터를 의존하는 추가 트랜잭션 작업들도 모두 취소되어야 한다.

2단계 커밋 소개

모든 노드가 커밋되거나 어보트되도록 보장하는 알고리즘이다.

2PC를 구현하기 위해서 트랜잭션 관리를 하는 코디네이터가 존재하고, 데이터베이스 노드들은 참여자라고 부른다. 이 때 흐름은

약속에 관한 시스템

1단계와 마찬가지로 2단계 커밋도 쉽게 손실될 수 있다. 하지만 2PC는 원자성 보장을 해주는가에 대해서는 과정을 자세히 이해해야 한다.

  1. 트랜잭션 ID 요청: 애플리케이션은 분산 트랜잭션을 시작하기 위해 코디네이터로부터 전역적으로 유일한 트랜잭션 ID를 요청합니다.
  2. 단일 노드 트랜잭션 시작: 애플리케이션은 참여자(데이터베이스 노드)에게 단일 노드 트랜잭션을 시작하라고 요청하고, 이 트랜잭션에 전역적으로 유일한 트랜잭션 ID를 부여합니다. 이 단계에서 실패하면 트랜잭션이 중단될 수 있습니다.
  3. 커밋 준비 요청: 애플리케이션이 커밋할 준비가 되면, 코디네이터는 모든 참여자에게 준비 요청을 보냅니다. 만약 준비 요청 중 하나라도 실패하거나 타임아웃되면, 코디네이터는 트랜잭션을 중단(어보트)합니다.
  4. 참여자의 준비 확인: 참여자는 트랜잭션 데이터를 디스크에 안전하게 저장하고, 충돌이나 제약 조건 위반 없이 트랜잭션을 커밋할 준비가 되었는지 확인합니다. 준비가 완료되면, 참여자는 코디네이터에게 커밋 준비 완료를 알립니다.
  5. 최종 결정: 코디네이터는 모든 참여자로부터 준비 완료 응답을 받으면 트랜잭션을 커밋할지 어보트할지 최종 결정합니다. 이 결정은 트랜잭션 로그에 기록됩니다.
  6. 커밋 또는 어보트 실행: 코디네이터의 결정이 디스크에 기록되면, 모든 참여자에게 커밋 또는 어보트 요청을 전송합니다. 요청이 실패하거나 타임아웃되면 코디네이터는 요청을 반복합니다.

따라서 이 프로토콜에는 2개의 중대한 “돌아갈 수 없는 지점”이 있다. 참여자는 “네”에 투표할 때 나중에 분명히 커밋할 수 있을 거라고 약속한다. 그리고 코디네이터가 한 번 결정하면 그 결정은 번복할 수 없다. 이런 약속은 2PC의 원자성을 보장한다.

코디네이터 장애

모든 참여자가 “네”에 투표하고, 코디네이터가 모든 노드에게 커밋 요청을 보냈다면 코디네이터는 커밋됐는지 어보트됐는지 회신을 기다려야 한다.

하지만 이 때 코디네이터가 죽거나 네트워크 장애가 발생하면 모든 참여자는 기다릴 수 밖에 없다. 이 상태에 있는 참여자의 트랜잭션을 의심스럽다(in doubt) 또는 불확실하다(uncertain)고 한다.

여기서 데이터베이스2는 커밋 요청을 받았고, 데이터베이스1에는 커밋 요청을 보내기 전에 코디네이터가 죽었다. 이 때 데이터베이스1은 커밋할지 어보트할지 알지 못한다.

2PC가 완료할 수 있는 유일한 방법은 결국 코디네이터가 복구되기를 기다리는 것 뿐이다. (코디네이터가 참여자들에게 커밋이나 어보트 요청하기 전에 디스크에 있는 트랜잭션 로그에 기록하는 이유)

3단계 커밋

2PC가 코디네이터가 복구하기를 기다리느라 멈출 수 있다는 사실 때문에 블로킹 원자적 커밋 프로토콜이라고 불린다. 3PC는 이론상 노드에 장애가 나도 멈추지 않도록 논블로킹 원자적 커밋을 만들 수 있다.

하지만 노드가 죽었는지 안 죽었는지 구별할 수 있는 신뢰성 있는 완벽한 장애 감지기 메커니즘이 필요하기 때문에 2PC가 계속해서 쓰이고 있다. (구현이 어려움)

현실의 분산 트랜잭션

2PC로 구현된 분산 트랜잭션은 운영상 문제를 일으키고 성능을 떨어뜨리기 때문에 평판이 엇갈리며 여러 클라우드 서비스들은 분산 트랜잭션을 구현하지 않는 선택을 한다.

어떤 분산 트랜잭션 구현은 무거운 성능 손해를 수반하며, Mysql 분산 트랜잭션은 단일 노드 트랜잭션보다 10배 이상 느리다고 보고된다. 따라서 사람들이 분산 트랜잭션을 쓰지 말라고 하는 것도 놀랍지 않다.

그러나 다중 트랜잭션을 완전히 일축하기 보단 여기서 배울 교훈에 집중해야 한다. 우선 분산 트랜잭션이 무엇을 의미하는지 정확해야 한다.

정확히 한 번 메시지 처리

이종 분산 트랜잭션은 다양한 시스템들이 강력한 방법으로 통합될 수 있게 한다. 메시지 전달이나 데이터베이스 트랜잭션 중 하나가 실패하면 둘 다 어보트되고 메시지 브로커는 나중에 메시지를 안전하게 다시 전달할 수 있다.

그러므로 처리 과정의 부수 효과를 원자적으로 커밋함으로써 메시지가 결과적으로 정확히 한번 처리됨을 보장할 수 있다. 성공을 위해 여러번 재시도가 있을 수 있지만 어보트는 부분적으로 완료된 트랜잭션의 부수 효과를 폐기한다.

= 하지만 영향을 받는 모든 시스템이 원자적 커밋 프로토콜을 사용할 수 있을 때만 사용이 가능하다. 예를 들어 메시지를 처리하는 부수효과가 이메일 전송이라면 실패나 재시도되면 이메일이 두 번 이상 전송될 수 있다.

XA 트랜잭션

XA/Open XA는 이종 기술에 걸친 2단계 커밋을 구현하는 표준이다. Mysql, Postgresql, DB2, SQL 서버, 오라클, 액티브MQ, 호닛Q, MSQM, IBM MQ등 여러 플랫폼에서 지원이 된다.

XA API를 통해 준비, 커밋 요청 등을 보낼 수 있고, 코디네이터가 장애가 발생 후 복구되도 XA 롤백을 사용해 커밋 혹은 롤백 요청이 가능하다는 듯.

의심스러운 상태에 있는 동안 잠금을 유지하는 문제

트랜잭션이 의심스러운 상태에 빠지는 것을 주의하는 이유는 잠금에 있다. 트랜잭션은 보통 더티 쓰기 방지를 위해 로우 락을 획득하게 된다. 데이터베이스는 트랜잭션이 커밋 or 어보트되기 전까지 잠금을 해제할 수 없는데, 만약 이때 코디네이터가 죽는다면 코디네이터가 복구될 때까지 해당 잠금은 해제되지 못한다.

코디네이터 장애에서 복구하기

코디네이터가 죽은 후 재시작하면 로그로부터 의심스러운 트랜잭션들을 해소해야 하지만, 현실에서는 고아가 된 의심스러운 트랜잭션이 발생할 수 있다. 이런 트랜잭션은 자동으로 해소될 수 없어 잠금을 유지하고 다른 트랜잭션을 차단하면서 데이터베이스에 영원히 남는다. (데이터베이스 서버를 재시작해도 고칠 수 없음)

이 문제의 유일한 해결책은 관리자가 수동으로 트랜잭션을 커밋하거나 롤백할지 결정하는 것 뿐이다.

여러 XA 구현에서는 확정적 결정을 얻지 않고 의심스러운 트랜잭션 해소를 위해 경험적 결정이라 부르는 비상탈출구를 제공한다. 여기서 경험적 결정은 2PC의 약속 체계를 위반하기 때문에 원자성을 깰 수 있음을 완곡하게 표현한 것이다. (= 큰 장애 시에 사용되도록 의도)

분산 트랜잭션의 제약

XA 트랜잭션은 여러 문제를 해결해주지만, 운영상 문제도 가져온다. 핵심 구현은 트랜잭션 코디네이터 자체가 (트랜잭션 결과를 저장할 수 있는) 일종의 데이터베이스여야 한다는 점이고, 따라서 데이터베이스와 동일하게 신경 써서 접근해야 한다.

내결함성을 지닌 합의

합의는 여러 노드가 어떤 것에 동의해야 한다는 뜻이다. 이러한 합의 알고리즘은 4가지 속성을 만족해야 한다.

내결함성이 상관 없다면 처음 3개 속성을 만족시키는건 쉽다. 그냥 한 노드를 독재자로 하드코딩하고 그 노드가 모든 결정을 내리게 하면 된다. (= 하지만 단일 장애 지점)

종료 속성은 내결함성 아이디어를 형식화한다. 그냥 그 상태에 머물러서 영원히 아무것도 안 할 수는 없다. 어떤 노드에 장애가 발생하더라도 다른 노드들은 여전히 결정을 내려야 한다.

합의 시스템 모델은 노드가 죽으면 그 노드가 갑자기 사라져서 영영 돌아오지 않는다고 가정한다.

그리고 합의 알고리즘에는 과반수(정족수)가 동의해야 하기 때문에 견딜 수 있는 장애의 수에는 제한이 있다.

대부분 합의 알고리즘은 비잔틴 결함이 없다고 가정한다. 즉 노드가 프로토콜을 올바르게 따르지 않으면 프로토콜 안정성 속성이 깨질 가능성이 존재한다. (하지만 3분의 1 미만의 노드만 비잔틴 결함이 있다면 비잔틴 결함에도 견고하게 만들 순 있다)

합의 알고리즘과 전체 순서 브로드캐스트

내결함성을 지닌 합의 알고리즘 중 가장 널리 알려진 것은 뷰 스탬프 복제, 팍소스, 라프트, 잽이다.

값의 순차열에 대해 결정해서 전체 순서 브로드캐스트 알고리즘을 만든다. 전체 순서 브로드캐스트는 모든 노드에게 메시지가 정확히 한번, 같은 순서로 전달돼야 함을 상기하자.

단일 리더 복제와 합의

단일 리더 복제에서 리더를 어떻게 선택하느냐에 따라 합의를 걱정할 필요가 생긴다.

독재자 방식(한 노드만 쓰기)의 합의 알고리즘을 사용해서 사람이 직접 개입해서 장애가 발생할 시 다른 리더를 새로운 리더로 선정한다. 하지만 사람의 개입이 필요하기 때문에 합의의 종료 속성을 만족하지 않는다.

어떤 데이터베이스는 기존 리더에 장애가 발생하면 팔로워 하나를 새 리더로 승격하여 자동 리더 선출과 장애 복구를 수행하는데, 이렇게 함으로써 내결함성을 지닌 전체 순서 브로드캐스트에 가까워지고 합의를 해결하는데 가까워진다.

에포크 번호 붙이기와 정족수

리더가 유일하다고 보장하지는 않지만, 더 약한 보장은 가능하다. 이 프로토콜은 에포크 번호, 뷰스탬프는 뷰 번호, 라프트에서는 텀번호를 정의하고 에포크 내에서 리더가 유일하다고 보장 받는다.

리더가 뭔가를 결정하도록 허용하기 전에 충돌되는 결정을 할지 모르는 에포크 번호가 더 높은 다른 리더가 없는지 먼저 확인한다. 리더는 자신이 다른 노드에 의해 쫓겨나지 않았음을 정족수로부터 투표를 받아 결정한다.

리더는 내리는 모든 결정에 제안된 값을 다른 노드에 보내서 정족수가 그 제안을 찬성한다고 응답하기를 기다려야 한다. (따라서 리더 선출 과정에 2번의 투표가 있다. 리더 선출과 리더의 제안에 투표)

합의의 제약

합의 알고리즘은 내결함성을 유지하며 안전성 속성과 원자적 연산 등 많은 이점이 있지만 모든 곳에 쓰이진 않는다.(트레이드 오프)

멤버십과 코디네이션 서비스

주키퍼나 etcd같은 프로젝트는 데이터베이스와 유사해보이는데, 왜 합의 알고리즘 구현에 노력을 다하는 것일까?

주키퍼와 etcd는 완전히 메모리 안에 들어올 수 있는 작은 양의 데이터를 보관하도록 설계됐다. 이 소량의 데이터는 내결함성을 지닌 전체 순서 브로드캐스트 알고리즘으로 사용해 모든 노드에 걸쳐 복제된다.

주키퍼는 구글의 처비 잠금 서비스를 모델로 다른 흥미로운 기능 집합도 구현한다.

이 기능 중 오직 선형성 원자적 연산만 실제로 합의가 필요하다.

작업을 노드에 할당하기

주키퍼나 처비 모델은 리더 선출, 작업 스케줄링, 파티션 재균형화와 같은 분산 시스템 관리 작업에 유용하다.

이들은 원자적 연산, 임시 노드, 알림 기능을 활용해 자동 결함 복구를 가능하게 하며, 주키퍼는 고정된 수의 노드에서 실행되며, 이를 통해 대규모 클라이언트 지원과 효율적인 합의 달성이 가능하다.

주키퍼는 느리게 변하는 데이터 관리에 적합하며, 빠르게 변하는 애플리케이션 상태 저장용이 아니며 상태 복제가 필요한 경우 다른 도구를 고려해야 한다.

서비스 찾기

가상 장비가 흔하게 사용되는 클라우드 환경에서는 IP 주소를 사전에 알지 못할 때가 자주 있다. 이 때 DNS를 통해 서비스 이름으로 IP 주소를 찾을 수 있다. 이 때 좋은 성능과 가용성 보장을 위해 다층 캐시를 사용한다.

서비스 찾기는 합의가 필요 없지만, 리더 선출은 필요하며, 합의 시스템이 누가 리더인지 이미 안다면 다른 서비스들이 리더가 누구인지 찾는데 그 정보를 사용하는 것은 타당하다. 이를 위해 어떤 합의 시스템은 읽기 전용 캐시 복제 서버를 지원한다.

이 복제 서버는 합의 알고리즘의 모든 결정에 능동적으로 참여하진 않지만, 선형적일 필요가 없는 읽기 요청을 서비스할 수 있다.

멤버십 서비스

주키퍼와 유사 프로젝트들은 오랜 멤버십 서비스 연구 역사의 일부로 볼 수 있다.

멤버십 서비스는 클러스터 내 노드가 현재 활성화된 살아 있는 멤버인지 결정한다. 여전히 실제로 노드가 살아있지만 합의에 의해 죽은 것으로 잘못 선언될 가능성은 존재하지만, 합의는 시스템에서 어떤 노드가 현재 멤버십을 구성하는지 동의하는 데 매우 유용하다.