DB Study

3장. Context · 스레드 · 동시성

하나의 서버 프로세스 안에서 쿼리·머지·백업이 뒤엉키지 않는 이유 — 설정 6단계 머지, 스레드 풀 계층, CPU slot이라는 교통정리 시스템을 해부합니다.

고급 ⏱ 약 30분 🎬 애니메이션 3개 선수 지식: 2장 내부 컴포넌트
🧭
이 장에서 배우는 것

① 설정이 6단계 Context 계층에서 머지되는 순서 ② 백그라운드 작업이 SET을 무시하는 이유 ③ Global → 특수 풀로 이어지는 스레드 풀 계층 ④ 쿼리 하나가 여러 스레드로 병렬 처리되는 구조(ThreadGroup) ⑤ CPU slot 기반 ConcurrencyControl

3.1 Context 계층 — 이 설정은 대체 어디서 온 걸까

ClickHouse의 모든 동작은 설정(settings)으로 제어됩니다. 문제는 설정의 출처가 한 곳이 아니라는 것 — 코드 기본값부터 쿼리의 SETTINGS 절까지 6단계가 겹겹이 쌓이고, 뒤 단계가 앞 단계를 오버라이드합니다.

설정 레이어 (아래가 위를 덮어씀) max_threads의 최종값 ① Global defaults (코드 내 기본값) ② Global configuration (config.xml) ③ Profile + ④ User 설정 (users.xml) ⑤ Session: SET max_threads = 8 ⑥ Query: SETTINGS max_threads = 4 auto (코어 수) 8 4 ✅ (쿼리 설정 승리) ⚠️ 예외: 백그라운드 작업(머지·뮤테이션)은 ⑤⑥을 무시한다 Global + 'background' 프로파일만 적용 — SET으로는 머지를 튜닝할 수 없음
<!-- 잘못된 시도: 세션 설정은 백그라운드 머지에 영향 없음 -->
-- SET max_bytes_to_merge_at_max_space_in_pool = ...  ❌

<!-- 올바른 방법: users.xml의 background 프로파일에 추가 ✅ -->
<profiles>
    <background>
        <max_bytes_to_merge_at_max_space_in_pool>161061273600</max_bytes_to_merge_at_max_space_in_pool>
    </background>
</profiles>
<!-- 프로파일명은 background_profile 서버 설정으로 변경 가능 -->

3.2 스레드 풀 계층 — 스레드를 빌려 쓰는 조직도

스레드 생성·소멸은 비싼 작업이므로, ClickHouse는 스레드 풀 체계를 씁니다. 모든 특수 풀은 최상위 싱글톤인 Global Thread Pool 위에 세워진 계층 구조입니다. 특수 풀의 역할은 스레드를 직접 만드는 게 아니라 동시 작업 수 제한과 스케줄링입니다.

담당핵심 설정 / 특징
Global Thread Pool모든 풀의 기반 (싱글톤)max_thread_pool_size, thread_pool_queue_size. 과부하 시 우선순위 큐로 대기
Server Pool클라이언트 커넥션스레드 1개가 커넥션 1개 전담. 상한 max_connection
IO Thread PoolI/O 블로킹 작업I/O 작업이 Global 풀을 고갈시켜 CPU 쿼리를 막는 것을 방지하는 격리벽
Backups IO PoolS3 백업 전용백업이 인터랙티브 쿼리의 I/O를 침범하지 않도록 재격리
Background Schedule Pool주기적 작업동일 작업의 동시 실행 방지, 실행 시점 지연 가능
MergeTreeBackgroundExecutor머지·뮤테이션·페치·무브작업을 선점 가능한 스텝으로 분할 — 작은 머지가 큰 머지보다 먼저 끝나게 스케줄링
💡
우선순위는 "줄 설 때"만 의미 있다

풀이 과부하일 때 대기열에서 우선순위가 높은 작업이 먼저 시작됩니다. 하지만 이미 실행 중인 작업 간에는 우선순위 차이가 없습니다. 우선순위로 실행 중인 무거운 쿼리를 밀어낼 수는 없다는 뜻입니다.

3.3 쿼리 하나 = 스레드 여러 개 — ThreadGroup의 구조

어느 풀에서 스레드를 받든, 작업 시작 시 ThreadStatus 인스턴스가 생성됩니다. Thread ID, Query ID, 성능 카운터, 리소스 소비량을 캡슐화한 객체로, thread-local 포인터라 CurrentThread::get()으로 어디서든 접근합니다. 그렇다면 쿼리 하나를 여러 스레드가 나눠 처리할 때, 이들은 어떻게 하나로 묶일까요?

👤 클라이언트 Master Thread Server pool에서 할당 ThreadGroupStatus 생성 QueryScope(query_context) 보유 Global Thread Pool 유휴 스레드들… Worker 1 attach Worker 2 attach Worker N attach 파트 A 처리 파트 B 처리 파트 C 처리 ThreadGroup이 집계: MemoryTracker + ProfileEvents 쿼리 종료 → 스레드는 풀로 반환, 집계 결과는 system.query_log에 기록 memory_usage · thread_ids · ProfileEvents 를 쿼리 단위로 조회 가능

