LabHub
学习 学习路径 课程

Apache Flink — 스트림을 엔진으로 돌린다 · 상태와 TTL · 讲解

상태는 어디에 얼마나 남는가 — 연산자·TTL·백엔드

在 LabHub 中继续学习

한 줄 요약

상태는 잡 전체가 아니라 연산자마다 따로 있다. 어느 연산자가 무엇을 쥐고 TTL 이 얼마인지는 COMPILE PLAN 이 JSON 으로 정확히 적어 준다. 끝없는 GROUP BY 와 일반 조인은 기본으로 영원히 쥐고, 창 집계는 창이 닫힐 때 스스로 비운다. 상태 백엔드는 그 상태를 어디에 어떤 모양으로 둘지를 정할 뿐 결과를 바꾸지 않는다.

概念图: 연산자마다 · 영원히 · 어디에 어떤 모양으로 · 끝없이 자랄 수 있고

왜 이게 필요했나

스트리밍 잡을 몇 주 돌리면 거의 반드시 받는 질문이 "메모리(또는 디스크)가 왜 계속 느나" 다. 공식 문서는 GROUP BY 에 대해 이렇게 경고한다. 스트리밍 쿼리의 결과를 계산하는 데 필요한 상태는 끝없이 자랄 수 있고, 크기는 그룹 수와 집계 함수의 종류에 달렸다(MIN·MAX 는 무겁고 COUNT 는 가볍다). 앞 모듈의 일반 조인은 양쪽 입력을 영원히 쥔다고 했다.

그래서 TTL(유휴 상태 보존 시간)을 건다. 그런데 TTL 은 공짜가 아니다. 문서의 결정성 장(Determinism)은 TTL 로 상태를 지우는 것이 흔히 필요한 타협이지만 결과를 비결정적으로 만들 수 있다고 적는다. 지워진 키에 뒤늦게 행이 오면 처음 본 키처럼 다시 센다. 그러니 "어느 연산자에 얼마를 걸지" 를 연산자 단위로 알고 정해야 한다. 이 모듈은 그 지도를 실행 계획에서 읽는 법이다.

어떻게 동작하나

한 원천에서 갈라진 다섯 쿼리의 계획. 거르기만 하는 Calc 에는 상태가 없다. 끝없는 GROUP BY 는 groupAggregateState 하나를, 일반 조인은 leftState 와 rightState 둘을 쥔다. 힌트로 조인 왼쪽 1 d, 오른쪽 2 h 를 주면 잡 기본값 30 min 보다 우선한다. 조인 둘이 이어지면 힌트 셋은 첫 조인 양쪽과 둘째 조인 오른쪽에 붙고 둘째 조인 왼쪽은 잡 기본값을 받는다. TUMBLE 창 집계 노드에는 TTL 상태 항목이 없고 창이 닫힐 때 비운다. 아래 띠는 상태 백엔드 두 가지로, hashmap 은 힙의 자바 객체, rocksdb 는 로컬 디스크의 직렬화된 바이트에 둔다

COMPILE PLAN 'file:///경로.json' FOR INSERT INTO ... 는 잡을 돌리지 않고 최적화된 계획을 JSON 으로 쓴다. 노드마다 type 이 있고, 상태가 있는 노드에는 state 목록이 붙는다. 이 실습 환경에서 뽑아 본 결과는 이렇다.

| 노드(type) | state 항목 | 기본 TTL |
| --- | --- | --- |
| stream-exec-calc (거르기·바꾸기) | 없음 | — |
| stream-exec-group-aggregate (끝없는 GROUP BY) | groupAggregateState | 0 ms |
| stream-exec-join (일반 조인) | leftState · rightState | 0 ms |
| stream-exec-deduplicate · stream-exec-rank | deduplicateState · rankState | 0 ms |
| 구간 조인 · temporal join · 창 집계 | 없음 | — |

0 ms 는 "지우지 않음" 이다. 문서의 설정 설명(table.exec.state.ttl)이 그대로다 — 갱신되지 않은 상태를 최소한 이만큼은 보존하고, 그보다 오래 쉬면 그 뒤 언젠가 지운다. 기본값 0 은 영원히 두라는 뜻이다. SET 'table.exec.state.ttl' = '30 min' 을 주면 위 표의 0 ms 가 모두 30 min 으로 바뀐다. 표의 마지막 줄 연산자들은 TTL 이 아니라 시간(워터마크가 창 끝·구간 끝을 지나는 것)으로 상태를 정리하므로 계획에 TTL 항목이 없다. 문서도 "짧게 사는 창 GROUP BY 는 문제가 아니다" 라고 적는다.

