Analytický DBMS ClickHouse zpracovává mnoho různých řádků a spotřebovává zdroje. Pro zrychlení systému jsou neustále přidávány nové optimalizace. Vývojář ClickHouse Nikolai Kochetov hovoří o datovém typu řetězce, včetně nového typu LowCardinality, a vysvětluje, jak můžete urychlit práci s řetězci.

- Nejprve zjistíme, jak můžete ukládat řetězce.

Máme řetězcové datové typy. Řetězec je dobrou výchozí hodnotou a měl by se používat téměř vždy. Má malou režii - 9 bajtů na řádek. Pokud chceme, aby velikost řetězců byla pevná a předem známá, pak je lepší použít FixedString. Můžete si v něm nastavit počet bytů, které potřebujeme, hodí se to pro data jako IP adresy nebo hashovací funkce.

Samozřejmě, někdy se věci zpomalí. Řekněme, že se ptáte na tabulku. ClickHouse čte poměrně velké množství dat, řekněme rychlostí 100 GB/s, přičemž se zpracovává několik řádků. Máme dvě tabulky, které ukládají téměř stejná data. ClickHouse čte data z druhé tabulky vyšší rychlostí, ale čte třikrát méně řádků za sekundu.

Pokud se podíváme na velikost komprimovaných dat, bude téměř stejná. Ve skutečnosti tabulky obsahují stejná data - první miliardu čísel - pouze v prvním sloupci jsou zapsány ve tvaru UInt64 a ve druhém - v řetězci. Z tohoto důvodu druhý požadavek trvá déle, než načte data z disku a rozbalí je.

Zde je další příklad. Předpokládejme, že existuje předem známá sada řetězců, která je omezena na konstantních 1000 nebo 10 000 a téměř se nikdy nemění. Pro tento případ se nám hodí datový typ Enum, v ClickHouse jsou dva - Enum8 a Enum16. Kvůli úložišti v Enum zpracováváme požadavky rychle.
ClickHouse má akcelerace pro GROUP BY, IN, DISTINCT a optimalizace pro některé funkce, například pro srovnání s konstantním řetězcem. Čísla v řetězci se samozřejmě nepřevádějí, ale naopak konstantní řetězec se převádí na hodnotu Enum. Poté se vše rychle srovná.
Ale jsou tu i nevýhody. I když známe přesnou sadu řádků, někdy se musí doplnit. Přišel nový řádek - musíme udělat ALTER.

ALTER pro Enum v ClickHouse je implementován optimálně. Data na disku nepřepisujeme, ale ALTER se může zpomalit díky tomu, že struktury Enum jsou uloženy ve schématu samotné tabulky. Musíme tedy počkat na požadavky na čtení z tabulky, kupř.
Otázka je, dá se to udělat lépe? Možná ano. Strukturu Enum můžete uložit nikoli ve schématu tabulky, ale v ZooKeeper. Mohou však nastat problémy se synchronizací. Například jedna replika data přijímala, druhá ne a pokud má staré Enum, tak se něco rozbije. (V ClickHouse jsme s neblokujícími požadavky ALTER téměř hotovi. Až s nimi skončíme, nebude třeba čekat na požadavky na čtení.)

Abyste si s ALTER Enum nezahráli, můžete použít externí slovníky ClickHouse. Dovolte mi připomenout, že se jedná o datovou strukturu klíč-hodnota uvnitř ClickHouse, pomocí které můžete získat data z externích zdrojů, jako jsou tabulky MySQL.
Ve slovníku ClickHouse ukládáme mnoho různých řetězců a v tabulce jejich identifikátory ve formě čísel. Pokud potřebujeme získat řetězec, zavoláme funkci dictGet a pracujeme s ní. Poté již nemusíme dělat ALTER. Abychom něco přidali do Enum, vložíme to do stejné tabulky MySQL.
Zde ale nastávají další problémy. Za prvé, nepohodlná syntaxe. Pokud chceme získat řetězec, musíme zavolat dictGet. Za druhé, nedostatek některých optimalizací. Srovnání s konstantním řetězcem pro slovníky nelze provést stejně rychle.
Problémy mohou být také s aktualizací. Předpokládejme, že jsme požadovali řetězec ve slovníku mezipaměti, ale ten se do mezipaměti nedostal. Poté musíme počkat, až se data načtou z externího zdroje.

