RegionServer 크기 조정 규칙

RegionServer 크기 조정 규칙 (Rules of Thumb)

이 문서는 HBase RegionServer의 메모리 크기와 스키마 설계에 대한 실용적인 규칙을 설명해요. region 크기, memstore 크기, HDFS 복제 계수, column family 수, rowkey 설계 등 성능에 직접 영향을 주는 요소를 다뤄요. HBase 클러스터를 설계하거나 운영할 때 참고할 수 있는 핵심 지침이에요.

출처: 문서

본문

Lars Hofhansl은 RegionServer 메모리 크기 조정에 대한 훌륭한 블로그 게시물을 썼어요. 핵심은 필요한 것보다 더 많은 메모리가 필요할 가능성이 있다는 거예요. 그는 region 크기, memstore 크기, HDFS 복제 계수 등 확인해야 할 사항들의 영향을 설명해요.

개인적으로 HBase로만 전용 서비스할 수 있는 머신당 최대 디스크 공간은 약 6T로 두겠어요. 물론 아주 읽기 중심 워크로드가 아니라면요. 그 경우 Java 힙은 32GB(20G regions, 128M memstores, 나머지 기본값)여야 해요.

— Lars Hofhansl http://hadoop-hbase.blogspot.com/2013/01/hbase-region-server-memory-sizing.html

column family 수에 대하여

HBase는 현재 두세 개 이상의 column family에서는 잘 동작하지 않으므로 스키마의 column family 수를 낮게 유지하세요. 현재 플러시는 Region 단위로 수행되어, 한 column family가 데이터의 대부분을 담아 플러시를 일으키면 인접한 family들도 데이터량이 적음에도 함께 플러시돼요. 많은 column family가 존재하면 플러시 상호작용이 불필요한 i/o를 많이 만들 수 있어요(플러시를 column family 단위로 변경해서 해결 예정). 또한 테이블/region 수준에서 트리거된 컴팩션도 store마다 발생할 거예요.

가능하다면 스키마에 하나의 column family로 해결해 보세요. 두 번째와 세 번째 column family는 데이터 접근이 보통 열 범위로 지정된 경우(즉 한 번에 둘 다가 아니라 보통 한 column family 또는 다른 것을 쿼리하는 경우)에만 도입하세요.

ColumnFamilies의 카디널리티

단일 테이블에 여러 ColumnFamily가 존재할 때 카디널리티(즉 행 수)를 인지하세요. ColumnFamilyA가 1백만 행이고 ColumnFamilyB가 10억 행이라면 ColumnFamilyA의 데이터는 많고 많은 region(그리고 RegionServer)에 분산될 가능성이 높아요. 이것은 ColumnFamilyA의 대량 스캔을 덜 효율적으로 만들어요.

Rowkey 설계

핫스팟팅(Hotspotting)

HBase의 행은 row key로 사전식 정렬돼요. 이 설계는 관련된 행이나 함께 읽을 행을 가까이 저장할 수 있게 해 스캔에 최적화돼요. 그러나 잘못 설계된 row key는 핫스팟팅의 흔한 원인이에요. 핫스팟팅은 클라이언트 트래픽의 많은 양이 클러스터의 한 노드 또는 몇 개 노드에 집중될 때 발생해요. 이 트래픽은 읽기, 쓰기 또는 다른 연산을 나타낼 수 있어요. 트래픽은 그 region을 호스팅하는 단일 머신을 압도해 성능 저하를 일으키고 잠재적으로 region을 사용 불가능하게 만들 수 있어요. 호스트가 요청된 부하를 처리하지 못하므로 같은 region server가 호스팅하는 다른 region에도 부정적 영향을 줄 수 있어요. 클러스터가 완전하고 고르게 활용되도록 데이터 접근 패턴을 설계하는 것이 중요해요.

쓰기 핫스팟팅을 방지하려면, 정말로 같은 region에 있어야 하는 행들이 그렇게 되도록 row key를 설계하되, 더 큰 그림에서는 데이터가 한 번에 하나가 아니라 클러스터의 여러 region에 쓰이도록 하세요. 핫스팟팅을 피하는 몇 가지 일반적인 기법과 그 장단점이 아래에 설명돼요.

Salting

여기서 Salting은 암호화와 무관하며 row key의 시작에 무작위 데이터를 추가하는 것을 말해요. 이 경우 salting은 row key에 무작위로 할당된 접두사를 추가해 원래 정렬과 다르게 정렬되게 하는 것을 의미해요. 가능한 접두사 수는 데이터를 분산시키려는 region 수에 해당해요. 다른 더 고르게 분산된 행들 사이에서 반복해서 나타나는 몇 개의 "핫" row key 패턴이 있다면 salting이 도움이 될 수 있어요. 다음 예시는 salting이 어떻게 쓰기 부하를 여러 RegionServer에 분산시킬 수 있는지 보여주고 읽기에는 어떤 부정적 영향이 있는지 보여줘요.

Salting 예시:

다음 row key 목록이 있고 테이블이 알파벳 각 문자에 하나의 region이 있도록 분할되어 있다고 가정해요. 접두사 'a'는 한 region, 접두사 'b'는 다른 region이에요. 이 테이블에서 'f'로 시작하는 모든 행은 같은 region에 있어요. 이 예시는 다음과 같은 키를 가진 행에 초점을 맞춰요.

foo0001
foo0002
foo0003
foo0004

이제 이 행들을 네 개의 다른 region에 분산시키고 싶다고 상상해 보세요. 네 개의 다른 salt인 a, b, c, d를 사용하기로 결정했어요. 이 시나리오에서 이 문자 접두사들은 각각 다른 region에 있을 거예요. salt를 적용한 후 rowkey는 다음과 같아져요. 이제 네 개의 별도 region에 쓸 수 있으므로, 이론적으로 모든 쓰기가 같은 region으로 가는 경우보다 쓰기 처리량이 네 배가 돼요.

a-foo0003
b-foo0001
c-foo0004
d-foo0002

그런 다음 다른 행을 추가하면 네 가지 가능한 salt 값 중 하나가 무작위로 할당되어 기존 행 중 하나 근처에 놓일 거예요.

a-foo0003
b-foo0001
c-foo0003
c-foo0004
d-foo0002

이 할당은 무작위이므로, 행을 사전식 순서로 검색하려면 더 많은 작업이 필요해요. 이런 식으로 salting은 쓰기 처리량을 높이려 하지만 읽기에는 비용이 들어요.

해싱

무작위 할당 대신 일방향 해시를 사용할 수 있어요. 이것은 주어진 행이 항상 같은 접두사로 "salted"되도록 하여 RegionServers에 부하를 분산시키지만 읽기 중 예측 가능성을 허용해요. 결정적 해시를 사용하면 클라이언트가 완전한 rowkey를 재구성하고 Get 연산으로 그 행을 평소처럼 검색할 수 있어요.

해싱 예시:

위의 salting 예시와 같은 상황에서, 키 foo0003을 가진 행이 항상 예측 가능하게 a 접두사를 받도록 하는 일방향 해시를 적용할 수 있어요. 그런 다음 그 행을 검색하려면 이미 키를 알면 돼요. 예를 들어 특정 키 쌍이 항상 같은 region에 있도록 최적화할 수도 있어요.

키 뒤집기

핫스팟팅을 방지하는 세 번째 일반적인 요령은 고정 폭 또는 숫자 row key를 뒤집어 가장 자주 바뀌는 부분(최하위 자릿수)이 앞에 오게 하는 것이에요. 이것은 row key를 효과적으로 무작위화하지만 행 정렬 속성을 희생해요.

핫스팟팅 방지에 대한 자세한 내용은 https://communities.intel.com/community/itpeernetwork/datastack/blog/2013/11/10/discussion-on-designing-hbase-tables, Phoenix 프로젝트의 Salted Tables 기사, HBASE-11682의 댓글 토론을 참고하세요.

단조 증가 row key/시계열 데이터

