스키마 설계 권장사항

컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.

Cloud Spanner의 분산 아키텍처를 사용하면 핫스팟을 방지하기 위해 스키마를 설계할 수 있습니다. 핫스팟은 기본 서버가 여러 유사 요청을 여러 서버에 동시에 배포하는 대신 의도치 않게 여러 유사 요청만 처리하도록 강제하는 테이블의 구조 결함입니다.

이 페이지에서는 이러한 핫스팟을 방지하도록 스키마를 설계하기 위한 권장사항을 설명합니다. 이렇게 하면 특히 일괄 데이터 삽입을 수행할 때 Cloud Spanner 데이터베이스가 효율적으로 작동할 수 있습니다.

기본 키 선택 시 부하 집중 방지

스키마 및 데이터 모델에서 설명했듯이, 기본 키를 선택할 때는 데이터베이스에서 예기치 않은 부하 집중이 발생하지 않도록 주의해야 합니다. 부하 집중의 원인 중 하나는 키의 첫 번째 부분에 값이 단조 증가하는 열이 있어서 모든 삽입이 키 공간의 끝에서 이루어지는 경우입니다. 이는 바람직하지 않은 패턴입니다. Spanner는 키 범위를 기준으로 서버 간에 데이터를 구분하기 때문에 단일 서버에서 모든 삽입이 이루어지기 때문입니다.

예를 들어 UserAccessLog 테이블의 행에서 마지막 액세스 타임스탬프 열을 유지한다고 가정해보세요. 테이블에 삽입 비율이 높을 경우, 타임스탬프 기반의 기본 키를 첫 번째 키 부분으로 사용하는 다음 테이블 정의는 피해야 할 패턴입니다.

Google 표준 SQL

-- ANTI-PATTERN: USING A COLUMN WHOSE VALUE MONOTONICALLY INCREASES OR
-- DECREASES AS THE FIRST KEY PART OF A HIGH WRITE RATE TABLE

CREATE TABLE UserAccessLog (
LastAccess TIMESTAMP NOT NULL,
UserId     INT64 NOT NULL,
...
) PRIMARY KEY (LastAccess, UserId);

PostgreSQL

-- ANTI-PATTERN: USING A COLUMN WHOSE VALUE MONOTONICALLY INCREASES OR
-- DECREASES AS THE FIRST KEY PART OF A HIGH WRITE RATE TABLE

CREATE TABLE UserAccessLog (
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (LastAccess, UserId)
);

여기서 문제는 최종 액세스 타임스탬프의 순서대로 이 테이블에 행이 기록되는데 최종 액세스 타임스탬프는 항상 증가하기 때문에 행이 항상 테이블의 끝에 기록된다는 점입니다. 하나의 Spanner 서버가 모든 쓰기를 수신하여 한 서버에 과부하가 걸리기 때문에 핫스팟이 생성됩니다.

아래의 다이어그램은 이러한 맹점을 보여줍니다.

해당 핫스팟을 포함하여 타임스탬프를 기준으로 정렬된 UserAccessLog 테이블

위에 제시된 UserAccessLog 테이블에는 데이터 행 5개가 예시로 표시되어 있는데, 여기서는 5명의 사용자가 서로 약 1밀리초 간격으로 일종의 사용자 작업을 수행하고 있습니다. 또한 행이 삽입되는 순서도 주석으로 표시되어 있습니다(표시된 화살표가 각 행의 쓰기 순서를 나타냄). 삽입은 타임스탬프를 기준으로 정렬되고 타임스탬프 값은 항상 증가하기 때문에 삽입은 항상 테이블의 끝에 추가되고 동일한 분할에서 이루어집니다. 스키마 및 데이터 모델에서 설명했듯이, 분할이란 서로 관련된 하나 이상의 테이블에서 row key 순서로 저장되는 행 집합입니다.

Spanner는 여러 서버에 분할 단위로 작업을 할당하므로 이 경우 이 특정 분할에 할당된 서버가 결국 모든 삽입 요청을 처리하게 되기 때문에 문제가 발생합니다. 사용자 액세스 이벤트의 빈도가 증가함에 따라 해당 서버에 대한 삽입 요청의 빈도도 증가합니다. 그러면 위의 그림에서 빨간색 경계선과 배경색으로 표시된 것처럼 해당 서버가 핫스팟이 되기가 쉽습니다. 위와 같이 간단하게 표현한 그림에서는 각 서버가 많아야 하나의 분할을 처리하지만 실제로는 각 Spanner 서버가 여러 개의 분할을 할당받을 수 있습니다.

