Spanner의 스키마 설계 최적화

Google 스토리지 기술은 세계 최대의 애플리케이션을 지원하고 있습니다. 하지만 이러한 시스템을 사용한다고 해서 항상 자동으로 확장되는 것은 아닙니다. 설계자는 여러 차원의 데이터 증가에 맞게 애플리케이션이 확장되고 작동할 수 있도록 데이터 모델링 방법을 신중하게 생각해야 합니다.

Spanner는 분산형 데이터베이스이며, 이를 효과적으로 사용하기 위해서는 스키마 설계 및 액세스 패턴을 기존의 데이터베이스와 다른 방식으로 생각해야 합니다. 분산형 시스템은 본질상 설계자가 데이터 및 처리 지역을 생각하게 끔 만듭니다.

Spanner는 수평 확장 기능과 함께 SQL 쿼리 및 트랜잭션을 지원합니다. Spanner의 이점을 모두 실현하기 위해서는 신중한 설계가 필요합니다. 이 백서에서는 애플리케이션을 임의 수준으로 확장할 수 있도록 보장하고, 성능을 극대화할 수 있게 해주는 몇 가지 핵심 아이디어에 대해 논의합니다. 이 중에서 키 정의와 인터리브라는 두 가지 도구는 확장성에 특히 중요한 영향을 줍니다.

테이블 레이아웃

Spanner 테이블의 행은 PRIMARY KEY이 사전편집 순으로 구성합니다. 개념적으로 키는 PRIMARY KEY 절에서 선언된 순서대로 열을 연결하여 정렬됩니다. 이는 지역의 모든 표준 속성을 보여줍니다.

  • 사전순으로 테이블을 검색하는 것이 효율적입니다.
  • 충분히 가까운 행은 동일한 디스크 블록에 저장되고, 읽기 및 캐시가 함께 수행됩니다.

Spanner는 가용성과 확장을 위해 여러 영역에 걸쳐 데이터를 복제하고, 각 영역에 데이터의 전체 복제본을 저장합니다. Spanner 인스턴스 노드를 프로비저닝하면 이러한 각 영역에서 해당하는 만큼의 컴퓨팅 리소스를 사용할 수 있습니다. 각 복제본은 하나의 완전한 데이터 집합이지만, 복제본 내의 데이터는 해당 영역의 컴퓨팅 리소스 간에 분할됩니다.

각 Spanner 복제본 내의 데이터는 두 가지 물리적 계층인 데이터베이스 분할블록으로 구성됩니다. 연속된 행 범위가 저장되는 분할은 Spanner가 컴퓨팅 리소스 간에 데이터베이스를 분산시킬 때 사용하는 단위입니다. 시간 경과에 따라 분할은 더 작은 부분으로 나뉘거나 병합되거나 인스턴스의 다른 노드로 이동되어 동시 노드를 늘리고 애플리케이션이 확장될 수 있게 해줍니다. 여러 분할에 걸친 작업은 그만큼 통신이 늘어나기 때문에 그렇지 않은 동일 작업보다 비용이 높습니다. 그러한 분할이 동일 노드에서 제공되는 경우에도 마찬가지입니다.

Spanner에는 루트 테이블(일부 경우에는 최상위 테이블이라고 부름)과 인터리브 처리된 테이블이라는 두 가지 유형의 테이블이 있습니다. 인터리브 처리된 테이블은 다른 테이블을 상위 요소로 지정해서 인터리브 처리된 테이블의 행이 상위 행과 함께 클러스터 처리되도록 정의됩니다. 루트 테이블은 상위 요소가 없으며, 루트 테이블의 각 행은 새로운 최상위 행 또는 루트 행을 정의합니다. 이 루트 행과 함께 인터리브 처리된 행을 하위 행이라고 부르며, 루트 행과 루트 행의 모든 종속 항목의 모음을 행 트리라고 부릅니다. 하위 행을 삽입하려면 먼저 상위 행이 있어야 합니다. 상위 행은 데이터베이스에 이미 존재하거나 동일 트랜잭션에서 하위 행이 삽입되기 전에 삽입될 수 있습니다.

