동적 스키마 레코드 수집

동적 스키마 레코드 수집 (Ingest Records with Dynamic Schemas)

로그처럼 레코드마다 키가 달라지는 동적 데이터를, 고정 스키마 테이블에 안전하게 넣고 텍스트 검색까지 가능하게 만드는 SchemaConformingTransformer를 소개해요.

출처: Ingest Records with Dynamic Schemas

본문

일부 도메인(예: 로깅)에서는 각 레코드가 서로 다른 키 집합을 가질 수 있는데, Pinot 테이블은 상대적으로 고정된 스키마를 가집니다. 키가 다양하게 변하는 레코드에서 각 필드를 테이블 컬럼에 따로 저장하는 것은 비현실적입니다. 하지만 (전부는 아니더라도) 대부분의 필드가 중요할 수 있으므로 필드를 불필요하게 버려서는 안 됩니다.

또한 그런 테이블의 검색 패턴도 복잡해지고 자주 바뀔 수 있습니다. 기존 키나 새로 생긴 키·값에 대해 정확 일치, 범위 쿼리, 접두사/접미사 매치, 와일드카드 검색, 집계 함수를 사용할 수 있어야 합니다.

SchemaConformingTransformer

SchemaConformingTransformer 는 동적 스키마 레코드를 고정 스키마 테이블에 수집할 수 있게 변환하는 RecordTransformer 입니다. 이 트랜스포머는 스키마에 없는 레코드 필드를 가져와 일종의 catchall 필드에 저장합니다. 또한 __mergedTextIndex 필드를 만들어 Lucene을 활용해 텍스트 검색을 수행합니다.

예를 들어 다음 레코드를 봅시다:

{
  "arrayField":[0, 1, 2, 3],
  "stringField":"a",
  "intField_noIndex":9,
  "string_noIndex":"z",
  "message": "a",
  "mapField":{
    "arrayField":[0, 1, 2, 3],
    "stringField":"a",
    "intField_noIndex":9,
    "string_noIndex":"z"
  },
  "mapField_noIndex":{
    "arrayField":[0, 1, 2, 3],
    "stringField":"a",
  },
  "nestedFields":{
    "arrayField":[0, 1, 2, 3],
    "stringField":"a",
    "intField_noIndex":9,
    "string_noIndex":"z",
    "mapField":{
      "arrayField":[0, 1, 2, 3],
      "stringField":"a",
      "intField_noIndex":9,
      "string_noIndex":"z"
    }
  }
}

테이블 스키마에 다음 필드가 있다고 가정해 봅시다:

  • arrayField
  • mapField
  • nestedFields
  • nestedFields.stringField
  • json\_data
  • json\_data\_no\_idx
  • \_\_mergedTextIndex

이 트랜스포머가 없으면 stringField와 _noIdx로 끝나는 필드는 버려집니다. mapField와 nestedFields의 저장은 복잡한 트랜스포머의 전역 설정에 의존하게 됩니다. 하지만 이 트랜스포머를 쓰면 레코드는 다음과 같이 변환됩니다:

{
  "arrayField":[0, 1, 2, 3],
  "nestedFields.stringField":"a",
  "json_data":{
    "stringField":"a",
    "mapField":{
      "arrayField":[0, 1, 2, 3],
      "stringField":"a",
      "stringField":"aA_123"
    },
    "nestedFields":{
      "arrayField":[0, 1, 2, 3],
      "mapField":{
        "arrayField":[0, 1, 2, 3],
        "stringField":"a"
      }
    }
  },
  "json_data_no_idx":{
    "intField_noIndex":9,
    "string_noIndex":"z",
    "mapField":{
      "intField_noIndex":9,
      "string_noIndex":"z"
    },
    "mapField_noIndex":{
      "arrayField":[0, 1, 2, 3],
      "stringField":"a",
    },
    "nestedFields":{
      "intField_noIndex":9,
      "string_noIndex":"z",
      "mapField":{
        "intField_noIndex":9,
        "string_noIndex":"z"
      }
    }
  },
  "__mergedTextIndex": [
    // To be explained in following sections
  ]
}

예약된(그리고 설정 가능한) 필드 json_data, json_data_no_idx, __mergedTextIndex가 3개 보입니다. 이 트랜스포머는 다음을 수행합니다:

  • 중첩 필드를 리프 노드까지 모두 펼치고:
    • 설정에 따라 필요하면 특별 처리를 수행
    • 키 경로가 스키마와 일치하면 전용 필드에 데이터를 넣음
    • 그렇지 않으면 키 접미사에 따라 json_data 또는 json_data_no_idx에 넣음
  • 전용 컬럼이나 json_data에 있는 키에 대해 "Begin Anchor + value + Separator + key + End Anchor" 형태로 __mergedTextIndex에 넣어 텍스트 매치를 지원
  • 설정으로 추가 기능 제공
    • fieldPathsToDrop 필드 버리기
    • 펼치지 않고 서브트리 보존 fieldPathsToPreserveInput, fieldPathsToPreserveInputWithIndex
    • 저장은 하지 않지만 인덱싱만 수행(예제의 message) fieldPathsToSkipStorage
    • 필드 인덱싱 스킵 unindexableFieldSuffix
    • 대소문자 무시 검색 최적화 optimizeCaseInsensitiveSearch
    • 입력 키 경로를 스키마 이름으로 매핑 columnNameToJsonKeyPathMap
    • 익명 점({'a.b': 'c'} vs {'a': {'b': 'c'}}) 지원 useAnonymousDotInFieldNames
    • 길이로 값 잘라내기 mergedTextIndexDocumentMaxLength
    • 스키마 진화를 위한 이중 수집 fieldsToDoubleIngest