테이블에 행이 추가됨에 따라 분할이 커지고, 분할이 약 8GB에 도달하면 부하 기반 분할의 설명과 같이 Spanner가 분할을 하나 더 생성합니다. 이어서 이 새 분할에 새 행이 추가되고 여기에 할당된 서버는 새로운 핫스팟이 될 수 있습니다.

핫스팟이 생기면 삽입 속도가 느리다는 느낌이 들며 동일한 서버의 다른 작업이 느려집니다. LastAccess 열의 순서를 오름차순으로 변경해도 이 문제는 해결되지 않습니다. 이렇게 하면 모든 쓰기가 테이블의 상단에 삽입되고 여전히 모든 삽입이 하나의 서버로 전송되기 때문입니다.

스키마 디자인 권장사항 #1: 쓰기 속도가 높은 테이블에 대해 첫 번째 키 부분으로 해당 값이 단조롭게 증가하거나 감소하는 열을 선택하지 마세요.

키의 순서 변경

키 공간에 쓰기를 분산하는 한 가지 방법은 단조 증가 또는 단조 감소하는 값이 포함된 열이 첫 번째 키 부분이 되지 않도록 키의 순서를 바꾸는 것입니다.

Google 표준 SQL

CREATE TABLE UserAccessLog (
UserId     INT64 NOT NULL,
LastAccess TIMESTAMP NOT NULL,
...
) PRIMARY KEY (UserId, LastAccess);

PostgreSQL

CREATE TABLE UserAccessLog (
UserId bigint NOT NULL,
LastAccess TIMESTAMPTZ NOT NULL,
...
PRIMARY KEY (UserId, LastAccess)
);

수정된 이 스키마에서는 이제 삽입이 시간 순서에 따른 최종 액세스 타임스탬프가 아니라 UserId를 기준으로 정렬됩니다. 이 스키마는 단일 사용자가 초당 수천 개의 이벤트를 생성할 가능성이 없으므로 여러 분할에 쓰기를 분산시킵니다.

아래의 그림은 액세스 타임스탬프가 아니라 UserId를 기준으로 정렬되는 UserAccessLog 테이블의 5개 행을 보여줍니다.

UserId를 기준으로 정렬된 UserAccessLog 테이블(쓰기 처리량이 균형 있게 분산됨)

여기서 UserAccessLog 데이터는 3개로 분할되고, 각각의 분할은 약 천 개의 정렬된 UserId 값 행을 포함합니다. 각 행이 약 1MB의 사용자 데이터를 포함하고 있고 최대 분할 크기가 약 8GB라고 가정할 때 이는 사용자 데이터의 분할 방법에 대한 합리적인 예상 값입니다. 사용자 이벤트가 약 1밀리초 간격으로 발생하더라도 각 이벤트가 서로 다른 사용자에 의해 발생했으므로, 타임스탬프 기반 정렬보다 삽입 정렬이 핫스팟을 생성할 가능성이 훨씬 더 작습니다.

타임스탬프 기반 키 정렬 권장사항도 참조하세요.

고유 키를 해시하고 여러 논리 분할에 쓰기 분산

부하를 여러 서버에 분산시키는 또 다른 일반적인 방법은 사실상 고유 키의 해시를 포함하는 열을 만든 다음, 이 해시 열을(또는 이 해시 열과 고유 키 열을 함께) 기본 키로 사용하는 것입니다. 이 패턴을 사용하면 새 열이 키 공간에 훨씬 더 균일하게 분산되기 때문에 핫스팟을 피하는 데 도움이 됩니다.

이 해시 값을 사용하여 데이터베이스에 논리 분할 또는 파티션을 만들 수 있습니다. (물리적으로 분할된 데이터베이스에서는 행이 몇 개의 데이터베이스에 분산됩니다. 논리적으로 분할된 데이터베이스에서는 분할이 표의 데이터에 의해 정의됩니다.) 예를 들어 UserAccessLog 테이블에 대한 쓰기를 N개의 논리 분할에 분산시키려면 테이블 앞에 ShardId 키 열을 추가할 수 있습니다.

