Spanner 읽기 및 쓰기 수명

Spanner는 Google에서 가장 중요한 애플리케이션 중 일부를 지원하기 위해 Google 엔지니어들이 제작한 데이터베이스로서, 일관성, 분산력, 확장성이 뛰어납니다. Spanner는 데이터베이스 및 분산 시스템의 핵심 아이디어를 차용해서 이를 새로운 방식으로 확장합니다. Spanner는 이러한 내부 Spanner 서비스를 Google Cloud Platform에서 일반에 제공되는 서비스로 제공합니다.

Spanner는 Google의 핵심 비즈니스 애플리케이션에 요구되는 까다로운 가동시간 및 확장 요구사항을 처리할 수 있어야 하기 때문에, 처음부터 광범위 분산형 데이터베이스로 구축되었습니다. 이 서비스는 여러 머신과 여러 데이터 센터 및 지역에 분산될 수 있습니다. 이러한 분산성을 활용하여 대규모 데이터세트와 대규모 작업 부하를 처리하고, 매우 높은 고가용성을 유지 관리할 수 있습니다. 또한 개발자에게 뛰어난 환경을 제공하기 위해 Spanner는 다른 엔터프라이즈급 데이터베이스와 동일한 수준의 일관성을 엄격하게 보장할 수 있도록 제작되었습니다. 행 수준 일관성 또는 개체 수준 일관성만 지원하거나 일관성 보장이 전혀 없는 데이터베이스보다는 strong consistency를 지원하는 데이터베이스가 논리를 추론하고 소프트웨어를 작성하기 훨씬 쉽습니다.

이 문서에서는 Spanner의 쓰기 및 읽기의 작동 방식과 strong consistency 보장 방식에 대해 자세히 살펴봅니다.

시작

일부 데이터세트는 크기가 너무 커서 단일 머신에 맞지 않습니다. 또한 데이터세트는 작은데 작업 부하가 하나의 머신에서 처리되지 못할 정도로 무거운 경우도 있습니다. 즉, 여러 머신에 저장할 수 있는 여러 조각으로 데이터를 분할하는 방법을 찾아야 합니다. 한 가지 접근 방식은 데이터베이스 표를 분할이라고 부르는 연속된 키 범위로 나누는 것입니다. 이러한 방식의 경우 단일 머신이 여러 개의 분할을 처리할 수 있고, 지정된 키 범위를 지원할 머신을 결정하는 빠른 조회 서비스가 있습니다. Spanner 사용자는 데이터가 분할되는 방식과 데이터가 저장되는 머신을 투명하게 확인할 수 있습니다. 그 결과 규모와 작업 부하가 매우 큰 상황에서도 읽기 및 쓰기 모두 낮은 지연 시간을 제공할 수 있습니다.

또한 장애가 발생하더라도 데이터에 액세스할 수 있도록 해야 합니다. 이를 위해 구분된 장애 도메인의 여러 머신에 각 분할이 복제됩니다. Paxos 알고리즘은 분할의 여러 복사본에 대한 일관된 복제를 관리합니다. Paxos에서는 해당 분할의 응답 복제본 대부분이 가동 상태로 있는 경우에 한해서 이러한 복제본 중 하나를 쓰기 처리용 리더로 선택하고, 다른 복제본은 읽기 처리용으로 지정할 수 있습니다.

Spanner는 읽기 전용 트랜잭션읽기-쓰기 트랜잭션을 모두 제공합니다. 전자는 데이터를 변형시키지 않는 작업(SQL SELECT 구문 포함)에서 선호되는 트랜잭션 유형입니다. 읽기 전용 트랜잭션은 strong consistency를 제공하며, 기본적으로 최신 데이터 복사본에서 작동합니다. 하지만 어떤 형태의 내부적 잠금도 필요 없이 실행될 수 있으므로, 속도와 확장성이 뛰어납니다. 읽기-쓰기 트랜잭션은 데이터 삽입, 업데이트 또는 삭제 트랜잭션에 사용됩니다. 여기에는 읽기 다음에 쓰기를 수행하는 트랜잭션이 포함됩니다. 이러한 읽기-쓰기 트랜잭션은 확장성이 뛰어나지만, 잠금을 유도하므로 Paxos 리더의 조정이 필요합니다. 잠금은 Spanner 클라이언트에 대해 투명하게 수행됩니다.

