스키마 설계 권장사항

Spanner의 분산 아키텍처를 사용하면 부하 집중을 방지하기 위한 스키마를 설계할 수 있습니다. 부하 집중은 동일한 서버로 전송되는 요청이 너무 많아서 서버의 리소스가 포화되고 지연 시간이 길어질 수 있는 상황을 말합니다.

이 페이지에서는 부하 집중을 방지하도록 스키마를 설계하기 위한 권장사항을 설명합니다. 부하 집중을 피하는 한 가지 방법은 Spanner가 여러 서버에 데이터를 분할하고 배포할 수 있도록 스키마 설계를 조정하는 것입니다. 여러 서버에 데이터를 분산하면 특히 일괄 데이터 삽입을 수행할 때 Spanner 데이터베이스가 효율적으로 작동하는 데 도움이 됩니다.

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

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

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

GoogleSQL

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

PostgreSQL

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

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

다음 다이어그램은 이러한 허점을 보여줍니다.

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

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

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

Spanner가 테이블에 행을 더 추가하면 분할이 커지고, 분할이 약 8GB에 도달하면 부하 기반 분할의 설명처럼 Spanner가 또 다른 분할을 만듭니다. Spanner는 이 새 분할에 후속 새 행을 추가하고 분할에 할당된 서버에 새로운 부하 집중이 발생할 수 있습니다.

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

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

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

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

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

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

STRING(36) 열의 경우 Spanner GENERATE_UUID() 함수(GoogleSQL 또는 PostgreSQL)를 열 기본값으로 사용하여 Spanner가 UUID 값을 자동으로 생성하게 할 수 있습니다.

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

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

순차 값의 비트 반전

숫자(GoogleSQL의 INT64 또는 PostgreSQL의 bigint) 기본 키가 순차적으로 증가하거나 감소하지 않는지 확인해야 합니다. 순차적 기본 키는 대규모 부하 집중을 유발할 수 있습니다. 이 문제를 방지하는 한 가지 방법은 순차 값을 비트 반전하여 기본 키 값을 키 공간에 고르게 배포하는 것입니다.

Spanner는 고유한 정수 비트 반전 값을 생성하는 비트 반전 시퀀스를 지원합니다. 기본 키의 첫 번째(또는 유일한) 구성요소에서 시퀀스를 사용하여 부하 집중 문제를 방지할 수 있습니다. 자세한 내용은 비트 역순 시퀀스를 참조하세요.

키의 순서 변경

키 공간에 쓰기를 보다 균일하게 분산하는 한 가지 방법은 단조로운 값을 포함하는 열이 첫 번째 키 부분이 되지 않도록 키 순서를 바꾸는 것입니다.

GoogleSQL

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

PostgreSQL

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

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

다음은 Spanner가 액세스 타임스탬프 대신 UserId를 기준으로 정렬하는 UserAccessLog 테이블의 행 5개를 보여줍니다.

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

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

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

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

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

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

GoogleSQL

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개의 서버에 쓰기 처리량이 균형 있게 분산됩니다.

Spanner를 사용하면 생성된 열에 해시 함수를 만들 수도 있습니다.

Google SQL에서 이 작업을 수행하려면 다음 예시와 같이 쓰기 시간 동안 FARM_FINGERPRINT 함수를 사용합니다.

GoogleSQL

CREATE TABLE UserAccessLog (
ShardId INT64 NOT NULL
AS (MOD(FARM_FINGERPRINT(CAST(LastAccess AS STRING)), 2048)) STORED,
LastAccess TIMESTAMP NOT NULL,
UserId    INT64 NOT NULL,
) PRIMARY KEY (ShardId, LastAccess, UserId);

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

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

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

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

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

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

GoogleSQL

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

스키마 설계 권장사항 #2: 내림차순 순서나 오름차순 순서는 사용자 쿼리에 따라 달라집니다(예: 최신 순서 또는 가장 오래된 순서).

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

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

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

GoogleSQL

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 열에 색인을 정의하는 방법은 편리해 보일 수 있습니다.

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

PostgreSQL

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

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

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

스키마 설계 권장사항 #3: 값이 단조 증가 또는 감소하는 쓰기 속도가 높은 열에는 인터리브 처리되지 않은 색인을 만들지 마세요. 인터리브 처리된 색인을 사용하는 대신 색인 열을 설계할 때 기본 테이블 기본 키 설계에 사용하는 것과 같은 기법을 사용합니다(예: `shardId` 추가).

다음 단계