Google 표준 SQL

CREATE TABLE UserAccessLog (
ShardId     INT64 NOT NULL,
LastAccess  TIMESTAMP NOT NULL,
UserId      INT64 NOT NULL,
...
) PRIMARY KEY (ShardId, LastAccess, UserId);

PostgreSQL

CREATE TABLE UserAccessLog (
ShardId bigint NOT NULL,
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (ShardId, LastAccess, UserId)
);

ShardId를 계산하려면 기본 키 열의 조합을 해시하고 이 해시의 모듈로 N(ShardId = hash(LastAccess and UserId) % N)을 계산합니다. 해시 함수의 선택과 열의 조합에 따라 키 공간에 행이 분산되는 방식이 결정됩니다. 그러면 Spanner가 성능을 최적화하기 위해 여러 행에 걸쳐 분할을 생성합니다. 분할은 논리 분할과 일치하지 않을 수도 있습니다.

아래의 그림은 해시를 사용하여 3개의 논리 분할을 만드는 것이 어떻게 쓰기 처리량을 여러 서버에 더 균일하게 분산시킬 수 있는지 보여줍니다.

ShardId를 기준으로 정렬된 UserAccessLog 테이블(쓰기 처리량이 균형 있게 분산됨)

여기서 UserAccessLog 테이블은 키 열의 해시 함수로 계산되는 ShardId를 기준으로 정렬됩니다. 5개의 UserAccessLog 행이 3개의 논리 분할로 분류되고, 각 논리 분할은 동시에 서로 다른 분할에 존재합니다. 삽입이 여러 분할 사이에 균일하게 분산되어 분할을 처리하는 3개의 서버에 쓰기 처리량이 균형 있게 분산됩니다.

삽입이 키 범위에 얼마나 잘 분산되는지는 해시 함수의 선택에 따라 결정됩니다. 암호화 해시 함수가 필수는 아니지만 암호화 해시를 사용하는 것이 좋습니다. 해시 함수를 선택할 때는 다음과 같은 요소를 고려해야 합니다.

  • 핫스팟 방지. 더 많은 해시 값을 만드는 함수가 핫스팟을 줄여주는 경향이 있습니다.
  • 읽기 효율성. 스캔할 해시 값이 적으면 읽기 속도가 더 빠릅니다.
  • 노드 수.

UUID(범용 고유 식별자)의 사용

RFC 4122에서 정의하는 범용 고유 식별자(UUID)를 기본 키로 사용할 수 있습니다. 임의의 값을 비트 순서로 사용하는 버전 4 UUID가 권장됩니다. 버전 1 UUID는 타임스탬프를 고차 비트에 저장하므로 권장되지 않습니다.

UUID를 기본 키로 저장하는 방법은 다음과 같습니다.

  • STRING(36) 열에서 저장
  • INT64 열의 쌍에서 저장
  • BYTES(16) 열에서 저장

UUID를 사용할 경우 다음과 같은 단점이 있습니다.

  • 16바이트 이상을 사용하므로 크기가 약간 큽니다. 기본 키를 저장하는 다른 방법은 이렇게 많은 스토리지를 사용하지 않습니다.
  • 레코드에 관한 정보를 담고 있지 않습니다. 예를 들어, SingerId와 AlbumId라는 기본 키는 이름에 의미가 담겨 있지만 UUID는 그렇지 않습니다.
  • 연관된 레코드 사이의 지역성이 사라집니다. UUID를 사용하면 핫스팟이 제거되는 이유도 이 때문입니다.

순차 값의 비트 반전

숫자로 된 고유 기본 키를 생성할 경우, 전체 숫자 공간에 순차적 숫자의 고차 비트가 고르게 분배되어야 합니다. 이렇게 하는 한 가지 방법은 기존 방식에 따라 순차적 숫자를 생성한 후 비트를 반전시켜 최종 값을 얻는 것입니다.

비트 반전은 기본 키에서 고유한 값을 유지합니다. 애플리케이션 코드에서 원래 값을 다시 계산할 수 있으므로 반전된 값만 저장하면 됩니다.

