아키텍처
아키텍처 (Architecture)
Apache Pinot™의 구성 요소들이 어떻게 협력해서 저지연·고동시성 쿼리를 대규모로 제공하는 확장 가능한 OLAP 데이터베이스를 만드는지 이해하는 페이지예요. Controller, Broker, Server, Minion 네 가지 노드가 분산 시스템에서 각각 어떤 역할을 하는지 흐름을 따라가 보면 전체가 잡혀요.
출처: Architecture
본문
Apache Pinot™은 실시간 사용자 대면(user-facing) 사용 사례를 제공하도록 설계된 분산 OLAP 데이터베이스예요. 즉 매우 큰 데이터 볼륨과 많은 동시 쿼리를 매우 낮은 쿼리 지연으로 처리하는 것을 의미해요. Pinot는 다음 요구 사항을 지원해요:
- 초저지연 쿼리 (P95 기준 10ms만큼 낮게)
- 높은 쿼리 동시성 (초당 100,000 쿼리까지)
- 높은 데이터 신선도 (스트리밍 데이터가 수집 즉시 쿼리 가능)
- 대용량 데이터 (페타바이트까지)
분산 설계 원칙 (Distributed design principles)
엄격한 지연 및 동시성 요구 사항이 있는 대용량 데이터를 수용하기 위해 Pinot는 다음 요구 사항을 지원하는 분산 데이터베이스로 설계됐어요:
- 높은 가용성 (Highly available): Pinot에는 단일 장애 지점(SPOF)이 없어요. 테이블이 복제로 구성되고 노드가 다운되면 클러스터가 쿼리 처리를 계속할 수 있어요.
- 수평 확장 (Horizontally scalable): 운영자는 워크로드가 증가할 때 새 노드를 추가해 Pinot 클러스터를 확장할 수 있어요. 심지어 두 가지 노드 유형(서버와 브로커)이 있어서 쿼리 볼륨, 쿼리 복잡성, 데이터 크기를 독립적으로 확장할 수 있어요.
- 불변 데이터 (Immutable data): Pinot는 저장된 모든 데이터가 불변이라고 가정해요. 이는 데이터 저장과 복제를 처리하는 시스템 부분을 단순화하는 데 도움이 돼요. 하지만 Pinot는 여전히 스트리밍 엔티티 데이터에 대한 upsert와 데이터 개인정보 보호 규정을 준수하기 위한 백그라운드 purge를 지원해요.
- 동적 구성 변경 (Dynamic configuration changes): 새 테이블 추가, 클러스터 확장, 데이터 수집, 기존 테이블 수정, 인덱스 추가 같은 작업은 쿼리 가용성이나 성능에 영향을 주지 않아요.
핵심 구성 요소 (Core components)
Pinot 구성 요소 문서에 설명된 대로 Pinot에는 네 가지 노드 유형이 있어요:
Apache Helix와 ZooKeeper
분산 시스템은 스스로 유지되지 않으며, 실제로 기능하려면 정교한 스케줄링과 리소스 관리가 필요해요. Pinot는 이를 위해 Apache Helix를 사용해요. Helix는 독립 프로젝트로 존재하지만 Pinot의 원래 제작자들이 Pinot 자체의 클러스터 관리 목적으로 설계했기 때문에 두 시스템의 아키텍처가 잘 맞아요. Helix는 controller의 프로세스 형태와 브로커 및 서버의 내장 에이전트 형태를 취해요. 내결함성(fault-tolerant), 강한 일관성, 내구성 있는 상태 저장소로 Apache ZooKeeper를 사용해요.
Helix는 서버와 브로커의 수, 모든 테이블의 설정과 스키마, 스트리밍 수집 소스 연결, 현재 실행 중인 배치 수집 작업, 테이블 세그먼트의 클러스터 서버 할당 등 클러스터의 의도된 상태에 대한 그림을 유지해요. 운영자는 테이블 스키마를 변경하고, 스트리밍 수집 소스를 추가/제거하고, 새 배치 수집 작업을 시작하는 일을 일상적으로 하기 때문에 이 모든 설정 항목은 잠재적으로 변경 가능한 값이에요. 또한 서버와 브로커가 실패하거나 네트워크 분할을 겪으면서 물리적 클러스터 상태가 변경될 수 있어요. Helix는 실제 클러스터 상태를 의도된 상태와 일치시키기 위해 끊임없이 작업하며, 필요에 따라 브로커와 서버에 구성 변경을 푸시해요.
Helix 클러스터에는 세 가지 물리적 노드 유형이 있어요:
- Participant: 데이터 저장이나 연산 수행 같은 일을 실제로 하는 노드예요. Participant는 Helix의 기본 저장 추상화인 *리소스(resources)*를 호스팅해요. Pinot 서버가 세그먼트 데이터를 저장하므로 participant예요.
- Spectator: spectator에 푸시되는 이벤트를 통해 participant들의 진화하는 상태를 관찰하며 보는 노드예요. Pinot 브로커는 어떤 서버가 어떤 세그먼트를 호스팅하는지 알아야 하므로 spectator예요.
- Controller: participant 노드의 상태를 관찰하고 관리하는 노드예요. 클러스터의 모든 상태 전이를 조정하고, 클러스터 안정성을 유지하면서 상태 제약 조건이 충족되도록 보장할 책임이 있어요.
또한 Helix는 저장 추상화를 표현하기 위해 두 가지 논리 구성 요소를 정의해요:
- Partition. 최소한 하나의 participant에 존재하는 데이터 저장 단위예요. 파티션은 여러 participant에 걸쳐 복제될 수 있어요. Pinot 세그먼트는 파티션이에요.
- Resource. 분산 시스템 전반에 걸쳐 저장된 잠재적으로 큰 데이터 집합에 대한 단일 뷰를 제공하는 파티션의 논리적 모음이에요. Pinot 테이블은 리소스예요.
요약하면 Pinot 아키텍처는 다음과 같이 Helix 구성 요소에 매핑돼요:
| Pinot 구성 요소 | Helix 구성 요소 |
|---|---|
| Segment | Helix Partition |
| Table | Helix Resource |
| Controller | Helix Controller 또는 클러스터 전체 상태를 구동하는 Helix 에이전트 |
| Server | Helix Participant |
| Broker | 클러스터의 세그먼트와 서버 상태 변화를 관찰하는 Helix Spectator. 멀티 테넌시를 지원하기 위해 브로커는 Helix Participant로도 모델링됨 |
| Minion | 데이터를 저장하기보다 연산을 수행하는 Helix Participant |
Helix는 클러스터 상태를 유지하기 위해 ZooKeeper를 사용해요. ZooKeeper는 클러스터 상태 변화(ZNode 변화에 해당)에 대한 알림을 Helix spectator에게 보내요. ZooKeeper는 클러스터에 대해 다음 정보를 저장해요:
| 리소스 | 저장 속성 |
|---|---|
| Controller | - 현재 리더로 할당된 Controller |
| Servers and Brokers | - 서버와 브로커 목록 - 현재 모든 서버와 브로커 구성 - 현재 모든 서버와 브로커의 상태(health) |
| Tables | - 테이블 목록 - 테이블 설정 - 테이블 스키마 - 테이블 세그먼트 목록 |
| Segment | - 세그먼트의 정확한 서버 위치 - 각 세그먼트 상태(online/offline/error/consuming) - 각 세그먼트에 대한 메타데이터 |
ZooKeeper는 Pinot 클러스터의 일급 시민으로서 운영 및 문제 해결 목적으로 잘 알려진 ZNode 구조를 사용할 수 있어요. 이 구조는 향후 Pinot 릴리스에서 변경될 수 있음을 유의하세요.

