QuestDB 9.4: posting/covering index와 Parquet sidecar로 읽기 경로 줄이기
QuestDB 9.4.0은 2026년 5월 18일 공개됐다. 이 릴리스에서 운영자가 주목할 변화는 단순히 인덱스 종류가 하나 늘었다는 사실이 아니다. SYMBOL 조건으로 좁힌 뒤 소수 컬럼만 읽는 쿼리는 posting/covering index sidecar에서 끝낼 수 있고, Parquet 파티션은 로컬 _pm metadata sidecar만 보고 읽어야 할 row group과 byte range를 고를 수 있게 됐다. 둘 다 넓은 시계열 테이블에서 불필요한 파일 접근을 줄이는 변화다.
다만 9.4.0을 그대로 운영 기준선으로 삼아서는 안 된다. 9.4.1부터 9.4.3까지 posting index의 O3(out-of-order) commit, partition squash, rollback, cursor 종료 경로에서 wrong result와 crash를 수정했다. 따라서 이 글은 9.4.3 이상에서 검증한다는 전제로 구조와 도입 절차를 설명한다.
문제는 인덱스 유무보다 읽기 경로의 길이다
금융 tick이나 telemetry 테이블은 보통 시간순으로 계속 넓어진다. symbol, device_id, region 같은 SYMBOL 컬럼으로 범위를 줄여도 다음 단계에서 timestamp, price, quantity가 든 본문 column file을 다시 열어야 한다면 선택적 조회의 I/O 비용은 여전히 남는다.
Parquet도 비슷하다. 어떤 row group을 건너뛸지 판단하려면 footer의 column chunk 위치와 min/max, bloom filter 정보를 먼저 알아야 한다. 파일이 object storage에 있으면 실제 데이터보다 metadata를 알아내기 위한 원격 요청이 선행될 수 있다.
QuestDB 9.4.x는 이 두 경로에 작은 로컬 구조를 둔다.
핵심은 sidecar 자체가 아니라 planner가 sidecar만으로 답할 수 있는 쿼리와 그렇지 않은 쿼리를 구분한다는 점이다. DDL을 적용했다는 사실보다 EXPLAIN에서 실제 접근 경로가 바뀌었는지를 확인해야 한다.
Posting index는 SYMBOL → row IDs를 압축한다
기존 bitmap index도 SYMBOL 값으로 row를 찾을 수 있다. 새 posting index는 각 key에 대응하는 정렬된 row ID 목록을 더 조밀하게 보관하는 데 초점을 둔다.
기본 POSTING 방식은 stride 안에서 두 encoding을 시험한다.
- Delta + Frame-of-Reference(FoR): 인접 row ID 차이를 block 단위로 bit-pack한다. 주기적으로 나타나는 key처럼 delta가 작고 반복적일 때 유리하다.
- Elias-Fano(EF): 단조 증가 정수열을 high/low bit로 나눠 압축한다. key당 값이 적어 Delta block header 비용이 상대적으로 클 때 유리할 수 있다.
- Adaptive(default): 두 결과 중 작은 쪽을 선택한다.
POSTING DELTA와POSTING EF는 특정 encoding을 강제하며 공식 문서도 주로 benchmark 용도로 설명한다.
CREATE TABLE trades (
ts TIMESTAMP,
symbol SYMBOL INDEX TYPE POSTING INCLUDE (price, quantity),
price DOUBLE,
quantity DOUBLE,
payload VARCHAR
)
TIMESTAMP(ts)
PARTITION BY DAY
WAL;
-- 기존 테이블에 추가
ALTER TABLE trades
ALTER COLUMN symbol
ADD INDEX TYPE POSTING INCLUDE (price, quantity);INCLUDE가 없으면 posting list는 row 위치만 알려 준다. price 같은 값을 반환하려면 본문 column file을 읽어야 한다. INCLUDE (price, quantity)를 지정하면 해당 값은 covering sidecar에도 기록되어 다음과 같은 쿼리를 index만으로 처리할 가능성이 생긴다.
SELECT ts, price, quantity
FROM trades
WHERE symbol = 'AAPL';여기서 9.4.2의 compatibility 변경을 정확히 알아야 한다.
INCLUDE가 있을 때만 designated timestamp가 자동 추가된다.cairo.posting.index.auto.include.timestamp=true가 기본값이다.INDEX TYPE POSTING만 쓴 bare index는 non-covering이다.- 따라서 timestamp까지 index에서 읽으려면
INCLUDE절이 반드시 있어야 한다. timestamp 이름을 직접 쓰지 않아도 자동 추가될 뿐이다.
SHOW COLUMNS와 table_columns()의 indexType, indexInclude로 최종 metadata를 확인해야 하는 이유가 여기에 있다.
Covering index는 본문 컬럼을 건너뛸 때만 의미가 있다
Covering 경로는 select와 filter에 필요한 값이 indexed SYMBOL과 INCLUDE 컬럼 안에 들어올 때 성립한다. 공식 문서가 제시하는 대표 형태는 다음과 같다.
WHERE symbol = 'X'WHERE symbol IN ('X', 'Y')- bind variable을 사용한 equality filter
LATEST ON ts PARTITION BY symbolSELECT DISTINCT symbol
EXPLAIN
SELECT ts, price
FROM trades
WHERE symbol = 'AAPL';계획에 다음과 같은 node가 나타나는지 본다.
SelectedRecord
CoveringIndex on: symbol with: ts, price
filter: symbol='AAPL'SELECT DISTINCT symbol은 covered value를 읽지 않으므로 CoveringIndex가 아니라 PostingIndex op: distinct로 나타난다. 반대로 payload처럼 INCLUDE 밖의 컬럼을 추가하면 본문 column file 접근이 다시 필요하다.
중요한 점은 planner 선택이 언제나 더 빠르다고 가정하지 않는 것이다. QuestDB 공식 문서는 일부 async group-by와 filter workload에서 covering 경로가 일반 계획보다 느릴 수 있다고 명시한다. A/B 비교를 위해 query hint를 남겨 둔 이유다.
-- covering 경로만 제외
SELECT /*+ no_covering */ ts, price
FROM trades
WHERE symbol = 'AAPL';
-- index 접근 전체 제외
SELECT /*+ no_index */ ts, price
FROM trades
WHERE symbol = 'AAPL';같은 data snapshot과 cache 상태에서 기본 계획, no_covering, no_index를 반복 측정해야 한다. upstream release가 제시한 압축률과 latency 수치는 프로젝트 자체 benchmark 결과이지, 자신의 cardinality·partition 크기·O3 비율에서 보장되는 수치가 아니다.
INCLUDE를 늘리면 read I/O 대신 write·seal 비용을 낸다
Covering index는 공짜 복사본이 아니다. 포함한 컬럼마다 sidecar 저장 공간과 write path가 추가된다. 숫자형은 FoR 또는 ALP, VARCHAR와 STRING은 조건에 따라 FSST 같은 압축을 사용하지만, 압축 전 데이터를 읽고 sidecar를 만드는 일 자체는 남는다.
특히 partition을 seal할 때 posting generation을 조밀한 형태로 합치는 비용은 partition 전체 row 수에 비례한다. O3 commit으로 파티션을 다시 만들면 영향받은 posting sidecar도 재구성된다. 늦은 데이터가 자주 과거 파티션을 건드리는 workload에서는 point read 절감보다 rewrite 비용이 더 커질 수 있다.
따라서 INCLUDE 후보는 “언젠가 읽을 컬럼”이 아니라 다음 조건을 만족해야 한다.
- latency가 중요한 hot query가 반복해서 함께 읽는다.
SYMBOLequality 또는IN으로 충분히 좁혀진다.- wide table 본문 접근이 실제 I/O 병목이다.
- 해당 컬럼 update와 O3 rewrite 비용을 감당할 수 있다.
EXPLAIN과 실측에서 covering path가 선택되고 더 낫다.
공식 문서는 수백~수천 개 distinct value를 가진 high-cardinality SYMBOL, 그리고 필요한 컬럼이 전체의 일부인 wide table을 좋은 후보로 든다. 저카디널리티 status 컬럼이나 거의 모든 컬럼을 반환하는 scan에는 먼저 benchmark가 필요하다.
Parquet _pm sidecar는 planner를 data file에서 분리한다
9.4.0의 두 번째 중요한 변화는 각 data.parquet 옆에 생성되는 binary _pm 파일이다. 이 파일에는 다음 정보가 들어간다.
- QuestDB와 Parquet column descriptor
- row group별 column chunk byte offset와 compressed length
- codec과 encoding
- null count, distinct count
- min/max statistics
- bloom filter의 위치와 길이
- CRC가 포함된 versioned footer
이전에는 QuestDB 전용 JSON metadata와 Parquet footer를 읽고 해석해야 했다. _pm이 있으면 planner는 작은 로컬 파일에서 row-group pruning과 필요한 byte range를 먼저 결정할 수 있다. 향후 data.parquet가 S3, GCS, Azure Blob 같은 remote object storage에 있어도 metadata 확인을 위한 원격 footer read를 줄일 수 있는 구조다.
업그레이드 시에는 Mig940 migration이 기존 Parquet partition의 _pm을 생성한다. 9.4.1에서는 _pm format 자체가 다시 migration됐다. Parquet partition이 많은 노드는 재시작 시간을 평소와 같다고 가정하지 말고 canary에서 startup log, migration 시간, local disk 증가량을 먼저 측정해야 한다.
9.4.3의 table-level Parquet는 새 파티션에만 적용된다
9.4.3에서는 새 파티션의 기본 형식을 table 수준에서 정할 수 있다.
CREATE TABLE trades_archive (
ts TIMESTAMP,
symbol SYMBOL,
price DOUBLE
)
TIMESTAMP(ts)
PARTITION BY DAY
FORMAT PARQUET
WAL;
ALTER TABLE trades_archive SET FORMAT PARQUET;
ALTER TABLE trades_archive SET FORMAT NATIVE;운영 경계는 명확하다.
FORMAT PARQUET는 partitioned WAL table에서만 쓸 수 있다.FORMAT NATIVE가 여전히 기본값이다.ALTER TABLE ... SET FORMAT은 metadata 변경이며 기존 파티션을 변환하지 않는다.- 변경 뒤 새로 생기는 파티션에만 기본 형식이 적용된다.
- Parquet partition은 immutable하므로 write는 O3 rewrite 경로를 탄다. 순차 append가 많은 hot table은 NATIVE보다 write amplification이 커질 수 있다.
즉, “Parquet가 더 작다”는 이유만으로 ingestion 중심 table 전체를 전환하면 안 된다. 오래된 파티션을 선택적으로 조회하고 remote/cold tier로 옮길 가능성이 큰 workload에서 read와 storage 이득을 write 비용과 비교해야 한다.
Decode cache는 global 256 MB가 아니라 per-cursor budget이다
Parquet row group은 random access 전에 QuestDB의 in-memory column 형식으로 decode해야 한다. 9.4.3은 기존의 “cursor당 8 slot” 제한을 byte budget으로 바꿨다.
| 설정 | 9.4.3 의미 | 운영 주의 |
|---|---|---|
cairo.sql.parquet.cache.memory.size | decoded row group의 per-cursor budget, 기본 256 MB | 동시 cursor와 join worker 수를 곱해 peak RSS 추정 |
cairo.sql.parquet.frame.cache.capacity | deprecated, 값은 받아도 동작에는 사용하지 않음 | 기존 설정만 남기면 의도한 제한이 적용되지 않음 |
cairo.posting.index.auto.include.timestamp | INCLUDE가 있을 때 designated timestamp 자동 추가, 기본 true | bare posting index를 covering으로 만들지는 않음 |
cairo.posting.index.row.id.encoding | posting row ID encoding 기본 선택 | 보통 adaptive 유지, 강제 encoding은 benchmark 후 결정 |
Random-access sort나 hash join처럼 scattered access를 하는 cursor는 전체 budget을 사용할 수 있다. monotonic access로 분류된 경로는 budget 일부만 사용한다. parallel join은 worker별 decode pool을 열 수 있으므로 “256 MB를 설정했으니 query 하나가 최대 256 MB”라고 해석하면 안 된다. 높은 동시성 환경에서는 실제 cursor 수와 worker fan-out을 넣어 상한을 계산해야 한다.
왜 9.4.0이 아니라 9.4.3부터 검증해야 하는가
Posting/covering index는 9.4.0에 처음 들어온 storage·query-path 기능이다. 이후 patch 내역에는 단순 UI 수정이 아니라 correctness fix가 연속으로 포함됐다.
| 버전 | posting/Parquet 관련 운영상 중요한 수정 |
|---|---|
| 9.4.1 | failed O3 commit 뒤 wrong result, Parquet partition의 covering build와 RSS, posting index가 있는 storage-policy conversion 수정 |
| 9.4.2 | partition squash 뒤 missing row/crash, posting-index query의 wrong result와 memory leak 수정; bare posting을 non-covering으로 명확화 |
| 9.4.3 | posting query wrong row/crash, rollback 뒤 covered read, covering O3의 native memory overrun, cursor 종료 leak 수정 |
새로운 on-disk 구조를 검증하면서 이미 공개된 correctness patch를 건너뛸 이유는 없다. 9.4.0 또는 9.4.1을 평가했던 결과가 있다면 9.4.3 이상에서 query plan과 결과 정합성을 다시 확인해야 한다.
마이그레이션과 rollback에서 놓치기 쉬운 경계
가장 위험한 rollback 조건은 posting index metadata다. 9.4.0 이전 binary는 새 index type을 인식하지 못해 posting index가 있는 table을 열지 못한다. binary downgrade가 필요하면 새 버전에서 먼저 모든 posting index를 제거해야 한다.
ALTER TABLE trades ALTER COLUMN symbol DROP INDEX;이 한 줄을 실행할 수 있다는 사실과 안전한 rollback plan이 있다는 것은 다르다. index 제거 시간, disk 여유 공간, concurrent query 영향, replica 동작을 canary에서 미리 측정해야 한다. 또한 snapshot restore가 posting sidecar와 _pm을 함께 검증하는지 실제 복구 rehearsal로 확인한다.
권장 순서는 다음과 같다.
- 9.4.3 이상을 replica 또는 canary node에 배포한다.
- 기존 Parquet partition의
_pmmigration 시간과 startup log를 확인한다. - 새 test table 또는 한 개의 저위험 table에 posting index를 만든다.
- 대표 query의 결과 checksum, row count,
EXPLAIN, latency를 기존 경로와 비교한다. - ingestion throughput, WAL apply, O3 rewrite, seal 시간, disk 증가량을 함께 본다.
no_covering과no_indexhint로 회귀 query의 escape hatch가 동작하는지 확인한다.- posting index를 drop하고 이전 binary가 table을 여는 rollback rehearsal를 수행한다.
- 검증을 통과한 query family만 대상으로
INCLUDE를 점진적으로 확대한다.
운영 검증 체크리스트
배포 전
- [ ] 대상 버전이 9.4.3 이상인가?
- [ ] posting index 대상
SYMBOLcardinality와 hot query 비율을 측정했는가? - [ ]
INCLUDE컬럼이 실제 hot projection과 일치하는가? - [ ] O3/backfill이 과거 파티션을 얼마나 자주 rewrite하는지 아는가?
- [ ] Parquet partition 수와
_pmmigration에 필요한 startup window를 확보했는가? - [ ] binary downgrade 전에 posting index를 drop하는 runbook이 있는가?
Canary
- [ ]
SHOW COLUMNS또는table_columns()에서indexType,indexInclude를 확인했는가? - [ ] 대표 query의
EXPLAIN에CoveringIndex또는PostingIndex가 실제로 나타나는가? - [ ] 기본 계획과
/*+ no_covering */,/*+ no_index */의 p50/p95/p99를 같은 조건에서 비교했는가? - [ ] 결과 row count와 checksum이 기존 계획과 같은가?
- [ ] ingestion throughput, WAL apply lag, O3 commit, partition seal 시간이 악화되지 않았는가?
- [ ]
cairo.sql.parquet.cache.memory.size × 동시 cursor/worker기준으로 peak RSS를 검증했는가?
운영 전환
- [ ] 9.4.0 benchmark 수치를 그대로 capacity plan에 사용하지 않았는가?
- [ ] async group-by/filter 회귀를 query별로 확인했는가?
- [ ]
FORMAT PARQUET가 기존 파티션을 변환하지 않는다는 점을 migration 계획에 반영했는가? - [ ] snapshot/restore와 posting index drop을 포함한 복구 rehearsal를 완료했는가?
- [ ] plan shape와 latency 회귀를 탐지할 dashboard 또는 정기 검증 query가 있는가?
정리
QuestDB 9.4.x의 핵심은 “새 인덱스가 더 빠르다”가 아니다. SYMBOL로 좁힌 query는 posting list로 row를 찾고, hot projection은 covering sidecar에서 바로 읽으며, Parquet query는 _pm으로 필요한 row group과 byte range를 먼저 고르는 식으로 읽기 경로를 단계별로 짧게 만들었다.
그 대가도 분명하다. INCLUDE가 늘수록 write와 seal 비용이 커지고, O3는 sidecar rewrite를 부르며, table-level Parquet는 순차 append fast path 대신 immutable partition rewrite를 택한다. Parquet decode cache도 global limit가 아니라 cursor와 worker 수에 따라 늘어나는 budget이다.
따라서 도입 판단의 단위는 server 전체가 아니라 query family여야 한다. 9.4.3 이상에서 결과 정합성을 먼저 확인하고, EXPLAIN과 hint A/B 측정으로 실제 접근 경로를 검증한 뒤, hot query에 필요한 최소 INCLUDE만 선택하는 것이 안전하다.
References
- QuestDB 9.4.0 release notes — GitHub, 2026-05-18
- Posting index and covering index — QuestDB documentation
- EXPLAIN keyword — QuestDB documentation
- PR #6861: add posting and covering index
- PR #6913: add local Parquet
_pmmetadata sidecar - QuestDB 9.4.1 hardening release — GitHub, 2026-06-03
- QuestDB 9.4.2 hardening release — GitHub, 2026-06-09
- QuestDB 9.4.3 release notes — GitHub, 2026-06-15
- PR #7107: table-level Parquet partition format
- PR #7230: memory-budgeted Parquet decode cache