타임스탬프 기반 키에 내림차순 사용

타임스탬프를 키로 사용하는 작업 내역 테이블이 있는 경우, 다음 조건에 하나라도 해당하면 키 열에 내림차순을 사용하시기 바랍니다.

  • 작업 내역에 인터리브 처리된 테이블을 사용하며 상위 행도 읽어야 하는 경우. 이러한 경우 DESC 타임스탬프 열을 사용하면 최근 작업 내역이 상위 행에 인접하여 저장됩니다. 이렇게 하지 않으면 상위 행과 해당 최근 내역을 읽을 때 중간에 과거 내역을 건너뛰어 탐색해야 합니다.
  • 순차 항목을 시간 역순으로 읽으면서 언제까지 거슬러 올라가야 하는지 정확히 모르는 경우. 예를 들어 LIMIT이 포함된 SQL 쿼리를 사용하여 가장 최근의 N개 이벤트를 가져오거나, 특정한 개수의 행을 읽은 후에 읽기를 취소해야 할 수 있습니다. 이러한 경우 가장 최근 항목부터 시작하여 특정 조건이 충족될 때까지 이전 항목을 순차적으로 읽으면 됩니다. 타임스탬프 키를 내림차순으로 저장하면 Spanner에서 이 작업을 더 효율적으로 수행할 수 있습니다.

DESC 키워드를 추가하여 타임스탬프 키를 내림차순으로 만듭니다. 예를 들면 다음과 같습니다.

Google 표준 SQL

CREATE TABLE UserAccessLog (
UserId     INT64 NOT NULL,
LastAccess TIMESTAMP NOT NULL,
...
) PRIMARY KEY (UserId, LastAccess DESC);

스키마 설계 권장사항 #2: 타임스탬프 기반 키에 내림차순을 사용하세요.

값이 단조 증가 또는 감소하는 열에 인터리브 처리된 색인 사용

피해야 할 기본 키 패턴과 비슷한 경우로서, 굳이 기본 키 열이 아니더라도 값이 단조 증가 또는 감소하는 열에는 인터리브 처리되지 않은 색인을 만들지 마시기 바랍니다.

예를 들어 LastAccess가 기본 키 열이 아닌 다음 테이블을 정의한다고 가정해 보겠습니다.

Google 표준 SQL

CREATE TABLE Users (
UserId     INT64 NOT NULL,
LastAccess TIMESTAMP,
...
) PRIMARY KEY (UserId);

PostgreSQL

CREATE TABLE Users (
UserId     bigint NOT NULL,
LastAccess TIMESTAMPTZ,
...
PRIMARY KEY (UserId)
);

다음과 같이 데이터베이스에 'X 시간 이후'의 사용자 액세스를 신속하게 쿼리하기 위해 LastAccess 열에 색인을 정의하는 방법은 편리해 보일 수 있습니다.

Google 표준 SQL

-- ANTI-PATTERN: CREATING A NON-INTERLEAVED INDEX ON A COLUMN WHOSE VALUE
-- MONOTONICALLY INCREASES OR DECREASES ON A HIGH WRITE RATE COLUMN

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

PostgreSQL

-- ANTI-PATTERN: CREATING A NON-INTERLEAVED INDEX ON A COLUMN WHOSE VALUE
-- MONOTONICALLY INCREASES OR DECREASES ON A HIGH WRITE RATE COLUMN

CREATE INDEX UsersByLastAccess ON Users(LastAccess)
WHERE LastAccess IS NOT NULL;

그러나 이렇게 되면 앞의 권장사항에서 설명한 것과 동일한 맹점이 생깁니다. 색인이 내부에서 표로 구현되고, 이러한 색인 표는 첫 번째 키 부분과 같이 값이 단조 증가하는 열을 사용할 것이기 때문입니다.

그래도 인터리브 처리된 색인의 행은 해당 상위 행에 인터리브 처리되고, 단일 상위 행이 초당 수천 개의 이벤트를 생성할 가능성이 없으므로, 이와 같이 인터리브 처리된 색인을 만들어도 괜찮습니다.

스키마 설계 권장사항 #3: 값이 단조 증가 또는 감소하는 쓰기 속도가 높은 열에는 인터리브 처리되지 않은 색인을 만들지 마세요.

다음 단계