이전의 여러 분산형 데이터베이스 시스템은 일반적으로 비용이 높은 머신 간 통신이 필요하기 때문에 강력한 일관성을 제공하지 못하는 것으로 인식되었습니다. Spanner는 TrueTime이라고 부르는 Google에서 개발된 기술을 사용해서 전체 데이터베이스 간에 일관성이 뛰어난 스냅샷을 제공합니다. TrueTime은 1985년 경 타임 머신 영화에 나왔던 플러스 커패시터처럼 Spanner가 출시될 수 있게 만들어준 핵심 장치입니다. 이 API는 Google 데이터 센터에 있는 모든 머신이 높은 정확도로 정확한 글로벌 시간을 알 수 있게 해줍니다(오차 범위 수 밀리초 이내). 여러 Spanner 머신은 이를 통해 종종 어떠한 통신 없이도 트랜잭션 작업의 순서를 추론하고 이러한 순서가 클라이언트에서 관측된 것과 일치하도록 조정할 수 있습니다. Google은 TrueTime이 작동할 수 있도록 데이터 센터에 특수 하드웨어(원자 시계)를 장착해야 했습니다. 그 결과 다른 프로토콜(예: NTP)보다 훨씬 뛰어난 시간 정밀도와 정확도를 달성할 수 있었습니다. 특히 Spanner는 모든 읽기 및 쓰기에 타임스탬프를 할당합니다. 타임스탬프 T1의 트랜잭션에 T1 이전에 발생한 모든 쓰기의 결과가 반영된다고 보장할 수 있습니다. 특정 머신이 T2에 읽기를 충족해야 할 경우 최소한 T2까지는 데이터 보기가 최신 상태임을 보장해야 합니다. TrueTime 덕분에 이러한 확인 비용은 일반적으로 매우 저렴합니다. 데이터 일관성을 보장하는 프로토콜은 복잡합니다. 이러한 프로토콜은 원본 Spanner 백서를 참조하세요. 이 백서에서는 Spanner와 일관성을 설명합니다.

실제 사례

이러한 모든 작동 방식을 이해하기 위해 몇 가지 실제 사례를 살펴보겠습니다.

CREATE TABLE ExampleTable (
 Id INT64 NOT NULL,
 Value STRING(MAX),
) PRIMARY KEY(Id);

이 예에서는 간단한 Integer 기본 키가 있는 테이블이 준비되어 있습니다.

분할 KeyRange
0 [-∞,3)
1 [3,224)
2 [224,712)
3 [712,717)
4 [717,1265)
5 [1265,1724)
6 [1724,1997)
7 [1997,2456)
8 [2456,∞)

위의 ExampleTable 스키마에서 기본 키 공간 파티션은 분할로 나뉘어져 있습니다. 예를 들어 Id3700ExampleTable에 행 하나가 있으면 이 행은 분할 8에 있습니다. 위에 설명된 것처럼 분할 8 자체는 여러 머신 간에 복제됩니다.

여러 영역 및 머신 간에 분산된 분할을 보여주는 표

이 예시 Spanner 인스턴스에서 고객에게는 5개 노드가 있고 인스턴스는 3개의 영역에 복제됩니다. 9개 분할에는 0-8의 번호가 지정되고, 짙은 음영으로 표시된 각 분할에 Paxos 리더가 사용됩니다. 분할은 또한 각 영역에 복제본을 가지고 있습니다(옅은 음영). 노드 간 분할의 분산은 각 영역별로 다를 수 있으며, Paxos 리더는 모두 동일한 영역에 상주하지 않습니다. 이러한 유연성 덕분에 특정 부하 프로필 및 오류 모드에서 Spanner의 견고성을 더 높일 수 있습니다.

단일 분할 쓰기