Spanner는 크기 또는 부하로 인해 필요하다고 판단될 경우 자동으로 분할을 나눕니다. 데이터 지역성을 보존하기 위해 Spanner는 루트 테이블 가까이에 분할 경계를 추가하는 것을 선호하므로 주어진 행 트리를 단일 분할에 보관할 수 있습니다. 즉, 행 트리 내의 작업은 다른 분할과의 통신이 필요하지 않기 때문에 더 효율적인 편입니다.

그러나 하위 행에 핫스팟이 있는 경우 Spanner는 해당 핫스팟 행과 그 아래에 있는 모든 하위 행을 분리하기 위해 분할 경계를 인터리브 처리된 테이블에 추가하려고 시도합니다.

애플리케이션 확장을 설계할 때는 루트로 지정할 테이블을 결정하는 것이 중요합니다. 일반적으로 사용자, 계정, 프로젝트 등이 루트로 지정되며, 해당 하위 요소 테이블에는 해당 개체의 기타 데이터 중 대부분이 저장됩니다.

권장사항:

  • 지역성을 개선하려면 동일 테이블에서 관련된 행에 공통 키 접두어를 사용합니다.
  • 가능한 모든 경우, 관련된 데이터를 또 다른 테이블로 인터리브 처리합니다.

지역성의 장단점

데이터를 함께 쓰거나 읽는 경우가 많은 경우, 기본 키를 신중하게 선택하고 인터리브 처리를 사용해서 이를 클러스터 처리하면 지연 시간과 처리량과 관련된 이점을 모두 얻을 수 있습니다. 서버 또는 디스크 블록과 통신하려면 고정 비용이 들기 때문에, 지역성의 이점을 최대한 활용하는 것이 좋습니다. 또한 통신 대상 서버가 늘어날수록 일시적으로 서버 사용량이 증가할 가능성이 높아지고, 이로 인해 지연 시간이 늘어날 수 있습니다. 마지막으로 Spanner에서 자동으로 투명하게 처리된다고 해도 여러 분할에 걸쳐 있는 트랜잭션은 2단계 커밋의 분산형 특징으로 인해 CPU 비용과 지연 시간이 약간 더 늘어날 수 있습니다.

이와 반대로, 데이터가 관련되어 있지만 함께 액세스되는 경우가 드물다면 데이터를 구분하는 것이 좋습니다. 드물게 액세스되는 데이터가 큰 경우에 이 방법으로 가장 큰 이점을 얻을 수 있습니다. 예를 들어 대다수의 데이터베이스는 기본 행 데이터에서 큰 바이너리 데이터를 대역 외로 저장하고, 인터리브 처리된 큰 데이터의 참조만 포함합니다.

어느 정도의 2단계 커밋과 비로컬 데이터 작업은 분산형 데이터베이스에서 피할 수 없습니다. 모든 작업에 완벽한 지역성을 구현하기 위해 과도하게 노력할 필요는 없습니다. 가장 중요한 루트 항목 및 가장 일반적인 액세스 패턴에 대해 원하는 지역성을 얻는 데 집중하고, 액세스 빈도가 낮거나 성능에 덜 민감한 분산 작업도 필요에 따라 실행되도록 허용하는 것이 좋습니다. 2단계 커밋과 분산형 읽기는 스키마를 간소화하고 프로그래머의 업무를 줄여주기 위한 것입니다. 성능이 가장 중요한 사용 사례를 제외한 모든 경우에는 그대로 두는 것이 더 낫습니다.

권장사항:

  • 읽기 또는 쓰기가 함께 수행되는 경향이 있는 데이터가 근처에 배치되도록 데이터를 계층구조로 구성합니다.
  • 액세스 빈도가 낮은 경우에는 인터리브 처리되지 않은 테이블에 큰 열을 저장하는 것이 좋습니다.

색인 옵션

보조 색인을 사용하면 기본 키가 아닌 다른 값으로 행을 빠르게 찾을 수 있습니다. Spanner는 인터리브로 처리되지 않은 색인과 인터리브로 처리된 색인을 모두 지원합니다. 비 인터리브 인덱스는 기본이며 전통적인 RDBMS에서 지원되는 것과 가장 유사한 유형입니다. 인덱싱되는 열에 제한을 두지 않으며 강력하지만 항상 최상의 선택은 아닙니다. 인터리브 처리된 색인은 상위 요소 테이블과 접두어를 공유하는 열로 정의되어야 하며, 지역성을 더 쉽게 제어할 수 있게 해줍니다.

