ClickHouse
e' un Columnar Database SQL Open Source con ottime prestazioni
sulle attivita' OLAP (On-Line Analytical Processing).
In questa paginetta ne vediamo, dal punto di vista pratico,
le funzionalita' di compressione dei dati.
Gia' con la configurazione di default ClickHouse comprime
tutti i dati con buoni risparmi ma e' anche possibile utilizzare
algoritmi specifici per ottenere risultati ottimali.
Gli argomenti contenuti in questa pagina sono:
Introduzione,
Compressione,
Test compressione,
Test prestazioni,
Conclusioni,
...
Il documento si riferisce alla versione 19.11 o successive di ClickHouse
[NdA perche' da tale versione stable dal 2019-07 sono stati introdotti nuovi Codec particolarmente efficaci].
ClickHouse e' il recente database colonnare sviluppato da Yandex
adatto alle attivita' OLAP.
Con il termine OLAP (On-Line Analytical Processing)
si indicano tecniche ed architetture adatte ad analizzare
grandi basi dati in tempi molto brevi.
ClickHouse utilizza un approccio differente rispetto
alla rappresentazione ISAM
e con indici B-Tree tipica dei DB relazionali
memorizzando i dati per colonna.
Questo consente di utilizzare algoritmi per l'accesso ai dati
che possono essere eseguiti in parallelo.
Con ClickHouse si utilizzano tutte le CPU sul nodo ospite,
ma e' possibile farlo anche in rete con piu' nodi.
Le query di ClickHouse in cluster scalano in modo pressoche' lineare come prestazioni.
I datatype disponibili in ClickHouse sono stati scelti per ottenere le massime prestazioni. Piu' i dati sono compatti e migliori sono le prestazioni: per un corretto disegno della base dati va scelto il tipo di dato piu' adatto e di minori dimensioni.
Per i valori numerici i datatype disponibili in ClickHouse sono:
UInt8, UInt16, UInt32, UInt64, Int8, Int16, Int32, Int64,
Float32, Float64, Decimal e Boolean.
Per le stringhe si usa praticamente un solo datatype: String.
ClickHouse non gestisce l'encoding
quindi le stringhe sono semplicemente una serie di byte
[NdA ma puo' essere specificato il COLLATE nell'ORDER BY].
In qualche caso limitato puo' essere utile anche il FixedString(N).
Per le date:
Date, DateTime, DateTime64 [NdA quest'ultimo dalla versione 20.1].
ClickHouse non gestisce la timezone e tutti i tempi fanno riferimento ad Epoch in UTC.
Molto efficiente e' il datatype Enum che pero' richiede definire nella DDL
tutti i valori possibili.
I NULL sono gestiti come un datatype aggiuntivo: Nullable(TypeName);
tuttavia e' consigliabile evitarli nel disegno.
Sono disponibili anche strutture complesse come Array(T) che invece sono molto efficienti.
Poiche' i dati sono raggruppati per colonna e quindi molto omogenei,
ClickHouse e' in grado di comprimere efficacemente anche i dati numerici.
La compressione dati e' molto utile anche per minimizzare lo spazio
occupato: una base dati ClickHouse e' tipicamente molto piu' piccola
rispetto alla quantita' di dati memorizzati.
Sugli altri database relazionali lo spazio occupato e' invece molto
piu' ampio sia per la memorizzazione degli indici che per la preallocazione degli spazi.
L'organizzazione dei dati per colonna consente di effettuare un'elevata compressione ed ottenere significativi miglioramenti prestazionali: e' l'argomento di questa paginetta.
La compressione di default automatica (LZ4) ottiene gia' ottimi risultati nella maggioranza delle situazioni. Sono normali compressioni oltre il 50% per i valori numerici ed oltre l'80% per le stringhe. Ma si puo' fare spesso anche molto di piu' [NdA vedremo degli esempi con compressioni superiori al 99%]
ClickHouse mantiene sul data dictionary system tutte le informazioni sulla compressione. La verifica della quantita' di spazio utilizzato/risparmiato si effettua con la seguente query:
SELECT database, table, column, type, column_data_compressed_bytes compressed, column_data_uncompressed_bytes, (column_data_uncompressed_bytes-column_data_compressed_bytes)*100/column_data_uncompressed_bytes ratio FROM system.parts_columns WHERE active AND database<>'system' AND column_data_uncompressed_bytes>0 ORDER BY database, table, column;
Sulla famiglia di Engine MergeTree e' possibile migliorare ulteriormente la compressione utilizzando Codec ed algoritmi specifici scegliendoli tra quelli descritti nella documentazione ufficiale. Un Codec e' una rappresentazione dei dati: per esempio e' possibile rappresentare un dato come differenza rispetto al dato precedente oppure utilizzando un dizionario. In altri casi si utilizza un algoritmo di compressione che utilizza tecniche statistiche per rappresentare con meno bit le sequenze di dati piu' frequenti e puo' essere loseless (con perdita di dati) o meno.
E' possibile applicare in cascata in pipeline piu' Codecs, ovvero piu' codifiche dei dati e quindi un algoritmo di compressione.
La scelta si effettua per singola colonna con la sintassi:
CREATE TABLE codec_example ( dt Date CODEC(ZSTD), ts DateTime Codec(DoubleDelta, LZ4), float_value Float32 CODEC(NONE), double_value Float64 CODEC(LZ4HC(9)) value Float32 CODEC(Delta, ZSTD) ) ...
I codecs specializzati, ovvero che sono adatti a particolari serie di valori sono:
Ai primi codecs generalmente si fa seguire una successiva compressione scegliendo tra:
Qual'e' il migliore? Dipende... proviamo!
In realta' la compressione reale dipende dai dati presenti.
Ogni caso e' diverso, la compressione puo' dipendere a seconda della provenienza dei dati
dalle frequenze di campionamento,
dalla qualita' dei sensori,
dalla provenienza dei dati,
dai datatype scelti,
da eventuali errori, ...
Il modo migliore e' quello di provare con i dati effettivi
e cosi' abbiamo fatto.
Abbiamo raccolto i dati di interesse (il traffico di rete),
abbiamo selezionato un campione significativo
(abbiamo scelto 10.000.000 di record, non molti ma sufficienti per il nostro scopo)
e li abbiamo inseriti su una tabella di test con differenti combinazioni di codec/compressione.
Lo stesso procedimento l'abbiamo eseguito su una tabella di dimensioni inferiori
che presentava tipologie di dati diversi (Float).
Le tabelle sono state create con piu' colonne con questa DDL:
machine_id_l String Codec(LZ4), machine_id_l1 String Codec(LZ4HC(1)), machine_id_l9 String Codec(LZ4HC(9)), machine_id_l12 String Codec(LZ4HC(12)), machine_id_z1 String Codec(ZSTD(1)), machine_id_z22 String Codec(ZSTD(22)), machine_id_lcl1 LowCardinality(String) Codec(LZ4HC(1)), machine_id_lcz LowCardinality(String) Codec(ZSTD(1)),
Vediamo i primi dati:
Database | Table | Column | Type | Compressed | Uncompressed | Gain % | On Default% | _ | Notes |
test_compr | traffic_4compress | machine_id_lcz | LowCardinality(String) | 226042 | 17106509 | 98.68 | 77.72% | ||
test_compr | traffic_4compress | machine_id_z22 | String | 276601 | 154822098 | 99.82 | 72.73% | ||
test_compr | traffic_4compress | machine_id_z1 | String | 287243 | 154822098 | 99.81 | 71.68% | ||
test_compr | traffic_4compress | machine_id_lcl1 | LowCardinality(String) | 413673 | 17106509 | 97.58 | 59.22% | ||
test_compr | traffic_4compress | machine_id_lc | LowCardinality(String) | 440649 | 17106509 | 97.42 | 56.56% | ||
test_compr | traffic_4compress | machine_id_l12 | String | 971983 | 154822098 | 99.37 | 4.18% | ||
test_compr | traffic_4compress | machine_id_l9 | String | 972007 | 154822098 | 99.37 | 4.18% | ||
test_compr | traffic_4compress | machine_id_l1 | String | 972109 | 154822098 | 99.37 | 4.17% | ||
test_compr | traffic_4compress | machine_id | String | 1014372 | 154822098 | 99.34 | 0.00% | Default | |
test_compr | traffic_4compress | machine_id_l | String | 1014372 | 154822098 | 99.34 | 0.00% | ||
test_compr | traffic_4compress | machine_id_n | String | 154848573 | 154822098 | -0.02 | |||
test_compr | traffic_4compress | router_id_lc | LowCardinality(String) | 2839844 | 8680526 | 67.28 | 62.82% | ||
test_compr | traffic_4compress | router_id | String | 7637100 | 160915073 | 95.25 | 0.00% | Default | |
test_compr | traffic_4compress | router_id_n | String | 160941548 | 160915073 | -0.02 |
Il primo datatype che analizziamo sono le String.
Innanzi tutto si deve notare che il risparmio in termine di spazio e'
notevolissimo, tutti gli algoritmi ottengono oltre il 95% di compressione.
Considerando solo la percentuale % Gain e' l'algoritmo ZSTD ad ottenere la maggiore compressione
con poca differenza tra i livelli passati come parametro.
Anche per LZ4 il livello di compressione non produce una variazione significativa di % Gain.
In realta' la specifica LowCardinality() consente un risparmio notevole gia' a monte
ed in coppia le due indicazione forniscono il maggior risparmio di spazio.
Anche sulla seconda stringa, piu' lunga, il LowCardinality() fornisce il miglior guadagno finale.
Ora diamo in numeri!
Utilizzando questa DDL specifichiamo diverse modalita' di compressione per gli interi:
bytes_out_n UInt64 Codec(NONE), bytes_out_dn UInt64 Codec(Delta), bytes_out_2n UInt64 Codec(DoubleDelta), bytes_out_gn UInt64 Codec(Gorilla), bytes_out_tn UInt64 Codec(T64), bytes_out_dl UInt64 Codec(Delta, LZ4), bytes_out_2l UInt64 Codec(DoubleDelta, LZ4), bytes_out_gl UInt64 Codec(Gorilla, LZ4), bytes_out_tl UInt64 Codec(T64, LZ4), bytes_out_dz UInt64 Codec(Delta, ZSTD), bytes_out_2z UInt64 Codec(DoubleDelta, ZSTD), bytes_out_gz UInt64 Codec(Gorilla, ZSTD), bytes_out_tz UInt64 Codec(T64, ZSTD),
Database | Table | Column | Type | Compressed | Uncompressed | Gain % | On Default% | _ | Notes |
test_compr | traffic_4compress | bytes_out_tz | UInt64 | 23236434 | 69227400 | 66.43 | 39.52% | ||
test_compr | traffic_4compress | bytes_out_tl | UInt64 | 25112027 | 69227400 | 63.73 | 34.64% | ||
test_compr | traffic_4compress | bytes_out_gz | UInt64 | 27923104 | 69227400 | 59.66 | 27.33% | ||
test_compr | traffic_4compress | bytes_out_2z | UInt64 | 31337113 | 69227400 | 54.73 | 18.44% | ||
test_compr | traffic_4compress | bytes_out_gl | UInt64 | 31426615 | 69227400 | 54.6 | 18.21% | ||
test_compr | traffic_4compress | bytes_out_dz | UInt64 | 31559836 | 69227400 | 54.41 | 17.86% | ||
test_compr | traffic_4compress | bytes_out_gn | UInt64 | 32537582 | 69227400 | 53 | 15.32% | ||
test_compr | traffic_4compress | bytes_out_tn | UInt64 | 32982644 | 69227400 | 52.36 | 14.16% | ||
test_compr | traffic_4compress | bytes_out_2n | UInt64 | 34044794 | 69227400 | 50.82 | 11.39% | ||
test_compr | traffic_4compress | bytes_out_2l | UInt64 | 34191393 | 69227400 | 50.61 | 11.01% | ||
test_compr | traffic_4compress | bytes_out | UInt64 | 38422906 | 69227400 | 44.5 | 0.00% | Default | |
test_compr | traffic_4compress | bytes_out_dl | UInt64 | 40941024 | 69227400 | 40.86 | -6.55% | ||
test_compr | traffic_4compress | bytes_out_n | UInt64 | 69253750 | 69227400 | -0.04 | |||
test_compr | traffic_4compress | bytes_out_dn | UInt64 | 69255858 | 69227400 | -0.04 |
Ora analizziamo tipi di dati interi che nel nostro caso sono UInt64.
Qui i risparmi ottenuti sono meno significativi perche' i
i numeri sono piu' corti delle stringhe.
Anche in questo caso la compressione maggiore si ottiene con ZSTD
in coppia al Codec T64, molto vicino e' anche la coppia Codec(T64, LZ4).
Vediamo altri esempi:
Database | Table | Column | Type | Compressed | Uncompressed | Gain % | On Default% | _ | Notes |
test_compr | traffic_4compress | timestamp_dz | DateTime | 7258100 | 34613700 | 79.03 | 53.36% | ||
test_compr | traffic_4compress | timestamp_dl | DateTime | 10348290 | 34613700 | 70.1 | 33.51% | ||
test_compr | traffic_4compress | timestamp_2z | DateTime | 15237638 | 34613700 | 55.98 | 2.09% | ||
test_compr | traffic_4compress | timestamp | DateTime | 15562730 | 34613700 | 55.04 | 0.00% | Default | |
test_compr | traffic_4compress | timestamp_2l | DateTime | 17065061 | 34613700 | 50.7 | -9.65% | ||
test_compr | traffic_4compress | timestamp_tz | DateTime | 17088075 | 34613700 | 50.63 | -9.80% | ||
test_compr | traffic_4compress | timestamp_gz | DateTime | 17722348 | 34613700 | 48.8 | -13.88% | ||
test_compr | traffic_4compress | timestamp_tl | DateTime | 18604365 | 34613700 | 46.25 | -19.54% | ||
test_compr | traffic_4compress | timestamp_gl | DateTime | 19450979 | 34613700 | 43.81 | -24.98% | ||
test_compr | traffic_4compress | timestamp_2n | DateTime | 23194120 | 34613700 | 32.99 | -49.04% | ||
test_compr | traffic_4compress | timestamp_gn | DateTime | 24184616 | 34613700 | 30.13 | -55.40% | ||
test_compr | traffic_4compress | timestamp_tn | DateTime | 24554428 | 34613700 | 29.06 | -57.78% | ||
test_compr | traffic_4compress | timestamp_n | DateTime | 34626950 | 34613700 | -0.04 | |||
test_compr | traffic_4compress | timestamp_dn | DateTime | 34628010 | 34613700 | -0.04 |
Le date sono un importantissime in tutti i DB e sui TSDB in particolare.
I DateTime sono rappresentati come UInt32 in ClickHouse e corrispondono
i secondi a partire da Epoch in UTC.
Nonostante la dimensione ridotta si ottengono significativi risparmi
con il codec Delta e poi comprimendo.
La differenza rispetto ad altri metodi e' significativa quindi
e' opportuno scegliere tra queste due alternative.
Non abbiamo finito:
Database | Table | Column | Type | Compressed | Uncompressed | Gain % | On Default% | _ | Notes |
test_compr | tinfo_4compress | distribution_gz | Float32 | 958624 | 8000000 | 88.02 | 26.43% | ||
test_compr | tinfo_4compress | distribution_gl | Float32 | 1061414 | 8000000 | 86.73 | 18.54% | ||
test_compr | tinfo_4compress | distribution_dl | Float32 | 1166430 | 8000000 | 85.42 | 10.48% | ||
test_compr | tinfo_4compress | distribution_2z | Float32 | 1247976 | 8000000 | 84.4 | 4.22% | ||
test_compr | tinfo_4compress | distribution | Float32 | 1303014 | 8000000 | 83.71 | 0.00% | Default | |
test_compr | tinfo_4compress | distribution_gn | Float32 | 1417393 | 8000000 | 82.28 | -8.78% | ||
test_compr | tinfo_4compress | distribution_2l | Float32 | 1530166 | 8000000 | 80.87 | -17.43% | ||
test_compr | tinfo_4compress | distribution_2n | Float32 | 2382970 | 8000000 | 70.21 | -82.88% | ||
test_compr | tinfo_4compress | distribution_n | Float32 | 8003100 | 8000000 | -0.04 | |||
test_compr | tinfo_4compress | distribution_dn | Float32 | 8003348 | 8000000 | -0.04 | |||
test_compr | tinfo_4compress | diffusion_dz | Float32 | 286526 | 8000000 | 96.42 | 53.35% | ||
test_compr | tinfo_4compress | diffusion_gz | Float32 | 344838 | 8000000 | 95.69 | 43.86% | ||
test_compr | tinfo_4compress | diffusion_gl | Float32 | 471599 | 8000000 | 94.11 | 23.22% | ||
test_compr | tinfo_4compress | diffusion_dl | Float32 | 491386 | 8000000 | 93.86 | 20.00% | ||
test_compr | tinfo_4compress | diffusion_2z | Float32 | 505480 | 8000000 | 93.68 | 17.71% | ||
test_compr | tinfo_4compress | diffusion | Float32 | 614232 | 8000000 | 92.32 | 0.00% | Default | |
test_compr | tinfo_4compress | diffusion_2l | Float32 | 663429 | 8000000 | 91.71 | -8.01% | ||
test_compr | tinfo_4compress | diffusion_gn | Float32 | 865506 | 8000000 | 89.18 | -40.91% | ||
test_compr | tinfo_4compress | diffusion_2n | Float32 | 1340085 | 8000000 | 83.25 | -118.17% | ||
test_compr | tinfo_4compress | diffusion_n | Float32 | 8003100 | 8000000 | -0.04 | |||
test_compr | tinfo_4compress | diffusion_dn | Float32 | 8003348 | 8000000 | -0.04 |
Gli ultimi esempi da analizzare sono relativi a Float32.
Con i Float non e' possibile utilizzare la codifica T64 e quindi non e' stato provato tale Codec.
I risultati migliori sono ottenuti con una prima codifica
con Gorilla o Delta e quindi la compressione ZSTD o LZ4.
Sicuramente ha inciso la scarsa distribuzione dei valori di input.
Infatti ottenere oltre il 90% di compressione su un dato che utilizza 4 byte
e' un risultato notevole.
Lo spazio non e' tutto. C'e' anche il tempo! E come e' ben noto lo spazio ed il tempo sono strettamente legati :)
Ma prima e' importante capire come ClickHouse tratta i dati e quando effettua le codifiche.
In memoria i dati sono rappresentati senza codifiche, solo in fase di scrittura su disco vengono
compressi.
Con l'Engine MergeTree, che e' quello fondamentale, i nuovi dati vengono inseriti i Parts ordinate
e solo successivamente viene eseguito il merge con i dati gia' presenti.
Piu' importante e' la fase di lettura perche' su un OLAP la scrittura avviene una sola volta
ma le letture sono molte. Da questo punto di vista una tabella o, per essere piu' precisi,
la Part della colonna di interesse puo' essere in stato Cold, Warm o Hot.
Quando il dato risiede solo su disco i tempi per accedere sono maggiori
e dipendono dalle meccaniche usate (in senso lato: un SSD non ha parti meccaniche):
questo e' lo stato Cold.
Minore e' la dimensione dei dato minore sara' il tempo per leggerlo da disco.
Nello stato Warm i dati sono gia' in memoria nella buffer cache del sistema,
sono ancora compressi ma non e' piu' necessario accedere al disco.
Una part acceduta di recente si trova in stato Hot: e' stata letta da disco,
ha attraversato la buffer cache ed e' stata decompressa in memoria.
E' nel passaggio da Warm a Hot che avviene la decodifica e si ottengono i dati in chiaro.
Quando i dati sono in memoria in chiaro le SELECT per l'analisi utilizzano piu' thread in parallelo
con algoritmi vettorizzati.
Dopo questa ampia premessa dovrebbe essere chiaro che non e' semplice misurare le reali
differenze prestazionali tra gli algoritmi... ma lo facciamo lo stesso!
Per misurare la differenza di prestazioni abbiamo utilizzato la stessa INSERT
su tabelle con Codecs differenti:
insert into test_compr.traffic_4compress select byte_in, byte_out from sensor_data.traffic limit 10000000;
I risultati ottenuti sono riassunti in questa tabella:
Codec | Time | Delta | Note | |
Delta | 0.462 | 57.92% | ||
NONE | 0.482 | 56.10% | ||
T64 | 0.692 | 36.98% | ||
LZ4 | 0.992 | 9.65% | Default | |
T64, LZ4 | 0.999 | 9.02% | ||
Gorilla, LZ4 | 1.098 | 0.00% | ||
DoubleDelta | 1.102 | -0.36% | ||
Gorilla | 1.184 | -7.83% | ||
DoubleDelta, LZ4 | 1.211 | -10.29% | ||
Delta, LZ4 | 1.307 | -19.03% | ||
Gorilla, ZSDT | 1.344 | -22.40% | ||
DoubleDelta, ZSTD | 1.402 | -27.69% | ||
T64, ZSDT | 1.746 | -59.02% | ||
ZSTD | 2.101 | -91.35% | ||
Delta, ZSTD | 2.307 | -110.11% |
Rispetto alla compressione LZ4 di default, non fare nulla e' piu' veloce!
E' normale, ma e' ugualmente veloce il Codec Delta e solo un poco piu' pesante il T64.
Tutti le combinazioni che utilizzando l'algoritmo ZSTD sono piu' pesanti in modo significativo,
fino al doppio piu' lenti.
Molte combinazioni di codec risultano avere un peso simile tra loro ed in generale la differenza di tempi non e' drammatica. In pratica la cosa piu' veloce e' non comprimere affatto ma la compressione piu' complessa e' piu' lenta solo di 4 volte.
Quanto riportato ovviamente e' solo un riassunto e va raccolto con intelligenza poiche' le combinazioni
possibili di tutti i codec, provando i diversi livelli di compressione, sono veramente moltissime.
La compressione dipende fortemente dai dati effettivamente presenti.
Quindi le indicazioni sull'algoritmo migliore non solo assolute ma da valutare caso per caso.
Un altro punto da sottolineare
e' che le modalita' di default sono sicuramente le piu' testate.
Nella scelta dell'algoritmo da utilizzare il solo risparmio di spazio non e' l'unico
fattore da tenere in considerazione.
Tenendo conto di queste premesse ci sono comunque una serie di indicazioni
che e' possibile fornire.
Se la quantita' di dati non e' elevata la compressione di default e' sufficiente.
ClickHouse per default utilizza l'algoritmo LZ4 per tutti i dati con buoni risultati
sia in lettura che in scrittura.
Per le stringhe con un numero limitato di valori e' assolutamente conveniente
utilizzare LowCardinality(String).
In pratica questa sola impostazione, associata alla compressione di default, e' sufficiente su basi dati di piccole dimensioni [NdA per piccole dimensioni con ClickHouse si puo' intendere fino ad un Terabyte].
Per colonne che sono utilizzate solo raramente e contengono dati storicizzati vanno utilizzate le combinazioni di algoritmi che consentono il maggior risparmio di spazio. Da questo punto di vista, anche se piu' pesante come algoritmo, generalmente ZSTD e' quello che ottiene la maggiore compressione. Ad esempio Codec(Delta, ZSTD) per le date, ZSTD per gli interi e ZSTD per i Float.
Per colonne di dimensioni significative come dimensioni totali e' consigliabile sperimentare
il reale vantaggio della compressione con i dati effettivi:
a seconda dei casi possono essere adatte combinazioni differenti di Codec.
Quindi utilizzare algoritmi che presentano
un buon compromesso tra prestazioni ed efficienza.
Ad esempio Codec(T64, LZ4) e Codec(Gorilla, LZ4).
Nel caso di colonne che variano di poco i valori
per i timestamp e' molto efficiente il Codec(DoubleDelta)
mentre per i float e' efficiente Codec(Gorilla).
L'articolo New ClickHouse Encodings contiene una trattazione molto completa e precisa dei Codecs e dei test effettuabili.
Il documento Introduzione a ClickHouse contiene una presentazione sulle funzioni di base, il documento Architettura di ClickHouse contiene una presentazione dell'architettura, DBA scripts contiene alcuni dei comandi piu' utili per il DBA.
In ClickHouse Cloud l'algoritmo di compressione di default e' ZSTD anziche' LZ4.
Titolo: Compressione in ClickHouse
Livello: Medio
Data:
31 Ottobre 2019 🎃 Halloween
Versione: 1.0.3 - 14 Febbraio 2021 ❤️ San Valentino
©
Autore: mail [AT] meo.bogliolo.name