Tom White의 책 Hadoop: The Definitive Guide(O'Reilly)의 HBase 장에는 import 과정이 모든 클라이언트와 함께 테이블의 한 region(따라서 단일 노드)을 두드리다 다음 region으로 이동하는 등 보폭이 맞는 현상을 주시하라는 최적화 메모가 있어요. 단조 증가 row key(즉 타임스탬프 사용)로는 이것이 발생할 거예요. IKai Lan의 이 만화가 BigTable 유사 데이터스토어에서 단조 증가 row key가 왜 문제인지 설명해요: monotonically increasing values are bad. 단조 증가 키가 만드는 단일 region에 대한 쌓임은 입력 레코드를 정렬된 순서가 아니게 무작위화해 완화할 수 있지만, 일반적으로 row-key로 타임스탬프나 시퀀스(예: 1, 2, 3)를 사용하는 것을 피하는 것이 가장 좋아요.

시계열 데이터를 HBase에 업로드해야 한다면 성공 사례인 OpenTSDB를 연구해야 해요. 그것은 HBase에서 사용하는 스키마를 설명하는 페이지가 있어요. OpenTSDB의 키 형식은 사실상 [metric_type][event_timestamp]이고, 이것은 언뜻 키로 타임스탬프를 사용하지 말라는 이전 조언과 모순되는 것처럼 보여요. 그러나 차이는 타임스탬프가 키의 선두 위치에 있지 않다는 것이고, 설계 가정은 수십 또는 수백(또는 그 이상)의 서로 다른 metric type이 있다는 것이에요. 따라서 metric type이 섞인 지속적인 입력 데이터 스트림에도 Puts는 테이블 region의 다양한 지점에 분산돼요.

몇 가지 rowkey 설계 예시는 schema.casestudies를 참고하세요.

행과 열 크기 최소화

HBase에서 값은 항상 자신의 좌표와 함께 운반돼요. 셀 값이 시스템을 통과할 때 항상 자신의 행, 열 이름, 타임스탬프가 동반돼요. 행과 열 이름이 크다면, 특히 셀 값 크기와 비교해서 크다면 흥미로운 시나리오를 만날 수 있어요. 그중 하나는 Marc Limotte가 HBASE-3551의 끝에서 설명한 경우예요(추천!). 거기서 무작위 접근을 용이하게 하기 위해 HBase storefile에 유지되는 인덱스(HFile Format 참고)는 셀 값 좌표가 크기 때문에 HBase에 할당된 RAM의 큰 덩어리를 차지하게 될 수 있어요. 위 인용 주석에서 Mark는 블록 크기를 높여 store file 인덱스의 엔트리가 더 큰 간격으로 생기게 하거나 테이블 스키마를 수정해 더 작은 행과 열 이름을 만들 것을 제안해요. 압축도 더 큰 인덱스를 만들 거예요. 사용자 메일링 리스트의 a question storefileIndexSize 스레드를 참고하세요.

대부분의 경우 작은 비효율은 그렇게 중요하지 않아요. 불행히도 이것은 중요한 경우예요. ColumnFamilies, 속성, rowkeys에 선택되는 어떤 패턴이든 데이터에서 수십억 번 반복될 수 있어요.

HBase가 내부적으로 데이터를 저장하는 방법에 대한 자세한 내용은 keyvalue를 참고해 이것이 왜 중요한지 확인하세요.

Column Families

ColumnFamily 이름을 가능한 한 작게 유지하세요. 가능하면 한 문자(예: 데이터/기본용 "d")가 좋아요.

HBase가 내부적으로 데이터를 저장하는 방법에 대한 자세한 내용은 KeyValue를 참고하세요.

속성

자세한 속성 이름(예: "myVeryImportantAttribute")은 읽기 쉽지만, HBase에 저장하려면 더 짧은 속성 이름(예: "via")을 선호하세요.

HBase가 내부적으로 데이터를 저장하는 방법에 대한 자세한 내용은 keyvalue를 참고해 이것이 왜 중요한지 확인하세요.

Rowkey 길이

필요한 데이터 접근(예: Get vs. Scan)에 여전히 유용할 수 있을 만큼 합리적으로 짧게 유지하세요. 데이터 접근에 쓸모없는 짧은 키가 더 나은 get/scan 속성을 가진 더 긴 키보다 낫지는 않아요. rowkey 설계 시 트레이드오프를 기대하세요.

바이트 패턴

long은 8바이트예요. 그 8바이트에 최대 18,446,744,073,709,551,615까지의 부호 없는 숫자를 저장할 수 있어요. 이 숫자를 String으로 저장한다면 — 문자당 바이트 하나를 가정 — 거의 3배의 바이트가 필요해요.

확신이 없다면? 아래는 직접 실행할 수 있는 샘플 코드예요.

// long
//
long l = 1234567890L;
byte[] lb = Bytes.toBytes(l);
System.out.println("long bytes length: " + lb.length);   // returns 8

String s = String.valueOf(l);
byte[] sb = Bytes.toBytes(s);
System.out.println("long as string length: " + sb.length);    // returns 10

// hash
//
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(Bytes.toBytes(s));
System.out.println("md5 digest bytes length: " + digest.length);    // returns 16

String sDigest = new String(digest);
byte[] sbDigest = Bytes.toBytes(sDigest);
System.out.println("md5 digest as string length: " + sbDigest.length);    // returns 26

불행히도 타입의 이진 표현을 사용하면 코드 밖에서 데이터를 읽기 어려워져요. 예를 들어 값을 증가시킬 때 셸에서 보게 되는 것은 다음과 같아요.

hbase(main):001:0> incr 't', 'r', 'f:q', 1
COUNTER VALUE = 1

hbase(main):002:0> get 't', 'r'
COLUMN                                        CELL
 f:q                                          timestamp=1369163040570, value=\x00\x00\x00\x00\x00\x00\x00\x01
1 row(s) in 0.0310 seconds

셸은 문자열을 인쇄하기 위해 최선을 다하고, 이 경우 hex를 인쇄하기로 했어요. region 이름 안의 row key에도 같은 일이 일어날 거예요. 저장되는 것이 무엇인지 알면 괜찮을 수 있지만, 같은 셀에 임의 데이터가 들어갈 수 있다면 읽을 수 없을 수도 있어요. 이것이 주요 트레이드오프예요.

역순 타임스탬프

HBASE-4811은 테이블 또는 테이블 내 범위를 역순으로 스캔하는 API를 구현해서, 정방향 또는 역방향 스캔을 위해 스키마를 최적화할 필요를 줄였어요. 이 기능은 HBase 0.98 이상에서 사용할 수 있어요. 자세한 내용은 Scan.setReversed()를 참고하세요.

데이터베이스 처리에서 흔한 문제는 값의 가장 최근 버전을 빠르게 찾는 것이에요. 키의 일부로 역순 타임스탬프를 사용하는 기법은 이 문제의 특수한 경우에 크게 도움이 될 수 있어요. Tom White의 책 Hadoop: The Definitive Guide(O'Reilly)의 HBase 장에도 있는 기법으로, 어떤 키든 끝에 (Long.MAX_VALUE - timestamp)를 추가하는 것, 예: [key][reverse_timestamp]를 포함해요.

테이블에서 [key]의 가장 최근 값을 찾으려면 [key]에 대한 Scan을 수행해 첫 레코드를 얻으면 돼요. HBase 키는 정렬된 순서이므로 이 키는 [key]에 대한 더 오래된 row-key들보다 먼저 정렬되어 첫 번째가 돼요.

이 기법은 모든 버전을 "영원히"(또는 아주 오래) 보유하면서도 같은 Scan 기법으로 다른 버전에 빠르게 접근하려는 Number of Versions 대신 사용될 거예요.

Rowkeys와 ColumnFamilies

Rowkey는 ColumnFamilies로 범위가 한정돼요. 따라서 테이블에 존재하는 각 ColumnFamily에 같은 rowkey가 충돌 없이 존재할 수 있어요.

Rowkey의 불변성

Rowkey는 변경할 수 없어요. 테이블에서 "변경"할 수 있는 유일한 방법은 행을 삭제한 다음 다시 삽입하는 것이에요. 이것은 HBase dist-list에서 꽤 흔한 질문이므로 처음에(그리고/또는 많은 데이터를 삽입하기 전에) rowkey를 올바르게 만드는 것이 중요해요.

RowKeys와 Region Split의 관계

테이블을 사전 분할한다면 rowkey가 region 경계에 어떻게 분산될지 이해하는 것이 중요해요. 이것이 왜 중요한지의 예로, 키의 선두 위치에 표시 가능한 hex 문자를 사용하는 경우(예: "0000000000000000"부터 "ffffffffffffffff")를 고려해 보세요. Bytes.split( Admin.createTable(byte[] startKey, byte[] endKey, numRegions)에서 region을 만들 때 사용되는 분할 전략)으로 10개 region에 대해 그 키 범위를 실행하면 다음 분할이 생성될 거예요.

48 48 48 48 48 48 48 48 48 48 48 48 48 48 48 48                                // 0
54 -10 -10 -10 -10 -10 -10 -10 -10 -10 -10 -10 -10 -10 -10 -10                 // 6
61 -67 -67 -67 -67 -67 -67 -67 -67 -67 -67 -67 -67 -67 -67 -68                 // =
68 -124 -124 -124 -124 -124 -124 -124 -124 -124 -124 -124 -124 -124 -124 -126  // D
75 75 75 75 75 75 75 75 75 75 75 75 75 75 75 72                                // K
82 18 18 18 18 18 18 18 18 18 18 18 18 18 18 14                                // R
88 -40 -40 -40 -40 -40 -40 -40 -40 -40 -40 -40 -40 -40 -40 -44                 // X
95 -97 -97 -97 -97 -97 -97 -97 -97 -97 -97 -97 -97 -97 -97 -102                // _
102 102 102 102 102 102 102 102 102 102 102 102 102 102 102 102                // f

(주: 선두 바이트는 오른쪽에 주석으로 나열됨.) 첫 분할이 '0'이고 마지막 분할이 'f'이므로 모든 것이 좋다, 맞죠? 천천히요.

문제는 모든 데이터가 처음 2개 region과 마지막 region에 쌓여 "울퉁불퉁한"(그리고 가능하면 "핫") region 문제를 만들 것이라는 것이에요. 이유를 이해하려면 ASCII Table을 참조하세요. '0'은 바이트 48이고 'f'는 바이트 102지만, 값이 [0-9]와 [a-f]뿐이므로 이 키스페이스에는 절대 나타나지 않을 바이트 값(바이트 58~96)의 큰 간격이 있어요. 따라서 중간 region은 절대 사용되지 않아요. 이 예시 키스페이스에서 사전 분할을 동작하게 하려면 (내장 분할 메서드에 의존하지 않는) 사용자 정의 분할 정의가 필요해요.

교훈 1: 테이블 사전 분할은 일반적으로 모범 사례지만, 모든 region이 키스페이스에서 접근 가능하도록 사전 분할해야 해요. 이 예시는 hex 키 키스페이스의 문제를 보여줬지만, 어떤 키스페이스에서도 같은 문제가 발생할 수 있어요. 자신의 데이터를 알세요.

교훈 2: 일반적으로 권장되지는 않지만, 생성된 모든 region이 키스페이스에서 접근 가능하기만 하다면 hex 키(그리고 더 일반적으로 표시 가능한 데이터)는 사전 분할 테이블에서도 여전히 동작할 수 있어요.

이 예시를 마무리하기 위해, 다음은 hex 키에 적절한 분할을 사전 생성하는 방법의 예시예요.

public static boolean createTable(Admin admin, HTableDescriptor table, byte[][] splits)
throws IOException {
  try {
    admin.createTable( table, splits );
    return true;
  } catch (TableExistsException e) {
    logger.info("table " + table.getNameAsString() + " already exists");
    // the table already exists...
    return false;
  }
}

public static byte[][] getHexSplits(String startKey, String endKey, int numRegions) {
  byte[][] splits = new byte[numRegions-1][];
  BigInteger lowestKey = new BigInteger(startKey, 16);
  BigInteger highestKey = new BigInteger(endKey, 16);
  BigInteger range = highestKey.subtract(lowestKey);
  BigInteger regionIncrement = range.divide(BigInteger.valueOf(numRegions));
  lowestKey = lowestKey.add(regionIncrement);
  for(int i=0; i < numRegions-1;i++) {
    BigInteger key = lowestKey.add(regionIncrement.multiply(BigInteger.valueOf(i)));
    byte[] b = String.format("%016x", key).getBytes();
    splits[i] = b;
  }
  return splits;
}

버전 수

최대 버전 수

저장할 최대 행 버전 수는 HColumnDescriptor를 통해 column family별로 구성돼요. 최대 버전의 기본값은 1이에요. Data Model 섹션에 설명된 대로 HBase는 행 값을 덮어쓰지 않고 시간(및 qualifier)별로 행당 다른 값을 저장하므로 이것은 중요한 파라미터예요. 초과 버전은 메이저 컴팩션 중 제거돼요. 최대 버전 수는 애플리케이션 필요에 따라 늘리거나 줄여야 할 수 있어요.

옛 값을 아주 소중하게 여기지 않는 한 최대 버전 수를 과도하게 높게(예: 수백 이상) 설정하는 것은 권장되지 않아요. StoreFile 크기를 크게 늘리기 때문이에요.

최소 버전 수

최대 행 버전 수와 마찬가지로 유지할 최소 행 버전 수는 ColumnFamilyDescriptorBuilder를 통해 column family별로 구성돼요. 최소 버전의 기본값은 0이며, 이는 기능이 비활성화됨을 의미해요. 최소 행 버전 수 파라미터는 time-to-live 파라미터와 함께 사용되며, 행 버전 수 파라미터와 결합해 "마지막 T분치 데이터를 최대 N 버전까지, 단 최소 M 버전은 유지" 같은 구성을 허용할 수 있어요(M은 최소 행 버전 수 값이고 M<N). 이 파라미터는 column family에 대해 time-to-live가 활성화된 경우에만 설정해야 하며 행 버전 수보다 작아야 해요.

지원되는 데이터 타입

HBase는 Put과 Result를 통해 "bytes-in/bytes-out" 인터페이스를 지원하므로, 바이트 배열로 변환될 수 있는 것은 무엇이든 값으로 저장할 수 있어요. 입력은 문자열, 숫자, 복잡한 객체, 심지어 바이트로 렌더링될 수 있기만 하면 이미지일 수도 있어요.

값 크기에는 실질적인 한계가 있어요(예: 10-50MB 객체를 HBase에 저장하는 것은 너무 무리일 거예요). 이 주제에 대한 대화는 메일링 리스트를 검색하세요. HBase의 모든 행은 Data Model을 따르며, 여기에는 버전 관리가 포함돼요. 설계할 때 이것과 ColumnFamily의 블록 크기를 고려하세요.

카운터

특별히 언급할 가치가 있는 지원 데이터 타입은 "카운터"(즉 숫자의 원자적 증가 능력)예요. Table의 Increment를 참고하세요.

카운터에 대한 동기화는 클라이언트가 아니라 RegionServer에서 수행돼요.

조인

여러 테이블이 있다면 스키마 설계에 Joins의 가능성을 포함시키는 것을 잊지 마세요.

TTL(Time To Live)

ColumnFamilies는 TTL 길이를 초 단위로 설정할 수 있고, HBase는 만료 시간에 도달하면 행을 자동으로 삭제해요. 이것은 행의 모든 버전 — 현재 버전조차 — 에 적용돼요. HBase에서 행에 인코딩되는 TTL 시간은 UTC로 지정돼요.

만료된 행만 포함하는 store 파일은 마이너 컴팩션 시 삭제돼요. hbase.store.delete.expired.storefile을 false로 설정하면 이 기능이 비활성화돼요. 최소 버전 수를 0이 아닌 다른 값으로 설정해도 비활성화돼요.

자세한 내용은 HColumnDescriptor를 참고하세요.

최근 HBase 버전은 per cell 기준 TTL 설정도 지원해요. 자세한 내용은 HBASE-10560을 참고하세요. Cell TTL은 Mutation#setTTL을 사용해 mutation 요청(Appends, Increments, Puts 등)의 속성으로 제출돼요. TTL 속성이 설정되면 연산에 의해 서버에서 업데이트되는 모든 셀에 적용돼요. cell TTL 처리와 ColumnFamily TTL에는 두 가지 주목할 차이점이 있어요.

  • Cell TTL은 초가 아니라 밀리초 단위로 표현돼요.
  • Cell TTL은 ColumnFamily 수준 TTL 설정 이상으로 셀의 유효 수명을 연장할 수 없어요.

삭제된 셀 유지

기본적으로 삭제 마커는 시간의 시작까지 확장돼요. 따라서 Get 또는 Scan 연산은 Get 또는 Scan 연산이 삭제 마커가 놓이기 전의 시간 범위를 나타내더라도 삭제된 셀(행 또는 열)을 보지 못해요.

ColumnFamilies는 선택적으로 삭제된 셀을 유지할 수 있어요. 이 경우 해당 셀에 영향을 주는 어떤 삭제의 타임스탬프보다 먼저 끝나는 시간 범위를 지정하는 한 삭제된 셀을 여전히 검색할 수 있어요. 이것은 삭제가 있어도 특정 시점(point-in-time) 쿼리를 허용해요.

삭제된 셀은 여전히 TTL의 대상이며 "최대 버전 수" 이상의 삭제된 셀은 결코 없을 거예요. 새로운 "raw" 스캔 옵션은 모든 삭제된 행과 삭제 마커를 반환해요.

HBase Shell로 KEEP_DELETED_CELLS 값 변경:

hbase> hbase> alter 't1', NAME => 'f1', KEEP_DELETED_CELLS => true

API로 KEEP_DELETED_CELLS 값 변경:

...
HColumnDescriptor.setKeepDeletedCells(true);
...

테이블에 KEEP_DELETED_CELLS 속성을 설정하는 기본 효과를 설명하겠어요.

먼저, 없을 때:

create 'test', {NAME=>'e', VERSIONS=>2147483647}
put 'test', 'r1', 'e:c1', 'value', 10
put 'test', 'r1', 'e:c1', 'value', 12
put 'test', 'r1', 'e:c1', 'value', 14
delete 'test', 'r1', 'e:c1',  11

hbase(main):017:0> scan 'test', {RAW=>true, VERSIONS=>1000}
ROW                                              COLUMN+CELL
 r1                                              column=e:c1, timestamp=14, value=value
 r1                                              column=e:c1, timestamp=12, value=value
 r1                                              column=e:c1, timestamp=11, type=DeleteColumn
 r1                                              column=e:c1, timestamp=10, value=value
1 row(s) in 0.0120 seconds

hbase(main):018:0> flush 'test'
0 row(s) in 0.0350 seconds

hbase(main):019:0> scan 'test', {RAW=>true, VERSIONS=>1000}
ROW                                              COLUMN+CELL
 r1                                              column=e:c1, timestamp=14, value=value
 r1                                              column=e:c1, timestamp=12, value=value
 r1                                              column=e:c1, timestamp=11, type=DeleteColumn
1 row(s) in 0.0120 seconds

hbase(main):020:0> major_compact 'test'
0 row(s) in 0.0260 seconds

hbase(main):021:0> scan 'test', {RAW=>true, VERSIONS=>1000}
ROW                                              COLUMN+CELL
 r1                                              column=e:c1, timestamp=14, value=value
 r1                                              column=e:c1, timestamp=12, value=value
1 row(s) in 0.0120 seconds

삭제 셀이 어떻게 버려지는지 주목하세요.

이제 테이블(테이블 또는 per-column-family로 가능)에 KEEP_DELETED_CELLS를 설정하고 같은 테스트를 실행해 볼게요.

hbase(main):005:0> create 'test', {NAME=>'e', VERSIONS=>2147483647, KEEP_DELETED_CELLS => true}
0 row(s) in 0.2160 seconds

=> Hbase::Table - test
hbase(main):006:0> put 'test', 'r1', 'e:c1', 'value', 10
0 row(s) in 0.1070 seconds

hbase(main):007:0> put 'test', 'r1', 'e:c1', 'value', 12
0 row(s) in 0.0140 seconds

hbase(main):008:0> put 'test', 'r1', 'e:c1', 'value', 14
0 row(s) in 0.0160 seconds

hbase(main):009:0> delete 'test', 'r1', 'e:c1',  11
0 row(s) in 0.0290 seconds

hbase(main):010:0> scan 'test', {RAW=>true, VERSIONS=>1000}
ROW                                                                                          COLUMN+CELL
 r1                                                                                          column=e:c1, timestamp=14, value=value
 r1                                                                                          column=e:c1, timestamp=12, value=value
 r1                                                                                          column=e:c1, timestamp=11, type=DeleteColumn
 r1                                                                                          column=e:c1, timestamp=10, value=value
1 row(s) in 0.0550 seconds

hbase(main):011:0> flush 'test'
0 row(s) in 0.2780 seconds

hbase(main):012:0> scan 'test', {RAW=>true, VERSIONS=>1000}
ROW                                                                                          COLUMN+CELL
 r1                                                                                          column=e:c1, timestamp=14, value=value
 r1                                                                                          column=e:c1, timestamp=12, value=value
 r1                                                                                          column=e:c1, timestamp=11, type=DeleteColumn
 r1                                                                                          column=e:c1, timestamp=10, value=value
1 row(s) in 0.0620 seconds

hbase(main):013:0> major_compact 'test'
0 row(s) in 0.0530 seconds

hbase(main):014:0> scan 'test', {RAW=>true, VERSIONS=>1000}
ROW                                                                                          COLUMN+CELL
 r1                                                                                          column=e:c1, timestamp=14, value=value
 r1                                                                                          column=e:c1, timestamp=12, value=value
 r1                                                                                          column=e:c1, timestamp=11, type=DeleteColumn
 r1                                                                                          column=e:c1, timestamp=10, value=value
1 row(s) in 0.0650 seconds

KEEP_DELETED_CELLS는 유일한 제거 이유가 삭제 마커일 때 HBase에서 Cells를 제거하지 않도록 하는 것입니다. 따라서 KEEP_DELETED_CELLS가 활성화되면 구성된 최대보다 많은 버전을 쓰거나 TTL이 있고 Cells가 구성된 타임아웃을 초과하는 등의 경우에만 삭제된 셀이 제거돼요.

보조 인덱스와 대체 쿼리 경로

이 섹션은 "테이블 rowkey가 이렇게 생겼지만 테이블을 저렇게 쿼리하고 싶다면?"이라는 제목일 수도 있어요. dist-list에서 흔한 예는 row-key 형식이 "user-timestamp"인데 특정 시간 범위에 걸친 사용자들의 활동에 대한 리포팅 요구가 있는 경우예요. 따라서 사용자로 선택하는 것은 키의 선두 위치에 있어 쉽지만, 시간은 쉽지 않아요.

가장 좋은 처리 방법에 대한 단일 답은 없어요. 다음에 달려 있기 때문이에요.

  • 사용자 수
  • 데이터 크기와 데이터 도착율
  • 리포팅 요구의 유연성(예: 완전 ad-hoc 날짜 선택 vs 사전 구성된 범위)
  • 원하는 쿼리 실행 속도(예: 어떤 사람에게는 ad-hoc 보고서에 90초가 합리적일 수 있지만 다른 사람에게는 너무 길 수 있음)

솔루션은 클러스터 크기와 솔루션에 투입할 수 있는 처리 능력의 영향도 받아요. 일반적인 기법은 아래 하위 섹션에 있어요. 이것은 접근 방식의 포괄적이지만 완전하지는 않은 목록이에요.

보조 인덱스가 추가 클러스터 공간과 처리를 요구하는 것은 놀랍지 않아요. RDBMS에서도 대체 인덱스 만드는 행위가 공간과 처리 주기가 모두 필요하므로 정확히 그런 일이 일어나요. RDBMS 제품은 이 점에서 대체 인덱스 관리를 기본으로 처리하는 데 더 진보했어요. 그러나 HBase는 더 큰 데이터 볼륨에서 더 잘 확장되므로, 이것은 기능 트레이드오프예요.

이 접근 방식 중 어떤 것을 구현할 때 Apache HBase Performance Tuning에 주의를 기울이세요.

또한 dist-list 스레드 HBase, mail # user - Stargate+hbase의 David Butler 응답을 참고하세요.

필터 쿼리

경우에 따라 Client Request Filters를 사용하는 것이 적절할 수 있어요. 이 경우 보조 인덱스는 만들어지지 않아요. 그러나 애플리케이션(즉 단일 스레드 클라이언트)에서 이렇게 큰 테이블에 대해 전체 스캔을 시도하지 마세요.

주기적 업데이트 보조 인덱스

보조 인덱스는 MapReduce 작업으로 주기적으로 업데이트되는 다른 테이블에 만들 수 있어요. 작업은 하루 중에 실행될 수 있지만, 로드 전략에 따라 여전히 기본 데이터 테이블과 동기화되지 않을 수 있어요.

자세한 내용은 mapreduce.example.readwrite를 참고하세요.

이중 쓰기 보조 인덱스

다른 전략은 클러스터에 데이터를 게시하면서 보조 인덱스를 구축하는 것이에요(예: 데이터 테이블에 쓰기, 인덱스 테이블에 쓰기). 데이터 테이블이 이미 존재한 후 이 접근을 취한다면, MapReduce 작업으로 보조 인덱스를 위한 부트스트래핑이 필요할 거예요(secondary.indexes.periodic 참고).

요약 테이블

시간 범위가 매우 넓고(예: 1년 보고서) 데이터가 방대한 경우 요약 테이블이 일반적인 접근이에요. 이들은 MapReduce 작업으로 다른 테이블에 생성될 거예요.

자세한 내용은 mapreduce.example.summary를 참고하세요.

Coprocessor 보조 인덱스

Coprocessor는 RDBMS 트리거처럼 동작해요. 이것들은 0.92에서 추가됐어요. 자세한 내용은 coprocessors를 참고하세요.

제약

HBase는 현재 전통적인(SQL) 데이터베이스 의미의 '제약(constraints)'을 지원해요. 제약의 권장 용법은 테이블 속성에 대한 비즈니스 규칙을 강제하는 것입니다(예: 값이 1-10 범위에 있는지 확인). 제약은 참조 무결성을 강제하는 데도 사용될 수 있지만, 무결성 확인이 활성화된 테이블의 쓰기 처리량을 극적으로 감소시킬 것이므로 강력히 권장되지 않아요. 제약 사용에 대한 광범위한 문서는 0.94 버전부터 Constraint에서 찾을 수 있어요.

스키마 설계 사례 연구

다음은 HBase를 사용한 일반적인 데이터 수집 사용 사례와 rowkey 설계·구축에 접근하는 방법을 설명해요. 주의: 이것은 잠재적 접근 방식의 예시일 뿐 완전한 목록이 아니에요. 자신의 데이터와 처리 요구를 아세요.

이 사례 연구들을 읽기 전에 먼저 나머지 HBase and Schema Design을 읽는 것을 강력히 권장해요.

설명되는 사례 연구:

  • 로그 데이터 / 시계열 데이터
  • 로그 데이터 / 강화된 시계열(Timeseries on Steroids)
  • 고객/주문
  • Tall/Wide/Middle 스키마 설계
  • 리스트 데이터

사례 연구 - 로그 데이터와 시계열 데이터

다음 데이터 요소가 수집되고 있다고 가정해요.

  • Hostname
  • Timestamp
  • Log event
  • Value/message

이를 LOG_DATA라는 HBase 테이블에 저장할 수 있는데, rowkey는 무엇일까요? 이 속성들로부터 rowkey는 hostname, timestamp, log-event의 어떤 조합이 될 거지만 — 구체적으로 무엇일까요?

rowkey 선두 위치의 Timestamp

rowkey [timestamp][hostname][log-event]는 Monotonically Increasing Row Keys/Timeseries Data에 설명된 단조 증가 rowkey 문제를 겪어요.

dist-lists에서 timestamp에 mod 연산을 수행해 타임스탬프를 "버케팅(bucketing)"하는 또 다른 패턴이 자주 언급돼요. 시간 중심 스캔이 중요하다면 이것이 유용한 접근일 수 있어요. 버킷 수에 주의해야 하는데, 결과를 반환하려면 같은 수의 스캔이 필요할 거예요.

long bucket = timestamp % numBuckets;

구성하려면:

[bucket][timestamp][hostname][log-event]

위에서 언급했듯이 특정 시간 범위의 데이터를 선택하려면 각 버킷에 대해 Scan을 수행해야 해요. 예를 들어 100개 버킷은 키스페이스에서 넓은 분산을 제공하지만 단일 타임스탬프의 데이터를 얻으려면 100번의 Scan이 필요하므로 트레이드오프가 있어요.

rowkey 선두 위치의 Host

rowkey [hostname][log-event][timestamp]는 쓰기와 읽기를 키스페이스에 분산시킬 만큼 충분히 많은 수의 호스트가 있다면 후보예요. hostname으로 스캔하는 것이 우선순위라면이 접근이 유용할 거예요.

Timestamp인가, Reverse Timestamp인가?

가장 중요한 접근 경로가 최근 이벤트를 가져오는 것이라면 타임스탬프를 역순 타임스탬프(예: timestamp = Long.MAX_VALUE – timestamp)로 저장하면 [hostname][log-event]에 대해 Scan을 수행해 가장 최근에 캡처된 이벤트를 얻을 수 있는 속성이 생겨요.

어느 접근도 틀리지 않아요. 상황에 가장 적절한 것에 달려 있어요.

HBASE-4811은 테이블 또는 테이블 내 범위를 역순으로 스캔하는 API를 구현해서 정방향 또는 역방향 스캔을 위해 스키마를 최적화할 필요를 줄였어요. 이 기능은 HBase 0.98 이상에서 사용할 수 있어요. 자세한 내용은 Scan.setReversed()를 참고하세요.

가변 길이 또는 고정 길이 Rowkeys?

HBase에서 rowkey는 모든 열에 스탬프된다는 것을 기억하는 것이 중요해요. hostname이 a이고 이벤트 타입이 e1이면 결과 rowkey는 꽤 작아질 거예요. 그러나 수집된 hostname이 myserver1.mycompany.com이고 이벤트 타입이 com.package1.subpackage2.subsubpackage3.ImportantService라면 어떨까요?

rowkey에 어떤 대체를 사용하는 것이 말이 될 수 있어요. 적어도 두 가지 접근이 있어요: 해시와 숫자. Hostname In The Rowkey Lead Position 예시에서 다음과 같이 보일 수 있어요.

해시가 있는 복합 Rowkey:

  • [hostname의 MD5 해시] = 16 bytes
  • [event-type의 MD5 해시] = 16 bytes
  • [timestamp] = 8 bytes

숫자 대체가 있는 복합 Rowkey:

이 접근을 위해 LOG_DATA 외에 LOG_TYPES라는 조회 테이블이 더 필요할 거예요. LOG_TYPES의 rowkey는:

  • [type] (예: hostname 대 event-type을 나타내는 byte)
  • [bytes] 원본 hostname 또는 event-type을 위한 가변 길이 바이트.

이 rowkey에 대한 열은 HBase counter를 사용해 얻을 수 있는 할당된 번호가 있는 long일 수 있어요.

따라서 결과 복합 rowkey는:

  • [hostname 대체 long] = 8 bytes
  • [event type 대체 long] = 8 bytes
  • [timestamp] = 8 bytes

Hash 또는 Numeric 대체 접근 어느 쪽이든 hostname과 event-type의 원본 값은 열로 저장될 수 있어요.

사례 연구 - 강화된 로그 데이터와 시계열 데이터

이것은 사실상 OpenTSDB 접근이에요. OpenTSDB가 하는 것은 특정 시간 기간에 대해 데이터를 다시 쓰고 행을 열로 패킹하는 것이에요. 자세한 설명은 http://opentsdb.net/schema.html과 HBaseCon2012의 Lessons Learned from OpenTSDB를 참고하세요.

그러나 일반적인 개념이 동작하는 방식은 이래요. 데이터는 예를 들어 이런 방식으로 수집돼요.

[hostname][log-event][timestamp1]
[hostname][log-event][timestamp2]
[hostname][log-event][timestamp3]

각 상세 이벤트에 대해 별도 rowkey를 가지지만, 이렇게 다시 쓰여져요.

[hostname][log-event][timerange]

그리고 위 각 이벤트는 시작 timerange에 상대적인 시간 오프셋(예: 5분마다)으로 저장된 열로 변환돼요. 이것은 분명히 아주 고급 처리 기법이지만 HBase가 이것을 가능하게 해요.

사례 연구 - 고객/주문

HBase가 고객과 주문 정보를 저장하는 데 사용된다고 가정해요. 수집되는 두 가지 핵심 레코드 타입이 있어요: Customer 레코드 타입과 Order 레코드 타입.

Customer 레코드 타입은 보통 기대하는 것들을 포함할 거예요.

  • Customer number
  • Customer name
  • Address (예: city, state, zip)
  • Phone numbers 등

Order 레코드 타입은 다음 같은 것들을 포함할 거예요.

  • Customer number
  • Order number
  • Sales date
  • 배송 위치와 라인 항목을 위한 일련의 중첩 객체(Order Object Design 참고)

customer number와 sales order의 조합이 주문을 고유하게 식별한다고 가정하면, 이 두 속성이 rowkey를 구성할 거고, 특히 다음과 같은 복합 키예요.

[customer number][order number]

ORDER 테이블용으로요. 그러나 더 많은 설계 결정이 있어요: 원본 값이 rowkey의 최선의 선택인가요?

로그 데이터 사용 사례와 같은 설계 질문이 여기서도 나타나요. customer number의 키스페이스는 무엇이고 형식은 무엇인가요(예: 숫자? 영숫자?) HBase에서 고정 길이 키와 키스페이스에서 합리적인 분산을 지원할 수 있는 키를 사용하는 것이 유리하므로, 비슷한 옵션이 나타나요.

해시가 있는 복합 Rowkey:

  • [customer number의 MD5] = 16 bytes
  • [order number의 MD5] = 16 bytes

숫자/해시 콤보 복합 Rowkey:

  • [customer number 대체 long] = 8 bytes
  • [order number의 MD5] = 16 bytes
단일 테이블? 다중 테이블?

전통적 설계 접근은 CUSTOMER와 SALES를 위한 별도 테이블을 가지는 것이에요. 또 다른 옵션은 단일 테이블(예: CUSTOMER++)에 여러 레코드 타입을 패킹하는 것이에요.

Customer 레코드 타입 Rowkey:

  • [customer-id]
  • [type] = customer 레코드 타입을 나타내는 1

Order 레코드 타입 Rowkey:

  • [customer-id]
  • [type] = order 레코드 타입을 나타내는 2
  • [order]

이 특정 CUSTOMER++ 접근의 장점은 customer-id별로 여러 레코드 타입을 구성해서(예: 단일 스캔으로 그 고객에 대한 모든 것을 얻을 수 있음) 많은 것을 정리한다는 것이에요. 단점은 특정 레코드 타입을 스캔하는 것이 그렇게 쉽지 않다는 것이에요.

Order 객체 설계

이제 Order 객체를 어떻게 모델링할지 다뤄야 해요. 클래스 구조가 다음과 같다고 가정해요.

Order

Order는 여러 ShippingLocations를 가질 수 있음

LineItem

ShippingLocation은 여러 LineItems를 가질 수 있음

이 데이터를 저장하는 여러 옵션이 있어요.

완전 정규화

이 접근으로는 ORDER, SHIPPING_LOCATION, LINE_ITEM을 위한 별도 테이블이 있을 거예요.

ORDER 테이블의 rowkey는 위에서 설명했어요: schema.casestudies.custorder

SHIPPING_LOCATION의 복합 rowkey는 대략 이렇게 될 거예요.

  • [order-rowkey]
  • [shipping location number] (예: 1번째 위치, 2번째 등)

LINE_ITEM 테이블의 복합 rowkey는 대략 이렇게 될 거예요.

  • [order-rowkey]
  • [shipping location number] (예: 1번째 위치, 2번째 등)
  • [line item number] (예: 1번째 lineitem, 2번째 등)

이런 정규화 모델은 RDBMS에서 하게 될 접근일 가능성이 높지만, 그것이 HBase에서의 유일한 옵션은 아니에요. 이 접근의 단점은 Order에 대한 정보를 검색하려면 다음이 필요하다는 것이에요.

  • Order에 대한 ORDER 테이블에 Get
  • ShippingLocation 인스턴스를 얻기 위해 그 order에 대한 SHIPPING_LOCATION 테이블에 Scan
  • 각 ShippingLocation에 대한 LINE_ITEM에 Scan

확실히 이것은 RDBMS가 내부에서 어차피 할 것이지만, HBase에는 조인이 없어서 이 사실을 더 의식하게 돼요.

레코드 타입이 있는 단일 테이블

이 접근으로는 다음을 포함하는 ORDER라는 단일 테이블이 존재할 거예요.

Order rowkey는 위에서 설명했어요: schema.casestudies.custorder

  • [order-rowkey]
  • [ORDER record type]

ShippingLocation 복합 rowkey는 대략 이렇게 될 거예요.

  • [order-rowkey]
  • [SHIPPING record type]
  • [shipping location number] (예: 1번째 위치, 2번째 등)

LineItem 복합 rowkey는 대략 이렇게 될 거예요.

  • [order-rowkey]
  • [LINE record type]
  • [shipping location number] (예: 1번째 위치, 2번째 등)
  • [line item number] (예: 1번째 lineitem, 2번째 등)
비정규화

Single Table With Record Types 접근의 변형은 객체 계층의 일부를 비정규화하고 평평하게 하는 것, 예를 들어 ShippingLocation 속성을 각 LineItem 인스턴스에 붙이는 것이에요.

LineItem 복합 rowkey는 대략 이렇게 될 거예요.

  • [order-rowkey]
  • [LINE record type]
  • [line item number] (예: 1번째 lineitem, 2번째 등. 전체 order에 걸쳐 고유하도록 주의해야 함)

LineItem 열은 대략 이렇게 될 거예요.

  • itemNumber
  • quantity
  • price
  • shipToLine1 (ShippingLocation에서 비정규화)
  • shipToLine2 (ShippingLocation에서 비정규화)
  • shipToCity (ShippingLocation에서 비정규화)
  • shipToState (ShippingLocation에서 비정규화)
  • shipToZip (ShippingLocation에서 비정규화)

이 접근의 장점은 덜 복잡한 객체 계층이지만, 단점 중 하나는 이 정보 중 어떤 것이 변경되면 업데이트가 더 복잡해진다는 것이에요.

객체 BLOB

이 접근으로는 전체 Order 객체 그래프가 어떤 식으로든 BLOB로 취급돼요. 예를 들어 ORDER 테이블의 rowkey는 위에서 설명했고, "order"라는 단일 열이 Order, ShippingLocations, LineItems의 컨테이너를 포함하는 역직렬화 가능한 객체를 포함할 거예요.

여기에는 많은 옵션이 있어요: JSON, XML, Java Serialization, Avro, Hadoop Writables 등. 모두 같은 접근의 변형이에요: 객체 그래프를 바이트 배열로 인코딩하는 것. 이 접근에서는 객체 모델이 변경되어도 옛 영속화된 구조를 여전히 HBase에서 읽을 수 있도록 하위 호환성을 보장하는 데 주의해야 해요.

장점은 최소 I/O로 복잡한 객체 그래프를 관리할 수 있는 것이지만(예: 이 예시에서 Order당 단일 HBase Get), 단점은 직렬화의 하위 호환성에 대한 앞서 언급한 경고, 직렬화의 언어 의존성(예: Java Serialization은 Java 클라이언트에서만 동작), BLOB 안의 어떤 정보 조각이든 얻기 위해 전체 객체를 역직렬화해야 한다는 사실, 그리고 Hive 같은 프레임워크를 이렇게 사용자 지정 객체와 함께 동작하게 하는 어려움이에요.

사례 연구 - "Tall/Wide/Middle" 스키마 설계 Smackdown

이 섹션은 dist-list에 나타나는 추가 스키마 설계 질문, 특히 tall과 wide 테이블에 대해 설명해요. 이들은 일반 지침이지 법칙이 아니에요. 각 애플리케이션은 자신의 필요를 고려해야 해요.

Rows vs. Versions

흔한 질문은 행을 선호해야 하는지 HBase의 내장 버전 관리를 선호해야 하는지예요. 컨텍스트는 보통 "많은" 버전의 행을 유지해야 하는 경우(예: HBase 기본값 1 최대 버전보다 상당히 높은 경우)예요. rows 접근은 각 연속 업데이트로 덮어쓰지 않도록 rowkey의 일부 부분에 타임스탬프를 저장해야 해요.

선호: Rows(일반적으로).

Rows vs. Columns

또 다른 흔한 질문은 행을 선호해야 하는지 열을 선호해야 하는지예요. 컨텍스트는 보통 wide 테이블의 극단적인 경우, 예를 들어 1백만 속성을 가진 1개 행 또는 각각 1열을 가진 1백만 행이에요.

선호: Rows(일반적으로). 명확히 하면, 이 지침의 컨텍스트는 수십 또는 수백 개의 열을 저장해야 하는 표준 사용 사례가 아닌 극도로 넓은 경우예요. 그러나 이 두 옵션 사이에는 중간 경로도 있는데, 그것이 "Rows as Columns"예요.

Rows as Columns

Rows vs. Columns 사이의 중간 경로는 별도의 행이 될 데이터를 특정 행에 대해 열로 패킹하는 것이에요. OpenTSDB는 단일 행이 정의된 시간 범위를 나타내고 개별 이벤트가 열로 취급되는 이 경우의 가장 좋은 예시예요. 이 접근은 종종 더 복잡하고 데이터를 다시 쓰는 추가 복잡성이 필요할 수 있지만 I/O 효율적이라는 장점이 있어요. 이 접근의 개요는 schema.casestudies.log-steroids를 참고하세요.

사례 연구 - 리스트 데이터

다음은 Apache HBase에서 사용자별 리스트 데이터를 처리하는 방법에 대한 꽤 흔한 질문에 대한 user dist-list의 교환이에요.

  • 질문 *

HBase에 (사용자별) 대량의 리스트 데이터를 저장하는 방법을 찾고 있는데, 어떤 종류의 접근 패턴이 가장 말이 되는지 알아내려고 해요. 한 옵션은 데이터의 대부분을 키에 저장하는 것이라서 이런 걸 가질 수 있어요.

<FixedWidthUserName><FixedWidthValueId1>:"" (no value)
<FixedWidthUserName><FixedWidthValueId2>:"" (no value)
<FixedWidthUserName><FixedWidthValueId3>:"" (no value)

가진 다른 옵션은 이것을 전적으로 다음을 사용해 하는 것이었어요.

<FixedWidthUserName><FixedWidthPageNum0>:<FixedWidthLength><FixedIdNextPageNum><ValueId1><ValueId2><ValueId3>...
<FixedWidthUserName><FixedWidthPageNum1>:<FixedWidthLength><FixedIdNextPageNum><ValueId1><ValueId2><ValueId3>...

각 행이 여러 값을 포함하는 경우에요. 그래서 한 경우에는 처음 30개 값을 읽는 것이:

scan { STARTROW => 'FixedWidthUsername' LIMIT => 30}

다른 경우에는:

get 'FixedWidthUserName\x00\x00\x00\x00'

일반적인 사용 패턴은 이 리스트의 처음 30개 값만 읽는 것이고, 리스트 깊숙이 읽는 것은 드물어요. 어떤 사용자는 이 리스트에 총 30개 이하의 값을 가질 것이고, 어떤 사용자는 수백만 개(즉 power-law 분포)를 가질 거예요.

단일 값 형식은 HBase에서 더 많은 공간을 차지할 것처럼 보이지만, 일부 개선된 검색/페이지네이션 유연성을 제공할 거예요. gets로 페이지네이션하는 것과 scans로 페이지네이션하는 것 사이에 상당한 성능 이점이 있을까요?

내 초기 이해는 페이지 크기를 모르는 경우 스캔이 더 빨라야 하고(캐싱이 적절히 설정된다면), 항상 같은 페이지 크기가 필요하다면 gets가 더 빨라야 한다는 것이었어요. 결국 서로 반대되는 말을 하는 사람들을 듣게 됐어요. 페이지 크기가 상대적으로 일관될 것이라고 가정하므로, 대부분의 사용 사례에서 고정 페이지 길이 경우에 데이터 페이지 하나만 원한다는 것을 보장할 수 있을 거예요. 또한 드문 업데이트가 있을 것이라고 가정하지만, 리스트 중간에 삽입이 있을 수 있다는 점(즉 모든 후속 행을 업데이트해야 함을 의미)도 가정해요.

도움/제안/후속 질문 감사합니다.

  • 답변 *

제대로 이해했다면, 궁극적으로 "user, valueid, value" 형식으로 트리플을 저장하려는 거죠? 예를 들어 이런 것:

"user123, firstname, Paul",
"user234, lastname, Smith"

(하지만 사용자 이름은 고정 폭이고 valueid는 고정 폭이에요).

그리고 접근 패턴은 "사용자 X에 대해 valueid Y에서 시작해 다음 30개 값을 나열"과 같은 형식이에요. 맞나요? 그리고 이 값들은 valueid로 정렬되어 반환되어야 하나요?

tl;dr 버전은 사용자+값당 하나의 행을 택하고, 정말 필요하다고 확신하지 않는 한 복잡한 행 내 페이지네이션 체계를 직접 구축하지 않아야 한다는 것이에요.

당신의 두 옵션은 HBase 스키마를 설계할 때 사람들이 가지는 흔한 질문을 반영해요: "tall" 또는 "wide"로 가야 할까? 첫 번째 스키마는 "tall"이에요. 각 행이 한 사용자에 대한 한 값을 나타내므로 테이블에는 사용자당 많은 행이 있어요. row key는 user + valueid이고 "(그 값)"을 의미하는 단일 column qualifier가 (아마) 있을 거예요. 이것은 row key로 정렬된 순서로 행을 스캔하려는 경우 훌륭해요(그래서 이 id들이 올바르게 정렬되는지에 대한 위의 질문). 어떤 user+valueid에서든 스캔을 시작해 다음 30개를 읽고 끝낼 수 있어요. 포기하는 것은 한 사용자의 모든 행 주변의 트랜잭션 보장 능력이지만, 그게 필요해 보이지는 않아요. 이렇게 하는 것이 일반적으로 권장돼요(여기 참고).

두 번째 옵션은 "wide"예요. 한 행에 많은 값을 다른 qualifier(qualifier가 valueid인 곳)를 사용해 저장해요. 그렇게 하는 간단한 방법은 한 사용자의 모든 값을 단일 행에 저장하는 것이에요. 한 행에 수백만 개의 열을 저장하는 것이 성능에 나쁠 것이라고 가정해서 "페이지네이션된" 버전으로 갔을 것 같아요. 사실일 수도 있고 아닐 수도 있어요. 단일 요청에서 너무 많이 하려 하거나 행의 모든 셀을 스캔하고 반환하려 하지 않는 한 근본적으로 더 나쁘지는 않아야 해요. 클라이언트에는 특정 열 조각을 얻을 수 있는 메서드가 있어요.

어느 경우도 근본적으로 다른 쪽보다 더 많은 디스크 공간을 사용하지는 않는다는 점에 유의하세요. 값의 식별 정보의 일부를 왼쪽(row key로, 옵션 1) 또는 오른쪽(column qualifiers로, 옵션 2)으로 "이동"시키는 것뿐이에요. 내부적으로 모든 키/값은 여전히 전체 row key와 column family 이름을 저장해요. (이것이 조금 혼란스럽다면 한 시간 내어 Lars George의 HBase 스키마 설계 이해에 대한 훌륭한 비디오를 보세요: http://www.youtube.com/watch?v=_HLoH_PgrLk).

수동 페이지네이션 버전은 당신이 지적한 대로 훨씬 더 많은 복잡성을 가져요. 각 페이지에 몇 개가 있는지 추적해야 하고, 새 값이 삽입되면 재셔플해야 하는 등요. 그것은 상당히 더 복잡해 보여요. 극도로 높은 처리량에서 약간의 속도 이점(또는 불리점!)이 있을 수 있고, 정말로 알려면 시도해 봐야 해요. 양쪽 모두 구축해 비교할 시간이 없다면, 가장 단순한 옵션(사용자+값당 하나의 행)으로 시작하는 것을 조언해요. 단순하게 시작하고 반복하세요! :)

운영 및 성능 구성 옵션

HBase 서버 RPC 처리 튜닝

  • 동시성을 위해 hbase.regionserver.handler.count( hbase-site.xml에서)를 cores x spindles로 설정하세요.
  • 선택적으로 차별화된 서비스를 위해 call 큐를 별도의 읽기 및 쓰기 큐로 분할하세요. 파라미터 hbase.ipc.server.callqueue.handler.factor가 call 큐 수를 지정해요.
    • 0은 단일 공유 큐
    • 1은 각 핸들러에 하나의 큐.
    • 0과 1 사이의 값은 수를 핸들러 수에 비례해 할당해요. 예를 들어 .5 값은 각 두 핸들러 사이에서 하나의 큐를 공유해요.
  • hbase.ipc.server.callqueue.read.ratio(0.98에서는 hbase.ipc.server.callqueue.read.share)를 사용해 call 큐를 읽기 및 쓰기 큐로 분할하세요.
    • 0.5는 읽기와 쓰기 큐가 같은 수가 있음을 의미
    • < 0.5는 쓰기가 더 많음
    • > 0.5는 읽기가 더 많음
  • hbase.ipc.server.callqueue.scan.ratio(HBase 1.0+)를 설정해 읽기 call 큐를 소형 읽기와 장기 읽기 큐로 분할하세요.
    • 0.5는 소형 읽기와 장기 읽기 큐가 같은 수가 있음을 의미
    • < 0.5는 소형 읽기가 더 많음
    • > 0.5는 장기 읽기가 더 많음

RPC에 Nagle 비활성화

Nagle 알고리즘을 비활성화하세요. 지연 ACK는 RPC 왕복 시간에 최대 ~200ms를 추가할 수 있어요. 다음 파라미터를 설정하세요.

  • Hadoop의 core-site.xml:
    • ipc.server.tcpnodelay = true
    • ipc.client.tcpnodelay = true
  • HBase의 hbase-site.xml:
    • hbase.ipc.client.tcpnodelay = true
    • hbase.ipc.server.tcpnodelay = true

서버 장애 영향 제한

합리적으로 가능한 한 빨리 regionserver 장애를 감지하세요. 다음 파라미터를 설정하세요.

  • hbase-site.xml에서 zookeeper.session.timeout을 30초 이하로 설정해 장애 감지 범위를 묶으세요(20-30초가 좋은 시작점).
    • 참고: Zookeeper 클라이언트는 클라이언트 초기화 중 서버와 세션 타임아웃을 협상해요. 서버는 이 타임아웃을 [minSessionTimeout, maxSessionTimeout] 범위로 강제하고, 두 타임아웃(밀리초 단위)은 Zookeeper 서비스 구성에서 구성할 수 있어요. 구성되지 않으면 기본값은 각각 2 * tickTime과 20 * tickTime이에요(tickTime은 ZooKeeper가 사용하는 밀리초 단위의 기본 시간 단위이며 하트비트, 타임아웃 등을 조절하는 데 사용). 추가 세부사항은 Zookeeper 문서를 참조하세요.
  • 불건전하거나 실패한 HDFS DataNode 감지 및 회피: hdfs-site.xml과 hbase-site.xml에서 다음 파라미터를 설정하세요.
    • dfs.namenode.avoid.read.stale.datanode = true
    • dfs.namenode.avoid.write.stale.datanode = true

저지연을 위한 서버 측 최적화

RegionServer가 HDFS에서 읽을 때 HDFS의 Short-Circuit Local Reads 기능을 활용해 로컬 블록용 네트워크를 건너뛰세요. 설정은 datanode와 dfsclient(즉 RegionServer) 양쪽에서 수행되어야 하며, 양쪽이 hadoop 네이티브 .so 라이브러리를 로드해야 한다는 점에 유의하세요. hadoop 설정 dfs.client.read.shortcircuit를 true로 구성하고 datanode와 dfsclient가 공유할 dfs.domain.socket.path 경로를 구성한 후 재시작한 다음, regionserver/dfsclient 측을 구성하세요.

  • hbase-site.xml에서 다음 파라미터를 설정하세요.
    • dfs.client.read.shortcircuit = true
    • dfs.client.read.shortcircuit.skip.checksum = true — 이중 체크섬을 하지 않게 하려고(HBase는 i/o를 아끼기 위해 자체 체크섬을 수행).
    • dfs.domain.socket.path를 datanode에 설정된 것과 일치하게.
    • dfs.client.read.shortcircuit.buffer.size = 131072 — OOME를 피하는 데 중요. hbase는 설정되지 않으면 자체 기본값을 사용하며, hbase.dfs.client.read.shortcircuit.buffer.size 참고. 기본값은 131072.
  • 데이터 지역성을 보장하세요. hbase-site.xml에서 hbase.hstore.min.locality.to.skip.major.compact = 0.7을 설정하세요(0.7 <= n <= 1을 의미).
  • DataNode가 블록 전송을 위한 충분한 핸들러를 가지도록 하세요. hdfs-site.xml에서 다음 파라미터를 설정하세요.
    • dfs.datanode.max.xcievers >= 8192
    • dfs.datanode.handler.count = 스핀들 수

재시작 후 RegionServer 로그를 확인하세요. 잘못 구성된 경우에만 불만이 보여야 해요. 그렇지 않으면 shortcircuit read는 백그라운드에서 조용히 동작해요. 메트릭을 제공하지 않아 얼마나 효과적인지 감이 없지만, 특히 좋은 데이터 지역성, 많은 랜덤 읽기, 데이터셋이 사용 가능한 캐시보다 클 때 읽기 대기 시간이 뚜렷이 개선되어야 해요.

특히 shortcircuit 기능이 로그에서 불평하는 경우 시도할 다른 고급 구성으로는 dfs.client.read.shortcircuit.streams.cache.size와 dfs.client.socketcache.capacity가 있어요. 이 옵션들에 대한 문서는 부족해요. 소스 코드를 읽어야 해요.

RegionServer 메트릭 시스템은 HDFS short circuit 읽기 메트릭 shortCircuitBytesRead를 노출해요. totalBytesRead(HDFS에서 읽은 총 바이트 수), localBytesRead(로컬 HDFS DataNode에서 읽은 바이트 수), zeroCopyBytesRead(HDFS zero copy로 읽은 바이트 수)를 포함한 다른 HDFS 읽기 메트릭도 사용할 수 있고 short-circuit 읽기 문제를 해결하는 데 사용할 수 있어요.

short-circuit 읽기에 대한 자세한 내용은 Colin의 옛 롤아웃 블로그, How Improved Short-Circuit Local Reads Bring Better Performance and Security to Hadoop를 참고하세요. HDFS-347 이슈도 HDFS 커뮤니티의 최선을 보여주는 흥미로운 읽을거리예요(몇 가지 댓글 주의).

JVM 튜닝

낮은 컬렉션 대기 시간을 위한 JVM GC 튜닝
  • CMS 컬렉터 사용: -XX:+UseConcMarkSweepGC

  • 평균 컬렉션 시간을 최소화하려면 eden 공간을 가능한 한 작게 유지하세요. 예시:

    -XX:CMSInitiatingOccupancyFraction=70
    
  • 처리량보다 낮은 컬렉션 대기 시간에 최적화하세요: -Xmn512m

  • eden을 병렬로 수집: -XX:+UseParNewGC

  • 압력 하에서 수집 회피: -XX:+UseCMSInitiatingOccupancyOnly

  • 모든 것이 survivor 공간에 들어가지만 tenured 되지 않도록 요청당 스캐너 결과 크기를 제한하세요. hbase-site.xml에서 hbase.client.scanner.max.result.size를 eden 공간의 1/8로 설정하세요(-Xmn512m 사용 시 ~51MB).

  • max.result.size x handler.count를 survivor 공간보다 작게 설정하세요.

OS 수준 튜닝
  • transparent huge pages(THP) 끄기:

    echo never > /sys/kernel/mm/transparent_hugepage/enabled
    echo never > /sys/kernel/mm/transparent_hugepage/defrag
    
  • vm.swappiness = 0 설정

  • vm.min_free_kbytes를 최소 1GB(더 큰 메모리 시스템에서는 8GB) 설정

  • vm.zone_reclaim_mode = 0으로 NUMA zone 재확보 비활성화

특수한 경우

빨리 실패하는 것이 기다리는 것보다 나은 애플리케이션

  • 클라이언트 측 hbase-site.xml에서 다음 파라미터를 설정하세요.
    • hbase.client.pause = 1000 설정
    • hbase.client.retries.number = 3 설정
    • split과 region 이동을 넘어 타고 싶다면 hbase.client.retries.number를 상당히 늘리세요(>= 20)
    • RecoverableZookeeper 재시도 횟수 설정: zookeeper.recovery.retry = 1(재시도 없음)
  • 서버 측 hbase-site.xml에서 서버 장애 감지를 위한 Zookeeper 세션 타임아웃을 설정하세요: zookeeper.session.timeout ⇐ 30초(20-30이 좋음).

약간 오래된 정보를 허용할 수 있는 애플리케이션

HBase timeline consistency (HBASE-10070) 읽기 복제가 활성화되면 region의 읽기 전용 복사본(복제본)이 클러스터에 분산돼요. 하나의 RegionServer가 기본 또는 기본 복제본을 서비스하며, 그것은 쓰기를 서비스할 수 있는 유일한 복제본이에요. 다른 RegionServers는 보조 복제본을 서비스하고, 기본 RegionServer를 따르며 커밋된 업데이트만 봐요. 보조 복제본은 읽기 전용이지만, 기본이 페일오버하는 동안 읽기를 즉시 서비스할 수 있어서 읽기 가용성 끊킴을 초에서 밀리초로 줄여요. Phoenix는 4.4.0부터 timeline consistency를 지원해요.

팁:

  • HBase 1.0.0 이상을 배포하세요.
  • 서버 측에서 timeline consistent 복제본을 활성화하세요.
  • 다음 방법 중 하나로 timeline consistency를 설정하세요.
    • ALTER SESSION SET CONSISTENCY = 'TIMELINE' 사용
    • JDBC 연결 문자열에서 연결 프로퍼티 Consistency를 timeline으로 설정

더 많은 정보

Bloom Filters, Table-configured regionsizes, compression, blocksizes 같은 운영 및 성능 스키마 설계 옵션에 대한 자세한 내용은 Performance 섹션 perf.schema을 참고하세요.

더 알아보기 (Learn more)