Spanner 분산 아키텍처를 사용하면 핫스팟을 방지하기 위한 스키마를 설계할 수 있습니다. 핫스팟은 같은 서버로 전송되는 요청이 너무 많아 서버 리소스가 포화되고 지연 시간이 길어질 수 있는 상황을 말합니다.
이 페이지에서는 핫스팟 생성을 방지하도록 스키마를 설계하기 위한 권장사항을 설명합니다. 핫스팟을 방지하는 한 가지 방법은 Spanner에서 데이터를 여러 서버에 분할하고 배포할 수 있도록 스키마 설계를 조정하는 것입니다. 여러 서버에 데이터를 분산하면 특히 일괄 데이터 삽입을 수행할 때 Spanner 데이터베이스가 효율적으로 작동하는 데 도움이 됩니다.
기본 키 선택 시 부하 집중 방지
스키마 및 데이터 모델에서 설명했듯이, 스키마 설계에서 기본 키를 선택할 때는 데이터베이스에서 예기치 않은 부하 집중이 발생하지 않도록 주의해야 합니다. 부하 집중의 원인 중 하나는 키의 첫 번째 부분에 값이 단조 변화하는 열이 있어서 모든 삽입이 키 공간의 끝에서 이루어지는 경우입니다. 이는 바람직하지 않은 패턴입니다. Spanner는 키 범위를 사용하여 서버 간에 데이터를 구분하므로 단일 서버에서 모든 삽입이 이루어지기 때문입니다.
예를 들어 UserAccessLog
테이블의 행에서 마지막 액세스 타임스탬프 열을 유지한다고 가정해보세요. 다음 테이블 정의에서는 타임스탬프 기반 기본 키를 첫 번째 키 부분으로 사용합니다. 테이블에서 삽입 빈도가 높은 경우에는 이 방법을 사용하지 않는 것이 좋습니다.
GoogleSQL
CREATE TABLE UserAccessLogs ( LastAccess TIMESTAMP NOT NULL, UserId STRING(1024), ... ) PRIMARY KEY (LastAccess, UserId);
PostgreSQL
CREATE TABLE useraccesslog ( lastaccess timestamptz NOT NULL, userid text, ... PRIMARY KEY (lastaccess, userid) );
여기서 문제는 최종 액세스 타임스탬프의 순서대로 이 테이블에 행이 기록되는데 최종 액세스 타임스탬프는 항상 증가하므로 행이 항상 테이블 끝에 기록된다는 점입니다. 단일 Spanner 서버에서 모든 쓰기를 수신하여 서버 하나에 과부하가 발생하므로 핫스팟이 생성됩니다.
다음 다이어그램은 이러한 허점을 보여줍니다.
앞선 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 값을 자동으로 생성하게 할 수 있습니다.
예를 들어 다음 테이블을 살펴보겠습니다.
GoogleSQL
CREATE TABLE UserAccessLogs (
LogEntryId STRING(36) NOT NULL,
LastAccess TIMESTAMP NOT NULL,
UserId STRING(1024),
...
) PRIMARY KEY (LogEntryId, LastAccess, UserId);
PostgreSQL
CREATE TABLE useraccesslog (
logentryid VARCHAR(36) NOT NULL,
lastaccess timestamptz NOT NULL,
userid text,
...
PRIMARY KEY (lastaccess, userid)
);
GENERATE_UUID()
를 삽입하여 LogEntryId
값을 생성할 수 있습니다.
GENERATE_UUID()
는 STRING
값을 생성하므로 LogEntryId
열은 GoogleSQL의 경우 STRING
유형을, PostgreSQL의 경우 text
유형을 사용해야 합니다.
GoogleSQL
INSERT INTO
UserAccessLog (LogEntryId, LastAccess, UserId)
VALUES
(GENERATE_UUID(), '2016-01-25 10:10:10.555555-05:00', 'TomSmith');
PostgreSQL
INSERT INTO
useraccesslog (logentryid, lastaccess, userid)
VALUES
(spanner.generate_uuid(),'2016-01-25 10:10:10.555555-05:00', 'TomSmith');
UUID를 사용할 경우 다음과 같은 단점이 있습니다.
- 16바이트 이상을 사용하므로 크기가 약간 큽니다. 기본 키를 저장하는 다른 방법은 이렇게 많은 스토리지를 사용하지 않습니다.
- 레코드에 대한 정보를 담고 있지 않습니다. 예를 들어
SingerId
및AlbumId
의 기본 키에는 의미가 있지만 UUID는 그렇지 않습니다. - 관련 레코드 사이의 지역성이 사라집니다. UUID를 사용하면 부하 집중이 제거되는 이유도 이 때문입니다.
순차 값의 비트 반전
숫자(GoogleSQL의 INT64
또는 PostgreSQL의 bigint
) 기본 키가 순차적으로 증가하거나 감소하지 않는지 확인해야 합니다. 순차 기본 키로 인해 대규모 핫스팟이 발생할 수 있습니다. 이 문제를 방지하는 한 가지 방법은 순차 값을 비트 반전시켜 키 공간에 기본 키 값을 고르게 분포시키는 것입니다.
Spanner는 고유한 정수 비트 반전 값을 생성하는 비트 반전 시퀀스를 지원합니다. 기본 키의 첫 번째(또는 유일한) 구성요소에서 시퀀스를 사용하여 핫스팟 문제를 방지할 수 있습니다. 자세한 내용은 비트 반전 시퀀스를 참조하세요.
키의 순서 변경
키 공간에 쓰기를 더 균일하게 분산하는 한 가지 방법은 단조 값이 포함된 열이 첫 번째 키 부분이 되지 않도록 키 순서를 바꾸는 것입니다.
GoogleSQL
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
를 기준으로 정렬됩니다. 이 스키마는 단일 사용자가 초당 수천 개의 이벤트를 생성할 가능성이 없으므로 여러 분할에 쓰기를 분산시킵니다.
다음 이미지에서는 Spanner가 액세스 타임스탬프 대신 UserId
를 기준으로 정렬하는 UserAccessLog
테이블의 행 5개를 보여줍니다.
여기서 Spanner는 UserAccessLog
데이터를 분할 3개로 분할하고 각 분할에는 정렬된 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을 계산합니다. 예를 들면 다음과 같습니다.
GoogleSQL
ShardId = hash(LastAccess and UserId) % N
해시 함수의 선택과 열의 조합에 따라 키 공간에 행이 분산되는 방식이 결정됩니다. 그러면 Spanner가 성능을 최적화하기 위해 여러 행에 걸쳐 분할을 생성합니다.
다음 그림에서는 해시를 사용하여 논리적 샤드 3개를 만드는 것이 어떻게 쓰기 처리량을 여러 서버에 더 균일하게 분산시킬 수 있는지 보여줍니다.
여기서 UserAccessLog
테이블은 키 열의 해시 함수로 계산되는 ShardId
를 기준으로 정렬됩니다. 5개의 UserAccessLog
행이 3개의 논리 분할로 분류되고, 각 논리 분할은 동시에 서로 다른 분할에 존재합니다. 삽입이 여러 분할 사이에 균일하게 분산되어 분할을 처리하는 3개의 서버에 쓰기 처리량이 균형 있게 분산됩니다.
또한 Spanner를 사용하면 생성된 열에 해시 함수를 만들 수 있습니다.
GoogleSQL에서 이 작업을 수행하려면 다음 예시와 같이 쓰기 시간에 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` 추가).