잡 전체에 한 값을 거는 것은 거칠다. 주문은 하루 기억해야 하지만 결제는 두 시간이면 되는 조인이 있다. 그래서 STATE_TTL 힌트가 있다. 일반 조인과 GROUP BY 에만 쓰고, 표 이름이나 별칭을 열쇠로 준다(별칭을 붙였으면 반드시 별칭).

SET 'table.exec.state.ttl' = '30 min';SELECT /*+ STATE_TTL('o' = '1d', 'p' = '2h') */ ...FROM orders o JOIN payments p ON o.order_id = p.order_id;-- 계획: leftState "1 d" · rightState "2 h"  (잡 기본값보다 힌트가 우선)

조인이 이어지면 규칙이 하나 더 있다. 문서는 힌트 셋이 첫 조인의 왼쪽·오른쪽과 둘째 조인의 오른쪽에 해석되고, 둘째 조인의 왼쪽(첫 조인의 결과)은 잡 설정에서 온다고 적는다. 실제로 STATE_TTL('o'='1d','p'='2h','u'='7d') 에 잡 기본값 30 min 을 주고 뽑아 보니 둘째 조인은 leftState "30 min" · rightState "7 d" 였다. 그 자리까지 정하려면 첫 조인을 뷰로 떼어 따로 힌트를 줘야 한다.

상태 백엔드는 이 상태들을 어디에 두느냐다. 문서에 따르면 아무것도 정하지 않으면 HashMapStateBackend 로, 상태를 자바 힙의 객체로 둔다. EmbeddedRocksDBStateBackend 는 TaskManager 로컬 디스크의 RocksDB 에 직렬화된 바이트로 둔다. 디스크만큼 담을 수 있는 대신 읽고 쓸 때마다 직렬화 비용을 치른다. 문서는 증분 체크포인트를 주는 백엔드로 이쪽을 꼽고, 원격 저장소에 상태를 두는 ForSt 백엔드는 아직 실험 단계라고 적는다. 잡마다 SET 'state.backend.type' = 'rocksdb' 로 바꿀 수 있고, 어느 백엔드로 돌았는지는 /jobs/<jid>/checkpoints/config 응답의 state_backend 칸에 남는다. 같은 GROUP BY 를 RocksDB 로 돌려도 결과는 한 글자도 다르지 않다.

현장에서 만나는 모습

상태가 자란다는 알림이 오면 먼저 계획을 뽑는다. 어떤 연산자가 TTL 0 ms 로 무엇을 쥐고 있는지가 한눈에 보인다. 흔히 범인은 무심코 쓴 끝없는 GROUP BY 나 차원 표와의 일반 조인이다. 창 집계로 바꿀 수 있는지, temporal join 으로 바꿀 수 있는지가 TTL 보다 먼저 볼 해법이다.

TTL 을 걸기로 했다면 잡 전체 값보다 힌트를 먼저 생각한다. 잡 전체 30분은 하루짜리 주문 상태까지 30분 만에 지워 결과를 틀리게 만든다. 반대로 힌트만 믿고 이어진 조인의 둘째 왼쪽 자리를 잊으면, 거기는 잡 기본값(기본 0 = 영원히)을 받는다. 계획 JSON 에서 숫자로 확인하는 습관이 답이다.

백엔드를 바꾸는 이유는 대개 힙이 모자라서다. 이 파드의 TaskManager 태스크 힙은 200MiB 남짓이라 큰 상태는 hashmap 에 담기지 않는다. RocksDB 로 옮기면 디스크로 넘치게 할 수 있지만 느려진다 — 크기와 속도를 맞바꾸는 결정이다.

다음 실습에서 할 것

원천 셋과 버리는 싱크를 정의하고, 거르기만 하는 쿼리의 계획에서 상태가 없음을 확인한다. 끝없는 GROUP BY 의 기본 TTL 을 읽고, 잡 전체 TTL 을 걸어 바뀐 값을 본다. 조인 양쪽에 다른 TTL 을 힌트로 주고, 이어진 조인에서 힌트가 닿지 않는 자리를 찾는다. 창 집계의 계획과 결과를 확인하고, RocksDB 백엔드로 같은 집계를 돌린 뒤 모든 숫자를 보고서로 정리한다.