클라이언트가 새 행 (7, "Seven")ExampleTable에 삽입하려 한다고 가정해 보겠습니다.

  1. API 레이어가 7을 포함하는 키 범위를 소유하는 분할을 조회합니다. 키 범위는 분할 1에 있습니다.
  2. API 레이어가 분할 1의 리더로 쓰기 요청을 보냅니다.
  3. 리더가 트랜잭션을 시작합니다.
  4. 리더가 Id=7 행에서 쓰기 잠금을 시도합니다. 이것은 로컬 작업입니다. 또 다른 동시 읽기-쓰기 트랜잭션이 현재 이 행을 읽고 있는 경우, 해당 트랜잭션이 읽기 잠금을 설정하며, 쓰기 잠금을 설정할 때까지 현재 트랜잭션이 차단됩니다.
    1. 트랜잭션 A는 트랜잭션 B에서 설정한 잠금을 기다리고 트랜잭션 B는 트랜잭션 A에서 설정한 잠금을 기다리고 있을 수 있습니다. 모든 잠금을 설정할 때까지는 어떠한 트랜잭션도 잠금을 해제하지 않으므로 이러한 상태는 교착 상태로 이어질 수 있습니다. Spanner는 트랜잭션이 진행될 수 있도록 보장하기 위해 표준 'wound-wait' 교착 상태 방지 알고리즘을 사용합니다. 이 알고리즘에서 '새로운' 트랜잭션은 '오래된' 트랜잭션이 설정한 잠금이 해제될 때까지 기다리지만, '오래된' 트랜잭션은 오래된 트랜잭션이 요청한 잠금을 설정하는 새로운 트랜잭션을 '종료'(중단)합니다. 따라서 잠금 대기자의 교착 상태가 발생하지 않습니다.
  5. 잠금이 설정되면 다음 리더는 TrueTime을 기준으로 트랜잭션에 타임스탬프를 할당합니다.
    1. 이 타임스탬프는 데이터에 영향을 주었던 이전에 커밋된 트랜잭션보다 항상 큽니다. 따라서 클라이언트에서 인식되는 트랜잭션 순서가 데이터의 변경사항 순서와 항상 일치합니다.
  6. 리더는 트랜잭션 및 해당 타임스탬프를 분할 1 복제본에 알립니다. 이러한 복제본 대부분이 트랜잭션 변형을 안정적인 저장소(분산형 파일 시스템)에 저장한 다음, 트랜잭션이 커밋됩니다. 이러한 방식은 일부 머신에 오류가 있더라도 트랜잭션을 복구할 수 있도록 보장합니다. (복제본은 아직 해당 데이터 복사본에 변형을 적용하지 않습니다.)
  7. 리더는 트랜잭션 타임스탬프가 실시간으로 처리되었는지 확인할 수 있을 때까지 기다립니다. 일반적으로 수 밀리초 정도면 확인할 수 있으므로, TrueTime 타임스탬프의 모든 불확실성이 해결될 때까지 기다릴 수 있습니다. 이렇게 해서 강력한 일관성을 보장할 수 있습니다. 한 클라이언트가 트랜잭션의 결과를 알게 되면, 다른 모든 리더도 트랜잭션 효과를 확인할 수 있도록 보장됩니다. 이러한 '커밋 대기'는 일반적으로 위 단계의 복제본 통신과 겹치므로 실제 지연 시간 비용은 최소한으로 유지됩니다. 자세한 내용은 이 백서에 설명되어 있습니다.

  8. 리더가 클라이언트에 회신하면서 트랜잭션이 커밋되었음을 알리고, 선택적으로 트랜잭션의 커밋 타임스탬프를 보고합니다.

  9. 리더가 클라이언트에 회신하는 동시에 트랜잭션 변형을 데이터에 적용합니다.

    1. 리더는 변형을 데이터 복사본에 적용한 후 해당 트랜잭션 잠금을 해제합니다.
    2. 리더는 또한 해당 데이터 복사본에 변형을 적용할 다른 분할 1 복제본을 알립니다.
    3. 변형 효과를 확인해야 하는 모든 읽기-쓰기 또는 읽기 전용 트랜잭션은 데이터 읽기를 시도하기 전, 변형이 적용될 때까지 기다립니다. 읽기-쓰기 트랜잭션의 경우, 트랜잭션이 읽기 잠금을 설정해야 하기 때문에 이러한 방식이 강제됩니다. 읽기 전용 트랜잭션의 경우에는 읽기 타임스탬프를 최근에 적용된 데이터의 타임스탬프와 비교하는 방식으로 강제됩니다.

이 모든 작업은 일반적으로 수 밀리초 내에 수행됩니다. 단일 분할이 관여하기 때문에 이 쓰기는 Spanner에서 수행되는 쓰기 작업 중 가장 비용이 낮습니다.

다중 분할 쓰기

다중 분할이 관여하는 경우, 표준 2단계 커밋 알고리즘을 사용한 추가적인 조정 레이어가 필요합니다.

표에 4000개 행이 포함된다고 가정해보세요.

1 '하나'
2 '둘'
... ...
4000 '사천'

