Aleksander Roszig
29 maja 2026 | 11 min czytaniaTimescaleDB - hypercore i kompresja kolumnowa z ratio do 98% w PostgreSQL
TimescaleDB pozwala osiągnąć kompresję nawet do 98% dla typowych danych time-series. Kompresja danych w szeregach czasowych (ang. time-series) wymaga zupełnie innego podejścia niż klasyczne algorytmy ogólnego przeznaczenia stosowane w bazach OLTP. W TimescaleDB odpowiedzialny za to jest silnik hypercore — hybrydowy silnik wierszowo-kolumnowy wykorzystujący wyspecjalizowane algorytmy: delta encoding, delta-of-delta, Gorilla XOR i run-length encoding. Ten artykuł wyjaśnia, jak to działa i jak skonfigurować kompresję, aby faktycznie osiągnąć takie ratio.
Kompresja w TimescaleDB - czym różni się od PostgreSQL TOAST
PostgreSQL ma wbudowany mechanizm nazwany TOAST (The Oversized-Attribute Storage Technique), ale kompresja TimescaleDB rozwiązuje fundamentalnie inny problem. TOAST radzi sobie z pojedynczymi dużymi wartościami (długie stringi, jsonb, bytea), kompresja TimescaleDB optymalizuje wzorce międzywierszowe w danych time-series. Te dwa mechanizmy są komplementarne, niekonkurencyjne — TimescaleDB nawet wewnętrznie wykorzystuje TOAST jako fallback dla niektórych typów danych. PostgreSQL używa stałej wielkości “page size” zazwyczaj o wielkości 8kB i nie pozwala krotkom (ang. tuples) zajmować kilku stron. Z tego powodu w przypadku bardzo dużych wartości pól musimy te dane skompresować i/lub podzielić na wiele fizycznych wierszy.
| Cecha | TOAST (vanilla PostgreSQL) | TimescaleDB hypercore |
|---|---|---|
| Cel projektowy | Pojedyncze wartości > 2 KB | Wzorce międzywierszowe w time-series |
| Aktywacja | Wiersz przekracza TOAST_TUPLE_THRESHOLD (~2 KB) | Polityka per chunk (np. starsze niż 7 dni) |
| Obsługiwane typy | Tylko variable-length (text, jsonb, bytea, numeric) | Wszystkie typy danych |
| Algorytmy | pglz (default), lz4 (od PG14, opt-in) | Kombinacja: delta encoding, delta-of-delta, simple-8b, run-length encoding, XOR-based, dictionary compression |
| Granularność kompresji | Per wartość (1 wartość = 1 strumień bajtowy) | Per batch (~1000 wierszy razem) |
| Wykorzystanie struktury danych | Nie - traktuje wartości jako opaque bytes | Tak - eksploatuje strukturę numeryczną, monotoniczność, powtarzalność |
| Typowe ratio dla floats z czujników | ~1.0× (brak kompresji) | 10-20× |
| Typowe ratio dla timestampów | ~1.0× (brak kompresji - typ stałej długości) | 50-100× (delta-of-delta dla regularnych interwałów) |
| Typowe ratio dla tekstów | 2-3× (general-purpose LZ) | 5-10× (dictionary + RLE jeśli powtarzalne) |
Tabela pokazuje skalę różnicy. Dla typowego workloadu IoT z floatami i timestampami — czyli kolumn, które TOAST w ogóle nie kompresuje — TimescaleDB osiąga ratio 10-100×, ponieważ jest przystosowany do tego typu danych.
Silnik Hypercore i kompresja kolumnowa
W TimescaleDB za kompresję odpowiada silnik nazwany hypercore — hybrydowy wierszowo-kolumnowy, w którym nowe dane lądują w wierszowych chunkach Postgresa (szybkie INSERTy i UPDATE’y), a starsze chunki są automatycznie konwertowane do formatu kolumnowego z kompresją. Zapytania analityczne, odczytujące te skompresowane dane, odczytują mniej bajtów i działają szybciej. Ta konwersja pozwala na kompresję danych nawet do 98%, co znacząco obniża koszty storage przy projektach z długą retencją danych. W przeciwieństwie do tradycyjnego przechowywania danych opartego na wierszach, w którym dane są przechowywane sekwencyjnie według wiersza, przechowywanie kolumnowe organizuje i kompresuje dane według kolumny. Dzięki temu zapytania mogą pobierać tylko niezbędne pola w partiach, zamiast skanować całe wiersze.
Co się dzieje z wierszami
Konwersja chunka grupuje wiersze w batche po maksymalnie 1000 sztuk i każdy batch staje się jednym wierszem w tabeli kompresowanej, w którym kolumny to tablice.
Każdy skompresowany batch:
- Enkapsuluje dane kolumnowe w skompresowanych tablicach do 1000 wartości per kolumna, przechowywanych jako pojedynczy wpis w tabeli kompresowanej.
- Używa formatu column-major wewnątrz batcha, co umożliwia efektywne skany przez kolokację wartości tej samej kolumny i pozwala wybrać pojedyncze kolumny bez czytania całego batcha.
- Aplikuje zaawansowane techniki kompresji na poziomie kolumny — run-length encoding, kodowanie delta (ang. delta encoding), Gorilla compression — redukując storage i poprawiając I/O.
Źródło: https://www.tigerdata.com/docs/learn/deep-dive/whitepaper#data-model
Przykład kompresji z wykorzystaniem kodowania delta (ang. delta encoding):
| time | machine_id | sensor_type | value |
|---|---|---|---|
| 12:00:00 | MACHINE_001 | temp | 72.5 |
| 12:00:00 | MACHINE_001 | speed | 2.0 |
| 12:00:05 | MACHINE_001 | temp | 72.7 |
| 12:00:05 | MACHINE_001 | speed | 2.1 |
| 12:00:10 | MACHINE_001 | temp | 72.4 |
| 12:00:10 | MACHINE_001 | speed | 2.4 |
Dzięki kodowaniu delta (ang. delta encoding) wystarczy zapisać tylko, o ile każda wartość zmieniła się względem poprzedniego punktu danych, co skutkuje mniejszymi wartościami do zapisania. Po pierwszym wierszu możesz reprezentować kolejne wiersze przy użyciu mniejszej ilości informacji, na przykład:
| time | machine_id | sensor_type | value |
|---|---|---|---|
| 12:00:00 | MACHINE_001 | temp | 72.5 |
| 0 seconds | MACHINE_001 | speed | 2.0 |
| 5 seconds | MACHINE_001 | temp | +0.2 |
| 0 seconds | MACHINE_001 | speed | +0.1 |
| 5 seconds | MACHINE_001 | temp | -0.3 |
| 0 seconds | MACHINE_001 | speed | +0.3 |
W przypadku danych time-series często jest tak, że pewne wartości się powtarzają przez jakiś okres. Na przykład, jeśli masz czujnik temperatury, który mierzy 72.5 stopni przez 10 minut, a następnie nagle wzrasta do 73.0 stopni i utrzymuje się na tym poziomie przez kolejne 10 minut, to można wykorzystać delta-of-delta encoding. Jeśli interwał jest stały (np. zawsze 5 sekund), delta-of-delta wynosi 0 i można ją zapisać na bardzo małej liczbie bitów.
| time | machine_id | sensor_type | value |
|---|---|---|---|
| 12:00:00 | MACHINE_001 | temp | 72.5 |
| +5 seconds | MACHINE_001 | temp | +0.2 |
| 0 seconds | MACHINE_001 | temp | -0.3 |
| 0 seconds | MACHINE_001 | temp | +0.3 |
| 0 seconds | MACHINE_001 | temp | -0.1 |
Delta encoding świetnie sprawdza się dla wartości liczbowych, które zmieniają się o małe kwoty, ale w danych time-series często występują też kolumny, w których ta sama wartość powtarza się przez wiele wierszy z rzędu — na przykład machine_id, sensor_type czy status urządzenia. W takich przypadkach stosuje się kodowanie długości serii (ang. run-length encoding, RLE), które zamiast zapisywać tę samą wartość wielokrotnie, zapisuje ją raz, wraz z liczbą powtórzeń.
Dane przed kompresją:
| time | machine_id | sensor_type | value |
|---|---|---|---|
| 12:00:00 | MACHINE_001 | temp | 72.5 |
| 12:00:05 | MACHINE_001 | temp | 72.7 |
| 12:00:10 | MACHINE_001 | temp | 72.4 |
| 12:00:15 | MACHINE_001 | temp | 72.6 |
| 12:00:20 | MACHINE_001 | temp | 72.5 |
Po zastosowaniu RLE na kolumnach machine_id i sensor_type:
| machine_id | sensor_type |
|---|---|
| MACHINE_001 × 5 | temp × 5 |
Zamiast pięciu kopii stringa MACHINE_001 (~55 bajtów) zapisujemy jedną wartość plus licznik (~15 bajtów). Przy milionach wierszy z tą samą wartością machine_id oszczędność jest ogromna.
Ostatecznie będzie to wyglądać tak:
| kolumna | technika | reprezentacja po kompresji |
|---|---|---|
time | delta-of-delta | 12:00:00, +5s, 0, 0, 0 |
machine_id | run-length encoding | MACHINE_001 × 5 |
sensor_type | run-length encoding | temp × 5 |
value | delta encoding | 72.5, +0.2, -0.3, +0.2, -0.1 |
Jest jeszcze wiele innych metod wykorzystywanych w TimescaleDB, o których można poczytać w oficjalnej dokumentacji.
Kompresja nie jest “jedna na wszystko” — TimescaleDB dobiera algorytm per typ kolumny, co jest kluczowe dla zrozumienia, dlaczego ratio waha się tak mocno między schematami:
- Integery, timestampy, boolean i typy integer-like — kombinacja kodowania delta (ang. delta encoding), delta-of-delta, simple-8b i run-length encoding. Delta-of-delta produkuje małe liczby (dla regularnych interwałów — same zera), a simple-8b dopiero te małe liczby fizycznie pakuje do paru bitów per wartość. Podobne podejście (delta-of-delta dla timestampów) stosuje algorytm Gorilla od Facebooka.
- Kolumny bez wielu powtórzeń (np. floaty z pomiarów temperatury i wibracji) — XOR-based compression (oparta na Gorilla) z domieszką dictionary compression. XOR sąsiednich floatów daje wynik z długim ciągiem zer wiodących i końcowych, gdy wartości są podobne — wtedy wystarczy zapisać tylko środkowe „znaczące" bity zamiast całych 64.
- JSONB — dwuwarstwowo: najpierw dictionary (gdy wartości się powtarzają), a w razie braku powtórzeń fallback do PostgreSQL TOAST (
pglzdomyślnie,lz4jeśli skonfigurowane). - Wszystko inne (stringi, dziwniejsze typy) — dictionary compression. Indeksy w słowniku też idą przez simple-8b + RLE, więc kompresja jest dwustopniowa.
To dlatego sensor_type w postaci 'TEMPERATURE'/'SPEED'/'PRESSURE' skompresuje się rewelacyjnie (słownik 3-elementowy plus RLE na indeksach), monotonicznie rosnące time schodzi prawie do zera bajtów per wartość, a wysokoentropowa kolumna typu UUID per row będzie znacznie gorsza — dictionary niewiele pomoże, bo każda wartość jest unikalna, więc słownik jest tak samo duży, jak oryginalne dane. TimescaleDB wykrywa ten przypadek i po prostu nie używa wtedy słownika.
segmentby i orderby — najważniejsze parametry
To są dwa parametry, które trzeba dobrać świadomie, bo decydują, jak wiersze są grupowane w batche przed kompresją.
segmentby— kolumna, której wartości są wspólne dla całego batcha (np.machine_idczysensor_id). Wartość trzymana jest raz na batch, nie jako tablica. Dodatkowo planner używa metadanych segmentby, żeby pominąć całe batche niepasujące doWHERE.orderby— sortowanie wewnątrz batcha (zwykletime DESC). Sortowanie po czasie daje delta-encodingowi i delta-of-delta maksimum — sąsiednie wartości są blisko siebie, więc różnice są małe i pakują się do kilku bitów.
ALTER TABLE iot_sensor_data SET (
timescaledb.orderby = 'time DESC',
timescaledb.segmentby = 'machine_id'
);
Zapytania z filtrem WHERE machine_id = '...' AND time BETWEEN ... na tak skonfigurowanej tabeli potrafią być rząd wielkości szybsze niż bez segmentby, bo planner pomija batche innych maszyn na podstawie metadanych — bez dotykania samych danych.
TimescaleDB pakuje wiersze w batche po ~1000 sztuk i kompresuje każdy batch osobno. Jeśli segmentby ma za wysoką kardynalność (np. segmentby = sensor_id przy tysiącach czujników w IoT, gdzie każdy czujnik ma zaledwie kilka wierszy na chunk), to każdy “segment” w chunku ma za mało wierszy, batche są niedopełnione i kompresja jest mało efektywna — encodery delta/XOR potrzebują serii podobnych wartości, żeby cokolwiek skompresować.
Oficjalna reguła z dokumentacji: każdy segment powinien zawierać co najmniej 100 wierszy w chunku, a optymalnie 100–10 000 unikalnych wartości segmentby per chunk.
Co kompresja robi z query performance?
Częste pytanie: czy kompresja spowalnia zapytania?
Krótka odpowiedź: dla typowych zapytań time-series — przyspiesza.
Zapytania które przyspieszają (większość workloadu):
- Range scan po czasie z agregacją (
SUM,AVG,MAXper time bucket) - Zapytania z filtrem na kolumnie
segmentby - Sequential scans na dużych zakresach
Kompresja kolumnowa redukuje I/O 10-20×. Zapytanie czytające 1 GB nieskompresowanych vs 100 MB skompresowanych = mniej disk reads, mniej pamięci, mniej CPU na deserializację.
Zapytania które spowalniają (rzadkie w time-series):
- Point lookup po pojedynczym wierszu (
WHERE time = '...' AND id = X) - UPDATE/DELETE na skompresowanych chunkach (decompress→modify→recompress cycle)
- Zapytania bez filtra na
segmentbyprzy wysokiej kardynalności tej kolumny
Jak to wdrożyć
-- Konfiguracja columnstore do monitorowania czujników IoT
ALTER TABLE iot_sensor_data SET (
timescaledb.compress,
timescaledb.segmentby = 'machine_id',
timescaledb.orderby = 'time DESC'
);
-- Polityka automatycznej konwersji chunków starszych niż 7 dni
SELECT add_columnstore_policy('iot_sensor_data', after => INTERVAL '7 days');
-- Weryfikacja
SELECT * FROM chunks_detailed_size('iot_sensor_data');
-- Co jest skompresowane
SELECT chunk_name, is_compressed, range_start,
pg_size_pretty(total_bytes) AS size
FROM timescaledb_information.chunks c
JOIN chunks_detailed_size('iot_sensor_data') cds USING (chunk_schema, chunk_name)
WHERE hypertable_name = 'iot_sensor_data'
AND is_compressed = true
ORDER BY range_start;
Przykład z prawdziwej bazy danych
W mojej tabeli mqtt_data mam ~180 unikalnych id z 4 000–113 000 wierszy każdy, w zależności od chunka. Konfiguracja:
ALTER TABLE mqtt_data SET (
timescaledb.enable_columnstore = true,
timescaledb.segmentby = 'id',
timescaledb.orderby = 'time DESC'
);
Efekt — porównanie tego samego zapytania na chunku rowstore vs columnstore
Zapytanie typu produkcyjnego, “punktowy odczyt po id i wąskim zakresie czasu”:
SELECT *
FROM mqtt_data
WHERE time >= '...'::timestamptz
AND time < '...'::timestamptz + interval '5 minutes'
AND id = 'Site1.Machine1.SPEED'
ORDER BY time DESC
LIMIT 10;
| Metryka | Rowstore (chunk 50, 2.3 GB) | Columnstore (chunk 46, 7.2 MB) |
|---|---|---|
| Execution time | 10.2 ms | 0.36 ms |
| Planning time | 19.0 ms | 1.9 ms |
| Total | 29.2 ms | 2.3 ms |
| Speed-up | — | ~12.7× total / 28× execution |
| Współczynnik kompresji danych | — | 42.8× (308 MB → 7.2 MB) |
Skompresowany chunk jest ~42× mniejszy na dysku (same dane; B-tree indeksy chunkowe znikają w columnstore, więc realna oszczędność jest jeszcze większa) i jednocześnie 28× szybszy w execution. To nie jest błąd — to wynik trzech rzeczy działających razem.
Plan zapytania — pokażmy to z EXPLAIN ANALYZE
Chunk columnstore (po kompresji)
Limit (actual time=0.058..0.259 rows=10 loops=1)
-> Custom Scan (ChunkAppend) on mqtt_data
Order: mqtt_data.time DESC
-> Index Scan using _hyper_1_47_chunk_mqtt_data_time_idx
on _hyper_1_47_chunk (rowstore, najnowszy)
-> Custom Scan (DecompressChunk) on _hyper_1_46_chunk (never executed)
Vectorized Filter: ((time >= '...') AND (time < '...'))
-> Index Scan using compress_hyper_28_823_chunk_id__ts_meta_min_1__ts_meta_max__idx
Index Cond: ((id = 'Site1.Machine1.ERROR')
AND (_ts_meta_min_1 < '...')
AND (_ts_meta_max_1 >= '...'))
Planning Time: 1.912 ms
Execution Time: 0.363 ms
Index, który TimescaleDB zbudował dla columnstore, to (id, _ts_meta_min_1, _ts_meta_max_1). Powstał automatycznie — nie był definiowany ręcznie. Po prostu dlatego, że id jest segmentby, a time jest orderby.
Chunk rowstore (przed kompresją)
Limit (actual time=3.562..10.076 rows=10 loops=1)
-> Custom Scan (ChunkAppend) on mqtt_data
Order: mqtt_data.time DESC
-> Index Scan using _hyper_1_50_chunk_mqtt_data_id_time_idx
on _hyper_1_50_chunk
Index Cond: ((id = 'Site1.Machine1.ERROR')
AND (time >= '...')
AND (time < '...'))
Planning Time: 19.014 ms
Execution Time: 10.217 ms
Klasyczny Index Scan po mqtt_data_id_time_idx (~750 MB B-tree na ten chunk). Działa, ale wolniej, ponieważ:
- Indeks nie mieści się w cache
- Planner musi przeczytać większe statystyki
- Postgres iteruje wiersz po wierszu
Dlaczego columnstore wygrywa również w prędkości?
1. Sparse minmax index na meta-kolumnach
TimescaleDB sam buduje index na (segmentby_col, _ts_meta_min_1, _ts_meta_max_1) — gdzie min/max to skrajne wartości orderby per batch 1000-wierszowy. Dzięki temu eliminuje całe batche bez ich czytania, sprawdzając tylko meta.
2. Segmentby jako natywny filtr
Wiersze z tym samym id są fizycznie zgrupowane razem. Index od razu trafia w odpowiedni segment — nie potrzeba osobnego B-tree na (id, time). Segmentby załatwia to “za darmo”, jako efekt uboczny układu danych.
3. Vectorized execution
Operacje na zakresach time wykonują się batchami (1000 wierszy naraz), zamiast wiersz po wierszu, jak w klasycznym Index Scan.
Ważne zastrzeżenia
Liczby dotyczą konkretnego use case’a — “punktowy odczyt po
idi wąskim zakresie czasu”. Dla zapytań agregujących pełne miesiące różnica będzie inna. Dla zapytań skanujących bez filtraidcolumnstore może być wolniejszy niż dobrze zaindeksowany rowstore.42× to mój dataset. Dane MQTT z sensorami mają wyjątkowo wysoką redundancję — wartości zmieniają się płynnie (Gorilla działa świetnie), topiki/jednostki powtarzają się w obrębie
id(dictionary encoding na maksymalnym poziomie). Realistyczne oczekiwanie dla typowego time-series: 8–20×.Świeże chunki zostają w rowstore — policy konwertuje tylko chunki starsze niż
after =>interval. Zapytania na bieżących danych (np. ostatnie 5 minut) nie są dotykane przez hypercore.
Jak sprawdzić, czy segmentby będzie działać dla twoich danych?
-- Rozkład wewnątrz konkretnego chunka
WITH per_id AS (
SELECT id, count(*) AS n
FROM _timescaledb_internal._hyper_X_Y_chunk
GROUP BY id
)
SELECT
count(*) FILTER (WHERE n < 100) AS ids_under_100_rows,
count(*) FILTER (WHERE n < 1000) AS ids_under_1000_rows,
count(*) AS total_ids
FROM per_id;
Jeśli ids_under_100_rows = 0 i total_ids mieści się w 100–10 000 → dobry segmentby.
Jeśli większość id ma <100 wierszy → zmień strategię.
Podsumowanie
Jeśli planujesz wdrożenie PostgreSQL TimescaleDB - szczególnie dla aplikacji IoT, monitoringu produkcji czy time-series w systemach finansowych - i chcesz upewnić się, że ratio kompresji będzie 15×, a nie 2×, w RoszigIT zajmujemy się projektowaniem i wdrażaniem stack’u Grafana + TimescaleDB + AWS dla przemysłu. Skontaktuj się, jeśli potrzebujesz opinii albo bezpośredniego wsparcia w architekturze.