Controller
Pinot controller는 메타데이터가 변경되거나 노드가 실패할 때 Pinot 클러스터의 리소스를 스케줄링하고 재스케줄링해요. Apache Helix Controller로서 클러스터를 구성하는 리소스를 스케줄링하고 특정 외부 프로세스와 클러스터 컴포넌트 간의 연결을 오케스트레이션해요(예: 실시간 테이블과 오프라인 테이블의 수집). 자체 서버의 단일 프로세스로 배포하거나 액티브/패시브 구성의 중복 서버 그룹으로 배포할 수 있어요.
내결함성 (Fault tolerance)
한 번에 오직 하나의 controller만 활성화될 수 있어요. 그래서 클러스터에 여러 controller가 있으면 리더를 선출해요. 해당 controller 인스턴스가 사용 불가능해지면 나머지 인스턴스가 자동으로 새 리더를 선출해요. 리더 선출은 Apache Helix를 사용해 이루어져요. Pinot 클러스터는 활성 controller 없이도 쿼리를 제공할 수 있지만, 테이블 추가나 새 세그먼트 소비 같은 메타데이터 수정 작업은 수행할 수 없어요.
Controller REST 인터페이스
Controller는 모든 논리 저장 리소스(예: 서버, 브로커, 테이블, 세그먼트)에 대한 읽기/쓰기 접근을 허용하는 REST 인터페이스를 제공해요. 웹 기반 관리 도구에 대한 자세한 내용은 Pinot Data Explorer를 참조하세요.
Broker
브로커의 책임은 쿼리를 적절한 서버 인스턴스로 라우팅하는 거예요. 멀티 스테이지 쿼리의 경우에는 완전한 쿼리 계획을 계산해 실행에 필요한 서버로 배포해요. 브로커는 모든 서버의 응답을 수집하고 병합해 최종 결과를 만든 다음 요청한 클라이언트로 다시 보내요. 브로커는 JSON 형식의 SQL 쿼리를 받아 JSON으로 응답을 반환하는 HTTP 엔드포인트를 노출해요.
각 브로커는 쿼리 라우팅 테이블을 유지해요. 라우팅 테이블은 세그먼트를 세그먼트를 저장하는 서버에 매핑해요. (테이블에 복제가 구성되면 각 세그먼트는 둘 이상의 서버에 저장돼요.) 브로커는 테이블에 대해 구성된 라우팅 전략에 따라 여러 라우팅 테이블을 계산해요. 기본 전략은 사용 가능한 모든 서버에 쿼리 부하를 분산하는 거예요.
💡 replica-aware 라우팅, 파티션 기반 라우팅, 최소 서버 선택 라우팅 같은 고급 라우팅 전략도 사용할 수 있어요.
//This is an example ZNode config for EXTERNAL VIEW in Helix
{
"id" : "baseballStats_OFFLINE",
"simpleFields" : {
...
},
"mapFields" : {
"baseballStats_OFFLINE_0" : {
"Server_10.1.10.82_7000" : "ONLINE"
}
},
...
}
쿼리 처리 (Query processing)
브로커가 처리하는 모든 쿼리는 단일 스테이지 엔진 또는 멀티 스테이지 엔진을 사용해요. 단일 스테이지 쿼리의 경우 브로커는 다음을 수행해요:
- 테이블 설정에 정의된 라우팅 전략을 기반으로 쿼리 경로를 계산해요.
- 각 서버에서 쿼리할 세그먼트 목록을 계산해요. (이 과정에 대한 자세한 내용은 라우팅 참조)
- 각 서버에 쿼리를 보내 해당 서버의 세그먼트에 대해 로컬 실행해요.
- 각 서버에서 결과를 받아 병합해요.
- 쿼리 결과를 클라이언트로 보내요.
// Query: select count(*) from baseballStats limit 10
// RESPONSE
// ========
{
"resultTable": {
"dataSchema": {
"columnDataTypes": ["LONG"],
"columnNames": ["count(*)"]
},
"rows": [
[97889]
]
},
"exceptions": [],
"numServersQueried": 1,
"numServersResponded": 1,
"numSegmentsQueried": 1,
"numSegmentsProcessed": 1,
"numSegmentsMatched": 1,
"numConsumingSegmentsQueried": 0,
"numDocsScanned": 97889,
"numEntriesScannedInFilter": 0,
"numEntriesScannedPostFilter": 0,
"numGroupsLimitReached": false,
"totalDocs": 97889,
"timeUsedMs": 5,
"segmentStatistics": [],
"traceInfo": {},
"minConsumingFreshnessTimeMs": 0
}
멀티 스테이지 쿼리의 경우 브로커는 다음을 수행해요:
- 여러 서버 집합에서 실행되는 쿼리 계획을 계산해요. 첫 번째 스테이지에 선택되는 서버는 쿼리 실행에 필요한 세그먼트를 기반으로 선택되며, 이는 단일 스테이지 쿼리와 유사한 과정으로 결정돼요.
- 쿼리 계획의 각 스테이지에 대해 관련 부분을 클러스터의 한 개 이상 서버로 보내요.
- 쿼리 계획을 받은 서버들이 각각 자신의 부분을 실행해요. 이 과정에 대한 자세한 내용은 멀티 스테이지 엔진을 읽어보세요.
- 브로커는 항상 단일 서버인 쿼리 최종 스테이지에서 완전한 결과 집합을 받아요.
- 브로커는 쿼리 결과를 클라이언트로 보내요.
Server
서버는 로컬 연결 저장소에 세그먼트를 호스팅하고 그 세그먼트에 대해 쿼리를 처리해요. 관례적으로 운영자는 "실시간"과 "오프라인" 서버라고 말하지만, 서버 프로세스 자체나 심지어 그 구성에도 둘을 구분하는 차이는 없어요. 이는 두 종류의 워크로드의 성능 제한 요소가 다르기 때문에 두 종류의 워크로드를 두 개의 물리 인스턴스 그룹으로 한정하는 테이블 할당 전략에 반영된 단순한 관례일 뿐이에요. 예를 들어 오프라인 서버는 더 큰 저장 용량을 위해, 실시간 서버는 메모리와 CPU 코어를 위해 최적화할 수 있어요.
오프라인 서버 (Offline servers)
오프라인 서버는 배치 데이터를 수집해 생성된 세그먼트를 호스팅해요. Controller는 테이블의 복제 계수와 세그먼트 할당 전략에 따라 이 세그먼트를 오프라인 서버로 보내요. 일반적으로 controller는 새 세그먼트를 deep store에 쓰고, 관련 서버가 deep store에서 세그먼트를 다운로드해요. 그런 다음 controller는 새 세그먼트가 존재하고 쿼리에 참여할 수 있다고 브로커에 알려요.
오프라인 테이블은 보존 기간이 긴 경향이 있어서, 오프라인 서버는 저장하는 데이터 크기에 따라 확장되는 경향이 있어요.
실시간 서버 (Real-time servers)
실시간 서버는 Apache Kafka®, Apache Pulsar®, AWS Kinesis 같은 스트리밍 소스에서 데이터를 수집해요. 스트리밍 데이터는 배치 데이터처럼 일반적인 세그먼트 파일로 끝나지만, 먼저 소비 세그먼트(consuming segment)로 알려진 인메모리 데이터 구조에 축적돼요. 스트리밍 소스에서 소비된 각 메시지는 즉시 해당 소비 세그먼트에 기록되고, 소비 세그먼트는 쿼리 처리에 일급 시민으로 참여하기 때문에 즉시 쿼리 처리에 사용 가능해요. 소비 세그먼트는 행 수, 수집 시간, 또는 세그먼트 크기로 계산할 수 있는 완료 임계값을 기반으로 주기적으로 디스크로 플러시돼요. 실시간 테이블에서 플러시된 세그먼트를 완료(completed) 세그먼트라고 하며, 기능적으로 오프라인 수집 중 생성된 세그먼트와 동일해요.
실시간 서버는 스트리밍 데이터를 수집하는 속도에 따라 확장되는 경향이 있어요.
Minion
Pinot minion은 브로커와 서버가 수행하는 쿼리 프로세스와 별개로 테이블 데이터에 대한 백그라운드 작업을 실행하는 선택적 클러스터 컴포넌트예요. Minion은 독립 하드웨어 리소스에서 실행되며 controller가 지시하는 minion 태스크를 실행할 책임이 있어요. minion 태스크의 예로는 Avro나 JSON 같은 표준 형식의 배치 데이터를 오프라인 테이블에 로드할 세그먼트 파일로 변환하는 것, GDPR 같은 데이터 개인정보 보호법이 요구하는 대로 레코드를 제거하기 위해 기존 세그먼트 파일을 다시 쓰는 것이 있어요. Minion 태스크는 한 번 실행하거나 주기적으로 실행되도록 스케줄할 수 있어요.
Minion은 대역 외(out-of-band) 데이터 처리의 계산 부담을 서버로부터 격리해요. Pinot 클러스터는 minion이 없어도 기능하지만, 일반적으로 배치 데이터 수집 같은 일상적인 작업을 지원하기 위해 존재해요.
데이터 수집 개요 (Data ingestion overview)
Pinot 테이블은 오프라인(또는 배치)과 실시간의 두 가지 유형으로 존재해요. 오프라인 테이블은 CSV, Avro, Parquet 파일 같은 배치 소스의 데이터를 포함하고, 실시간 테이블은 Apache Kafka®, Apache Pulsar®, AWS Kinesis 같은 스트리밍 소스의 데이터를 포함해요.
오프라인 (배치) 수집
Pinot는 수집 작업을 사용해 배치 데이터를 수집해요. 이 과정은 다음과 같아요:
- 작업이 원시 데이터 소스(예: CSV 파일)를 세그먼트로 변환해요. 이는 잠재적으로 복잡한 과정으로, 일반적으로 수백 메가바이트 크기의 파일이 생성돼요.
- 작업은 파일을 클러스터의 deep store로 전송하고 새 세그먼트가 존재한다고 controller에 알려요.
- Controller(Helix controller로서)가 클러스터 메타데이터 맵의 이상 상태(ideal state)를 업데이트해요.
- Controller는 (복제 계수에 따라) 세그먼트를 하나 이상의 "오프라인" 서버에 할당하고 새 세그먼트가 사용 가능하다고 알려요.
- 서버는 deep store에서 새로 생성된 세그먼트를 직접 다운로드해요.
- Helix spectator로서 상태 변화를 지켜보는 클러스터의 브로커들이 새 세그먼트를 감지하고 그에 따라 세그먼트 라우팅 테이블을 업데이트해요. 이제 클러스터가 새 오프라인 세그먼트를 쿼리할 수 있게 돼요.
실시간 수집
수집은 실시간 테이블이 생성될 때 구축되고, 테이블이 존재하는 한 계속돼요. Controller가 새 실시간 테이블을 만들기 위한 메타데이터 업데이트를 받으면, 테이블 설정이 스트리밍 입력 데이터의 소스(종종 Kafka 클러스터의 토픽)를 지정해요. 그러면 다음과 같은 과정이 시작돼요:
- Controller가 스트리밍 입력 소스의 직접 소비자 역할을 할 서버 한 개 이상을 선택해요.
- Controller가 새 테이블의 소비 세그먼트를 만들어요. 1단계에서 선택된 각 실시간 서버에 대해 새 소비 세그먼트를 전역 메타데이터 맵에 항목으로 만들어서 이 작업을 수행해요.
- Controller와 관련 서버의 Helix 기능을 통해 서버가 메모리에 소비 세그먼트를 만들고 스트리밍 입력 소스에 연결을 설정해요. 입력 소스가 Kafka면 각 서버가 통합에 다른 컴포넌트를 관여시키지 않고 직접 Kafka 소비자로 동작해요.
- Controller와 모든 클러스터 브로커의 Helix 기능을 통해 브로커가 소비 세그먼트를 인식하고 즉시 쿼리 라우팅에 포함시키기 시작해요.
- 소비 서버가 동시에 스트리밍 입력 소스에서 메시지를 소비하고 소비 세그먼트에 저장하기 시작해요.
- 서버가 자신의 소비 세그먼트가 완료됐다고 판단하면 인메모리 소비 세그먼트를 일반적인 세그먼트 파일로 커밋하고, deep store에 업로드하고, controller에 알려요.
- Controller와 서버가 실시간 수집을 계속하기 위해 새 소비 세그먼트를 만들어요.
- Controller가 새로 커밋된 세그먼트를 온라인으로 표시해요. 그러면 브로커가 Helix 알림 메커니즘을 통해 새 세그먼트를 발견해서 일반적인 방식으로 쿼리를 라우팅할 수 있어요.