그리고 클라이언트가 하나의 트랜잭션 내에서 1000번 행의 값을 읽고 2000, 3000, 4000번 행에 값을 쓴다고 가정해보세요. 이러한 작업은 다음과 같은 읽기-쓰기 트랜잭션 내에서 수행될 것입니다.

  1. 클라이언트가 읽기-쓰기 트랜잭션인 t를 시작합니다.
  2. 클라이언트가 1000번 행 읽기를 API 레이어에 요청하고 t의 일부로 태그를 지정합니다.
  3. API 레이어가 1000 키를 소유하는 분할을 조회합니다. 이 키는 분할 4에 있습니다.
  4. API 레이어가 분할 4의 리더에 읽기 요청을 보내고 t의 일부로 태그를 지정합니다.

  5. 분할 4의 리더가 Id=1000 행에서 읽기 잠금을 시도합니다. 이것은 로컬 작업입니다. 또 다른 동시 트랜잭션이 이 행에 쓰기 잠금을 설정한 경우, 현재 트랜잭션은 잠금을 설정할 수 있을 때까지 차단됩니다. 하지만 이 읽기 잠금은 다른 트랜잭션이 읽기 잠금을 설정하지 못하도록 방해하지 않습니다.

    1. 단일 분할 사례와 마찬가지로, 교착 상태는 'wound-wait'를 통해 방지됩니다.
  6. 리더가 Id 1000('일천')의 값을 조회하고 읽기 결과를 클라이언트에 반환합니다.


    나중에...

  7. 클라이언트는 트랜잭션 t에 대한 커밋 요청을 실행합니다. 이 커밋 요청에는 변형 3개([2000, "Dos Mil"], [3000, "Tres Mil"], [4000, "Quatro Mil"])가 포함됩니다.

    1. 트랜잭션에 관여하는 모든 분할은 해당 트랜잭션의 참여자가 됩니다. 이 예에서는 분할 4(1000 키 읽기 수행), 분할 7(2000 키 변형 처리), 분할 8(30004000 키 변형 처리)이 참여자입니다.
  8. 참여자 하나는 조정자가 됩니다. 이 예에서는 분할 7의 리더가 조정자가 될 수 있습니다. 조정자는 모든 참여자 간에 자동으로 트랜잭션이 커밋 또는 중단되는지 확인합니다. 즉, 한 참여자에서 커밋되고 다른 참여자에서 중단되지 않아야 합니다.

    1. 참여자 및 조정자가 수행하는 작업은 해당 분할의 리더 머신이 실제로 수행합니다.
  9. 참여자가 잠금을 설정합니다. (2단계 커밋의 첫 번째 단계입니다.)

    1. 분할 7이 2000 키에서 쓰기 잠금을 설정합니다.
    2. 분할 8이 30004000 키에서 쓰기 잠금을 설정합니다.
    3. 분할 4가 1000 키에 읽기 잠금이 여전히 설정되어 있는지 확인합니다. 즉, 머신 장애 또는 wound-wait 알고리즘으로 인해 잠금이 손실되지 않았는지 확인합니다.
    4. 각 참여자 분할이 잠금 집합을 분할 복제본의 대부분(최소한)으로 복제하는 방식으로 기록합니다. 이렇게 하면 서버 오류가 발생하더라도 잠금을 설정된 상태로 유지할 수 있습니다.
    5. 모든 참여자가 조정자에게 해당 잠금이 설정되었음을 성공적으로 알리면 전체 트랜잭션이 커밋될 수 있습니다. 이렇게 하면 트랜잭션에 필요한 모든 잠금이 설정되는 시점을 보장하고, 이 시점을 트랜잭션 커밋 시점으로 사용해서 다른 트랜잭션의 순서에 따라 이 트랜잭션의 적용 순서를 올바르게 조정할 수 있습니다.
    6. wound-wait 알고리즘을 통해 교착 상태가 발생할 수 있음을 알게 된 경우에는 잠금을 설정하지 못할 수 있습니다. 참여자가 트랜잭션을 커밋할 수 없음을 알릴 경우, 전체 트랜잭션이 중단됩니다.
  10. 모든 참여자 및 조정자가 잠금을 성공적으로 설정하면, 조정자(분할 7)가 트랜잭션 커밋을 결정합니다. TrueTime을 기준으로 트랜잭션에 타임스탬프를 할당합니다.

    1. 이러한 커밋 결정은 2000 키에 대한 변형과 함께 분할 7의 멤버에 복제됩니다. 대부분의 분할 7 복제본이 커밋 결정을 안정적인 스토리지에 기록하면 트랜잭션이 커밋됩니다.
  11. 조정자가 트랜잭션 결과를 모든 참여자에게 알립니다. (2단계 커밋의 두 번째 단계입니다.)

    1. 각 참여자 리더가 커밋 결정을 참여 분할의 복제본에 복제합니다.
  12. 트랜잭션이 커밋된 경우, 조정자 및 모든 참여자가 변형을 데이터에 적용합니다.

    1. 단일 분할 사례와 마찬가지로, 조정자 또는 참여자의 이후 데이터 판독기는 데이터가 적용될 때까지 기다려야 합니다.
  13. 조정자 리더가 클라이언트에 회신하면서 트랜잭션이 커밋되었음을 알리고, 선택적으로 트랜잭션의 커밋 타임스탬프를 반환합니다.

    1. 단일 분할 사례와 마찬가지로, strong consistency을 보장하기 위해 커밋 대기 후 클라이언트에 결과가 전달됩니다.