Společnou nevýhodou obou metod je, že všechny klíče ukládáme na jedno místo a synchronizujeme je. Proč tedy neukládat slovníky lokálně? Žádná synchronizace, žádný problém. Slovník můžete uložit lokálně v bloku na disku. To znamená, že jsme udělali přílohu, nahráli slovník. Pokud pracujeme s daty v paměti, můžeme zapsat slovník buď do datového bloku, nebo do kousku sloupce, nebo do nějaké mezipaměti, abychom urychlili výpočty.
Kódování řetězce slovníku
Došli jsme tedy k vytvoření nového datového typu v ClickHouse – LowCardinality. Toto je formát ukládání dat: jak jsou zapisovány na disk a jak jsou čteny, jak jsou reprezentovány v paměti a jak jsou zpracovávány.

Snímek má dva sloupce. Vpravo jsou standardně uloženy řetězce, v typu String. Je vidět, že se jedná o některé modely mobilních telefonů. Vlevo je úplně stejný sloupec, jen v typu LowCardinality. Skládá se ze slovníku s mnoha různými řetězci (řetězce ze sloupce vpravo) a seznamu pozic (čísla řádků).
Pomocí těchto dvou struktur můžete obnovit původní sloupec. Existuje také reverzní reverzní index - hashovací tabulka, která pomáhá najít pozici ve slovníku podle řetězce. Je to potřeba pro urychlení některých dotazů. Chceme-li například porovnat, vyhledejte řetězec v našem sloupci nebo je spojte dohromady.
LowCardinality je parametrický datový typ. Může to být buď číslo, nebo něco, co je uloženo jako číslo, nebo řetězec, nebo z nich s možností Null.

Zvláštností LowCardinality je, že ji lze uložit pro některé funkce. Snímek ukazuje příklad požadavku. V prvním řádku jsem vytvořil sloupec typu LowCardinality z String, pojmenoval jsem ho S. Dále jsem se zeptal na jeho název - ClickHouse řekl, že je to LowCardinality z String. Dobře.
Třetí řádek je téměř stejný, jen jsme zavolali funkci délky. V ClickHouse vrací funkce length datový typ UInt64. Ale máme LowCardinality z UInt64. Jaký to má smysl?

Ve slovníku byly uloženy názvy mobilních telefonů, aplikovali jsme funkci délky. Nyní máme podobný slovník složený pouze z čísel – to jsou délky řetězců. Sloupec pozice se nezměnil. V důsledku toho jsme zpracovali méně dat a ušetřili čas dotazu.
Mohou existovat další optimalizace, například přidání jednoduché mezipaměti. Při výpočtu hodnoty funkce si ji můžete zapamatovat a složit stejnou, aniž byste ji přepočítávali.
Optimalizaci GROUP BY lze také provést, protože náš sloupec slovníku je již částečně agregován - můžete rychle vypočítat hodnotu hashovacích funkcí a zhruba najít kbelík, kam umístit další řádek. Můžete také specializovat některé agregační funkce, jako je uniq, protože do něj můžete poslat pouze slovník a ponechat pozice nedotčené - díky tomu bude vše fungovat rychleji. Do ClickHouse jsme již přidali první dvě optimalizace.

Co když ale vytvoříme sloupec s naším datovým typem a vložíme do něj spoustu špatných různých řádků? Přetéká naše paměť? Ne, ClickHouse má pro to dvě speciální nastavení. První je low_cardinality_max_dictionary_size. Toto je maximální velikost slovníku, který lze zapisovat na disk. Vkládání probíhá následovně: když vkládáme data, přichází k nám proud řádků, ze kterých tvoříme velký společný slovník. Pokud je slovník větší než nastavená hodnota, zapíšeme aktuální slovník na disk a zbytek řádků někam „na stranu“ vedle indexů. Díky tomu nikdy nepřepočítáme velký slovník a nebudeme mít problémy s pamětí.
Druhé nastavení se nazývá low_cardinality_use_single_dictionary_for_part. Představte si, že v předchozím schématu, když jsme vložili data, náš slovník přetekl a zapsali jsme je na disk. Nabízí se otázka, proč si nyní nevytvořit další úplně stejný slovník?
Když přeteče, zapíšeme jej znovu na disk a začneme tvořit třetí. Toto nastavení pouze ve výchozím nastavení tuto funkci zakáže.
Ve skutečnosti může být mnoho slovníků užitečných, pokud chceme vložit nějakou sadu řádků, ale omylem vložených „odpadků“. Řekněme, že jsme nejprve vložili špatné řádky a potom jsme vložili dobré. Poté bude slovník rozdělen do mnoha malých slovníků. Některé z nich budou s "odpadky", ale ty poslední budou s dobrými liniemi. A když čteme řekněme jen poslední granuli, tak také vše rychle půjde.

