다중 지역에 걸친 CockroachDB 클러스터에서 Global Tables가 모든 지역에서 강한 일관성을 유지하면서도 지역-로컬 지연 시간으로 읽기를 제공하는 방식과 그 대가를 설명합니다.
다중 지리적 지역에 걸친 클러스터에서 Global Tables는 어떤 지역의 데이터베이스 클라이언트든 지역-로컬 지연 시간으로 데이터를 읽을 수 있게 해줍니다. 전 세계 어디에서나 읽히는 데이터가 담긴 테이블을 상상해 보세요. 예를 들어, 글로벌 은행 데이터베이스의 환율 테이블 같은 것입니다. 어디에 있는 사용자를 대신해 실행되는 쿼리도 이 환율에 접근할 수 있습니다. 이는 다른 유형의 데이터(예: 사용자의 계정 데이터)처럼 사용자의 지역과 친화성(affinity)을 갖는 데이터와는 대조적입니다. Global Tables는 이러한 환율 접근을 빠르게 만들어 줍니다.
좀 더 구체적으로 말하면, 이 테이블들은 어떤 지역의 클라이언트든 지역-로컬 지연 시간으로 데이터에 대해 비-스테일 (때로는 _강한 일관성_이라고도 부르는) 읽기를 수행할 수 있게 해줍니다. 즉, 모든 지역에서의 로컬 읽기는 항상 최신 버전의 데이터를 제공하며, 잠재적으로 오래된(stale) 사본을 제공하지 않습니다.
여기에 더해 Global Tables는 시간 지연을 사용해 리더(reader)와 동시 라이터(writer) 간의 충돌을 해결하여, 리더가 라이터 때문에 거의 블로킹되지 않도록 합니다. 이를 결합하면 일반적인 경우뿐 아니라 테일(tail)에서도 낮은 지연 시간 읽기를 달성합니다. 이는 읽기 비중이 높은 데이터에 매우 유리합니다.
하지만 공짜 점심은 아닙니다. Global Tables에 대한 쓰기는 지연 시간 측면에서 일반 테이블 쓰기보다 더 비쌉니다. 따라서 이 기능은 주로 쓰기보다 읽기가 훨씬 더 빈번한 데이터에 유용합니다.
Global Tables는 약 1년 전, CockroachDB 21.1 버전에 도입되었습니다. 우리는 이 기능이 더 정교한 사용자들이 겪는 중요한 문제를 해결하는 데 도움을 준다는 점에서, 그리고 기능의 설계와 구현이 몇몇 면에서 새롭다는 점에서 이 기능을 자랑스럽게 생각합니다. 예를 들어, 앞으로 보겠지만 어떤 복제본도 “스테일” 데이터를 제공하지 않도록 보장하는 데 필요한 동기화는 락과 네트워크 통신 같은 더 흔한 메커니즘이 아니라 시간의 경과(즉, 반(半)동기화된 시계)를 통해 암묵적으로 이뤄집니다. 이 설계는 읽기/쓰기 경합 상황에서 좋은 지연 시간 특성을 제공하고, 지역 장애 상황에서 좋은 가용성 특성을 제공합니다.
Global Tables는 읽기 지연 시간, 쓰기 지연 시간, 읽기의 선형화 가능성(linearizability), 장애 허용, 데이터 파티셔닝, 스토리지 비용 사이의 다차원 트레이드오프 공간에서 새로운 지점을 나타냅니다.
Global Tables를 도입하기 전, CockroachDB(및 많은 다른 데이터베이스) 사용자들은 다중 지역 클러스터에서 데이터를 관리할 때 몇 가지 선택지가 있었는데, 대체로 서로 다른 클라이언트가 서로 다른 파티션에 낮은 지연 시간으로 접근할 수 있도록 데이터를 파티셔닝하는 방식에 기반해 있었습니다. 파티셔닝의 문제는 “비-지역화된(non-localized)” 데이터(특정 지역과 친화성이 없는 데이터)에는 적용되지 않는다는 점입니다. Global Tables는 비-지역화된 데이터에 더 적합한 다른 트레이드오프를 선택합니다. 즉, 드물게 쓰이는 데이터의 쓰기가 더 느리게 수행되도록 허용하는 대신, 모든 클라이언트에게 낮고 예측 가능한 지연 시간의 읽기를 제공합니다.
우리는 follower reads에 대한 이전 작업을 이어가고 있습니다. 이에 대해서는 여기에 글을 썼으며, 맥락을 위해 해당 이전 블로그 글을 빠르게 훑어보는 것을 권합니다.
간단히 말해 CockroachDB는 데이터를 512MB 크기의 “range”로 나눕니다. 각 range는 최소 3중으로 복제되며, 해당 range의 복제본들은 Raft 합의 그룹을 이룹니다. 쓰기는 적용되기 전에 복제본 과반수의 확인을 받아야 합니다.
어느 시점이든 하나의 복제본이 range의 “leaseholder” 역할을 합니다. leaseholder는 모든 쓰기 쿼럼(quorum)의 일부여야 하며, 강한 일관성 읽기를 제공할 수 있는 유일한 복제본입니다. 다른 복제본들은 과거의 스테일 읽기만 제공할 수 있습니다. 따라서 클라이언트 관점에서 읽기 지연 시간은 읽는 range의 leaseholder까지의 왕복 시간에 의해 결정됩니다. 쓰기 지연 시간은 leaseholder까지의 왕복 시간에 더해, 해당 쓰기에 대한 합의를 달성하는 데 걸리는 시간에 의해 결정됩니다. 합의 지연 시간은 leaseholder에서 가장 가까운 복제본 과반수까지의 왕복 시간 정도입니다. 이는 CockroachDB가 실행하는 복잡한 SQL 트랜잭션에서 일어나는 일을 크게 단순화한 것이지만, 유용한 멘탈 모델을 제공합니다. 특히 leaseholder까지의 지연 시간과 쿼럼 지연 시간이 중요함을 보여줍니다.
다중 지역 CockroachDB 클러스터에서 데이터 접근 지연 시간을 최소화하는 것이 핵심 과제입니다.
이전의 multi-region SQL에 대한 블로그 글에서 설명했듯이, CockroachDB는 관리자가 leaseholder와 quorum의 위치를 구성할 수 있게 함으로써 이를 달성하는 한 가지 방법을 제공합니다. 이는 스키마 수준에서 할 수 있습니다. 다중 지역 데이터베이스의 SQL 테이블에는 locality 설정이 있습니다. 기본 옵션인 REGIONAL locality는 테이블 데이터가 들어 있는 모든 range의 leaseholder를 한 지역에 두도록 관리자가 지정할 수 있게 합니다. 복제본의 쿼럼은 테이블의 생존 목표(survival goals)에 따라 leaseholder 주변으로 분산됩니다. 기본적으로 테이블은 한 지역 내에서 하나의 가용 영역(availability zone) 손실을 견디도록(SURVIVE ZONE FAILURE) 구성되며, 이 경우 모든 복제본은 leaseholder 지역의 가용 영역들 사이에 분산됩니다. 테이블은 전체 지역 장애를 견디도록(SURVIVE REGION FAILURE) 구성할 수도 있는데, 이 경우 복제본이 다른 지역들로 분산됩니다.
CockroachDB는 또한 하나의 테이블 내 개별 행(row)을 서로 다른 지역에서 접근하기 좋게 최적화할 수 있게 합니다. 테이블은 REGIONAL BY ROW locality로 구성할 수 있으며, 이 경우 각 행은 숨겨진 region 컬럼을 갖게 되어 어느 지리적 지역이 해당 행에 빠르게 접근할 수 있을지를 제어합니다. 기술적으로는 테이블과 모든 인덱스가 region 컬럼을 기준으로 파티셔닝되며, 서로 다른 파티션의 leaseholder는 서로 다른 위치에 고정됩니다.
데이터가 특정 지역과 자연스러운 친화성을 갖는 경우, REGIONAL 및 REGIONAL BY ROW 테이블은 지연 시간 목표를 잘 충족합니다. 특정 친화성이 없는 데이터, 즉 여러 지역에서 흔히 접근되는 데이터는 더 문제가 됩니다.
이런 접근이 강한 일관성이 필요 없는 읽기(즉, 반드시 가장 최신 데이터를 볼 필요는 없는 읽기)라면 CockroachDB는 모든 지역이 이를 제공할 수 있게 합니다. 기본적으로 모든 테이블에는 모든 지역에 비-투표(non-voting) 복제본이 있으며(비-투표 복제본으로의 복제는 비동기), 이 복제본들은 수 초 정도 오래된 데이터 스냅샷을 제공할 수 있습니다. leaseholder가 아닌 복제본이 제공하는 읽기를 “follower reads”라고 부릅니다. CockroachDB는 두 가지 종류의 스테일 읽기를 지원합니다. SELECT … AS OF SYSTEM TIME now() - ‘5s’ 구문을 통한 정확한 스테일니스(exact staleness) 읽기, 그리고 SELECT … AS OF SYSTEM TIME with_max_staleness('10s') 구문을 통한 경계 스테일니스(bounded staleness) 읽기입니다.
스테일 follower reads는 중요한 도구이지만 항상 사용할 수 있는 것은 아닙니다. 특히 읽기-쓰기 트랜잭션에서는 사용할 수 없습니다. 이러한 트랜잭션은 일관된 읽기를 수행해야 하며, 그렇지 않으면 직렬화 가능한(serializable) 트랜잭션 격리 때문에 문제가 생깁니다. 스테일 읽기를 언제 사용할 수 있고 언제 사용할 수 없는지에 대해서는 follower reads 블로그 글의 이 섹션에서 더 자세히 다룹니다.
데이터에 특정 읽기 친화성이 없고, 스테일 읽기도 허용되지 않는다면, Global Tables가 도움이 될 수 있습니다.
Global Tables의 전체 목표는 leaseholder뿐 아니라 range의 여러(또는 모든) 복제본에서 일관된 읽기를 제공하는 것입니다. 각 복제본이 다른 복제본과 조율할 필요 없이 읽기를 제공하길 원합니다.
Global Tables를 설계할 때 우리는 여러 옵션을 검토했습니다. 최종적으로 선택한 구현으로 이끈 고려 사항은, 읽기가 읽기/쓰기 경합을 만났을 때의 동작—즉 같은 행을 쓰고 있는 쓰기 트랜잭션과 마주쳤을 때의 동작—이었습니다.
경합은 읽기의 높은 테일 지연 시간을 유발하는 흔한 원인이며, 우리는 Global Tables가 경합 상황에서도 예측 가능한 지연 시간을 제공하길 원했습니다. 아래에서는 단순화된 모델로 시작한 뒤 CockroachDB의 세부 사항으로 돌아오며, 이 테이블들이 어떻게 동작하는지 살펴보겠습니다.
먼저 “일관된 읽기”가 무엇을 의미하는지 확장해 봅시다. 읽기가 일관적이라면 최신 커밋 데이터를 반환합니다. 이 맥락에서 “일관성”의 다른 말은 “선형화 가능성(linearizability)”으로, 모든 읽기와 쓰기가 현실 시간에 부합하는 순차적 순서로(즉 한 번에 하나씩 실행된 것처럼) 동작해야 함을 의미합니다(즉 읽기는 읽기가 시작되기 전에 끝난 모든 쓰기를 “보아야” 합니다). 복제 시스템이 선형화 가능하면, 직관적으로는 논리적으로 동등한 비복제 시스템처럼 동작합니다. 클라이언트는 복제의 효과를 관찰할 수 없습니다. 특히 최신 쓰기와 동기화되지 않은 복제본을 질의하더라도 “스테일 데이터”를 볼 수 없습니다. 이는 CockroachDB 일관성 모델에 대한 이전 글에서 꽤 길게 논의한 바 있습니다.
어떤 어려움이 있는지 직관을 얻기 위해 복제되었지만 선형화 가능한 시스템을 만들어 보겠습니다. 가장 단순한 시스템인 단일 레지스터(register)가 읽기와 쓰기를 받는다고 추상적으로 이야기하겠습니다. 이후 섹션에서 이를 훨씬 복잡한 트랜잭션 데이터베이스로 옮길 것입니다. 따라서, 복제 레지스터가 있고 모든 복제본이 읽기를 제공할 수 있길 원합니다. 단순화를 위해 복제본 중 하나만 쓰기를 받을 수 있다고 하며, 이를 리더(leader)라고 부르겠습니다. 또한 시스템이 내결함성이 없다고 가정하겠습니다. 어떤 복제본이든 장애가 나면 쓰기가 블로킹됩니다(실제로는 치명적인 특성일 것입니다).
모든 복제본이 다른 복제본과 통신하지 않고도 일관된 읽기를 제공하길 원하므로, 모든 쓰기가 모든 복제본에 전달되어야 한다는 것은 분명해 보입니다. 그래서 리더는 모든 쓰기를 브로드캐스트해야 합니다. 만약 이 브로드캐스트가 “fire and forget” 방식으로 수행된다면—즉 리더든 다른 어떤 복제본이든 해당 쓰기를 알게 되는 즉시 그 쓰기의 결과를 제공하기 시작한다면—무슨 일이 일어날 수 있는지 생각해 봅시다.
이 다이어그램은 위험 요소를 보여줍니다. 레지스터가 처음에는 값 0을 저장하고 있다가, 한 클라이언트가 1을 쓴다고 가정합시다. 복제본들을 리더로부터의 브로드캐스트가 도달하는 데 걸리는 지연 시간 순으로 정렬해 보세요. 타임라인을 보면 서로 다른 복제본이 새로운 값 1을 서로 다른 시점에 제공하기 시작함이 분명해지고, r1이 제공한 읽기가 1을 반환한 뒤에도 r4가 제공한 읽기가 0을 반환할 수 있는 여지를 만듭니다. 두 번째 읽기는 “비일관적”입니다. 이 복제 레지스터는 선형화 가능한 동작을 보이지 않습니다. 현실 세계로 옮기면, 내가 당신에게 은행 송금을 한 뒤 전화로 계좌 확인을 부탁했는데, 당신이 확인했을 때 돈이 없는 것처럼 보이는 상황일 수 있습니다.
좋습니다. 이 단순한 방식은 동작하지 않습니다. 어떻게든 레지스터에 대한 쓰기를 모든 복제본에 걸쳐 “원자적(atomic)”으로 만들어야 합니다. 즉 어느 시점이 존재하여, 그 이전에는 모든 읽기가 옛 값을 반환하고 그 이후에는 모든 읽기가 새 값을 반환해야 합니다. 이는 어떤 복제본도 새 값을 반환하기 전에 모든 복제본이 새 값에 합의하는 프로토콜이 필요함을 시사합니다.
이런 합의, 즉 “컨센서스(consensus)” 알고리즘의 간단한 예가 Two-Phase Commit(2PC)입니다. 2PC에서 리더는 먼저 모든 복제본에 새 값을 제안(“prepare”)하고, 모두가 그 제안을 확인했음을 확인한 뒤 “commit” 메시지를 브로드캐스트합니다. 복제본이 제안을 들은 순간, 그 복제본은 옛 값도 새 값도 제공할 수 없습니다. 옛 값은 다른 복제본이 이미 새 값을 제공하기 시작하지 않았다고 가정할 수 없기 때문에 제공할 수 없고, 새 값은 다른 복제본이 옛 값을 제공하는 것을 이미 중단했다고 가정할 수 없기 때문에 제공할 수 없습니다.
본질적으로, 복제본 위의 prepared 값은 로컬 락처럼 동작하여 다른 접근을 블로킹합니다. commit 메시지의 브로드캐스트가 복제본에 도달하면 그 복제본은 새 값을 제공하기 시작할 수 있습니다. 그 복제본은 누구도 더 이상 옛 값을 제공하지 않을 것임을 알며, 락이 해제됩니다.
이 다이어그램은 서로 다른 복제본이 락에 걸리는 기간(빨간색)을 보여줍니다. 이 기간에 도착한 읽기는 블로킹되어야 합니다. 다이어그램의 “success”로 표시된 지점은 쓰기의 “선형화 지점(linearization point)”을 나타냅니다. 그 지점 이전에 반환된 모든 읽기는 옛 값을 반환했고, 이제부터 반환될 모든 읽기는 새 값을 반환합니다. 쓰기가 시작된 후에 시작된 일부 읽기는(다이어그램의 r4의 첫 읽기처럼) 옛 값을 반환했을 수도 있고, 다른 일부는 락에 막혀 블로킹되며 시간이 걸린 뒤(다이어그램의 r4의 두 번째 읽기처럼) 새 값을 반환했을 수도 있습니다. 이 읽기들은 모두 쓰기와 동시(concurrent)였고, 쓰기와 “경주(racing)”한 것이므로, 선형화 가능성을 위반하지 않고(또는 일반적으로 레지스터가 어떻게 동작해야 하는지에 대한 대부분의 직관을 위반하지 않고) 옛 값이나 새 값 중 무엇을 반환해도 됩니다.
이 컨센서스 방식은 일관성 목표를 달성하는 데는 동작합니다. 내결함성 문제는 크지만 여기서는 무시하겠습니다. 대신 읽기 지연 시간 문제에 집중하겠습니다. 시각적으로도 각 “락”이 리더와 가장 먼 복제본 사이의 왕복 시간만큼 유지됨을 알 수 있습니다. 다중 지역 환경에서는 수백 밀리초가 될 수 있습니다. 이 레지스터는 복제본 간 통신으로 락 획득과 해제를 조율하고 있으며, 이는 광역 네트워크에서는 근본적으로 느립니다.
또한 여기서는 매우 단순한 시스템에서 값을 커밋하는 비용을 논의했습니다. 여기서 중요한 지연 시간은 커밋(또는 합의) 지연 시간뿐이었습니다. CockroachDB 같은 상호작용형 트랜잭션 데이터베이스에서는 서로 다른 종류의 락이 더 오래 유지될 수 있습니다. 예를 들어 트랜잭션 전체 기간 동안 유지될 수 있는데, 그 기간에는 애플리케이션 코드와의 상호작용이 포함됩니다. 우리는 락의 지속 시간을 쓰기(더 일반적으로는 쓰기 트랜잭션)의 “경합 풋프린트(contention footprint)”라고 부릅니다.
락이 통신 기간만큼 유지될 수밖에 없는 근본적인 이유가 있으므로, “해피 패스”에서 읽기가 이 락들을 마주치지 않아도 되는 설계를 찾으려 했습니다. 한 가지 방법은, 라이터가 스케줄된 타이머가 발화하기 전에 락을 해제할 충분한 시간을 갖도록 쓰기가 미래의 어떤 시점에 가시화되도록 스케줄하는 것입니다. 예를 들어 리더는 각 쓰기에 미래 타임스탬프(예: 1초 뒤)를 할당할 수 있습니다. 각 복제본은 그 타임스탬프를 쓰기가 가시화되어야 하는 시간으로 해석합니다. 그러면 복제본들은 로컬 시계를 사용해 서로 간에 암묵적으로 동기화하여, 모두 같은 시점에 새 값을 반환하기 시작할 수 있습니다.
이 방식에는 해결해야 할 문제가 두 가지 있습니다:
read-your-writes 위반.
시계 스큐(clock skew).
이 방식을 그대로 구현하면, 쓰기를 수행한 클라이언트는 성공 응답을 받은 뒤에도 방금 쓴 값을 읽지 못할 수 있습니다. 값이 아직 나중에 가시화되도록 스케줄되어 있기 때문입니다. 이는 선형화 가능성과 상식 모두에 대한 위반입니다.
이 문제는 리더가 값의 타이머가 만료되어(따라서 값이 전역적으로 가시화되어)야만 클라이언트에 응답하도록 함으로써 해결할 수 있습니다. 우리는 이 대기 기간을 “commit-wait”라고 부릅니다. 독자 중에는 Google Spanner가 같은 이름의 유사한 개념을 갖고 있음을 알고 있을지도 모릅니다. 이는 쓰기 클라이언트가 쓰기 확인을 받기까지 1초를 기다려야 함을 의미합니다. 라이터에게는 큰 지연 시간 비용이지만, 리더가 블로킹되지 않는다는 이점이 있습니다. commit-wait는 쓰기에 대한 컨센서스를 달성하기 위한 통신 지연과 겹친다는 점에 유의하세요.
두 번째 문제는 서로 다른 복제본이 사용하는 시계가 완벽히 동기화되어 있지 않다는 점입니다. 시계가 빠른 복제본은 시계가 느린 복제본보다 먼저 새 값을 반환하기 시작할 수 있습니다. 우리는 어떤 두 복제본 사이에서도 최대 시계 스큐가 어느 정도인지에 대한 가정을 둠으로써 이를 해결할 수 있습니다. 일반적으로 NTP 같은 소프트웨어 방식으로 수 밀리초 이내로 시계를 동기화할 수 있고, 원자시계나 GPS 시계를 사용하면 1밀리초 아래로도 가능합니다. 이 수치는 통신 지연 시간보다 훨씬 좋다는 점에 유의하세요. 최대 시계 스큐가 10ms라고 가정합시다.
스큐를 허용하기 위해, 리더에 대해 “불확실성 구간(uncertainty interval)”이라는 개념을 도입합니다. 리더 관점에서 앞으로 10ms 내에 가시화되도록 스케줄된 값은 “불확실”한 것으로 간주합니다. 다른 복제본이 이미 그 값을 가시적인 것으로 간주하고 있지 않다고 확신할 수 없기 때문입니다. 불확실한 값을 만나면 리더는 로컬 시계가 그 값의 타임스탬프에 도달할 때까지 기다릴 수 있습니다. 그 시점이 되면 리더는 다른 모든 복제본이 그 값을 가시적이라고 간주하고 있거나, 적어도 불확실하다고 간주하고 있음을 확신할 수 있습니다. 어느 쪽이든, 앞으로의 어떤 읽기도 옛 값을 보지 않게 됩니다(이는 종종 “단조 읽기(monotonic reads)” 특성이라고도 합니다).
여기서 달성한 것은, 읽기가 동시 쓰기와 조율할 때 최대 시계 스큐까지만 기다리면 된다는 점입니다. 최신 쓰기의 가시성과 동기화하기 위해 느리고 변동성이 큰 광역 네트워크 통신을 기다릴 필요가 없습니다. 이 전략은 일관된 읽기의 테일 지연 시간을 수백 밀리초에서 한 자릿수 밀리초로 줄일 수 있습니다.
위 다이어그램은 읽기가 일반적으로 통신 때문에 블로킹되지 않음을 보여줍니다. 값 “1”이 복제되는 동안 발생하는 읽기는 이전 값을 반환합니다. “1”의 복제가 끝난 뒤에 발생한 읽기조차도 잠시 동안은 이전 값을 반환합니다. 새 값이 각 복제본의 시계 불확실성 구간에 들어오면서부터 읽기가 블로킹되기 시작하며, 그 블로킹은 허용된 시계 스큐만큼만 발생합니다.
CockroachDB로 돌아오면, Global Tables는 앞 섹션에서 스케치한 아이디어를 사용하여 모든 복제본에서 낮은 지연 시간의 강한 일관성 읽기를 제공합니다. 이는 강한 일관성 읽기이기 때문에 스테일 읽기와 달리 읽기-쓰기 트랜잭션에서 사용할 수 있습니다. Global Table에서 읽을 때, 읽기는 일반적으로 어떤 복제본에서든 제공될 수 있습니다(특히 읽기를 수행하는 SQL 클라이언트와 지리적으로 가까운 곳에 있는 복제본에서), 즉 읽히는 데이터 range의 leaseholder가 제공할 필요가 없습니다.
모든 복제본이 강한 읽기를 제공할 수 있게 하는 명백한 이점은, 클라이언트가 복제본과 같은 지역에 있다면 지역-로컬 지연 시간으로 읽을 수 있다는 점입니다. 두 번째 이점은 수평 확장을 통한 로드 밸런싱 측면입니다. 높은 읽기 처리량을 가진 range에서 leaseholder가 더 이상 병목이 아닙니다.
테이블은 locality를 GLOBAL로 설정하여 “global”로 구성합니다: GLOBAL: ALTER TABLE foo SET LOCALITY GLOBAL. 이는 투표 복제본이 없는 모든 지역에 비-투표 복제본을 배치하여, 모든 지역이 follower reads를 제공할 수 있게 합니다. 그리고 아래에서 자세히 설명하겠지만, 해당 테이블과 인덱스의 데이터가 들어 있는 range에 쓰는 트랜잭션이 “미래”에서 동작하도록 조건이 맞춰집니다. 그 대가로 이 테이블들에 대한 쓰기는 더 느립니다.
Global Tables는 CockroachDB에 자연스럽게 통합되며, 다른 기능들과도 놀랍도록 잘 조합됩니다. 예를 들어, 다른 자식 테이블이 참조하는 대부분 정적인 데이터를 담는 “reference table”을 갖는 경우가 흔합니다. 많은 경우 reference table은 위치 친화성이 없으므로, 이를 참조하는 다른 테이블들이 파티셔닝되어 있더라도 reference table은 Global Table이 될 수 있습니다. 자식 테이블을 삽입하거나 업데이트할 때 암묵적으로 수행되는 외래 키 검사는 follower reads를 사용하여 한 지역 내에서 로컬로 처리될 수 있으며, 자식과 부모 간 조인도 마찬가지입니다.
CockroachDB v21.1 이전에는, 이른바 Duplicate Indexes Topology를 사용해 여러 복제본에서 일관된 읽기를 달성하는 방법이 있었습니다. 요령은 테이블에 대해 여러 개의 동일한 인덱스를 정의하되, 모두 모든 컬럼을 저장하도록 하고, 각 인덱스를 서로 다른 지역에 바인딩하는 것이었습니다. 이 패턴은 사용성(데이터베이스의 지역을 바꾸려면 인덱스를 추가/삭제하는 변경이 필요함)부터 비용(각 인덱스가 내결함성을 위해 개별적으로 복제되어야 함), 테일 지연 시간(라이터가 잡고 있는 락이 리더를 블로킹함)까지 여러 문제가 있었습니다.
중복 인덱스 아이디어는 어떤 의미에서는, 더 낮은 스택 레벨에서 더 효율적으로 제공될 수 있는 기능을 얻기 위해 SQL 인덱스 기능을 (남)용한 다소 해키한 방식이었습니다. 눈을 가늘게 뜨고 보면, 이 방식은 읽기가 이들 복제본 중 어느 하나에서든 제공될 수 있도록, 쓰기가 여러 복제본과 통신하도록 만드는 것입니다. 이 기법은 학술적으로 연구된 Quorum Leases와 정신적으로 유사합니다. Global Tables에서는 복제 레지스터 논의에서 제시한 방향에 따라 다른 길을 택했습니다.
CockroachDB는 Multi-Version Concurrency Control (MVCC) 시스템이며, 모든 읽기와 쓰기는 로컬 시계를 사용해 타임스탬프가 부여됩니다. Global Tables에 대한 쓰기는 미래의 MVCC 타임스탬프에서 동작합니다. 어떤 의미에서 쓰기는 몇백 밀리초 앞선 시점에 효과가 나도록 “스케줄”됩니다.
라이터가 Global Table에 쓰기를 시도하면, 현재 시점의 MVCC 히스토리는 이미 확정되어(“set in stone”) 있다고 통보받습니다. 라이터에게 남은 유일한 선택지는 현재 시점보다 위에서 쓰는 것입니다.
CockroachDB에 익숙한 독자라면, 이 “확정” 메커니즘이 “closed timestamp”임을 알아볼 것입니다.
이전 블로그 글에서 자세히 설명했듯이, “closed timestamps”는 복제본이 특정 타임스탬프 아래의 읽기를 제공할 수 있음을(그 타임스탬프 아래로는 더 이상 쓰기를 받지 않을 것이 보장되므로) 알게 되는 메커니즘입니다. 따라서 closed timestamp는 range의 leaseholder가 다른 복제본들에게 그 타임스탬프 아래에서는 더 이상 쓰기를 평가(evaluate)하지 않겠다는 약속입니다. 대신 클라이언트가 그런 쓰기를 시도하면 leaseholder는 쓰기 트랜잭션을 “push”하여 더 높은 타임스탬프에서 쓰도록 강제합니다(각주: push는 어떤 경우 트랜잭션 재시작을 유발할 수 있습니다).
Closed timestamp는 보통 Raft 복제 스트림을 통해 전달되며, 따라서 쓰기와 동기화됩니다. closed timestamp 업데이트를 받은 복제본은 더 낮은 MVCC 타임스탬프의 모든 쓰기를 이미 보았음을 알고 있습니다.
REGIONAL 테이블에서는 closed timestamp가 보통 과거 수 초 시점에 설정되는 반면, Global Tables에서는 closed timestamp가 현재 시점보다 몇백 밀리초 _미래_에 설정됩니다. 이 레버를 앞으로 옮김으로써, Global Tables는 모든 복제본에서 현재 시점의 MVCC 히스토리가 닫히도록 오케스트레이션하여, 표준 follower read 메커니즘을 통해 모든 복제본이 비-스테일 읽기를 제공할 수 있게 합니다.
Leaseholder가 미래 타임스탬프를 닫음으로써 모든 복제본이 현재 시점을 닫힌 것으로 간주하게 만드는 것이 핵심 아이디어이며, 다른 모든 요소는 여기서 따라옵니다. Closed timestamp는 라이터가 미래 MVCC 타임스탬프에서 쓰도록 강제하며, 이는 장난감 레지스터에서 했던 것처럼 쓰기가 미래에 효과가 나도록 스케줄하는 것과 유사합니다. 거기서 설명했듯이, 라이터는 커밋이 클라이언트에 확인된 뒤의 어떤 미래 읽기도 쓰기를 “볼” 수 있도록 보장하기 위해 commit-wait를 수행합니다.
레지스터에서 설명한 것처럼, CockroachDB는 시계 불확실성 윈도우를 사용하여 복제본 A가 제공한 읽기가 어떤 값을 반환하면(느린 시계를 가진 복제본을 포함해) 다른 어떤 복제본이 제공하는 읽기에서도 그 값이 계속 반환되도록 보장합니다. CockroachDB는 최대 허용 시계 스큐로 구성됩니다(참고: Living Without Atomic Clocks). 최대 스큐가 10ms라고 하고, 타임스탬프 에서의 읽기가 타임스탬프 인 값을 만난다면, 그 리더는 자신의 타임스탬프를 로 바꾸도록 강제됩니다. 이는 refresh operation을 통해 이전의 모든 읽기를 검증하는 것을 의미합니다. 타임스탬프가 현재 시점보다 미래라면, 읽기 트랜잭션도 미래 시점 라이터처럼 commit-wait를 수행합니다. 이는 읽기의 단조성을 보장합니다. 이 리더가 커밋하면, 이후의 모든 트랜잭션은 문제의 쓰기를 반환함이 보장됩니다. 왜냐하면 타임스탬프는 새 트랜잭션의 읽기 타임스탬프 아래에 있거나, 최소한 그들의 불확실성 구간에 들어가기 때문입니다.
이 방식의 핵심 이점은, 타임스탬프가 현재 시점보다 충분히 앞서 닫히면, 리더는 경합하는 라이터 때문에 블로킹되지 않는다는 점입니다. 대화형 트랜잭션의 생애 동안 라이터가 명시적으로 잡고 있는 락 때문도 아니고, 커밋 시 라이터의 합의 라운드 동안 암묵적으로 잡히는 락 때문도 아닙니다. 이는 트랜잭션 커밋의 테일 지연 시간에 매우 중요합니다. 읽기/쓰기 경합 상황에서도 리더는 commit-wait만큼만 블로킹되며, 그 지속 시간은 시계 동기화 정도에 달려 있습니다. commit-wait는 전 세계를 가로지르는 통신 라운드보다 훨씬 빠를 것으로 기대됩니다.
Closed timestamp가 사용하는 “리드 타임(lead time)”—현재 시점보다 얼마나 앞선 기간인지—은 그 타임스탬프가 현재 시점이 될 때쯤이면 트랜잭션이 아마도 락을 해제했고, 복제가 업데이트된 데이터를 전파했으며, 현재 하이브리드 논리 시계(HLC) 시간이 이미 모든 복제본에서 닫혀 있도록 선택됩니다.
Closed timestamp가 사용해야 하는 “리드 타임”의 크기(즉 closed timestamp가 현실 시간보다 얼마나 앞서야 하는지)는 다음의 함수로 계산됩니다:
최대 시계 스큐.
트랜잭션 커밋에 대한 합의를 달성하는 데 걸리는 시간.
커밋된 값이 모든 비-투표 복제본으로 복제되는 데 걸리는 시간.
쓰기 트랜잭션의 수명.
최대 시계 스큐는 다음 이유로 고려해야 합니다. follower가 타임스탬프 에서 읽기를 제공하려면, 가 닫힌 것만으로는 충분하지 않고 도 닫혀 있어야 합니다(읽기가 제공된 뒤, 읽기의 불확실성 윈도우 안에 새 쓰기가 나타나는 것이 허용되면 안 됨).
트랜잭션 커밋에 대한 합의를 달성하는 시간은, 현재 시점이 트랜잭션의 타임스탬프를 따라잡기 전에 쓰기 트랜잭션이 커밋되길 바라기 때문에 고려해야 합니다. 합의 지연 시간은 leaseholder에서 투표 복제본 쿼럼까지의 왕복 시간 한 번 정도입니다. 데이터베이스의 생존 목표에 따라, 이 복제본들은 leaseholder 지역에 있거나 인접 지역들에 있습니다.
비슷하게, 커밋된 값이 모든 비-투표 복제본으로 전파되는 데 걸리는 시간도 고려해야 합니다. 쓰기가 타임스탬프 에서 커밋되면, 이 커밋의 기록과 에 대한 closed timestamp 업데이트가 가장 먼 복제본에 도달해야 그 복제본이 에서 읽기를 제공할 수 있습니다.
마지막으로 쓰기 SQL 트랜잭션의 지속 시간도 고려해야 합니다. 트랜잭션이 쓰는 타임스탬프가 리더의 불확실성 구간에 들어오기 전에 트랜잭션이 모든 락을 해제하길 원하기 때문입니다(그렇지 않으면 리더가 이 락들 때문에 블로킹되기 시작하는데, 이를 피하려는 것입니다). 다행히 이 지속 시간은 방정식의 최대 시계 스큐 컴포넌트와 겹치므로, 둘 중 최대값만 중요합니다.
이 요소들의 합이 Global Table range에서 closed timestamp의 리드 기간을 제어하고, 이는 다시 라이터의 commit-wait 지속 시간에 대한 상한으로 이어집니다. 리더의 commit-wait는 더 작으며, 최대 시계 스큐만으로 제한됩니다.
여러 복제본에서 강한 읽기를 제공하는 것이 해당 데이터의 가용성에 미치는 함의는 논의할 가치가 있습니다. CAP 정리는 네트워크 분할이 발생할 경우 분할의 양쪽에서 데이터의 가용성과 일관성을 동시에 제공하는 것은 불가능하다고 말합니다. 네트워크 분할(및 다른 장애) 상황에서는 무언가를 포기해야 합니다. CockroachDB의 Global Tables 구현 맥락에서 설계 결정은 “어디서나 읽기 가용성” vs “어딘가에서 읽기/쓰기 가용성” 중에서 선택하는 것이었습니다. 우리는 후자를 택했습니다. 분할 또는 지역 장애 시, 분할의 한쪽은 데이터에 대한 읽기/쓰기 가용성을 유지하는 반면, 다른 쪽은 둘 다 잃습니다. 대안 구현은 쓰기 가용성을 포기함으로써 모든 지역에서 읽기 가용성을 유지할 수 있습니다(즉, 쓰기가 모든 지역과 조율한다면, 각 지역은 나머지와 연결이 끊기더라도 강한 읽기를 계속 제공할 수 있습니다). 쓰기 가용성을 유지하는 쪽이 CockroachDB에 더 올바른 선택으로 보이는 이유는 몇 가지입니다:
클러스터의 지리적 지역 수가 늘어날수록(우리는 늘 것이라고 봅니다), 한 지역이 일시적으로 사용 불가가 되는 경우와 지역 간 네트워킹 이슈가 더 흔해질 것입니다. 한 지역의 장애 영향이 다른 지역들로 전파되는 것을 허용하는 비용은 장기적으로 더 커질 가능성이 높습니다.
쓰기 가용성은 CockroachDB의 트랜잭션 쓰기 시맨틱을 통해 읽기 가용성과 교묘하게 연결되어 있습니다. 트랜잭션을 대신해 쓰기를 수행할 때(그리고 CockroachDB는 다중 지역뿐 아니라 트랜잭션이 핵심입니다), 그 쓰기들은 트랜잭션이 커밋될 때까지 수정된 데이터에 락을 잡습니다. 트랜잭션을 커밋할 수 없다면 락은 해제될 수 없습니다. 트랜잭션을 커밋하려면 트랜잭션이 건드린 여러 range 중 특정 range에 대한 쓰기 가용성이 필요합니다. 따라서 그 range에 대한 쓰기 불가용성은 임의로 다른 range들에 대한 읽기 불가용성으로 이어질 수 있습니다. 결국 CockroachDB는 필연적으로 높은 (쓰기) 가용성에 관한 시스템이기도 합니다.
Global Table의 경우, 테이블 데이터에 대한 쓰기는 SURVIVE 설정에 따라 한 지역 내 복제본들 또는 세 지역에 걸친 복제본들로 구성된 쿼럼을 필요로 합니다. 네트워크 분할 시 이 쿼럼에서 분리된 지역의 클라이언트는 읽기나 쓰기를 수행할 수 없습니다. 쿼럼과 연결성이 유지되는 지역의 클라이언트는 읽기/쓰기 가용성을 유지합니다. 쿼럼이 지역 장애를 지원하도록 구성되어 있지 않다면, 쿼럼 지역의 장애는 다른 모든 지역에 대해 데이터의 읽기/쓰기 가용성을 앗아갑니다. 다른 지역들의 장애는 장애가 아닌 지역들에 영향을 주지 않습니다.
앞서 링크한 Duplicate Index Topology 패턴은 그 반대로, 쓰기 가용성보다 읽기 가용성을 선택합니다. 이는 설계라기보다 우연의 결과입니다.
Global Tables는 어떤 지역의 데이터베이스 클라이언트든 지역-로컬 지연 시간으로 강한 일관성 데이터를 읽을 수 있게 해줍니다. 이는 다중 지역 퍼즐의 중요한 조각으로, 읽기 중심이며 비-지역화된 데이터에 잘 맞는 지연 시간 특성을 제공합니다.
Global Tables는 지역화된 데이터에 잘 맞는 대응 기능인 Regional Tables와도 잘 조합됩니다. regional table에서 데이터 호밍(data homing)에 대해 더 알아보려면 이 블로그 글을 확인하세요. 이 두 테이블 locality가 어떻게 함께 맞물려 응집력 있는 다중 지역 스토리를 만드는지 보려면 이 블로그 글에서 계속 읽어보세요.