분할 간 추가 조정으로 인해 단일 분할 사례보다는 일반적으로 조금 더 시간이 걸릴 수 있더라도, 이 모든 작업은 일반적으로 수 밀리초 내에 수행됩니다.

강력한 읽기(다중 분할)

클라이언트가 읽기 전용 트랜잭션의 일부로 Id >= 0Id < 700이 있는 모든 행을 읽으려고 한다고 가정해 보겠습니다.

  1. API 레이어가 [0, 700) 범위의 모든 키를 소유하는 분할을 조회합니다. 이러한 행은 분할 0, 분할 1, 분할 2가 소유하고 있습니다.
  2. 여러 머신 간 강력한 읽기이므로 API 레이어에서 현재 TrueTime을 사용하여 읽기 타임스탬프를 선택합니다. 그러면 두 읽기 모두 데이터베이스의 동일 스냅샷에서 데이터를 반환할 수 있습니다.
    1. 비활성 읽기와 같은 다른 유형의 읽기도 읽기 지점의 타임스탬프를 선택합니다. 하지만 이 타임스탬프는 과거 지점일 수 있습니다.
  3. API 레이어가 분할 0의 일부 복제본, 분할 1의 일부 복제본, 분할 2의 일부 복제본에 읽기 요청을 보냅니다. 여기에는 또한 위 단계에서 선택된 읽기-타임스탬프가 포함됩니다.
  4. 강력한 읽기의 경우 제공 복제본은 일반적으로 리더에 RPC를 수행하여 적용해야 하는 마지막 트랜잭션의 타임스탬프를 요청하며 해당 트랜잭션이 적용되면 읽기를 진행할 수 있습니다. 복제본이 리더이거나 내부 상태와 TrueTime에서 요청을 처리하기에 충분하다고 확인하면 복제본에서 직접 읽기를 제공합니다.

  5. 복제본 결과가 결합되고 API 레이어를 통해 클라이언트로 반환됩니다.

읽기는 읽기 전용 트랜잭션에서 어떠한 잠금도 설정하지 않습니다. 그리고 읽기는 지정된 분할의 최신 복제본으로 지원될 수 있기 때문에, 시스템의 읽기 처리량이 매우 높을 수 있습니다. 클라이언트가 최소한 10초 이상 정지되는 읽기를 허용할 수 있는 경우, 읽기 처리량이 더 높아질 수 있습니다. 리더가 일반적으로 10초 간격으로 복제본을 최신 안전 타임스탬프로 업데이트하므로 비활성 타임스탬프 읽기는 리더에 대한 추가 RPC를 방지할 수 있습니다.

결론

이전까지 분산형 데이터베이스 시스템 설계자들 사이에서 강력한 트랜잭션 보장은 머신 간 통신이 필요하기 때문에 비용이 많이 든다는 인식이 있었습니다. Google은 Spanner를 통해 트랜잭션 비용을 낮추고, 분산 환경에서도 강력한 트랜잭션 보장을 대규모로 지원할 수 있도록 노력했습니다. 이것을 가능하게 해준 한 가지 핵심 요소가 바로 각종 조정을 위한 머신 간 통신을 줄여주는 TrueTime입니다. 이러한 노력 외에도 신중한 엔지니어링 및 성능 튜닝을 통해 강력한 보장을 제공하면서도 성능이 뛰어난 시스템이 개발되었습니다. Google 내부적으로도 보장 수준이 약한 다른 데이터베이스 시스템에 비해 Spanner에서 애플리케이션을 개발하는 것이 훨씬 쉽다는 사실이 확인되었습니다. 애플리케이션 개발자가 데이터의 경합 상태 또는 불일치 문제를 걱정할 필요가 없으면 뛰어난 애플리케이션을 빌드 및 제공하는 핵심 업무에 집중할 수 있습니다.