Než budu mluvit o výhodách LowCardinality, hned řeknu, že je nepravděpodobné, že dosáhneme snížení dat na disku (i když se to může stát), protože ClickHouse komprimuje data. K dispozici je výchozí možnost - LZ4. Můžete také komprimovat pomocí ZSTD. Oba algoritmy však již implementují kompresi slovníku, takže náš externí slovník ClickHouse moc nepomůže.
Abych nebyl neopodstatněný, vzal jsem některá data z metriky – String, LowCardinality(String) a Enum – a uložil je do různých datových typů. Ukázalo se, že tři sloupce, kde je zapsána miliarda řádků. První sloupec, CodePage, má celkem 62 hodnot. A je jasné, že v LowCardinality(String) jsme je komprimovali lépe. Struna je na tom o něco hůře, ale to je nejspíš tím, že jsou struny krátké, jejich délky skladujeme, zabírají hodně místa a špatně se stlačují.
Když si vezmete PhoneModel, je jich 48 tisíc – už více a mezi String a LowCardinality (String) není téměř žádný rozdíl. U adresy URL jsme také ušetřili pouze 2 GB - myslím, že byste na to neměli spoléhat.
Hodnocení rychlosti práce

Nyní zhodnoťme rychlost práce. K jeho vyhodnocení jsem použil dataset popisující jízdy taxíkem v New Yorku. On na GitHubu. Má něco přes miliardu cest. Odráží polohu, čas začátku a konce cesty, způsob platby, počet cestujících a dokonce i typ taxi – zelené, žluté a Uber.

První požadavek, který jsem vznesl, byl celkem jednoduchý – zeptal jsem se, kde si nejčastěji objednávají taxi. Chcete-li to provést, musíte vzít místo, odkud jste si objednali, vytvořit na něm GROUP BY a vypočítat funkci počítání. Tady ClickHouse něco dává.

Pro měření rychlosti zpracování dotazu jsem vytvořil tři tabulky se stejnými daty, ale pro naše výchozí umístění jsem použil tři různé datové typy – String, LowCardinality a Enum. LowCardinality a Enum jsou pětkrát rychlejší než String. Enum je rychlejší, protože pracuje s čísly. LowCardinality - protože je implementována optimalizace GROUP BY.

Pojďme si dotaz ještě zkomplikovat – zeptejte se, kde se nachází nejoblíbenější park v New Yorku. Opět to budeme měřit podle toho, kde se nejčastěji objednávají taxíky, ale zároveň budeme filtrovat jen ty lokality, kde je slovo „park“. Přidáme také podobnou funkci.

Podíváme se na čas – vidíme, že Enum najednou začal zpomalovat. A funguje ještě pomaleji než standardní datový typ String. Je to proto, že podobná funkce není pro Enum vůbec optimalizována. Musíme převést naše řetězce Enum na normální řetězce - děláme více práce. LowCardinality(String) také není ve výchozím nastavení optimalizován, ale funguje jako na slovníku, takže dotaz je rychlejší ve srovnání s String.
Při práci s Enum nastává globálnější problém. Pokud to chceme optimalizovat, musíme to udělat na každém místě v kódu. Předpokládejme, že jsme napsali novou funkci – určitě musíme přijít s optimalizací pro Enum. A v LowCardinality je vše ve výchozím nastavení optimalizováno.

Podívejme se na poslední dotaz, který je více umělý. Jednoduše vypočítáme hashovací funkci z našeho umístění. Hašovací funkce je poměrně pomalý dotaz, trvá dlouho, takže se vše zpomalí trojnásobně.

LowCardinality je stále rychlejší, i když neexistuje žádné filtrování. Je to proto, že naše funkce fungují pouze ve slovníku. Funkce výpočtu hash má jeden argument – dokáže zpracovat méně dat a může také vrátit LowCardinality.

Naším globálním plánem je dosáhnout výkonu, který v žádném případě nebude nižší než u String, a udržet zrychlení. A možná někdy nahradíme String za LowCardinality, aktualizujete ClickHouse a vše vám bude fungovat o něco rychleji.
Zdroj: www.habr.com