3.4 ConcurrencyControl — CPU slot으로 하는 좌석 배정

max_threads의 기본값은 쿼리 하나가 모든 코어를 쓰도록 되어 있습니다. 그런데 그런 쿼리가 10개 동시에 실행되면? 스레드가 코어 수의 10배가 되어 컨텍스트 스위칭·캐시 미스·메모리 오버헤드가 폭증합니다. ConcurrencyControlCPU slot이라는 좌석제를 도입해 이 문제를 풉니다.

// 1단계: 최소 1개 ~ 최대 max_threads개 slot 요청
//        첫 slot은 즉시 부여 — 이것이 "soft limit"의 핵심
auto slots = ConcurrencyControl::instance().allocate(1, max_threads);

// 2단계: slot이 부여될 때마다 스레드 생성
while (auto slot = slots->tryAcquire())
    spawnThread([slot = std::move(slot)] { ... });

// 3단계: 전체 slot 수는 재시작 없이 런타임 조정 가능
ConcurrencyControl::setMaxConcurrency(concurrent_threads_soft_limit_num);
서버 전체 slot: 8개 (soft limit) ① 쿼리 A 혼자 A A A A A A A A ② 쿼리 B 도착 A A A A A A A B 최소 1개 즉시! ③ 점진적 재분배 A A A A B B B B A가 slot을 반환할 때마다 B에게 → 4 : 4로 수렴 ④ 쿼리 C·D 도착 A A A B B B C D soft limit: 쿼리 100개가 와도 각자 최소 1 스레드 보장 — "실행조차 안 되는" 쿼리는 없다
⚠️
concurrent_threads_soft_limit_num 기본값은 0 (무제한)

동시 쿼리가 많은 환경이라면 CPU 코어 수의 1~2배로 설정하는 것을 고려하세요. 너무 낮으면 개별 쿼리가 느려지고, 0(무제한)이면 스레드 폭증으로 컨텍스트 스위칭 비용이 커집니다.

3.5 종합 — SELECT 쿼리 하나의 라이프사이클

  1. 커넥션 수락 → Server pool에서 Master thread 할당
  2. Context 구성 → Global → Profile → User → Session → Query 순서로 설정 머지
  3. 파싱·해석 → Parser → AST → Interpreter → QueryPipeline (2장)
  4. slot 할당allocate(1, max_threads), 최소 1개 즉시 확보
  5. 스레드 실행 → ThreadGroup 생성, Worker들이 attach하여 MemoryTracker·ProfileEvents 집계
  6. Pipeline 실행 → Processor DAG가 Block 단위로 필터·집계 처리
  7. 결과 출력 → Format → WriteBuffer → 클라이언트
  8. 정리 → slot 반환·재분배, system.query_log 기록

3.6 모니터링 — 스레드와 동시성 들여다보기

-- 현재 활성 스레드 현황
SELECT metric, value FROM system.metrics
WHERE metric IN ('GlobalThread', 'GlobalThreadActive',
                 'IOThreads', 'IOThreadsActive',
                 'BackgroundMergesAndMutationsPoolTask');

-- ConcurrencyControl 영향 확인: 요청한 스레드 vs 실제 사용한 스레드
SELECT query_id,
       Settings['max_threads']  AS requested_threads,
       length(thread_ids)       AS actual_threads,
       peak_memory_usage
FROM system.query_log
WHERE type = 'QueryFinish'
ORDER BY event_time DESC
LIMIT 20;
🔬
S3 스토리지 사용자라면

max_io_thread_pool_sizemax_backups_io_thread_pool_size를 독립적으로 튜닝하세요. 백업 작업이 인터랙티브 쿼리의 I/O 스레드를 잡아먹는 것을 막는 것이 두 풀을 분리한 이유입니다.

✍️ 이해도 체크

세션에서 SET max_bytes_to_merge_at_max_space_in_pool = ...을 실행했는데 백그라운드 머지 동작이 바뀌지 않습니다. 이유는?
✅ 머지·뮤테이션 같은 백그라운드 작업은 Background context를 사용합니다 — Global 설정 + 'background' 프로파일만 봅니다. 튜닝하려면 <profiles>의 background 프로파일을 수정하거나 background_profile로 커스텀 프로파일을 지정해야 합니다.
concurrent_threads_soft_limit_num = 64인 서버에 쿼리 100개가 동시에 실행되면?
✅ "soft" limit의 핵심입니다. allocate(1, max_threads)의 첫 slot은 항상 즉시 부여되므로, 총 스레드 수가 제한(64)을 초과해 100이 되더라도 모든 쿼리가 실행됩니다. 이후 slot이 반환되면 ConcurrencyControl이 재분배합니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch03_context_threads_concurrency.md — slot 상태 머신 상세, BackgroundSchedulePool의 보장 사항, 추가 모니터링 쿼리가 담겨 있습니다.