Spanner는 색인 항목별로 하나의 행을 사용해서 테이블과 동일한 방식으로 색인 데이터를 저장합니다. 테이블과 관련된 많은 설계 고려 사항이 색인에도 적용됩니다. 인터리브 처리되지 않은 색인은 루트 테이블에 데이터를 저장합니다. 루트 테이블은 모든 루트 행에 분할될 수 있기 때문에 이렇게 하면 인터리브로 처리되지 않은 색인을 임의 크기로 확장하고, 거의 대부분의 워크로드에서 핫스팟을 무시할 수 있습니다. 하지만 이렇게 하면 색인 항목이 일반적으로 기본 데이터와 동일한 분할에 들어가지 않습니다. 따라서 모든 쓰기 프로세스에 대한 추가 작업 및 지연 시간이 발생하고, 읽기 시 확인할 추가 분할이 늘어납니다.

반대로, 인터리브 처리된 색인은 인터리브 처리된 테이블에 데이터를 저장합니다. 이는 단일 개체의 도메인 내에서 검색할 때 적합합니다. 인터리브 처리된 색인은 데이터 및 색인 입력이 동일 행 트리에 유지되도록 강제하고, 둘 간의 조인을 훨씬 더 효율적으로 만듭니다. 인터리브 처리된 색인의 사용 예:

  • 촬영 날짜, 마지막 수정 날짜, 제목, 앨범 등과 같은 여러 정렬 순서로 사진에 액세스
  • 특정 태그 집합을 포함하는 모든 사진 찾기
  • 특정 항목이 포함된 이전 쇼핑 주문 찾기

권장사항:

  • 데이터베이스의 모든 곳에서 행을 찾아야 할 때는 인터리브로 처리되지 않은 색인을 사용합니다.
  • 검색 범위가 단일 개체로 지정될 때마다 인터리브 처리된 색인을 사용합니다.

STORING 색인 절

보조 색인을 사용하면 기본 키 이외의 속성으로 행을 찾을 수 있습니다. 요청된 모든 데이터가 색인 자체에 있는 경우, 기본 레코드를 읽지 않아도 자체적으로 참조될 수 있습니다. 이렇게 하면 조인이 필요하지 않기 때문에 상당한 리소스를 절약할 수 있습니다.

하지만 색인 키는 숫자가 16으로 제한되고, 집계 크기가 8KiB로 제한되어, 안에 넣을 수 있는 항목이 제한됩니다. 이러한 제한을 보완하기 위해, Spanner는 STORING 절을 통해 색인에 추가 데이터를 저장할 수 있는 기능을 제공합니다. 색인에 열을 저장하는 STORING 절은 색인에 복사본을 저장하여 값이 중복되도록 합니다. STORING을 사용한 색인은 간단한 단일 테이블의 구체화된 뷰(뷰는 현재 Spanner에서 기본적으로 지원되지 않음)로 생각할 수 있습니다.

또 다른 유용한 STORING 적용 사례는 NULL_FILTERED 색인의 일부로 사용된 경우입니다. 이 경우 효율적으로 검색할 수 있는 테이블의 희소 하위 집합에서 실질적으로 구체화된 뷰를 정의할 수 있습니다. 예를 들어 모든 편지함의 전체 사본을 지불하지 않고 단일 테이블 스캔에서 읽지 않은 메일보기를 제공 할 수 있도록 편지함의 is_unread 열에 색인을 만들 수 있습니다.

권장사항:

  • 읽기 시간 성능과 저장소 크기 및 쓰기 시간 성능이 적절히 균형을 이루도록 STORING을 신중하게 사용합니다.
  • NULL_FILTERED를 사용하여 희소 색인의 저장소 비용을 제어합니다.

피해야 할 패턴

피해야 할 패턴: 타임스탬프순 정렬

많은 스키마 설계자들은 타임스탬프 순서로 정렬되고 쓰기 작업마다 업데이트되는 루트 테이블을 정의하는 경향이 있습니다. 이 방법은 확장성이 가장 낮은 작업 중 하나입니다. 그 이유는 이 설계로 인해 테이블 끝에 큰 핫스팟이 생기기 때문입니다. 쓰기 비율이 높아짐에 따라 잠금 경합 이벤트 및 기타 문제가 늘어나는 것처럼 단일 분할에 대한 RPC도 늘어납니다. 이러한 종류의 문제는 작은 부하 테스트에서는 발견되지 않으며, 애플리케이션이 일정 시간 동안 프로덕션 상태로 운영된 다음에 나타납니다. 하지만 그 때는 이미 늦게 됩니다!

