대용량 데이터 쿼리 작업에서 시스템 안정성을 보장하는 것은 무엇입니까? 이는 효과적인 메모리 할당 및 모니터링 메커니즘입니다. 이것이 바로 계산 속도를 높이고, 메모리 핫스팟을 방지하고, 메모리 부족에 신속하게 대응하고, OOM 오류를 최소화하는 방법입니다.
데이터베이스 사용자의 관점에서 볼 때 잘못된 메모리 관리로 인해 어떤 어려움을 겪게 됩니까? 다음은 사용자를 괴롭히는 것들의 목록입니다.
OOM 오류로 인해 백엔드 프로세스가 중단됩니다. 커뮤니티 회원 중 한 사람의 말을 인용하자면, 안녕하세요, Apache Doris입니다. 메모리가 부족할 때 작업 속도가 느려지거나 몇 가지 작업이 실패하는 것은 괜찮지만 다운타임이 발생하는 것은 좋지 않습니다.
백엔드 프로세스는 너무 많은 메모리 공간을 소비하지만 단일 쿼리에 대한 메모리 사용량을 비난하거나 제한할 정확한 작업을 찾을 수 있는 방법이 없습니다.
각 쿼리마다 적절한 메모리 크기를 설정하는 것이 어렵기 때문에 메모리 공간이 충분하더라도 쿼리가 취소될 가능성이 있습니다.
동시성이 높은 쿼리는 불균형적으로 느리고 메모리 핫스팟을 찾기가 어렵습니다.
HashTable 생성 중 중간 데이터는 디스크로 플러시될 수 없으므로 OOM으로 인해 두 개의 큰 테이블 간의 조인 쿼리가 실패하는 경우가 많습니다.
운 좋게도 우리는 메모리 관리 메커니즘을 아래에서 위로 개선했기 때문에 이러한 암울한 시절은 지나갔습니다. 이제 준비하세요. 일이 집중적으로 진행될 것입니다.
Apache Doris에는 메모리 할당을 위한 유일한 인터페이스인 Allocator가 있습니다. 메모리 사용을 효율적으로 유지하고 제어할 수 있도록 적절하다고 판단되는 대로 조정합니다.
또한 MemTracker는 할당되거나 해제된 메모리 크기를 추적하기 위해 마련되었으며 세 가지 다른 데이터 구조는 연산자 실행 시 대규모 메모리 할당을 담당합니다(즉시 확인하겠습니다).
쿼리마다 실행 시 메모리 핫스팟 패턴이 다르기 때문에 Apache Doris는 Arena , HashTable 및 PODArray 라는 세 가지 메모리 내 데이터 구조를 제공합니다. 그들은 모두 할당자의 통치 아래 있습니다.
Arena는 Allocator의 요청에 따라 할당될 청크 목록을 유지 관리하는 메모리 풀입니다. 청크는 메모리 정렬을 지원합니다. 이는 아레나 수명 동안 존재하며 파괴 시(보통 쿼리가 완료되면) 해제됩니다.
청크는 주로 Shuffle 중에 직렬화 또는 역직렬화된 데이터를 저장하거나 HashTable에 직렬화된 키를 저장하는 데 사용됩니다.
청크의 초기 크기는 4096바이트입니다. 현재 청크가 요청된 메모리보다 작으면 새 청크가 목록에 추가됩니다.
현재 청크가 128M보다 작으면 새 청크의 크기는 두 배가 됩니다. 128M보다 크면 새 청크는 필요한 것보다 많아야 128M 더 커집니다.
이전의 작은 청크는 새로운 요청에 할당되지 않습니다. 할당된 청크와 할당되지 않은 청크 사이를 구분하는 선을 표시하는 커서가 있습니다.
HashTable은 해시 조인, 집계, 집합 작업 및 창 함수에 적용할 수 있습니다. PartitionedHashTable 구조는 16개 이하의 하위 HashTable을 지원합니다. 또한 HashTable의 병렬 병합을 지원하며 각 하위 해시 조인을 독립적으로 확장할 수 있습니다.
이를 통해 전체 메모리 사용량과 확장으로 인한 대기 시간을 줄일 수 있습니다.
현재 HashTable이 8M보다 작으면 4배로 크기가 조정됩니다.
8M보다 크면 2배로 크기가 조정됩니다.
2G보다 작은 경우 50%가 채워지면 크기가 조정됩니다.
2G보다 큰 경우 75%가 채워지면 크기가 조정됩니다.
새로 생성된 HashTable은 보유할 데이터 양에 따라 사전 크기가 조정됩니다. 또한 다양한 시나리오에 대해 다양한 유형의 HashTable을 제공합니다. 예를 들어 집계의 경우 PHmap을 적용할 수 있습니다.
PODArray는 이름에서 알 수 있듯이 POD의 동적 배열입니다. std::vector
와 차이점은 PODArray가 요소를 초기화하지 않는다는 것입니다. 메모리 정렬과 std::vector
의 일부 인터페이스를 지원합니다.
이는 2배로 크기가 조정됩니다. 소멸 시 각 요소에 대해 소멸자 함수를 호출하는 대신 전체 PODArray의 메모리를 해제합니다. PODArray는 주로 문자열을 열에 저장하는 데 사용되며 많은 함수 계산 및 표현식 필터링에 적용 가능합니다.
Arena, PODArray 및 HashTable을 조정하는 유일한 인터페이스인 Allocator는 64M보다 큰 요청에 대해 메모리 매핑(MMAP) 할당을 실행합니다.
4K보다 작은 것들은 malloc/free를 통해 시스템에서 직접 할당됩니다. 그 사이의 속도는 벤치마킹 결과에 따라 10% 성능 향상을 가져오는 범용 캐싱 ChunkAllocator에 의해 가속화됩니다.
ChunkAllocator는 잠금 없는 방식으로 현재 코어의 FreeList에서 지정된 크기의 청크를 검색하고 검색합니다. 그러한 청크가 존재하지 않으면 잠금 기반 방식으로 다른 코어에서 시도합니다. 그래도 실패하면 시스템에 지정된 메모리 크기를 요청하고 이를 청크로 캡슐화합니다.
우리는 두 가지를 모두 경험한 후 TCMalloc 대신 Jemalloc을 선택했습니다. 우리는 높은 동시성 테스트에서 TCMalloc을 시도했고 CentralFreeList의 Spin Lock이 총 쿼리 시간의 40%를 차지하는 것을 확인했습니다.
"공격적인 메모리 해제"를 비활성화하면 상황이 좋아졌지만 이로 인해 훨씬 더 많은 메모리 사용량이 발생했기 때문에 정기적으로 캐시를 재활용하기 위해 개별 스레드를 사용해야 했습니다. 반면에 Jemalloc은 동시성이 높은 쿼리에서 더 성능이 좋고 안정적이었습니다.
다른 시나리오에 대해 미세 조정한 후 TCMalloc과 동일한 성능을 제공했지만 메모리를 덜 소비했습니다.
메모리 재사용은 Apache Doris의 실행 계층에서 광범위하게 실행됩니다. 예를 들어, 데이터 블록은 쿼리 실행 전체에서 재사용됩니다. Shuffle 중에는 발신자 측에 두 개의 블록이 있으며 교대로 작동하며 하나는 데이터를 수신하고 다른 하나는 RPC 전송에서 작동합니다.
태블릿을 읽을 때 Doris는 조건자 열을 재사용하고 순환 읽기를 구현하고 필터링하고 필터링된 데이터를 상위 블록에 복사한 다음 삭제합니다.
Aggregate Key 테이블에 데이터를 수집할 때 데이터를 캐시하는 MemTable이 특정 크기에 도달하면 사전 집계된 후 더 많은 데이터가 기록됩니다.
데이터 스캐닝 시에도 메모리 재사용이 수행됩니다. 스캐닝이 시작되기 전에 스캐닝 작업에 여러 개의 여유 블록(스캐너 및 스레드 수에 따라 다름)이 할당됩니다.
각 스캐너 예약 중에 사용 가능한 블록 중 하나가 데이터 읽기를 위해 저장 계층으로 전달됩니다.
데이터를 읽은 후 블록은 후속 계산에서 상위 연산자의 소비를 위해 생산자 대기열에 배치됩니다. 상위 운영자가 블록에서 계산 데이터를 복사하면 해당 블록은 다음 스캐너 예약을 위해 사용 가능한 블록으로 돌아갑니다.
여유 블록을 사전 할당하는 스레드는 데이터 스캔 후 이를 해제하는 역할도 담당하므로 추가 오버헤드가 발생하지 않습니다. 사용 가능한 블록의 수에 따라 데이터 검색의 동시성이 결정됩니다.
Apache Doris는 MemTrackers를 사용하여 메모리 핫스팟을 분석하는 동안 메모리 할당 및 해제에 대한 후속 조치를 취합니다. MemTrackers는 각 데이터 쿼리, 데이터 수집, 데이터 압축 작업, 캐시 및 TabletMeta와 같은 각 전역 개체의 메모리 크기에 대한 기록을 유지합니다.
수동 계산과 MemHook 자동 추적을 모두 지원합니다. 사용자는 웹 페이지의 Doris 백엔드에서 실시간 메모리 사용량을 볼 수 있습니다.
Apache Doris 1.2.0 이전의 MemTracker 시스템은 process_mem_tracker, query_pool_mem_tracker, query_mem_tracker, instance_mem_tracker, ExecNode_mem_tracker 등으로 구성된 계층적 트리 구조였습니다.
인접한 두 레이어의 MemTracker는 상위-하위 관계입니다. 따라서 하위 MemTracker의 모든 계산 실수는 계속 누적되어 더 큰 규모의 신뢰성을 가져옵니다.
Apache Doris 1.2.0 이상에서는 MemTrackers의 구조를 훨씬 간단하게 만들었습니다. MemTracker는 역할에 따라 MemTracker Limiter 와 기타 두 가지 유형으로만 구분됩니다.
메모리 사용량을 모니터링하는 MemTracker Limiter는 모든 쿼리/수집/압축 작업 및 전역 개체에서 고유합니다. 다른 MemTrackers는 Join/Aggregation/Sort/Window 함수의 HashTable 및 직렬화의 중간 데이터와 같은 쿼리 실행의 메모리 핫스팟을 추적하여 다양한 연산자에서 메모리가 사용되는 방식에 대한 그림을 제공하거나 메모리 제어에 대한 참조를 제공합니다. 데이터 플러시.
MemTracker Limiter와 다른 MemTracker 간의 상위-하위 관계는 스냅샷 인쇄에서만 나타납니다. 이러한 관계를 기호 링크로 생각할 수 있습니다. 동시에 소비되지 않으며 하나의 수명 주기가 다른 수명 주기에 영향을 주지 않습니다.
이를 통해 개발자는 이를 이해하고 사용하기가 훨씬 쉬워집니다.
MemTracker(MemTracker Limiter 및 기타 포함)는 지도 그룹에 포함됩니다. 이를 통해 사용자는 전체 MemTracker 유형 스냅샷, 쿼리/로드/압축 작업 스냅샷을 인쇄하고 메모리 사용량이 가장 많거나 메모리 초과 사용량이 가장 많은 쿼리/로드를 찾을 수 있습니다.
특정 실행의 메모리 사용량을 계산하기 위해 MemTracker가 현재 스레드의 스레드 로컬에 있는 스택에 추가됩니다. Jemalloc 또는 TCMalloc에서 malloc/free/realloc을 다시 로드함으로써 MemHook은 할당되거나 해제된 메모리의 실제 크기를 얻고 이를 현재 스레드의 Thread Local에 기록합니다.
실행이 완료되면 관련 MemTracker가 스택에서 제거됩니다. 스택 맨 아래에는 전체 쿼리/로드 실행 프로세스 동안 메모리 사용량을 기록하는 MemTracker가 있습니다.
이제 단순화된 쿼리 실행 과정을 통해 설명하겠습니다.
Doris 백엔드 노드가 시작된 후 모든 스레드의 메모리 사용량이 Process MemTracker에 기록됩니다.
쿼리가 제출되면 Query MemTracker가 조각 실행 스레드의 TLS(Thread Local Storage) 스택에 추가됩니다.
ScanNode가 예약되면 ScanNode MemTracker가 조각 실행 스레드의 TLS(Thread Local Storage) 스택에 추가됩니다. 그런 다음 이 스레드에서 할당되거나 해제된 모든 메모리는 Query MemTracker와 ScanNode MemTracker 모두에 기록됩니다.
Scanner가 예약되면 Query MemTracker와 Scanner MemTracker가 Scanner 스레드의 TLS 스택에 추가됩니다.
스캔이 완료되면 스캐너 스레드 TLS 스택의 모든 MemTracker가 제거됩니다. ScanNode 예약이 완료되면 ScanNode MemTracker가 조각 실행 스레드에서 제거됩니다. 그런 다음 마찬가지로 집계 노드가 예약되면 AggregationNode MemTracker가 조각 실행 스레드 TLS 스택에 추가되고 예약이 완료된 후 제거됩니다.
쿼리가 완료되면 Query MemTracker가 조각 실행 스레드 TLS 스택에서 제거됩니다. 이 시점에서 이 스택은 비어 있어야 합니다. 그런 다음 QueryProfile에서 전체 쿼리 실행은 물론 각 단계(스캔, 집계 등) 동안 최대 메모리 사용량을 볼 수 있습니다.
Doris 백엔드 웹 페이지는 쿼리/로드/압축/글로벌 유형으로 구분된 실시간 메모리 사용량을 보여줍니다. 현재 메모리 소비량과 최대 소비량이 표시됩니다.
Global 유형에는 Cache 및 TabletMeta의 MemTrackers가 포함됩니다.
쿼리 유형에서 현재 쿼리의 현재 메모리 소비량과 최대 소비량 및 관련 연산자를 확인할 수 있습니다(레이블을 보면 이들 간의 관계를 알 수 있습니다). 기록 쿼리의 메모리 통계는 Doris FE 감사 로그 또는 BE INFO 로그를 확인할 수 있습니다.
Doris 백엔드에 널리 구현된 메모리 추적을 통해 백엔드 가동 중지 시간 및 대규모 쿼리 오류의 원인인 OOM 제거에 한 걸음 더 가까워졌습니다. 다음 단계는 쿼리 및 프로세스의 메모리 제한을 최적화하여 메모리 사용량을 제어하는 것입니다.
사용자는 모든 쿼리에 메모리 제한을 적용할 수 있습니다. 실행 중에 해당 제한을 초과하면 쿼리가 취소됩니다. 그러나 버전 1.2부터는 보다 유연한 메모리 제한 제어인 메모리 오버커밋을 허용했습니다.
메모리 리소스가 충분하면 쿼리가 취소되지 않고 제한보다 많은 메모리를 사용할 수 있으므로 사용자는 메모리 사용량에 특별히 주의를 기울일 필요가 없습니다. 그렇지 않은 경우 쿼리는 새로 해제된 메모리가 쿼리에 충분하지 않은 경우에만 새 메모리 공간이 할당될 때까지 기다립니다. 그러면 쿼리가 취소됩니다.
Apache Doris 2.0에서는 쿼리에 대한 예외 안전성을 실현했습니다. 즉, 메모리 할당이 충분하지 않으면 쿼리가 즉시 취소되므로 후속 단계에서 "취소" 상태를 확인하는 수고가 줄어듭니다.
Doris 백엔드는 정기적으로 프로세스의 물리적 메모리와 시스템에서 현재 사용 가능한 메모리 크기를 검색합니다. 동시에 모든 쿼리/로드/압축 작업의 MemTracker 스냅샷을 수집합니다.
백엔드 프로세스가 메모리 제한을 초과하거나 메모리가 부족한 경우 Doris는 캐시를 지우고 여러 쿼리 또는 데이터 수집 작업을 취소하여 일부 메모리 공간을 확보합니다. 이는 개별 GC 스레드에 의해 정기적으로 실행됩니다.
소비된 프로세스 메모리가 SoftMemLimit(기본적으로 총 시스템 메모리의 81%)를 초과하거나 사용 가능한 시스템 메모리가 Warning Water Mark(3.2GB 미만) 아래로 떨어지면 Minor GC가 트리거됩니다.
이때 메모리 할당 단계에서 쿼리 실행이 일시 중지되고, 데이터 수집 작업에서 캐시된 데이터가 강제 플러시되며, 데이터 페이지 캐시의 일부와 오래된 세그먼트 캐시가 해제됩니다.
새로 해제된 메모리가 프로세스 메모리의 10%를 차지하지 않는 경우 메모리 오버커밋이 활성화된 상태에서 Doris는 10% 목표가 충족되거나 모든 쿼리가 취소될 때까지 가장 큰 "오버커밋자"인 쿼리를 취소하기 시작합니다.
그러면 Doris는 시스템 메모리 확인 간격과 GC 간격을 단축합니다. 더 많은 메모리를 사용할 수 있게 되면 쿼리가 계속됩니다.
소비된 프로세스 메모리가 MemLimit(기본적으로 전체 시스템 메모리의 90%)을 초과하거나 사용 가능한 시스템 메모리가 Low Water Mark(1.6GB 미만) 아래로 떨어지면 Full GC가 트리거됩니다.
이때 데이터 수집 작업이 중지되며 모든 Data Page Cache 및 기타 대부분의 Cache가 해제됩니다.
이러한 모든 단계를 수행한 후에도 새로 해제된 메모리가 프로세스 메모리의 20%를 차지하지 않으면 Doris는 모든 MemTracker를 조사하여 메모리를 가장 많이 소비하는 쿼리 및 수집 작업을 찾아 하나씩 취소합니다.
20% 목표가 달성된 후에야 시스템 메모리 확인 간격과 GC 간격이 연장되고 쿼리 및 수집 작업이 계속됩니다. (한 번의 가비지 수집 작업에는 일반적으로 수백 μs에서 수십 ms가 소요됩니다.)
메모리 할당, 메모리 추적 및 메모리 제한을 최적화한 후 실시간 분석 데이터 웨어하우스 플랫폼인 Apache Doris의 안정성과 높은 동시성 성능을 크게 향상시켰습니다. 백엔드에서 OOM 충돌이 발생하는 경우는 이제 거의 발생하지 않습니다.
OOM이 발생하더라도 사용자는 로그를 기반으로 문제의 원인을 찾아 해결할 수 있습니다. 또한 쿼리 및 데이터 수집에 대한 보다 유연한 메모리 제한을 통해 사용자는 메모리 공간이 충분할 때 메모리를 관리하기 위해 추가 노력을 들이지 않아도 됩니다.
다음 단계에서는 메모리 오버커밋에서 쿼리가 완료되도록 보장할 계획입니다. 즉, 메모리 부족으로 인해 취소해야 하는 쿼리가 줄어듭니다.
우리는 이 목표를 예외 안전성, 리소스 그룹 간 메모리 격리, 중간 데이터 플러시 메커니즘 등 특정 작업 방향으로 세분화했습니다.
우리 개발자를 만나고 싶다면 여기에서 우리를 찾으세요 .