테이블 구성 (Table Configurations)

SchemaConformingTransformer 구성

트랜스포머를 사용하려면 테이블 구성의 ingestionConfig 섹션에 schemaConformingTransformerConfig 옵션을 추가합니다. 예를 들면:

"schemaConformingTransformerConfig": {
  "enableIndexableExtras": true,
  "indexableExtrasField": "json_data",
  "enableUnindexableExtras": true,
  "unindexableExtrasField": "json_data_no_idx",
  "unindexableFieldSuffix": "_noindex",
  "fieldPathsToDrop": [],
  "fieldPathsToSkipStorage": [
    "message"
  ],
  "columnNameToJsonKeyPathMap": {},
  "mergedTextIndexField": "__mergedTextIndex",
  "useAnonymousDotInFieldNames": true,
  "optimizeCaseInsensitiveSearch": false,
  "reverseTextIndexKeyValueOrder": true,
  "mergedTextIndexDocumentMaxLength": 32766,
  "mergedTextIndexBinaryDocumentDetectionMinLength": 512,
  "mergedTextIndexPathToExclude": [
    "_timestampMillisNegative",
    "__mergedTextIndex",
    "_timestampMillis"
  ],
  "fieldsToDoubleIngest": [],
  "jsonKeyValueSeparator": "\u001e",
  "mergedTextIndexBeginOfDocAnchor": "\u0002",
  "mergedTextIndexEndOfDocAnchor": "\u0003",
  "fieldPathsToPreserveInput": [],
  "fieldPathsToPreserveInputWithIndex": []
}

사용 가능한 구성 옵션은 SchemaConformingTransformerConfig 에 나와 있습니다.

예약 필드 구성

3개의 예약 컬럼에 대한 인덱스 구성은 다음과 같이 설정할 수 있습니다:

"fieldConfigList": [
  {
    "name": "json_data",
    "encodingType": "RAW",
    "indexTypes": [],
    "compressionCodec": "LZ4",
    "indexes": null,
    "properties": {
      "rawIndexWriterVersion": "4"
    },
    "tierOverwrites": null
  },
  {
    "name": "json_data_no_idx",
    "encodingType": "RAW",
    "indexTypes": [],
    "compressionCodec": "ZSTANDARD",
    "indexes": null,
    "properties": {
      "rawIndexWriterVersion": "4"
    },
    "tierOverwrites": null
  },
  {
    "name": "__mergedTextIndex",
    "encodingType": "RAW",
    "indexType": "TEXT",
    "indexTypes": [
      "TEXT"
    ],
    "compressionCodec": "LZ4",
    "indexes": null,
    "properties": {
      "enableQueryCacheForTextIndex": "false",
      "luceneAnalyzerClass": <analyzerClass>,
      "luceneAnalyzerClassArgTypes": <>,
      "luceneAnalyzerClassArgs": <>,
      "luceneMaxBufferSizeMB": "50",
      "luceneQueryParserClass": <parserClass>,
      "luceneUseCompoundFile": "true",
      "noRawDataForTextIndex": "true",
      "rawIndexWriterVersion": "4"
    },
    "tierOverwrites": null
  }
]


"jsonIndexConfigs": {
  "json_data": {
    "disabled": false,
    "maxLevels": 3,
    "excludeArray": true,
    "disableCrossArrayUnnest": true,
    "maxValueLength": 1000,
    "skipInvalidJson": true
  }
}

특히 커스터마이즈 가능한 json 인덱스는 json 인덱스 indexPaths 에 따라 설정할 수 있습니다.

스키마 설계

SchemaConformingTransformer를 사용하면 테이블 스키마에 특별한 전용 컬럼을 지정하지 않아도 모든 데이터를 보존할 수 있습니다. 하지만 저장 공간과 다양한 쿼리 패턴을 최적화하려면 사용에 따라 전용 컬럼을 만들어야 합니다:

  • 빈번한 정확 일치 쿼리 필드(예: region, log\_level, runtime\_env)
  • 범위 쿼리 필드(예: timestamp)
  • 메시지에서 고빈도로 나오는 필드
    • json 인덱스 크기 줄이기
    • group by 쿼리 최적화

텍스트 검색

각 key/value 쌍을 __mergedTextIndex 필드에 넣은 뒤에는 문서를 토큰화할 luceneAnalyzerClass와 토큰으로 쿼리할 luceneQueryParserClass가 필요합니다. 흔한 검색 패턴과 그 쿼리의 예는 다음과 같습니다:

  • 정확한 키/값 매치 TEXT_MATCH(__mergedTextIndex, '"valuer:key"')
  • 특정 키에서 와일드카드 값 검색 TEXT_MATCH(__mergedTextIndex, '/.* value .*:key/')
  • 키 존재 확인 TEXT_MATCH(__mergedTextIndex, '/.*:key/')
  • 전역 값 정확 매치 TEXT_MATCH(__mergedTextIndex, '/"value"/')
  • 전역 값 와일드카드 매치 TEXT_MATCH(__mergedTextIndex, '/.* value .*/')

luceneAnalyzerClass와 luceneQueryParserClass는 보통 비슷한 구분자 집합을 가져야 합니다. 아래 값들도 고려해야 합니다.

"jsonKeyValueSeparator": "\u001e",
"mergedTextIndexBeginOfDocAnchor": "\u0002",
"mergedTextIndexEndOfDocAnchor": "\u0003",

주어진 예에서 각 key/value 쌍은 "\u0002value\u001ekey\u0003" 형태로 저장됩니다. 키 또는 값의 접두사/접미사 매치는 luceneQueryParserClass에서 그에 맞게 조정해야 합니다.

더 알아보기 (Learn more)