애플리케이션에 타임스탬프 순서로 정렬된 로그가 반드시 필요한 경우, 다른 루트 테이블 중 하나로 인터리브 처리하여 로그를 로컬로 만들 수 있는지 살펴봅니다. 이는 핫스팟을 여러 루트로 분산하는 이점이 있습니다. 하지만 여전히 각 구역 루트의 쓰기 비율을 충분히 낮게 유지하도록 주의해야 합니다.

전역(루트 간) 타임스탬프 정렬 테이블이 필요하고 단일 노드가 지원할 수 있는 것보다 높은 쓰기 비율을 해당 테이블에 지원해야 하는 경우, 애플리케이션 수준의 분할을 사용합니다. 테이블을 분할하면 거의 동일한 크기의 N개 분할로 나뉩니다. 이 작업은 일반적으로 원래 기본 키에 [0, N) 사이의 정수 값을 포함하는 추가 ShardId 열로 접두어를 지정하는 방식으로 수행됩니다. 주어진 쓰기의 ShardId는 일반적으로 임의로 선택되거나 기본 키의 일부를 해싱하여 선택됩니다. 해싱은 지정된 유형의 모든 레코드가 동일 분할로 이동하도록 보장하여 검색 성능을 개선하기 때문에 선호되는 경우가 많습니다. 어느 쪽이든, 목표는 시간 경과에 따라 쓰기가 모든 분할에 균일하게 배분되도록 하는 것입니다. 이러한 접근 방식에 따라 일부 경우에는 쓰기의 원래 총 순서를 재구성하기 위해 읽기 시 모든 분할을 검색해야 합니다.

샤드별 시간 순서의 동시성 및 행 샤드 설명

권장사항:

  • 쓰기 비율이 높은 타임스탬프 정렬 테이블과 색인은 반드시 방지합니다.
  • 핫스팟 분산 기술을 사용하고 다른 테이블 또는 분할에 인터리브 처리되도록 합니다.

피해야 할 패턴: 시퀀스

애플리케이션 개발자는 데이터베이스 시퀀스(또는 자동 증분)를 사용해서 기본 키를 생성하기를 좋아합니다. 하지만 과거의 RDBMS부터 이어져온 이러한 습관(대리 키라고 부름)은 위에서 설명한 타임스탬프순 정렬 패턴만큼이나 유해합니다. 데이터베이스 시퀀스는 시간 경과에 따라 거의 단조로운 방식으로 값을 방출하여 서로 클러스터링된 값을 생성하는 경향이 있기 때문입니다. 이렇게 하면 특히 루트 행의 기본 키로 사용될 때 일반적으로 핫스팟이 발생합니다.

과거의 RDBMS에서 이어져 내려온 비법과는 반대로, 여기에서는 가능한 모든 경우에 기본 키의 실제 속성을 사용하는 것이 좋습니다. 특히 속성이 절대로 변경되지 않는 경우에 특히 그러합니다.

숫자로 된 고유한 기본 키를 생성하려는 경우에는 전체 숫자 공간에 순차 숫자의 고차 비트를 고르게 분배하는 것을 목표를 잡습니다. 한 가지 트릭은 기존 방식에 따라 순차적 숫자를 생성한 후 비트 반전을 통해 최종 값을 얻는 것입니다. 또는 UUID 생성기를 사용할 수도 있지만, 이 때는 주의가 필요합니다. 모든 UUID 함수가 동일하게 생성되는 것은 아니며, 일부는 고차 비트에 타임스탬프를 저장하여 실질적인 이점이 상쇄되어 버리고 맙니다. UUID 생성기가 의사 랜덤 방식으로 고차 비트를 선택하도록 해야 합니다.

권장사항:

  • 증분 시퀀스 값을 기본 키로 사용하지 않고, 대신 시퀀스 값을 비트 반전하거나 신중하게 선택된 UUID를 사용합니다.
  • 대리 키 대신 기본 키의 실제 값을 사용합니다.