O tom, co je databáze KDB+, jazyk programování Q, jaké mají silné a slabé stránky, si můžete přečíst v mém předchozím a stručně v úvodu. V tomto článku zrealizujeme na Q službu, která bude zpracovávat příchozí tok dat a počítat minutově různé agregované funkce v režimu "reálného času" (tj. bude stíhat vše spočítat do další dávky dat). Hlavní charakteristikou Q je, že se jedná o vektorový jazyk, který umožňuje pracovat ne s jednotlivými objekty, ale s jejich poli, poli polí a dalšími složenými objekty. Takové jazyky jako Q a jemu podobné K, J, APL jsou známé svou stručností. Často může být program, který zabírá několik obrazovek kódu v obvyklém jazyce jako Java, napsán v několika řádcích. Přesně to bych chtěl demonstrovat v tomto článku.

Úvod
KDB+ je sloupcová databáze zaměřená na velmi velké objemy dat, uspořádané určitém způsobem (především podle času). Používá se především ve finančních institucích – bankách, investičních fondech, pojišťovnách. Jazyk Q je interní jazyk KDB+, který umožňuje efektivně pracovat s těmito daty. Ideologie Q je stručnost a efektivita, přičemž srozumitelnost je obětována. Odůvodňuje se to tím, že vektorový jazyk bude v každém případě těžko srozumitelný, a stručnost a hutnost zápisu umožňuje vidět na jednom displeji mnohem větší část programu, což nakonec usnadňuje jeho pochopení.
V článku zrealizujeme plnohodnotný program v Q a možná se budete chtít pokusit ho vyzkoušet v praxi. K tomu potřebujete vlastně Q. Stáhnout si bezplatnou 32bitovou verzi můžete na webu společnosti kx – . Tam také, pokud máte zájem, najdete referenční informace o Q, knihu a různé články na toto téma.
Formulace úkolu
Existuje zdroj, který každých 25 milisekund posílá tabulku s daty. Vzhledem k tomu, že se KDB+ používá především ve financích, budeme považovat, že se jedná o tabulku obchodů (trades), která má následující sloupce: time (čas v milisekundách), sym (označení společnosti na burze – IBM, AAPL,…), cena (cena, za kterou byly akcie zakoupeny), velikost (velikost obchodu). Interval 25 milisekund byl vybrán náhodně, není příliš malý ani příliš velký. Jeho existence znamená, že data přicházejí do služby již vyrovnaná. Bylo by snadné implementovat vyrovnávání na straně služby, včetně dynamického, závislého na aktuálním zatížení, ale pro jednoduchost se omezíme na fixní interval.
Služba by měla počítat po minutách pro každý příchozí znak ve sloupci sym sadu agregujících funkcí – max cena, průměrná cena, součet velikosti atd. pro užitečné informace. Pro jednoduchost předpokládáme, že všechny funkce lze počítat inkrementálně, tj. pro získání nové hodnoty stačí znát dvě čísla – starou a příchozí hodnotu. Například funkce max, průměr, součet mají tuto vlastnost, zatímco funkce medián nikoli.
Také předpokládáme, že příchozí tok dat je seřazen podle času. To nám umožní pracovat pouze s poslední minutou. V praxi je dost umět pracovat s aktuální a předchozí minutou na případ, že některé aktualizace přišly pozdě. Pro jednoduchost se tomuto případu nebudeme věnovat.
Agregující funkce
Níže jsou uvedeny potřebné agregující funkce. Vzal jsem jich co nejvíce, abych zvýšil zátěž na službu:
- high – max cena – maximální cena za minutu.
- low – min cena – minimální cena za minutu.
- firstPrice – první cena – první cena za minutu.
- lastPrice – poslední cena – poslední cena za minutu.
- firstSize – první velikost – první velikost obchodu za minutu.
- lastSize – poslední velikost – poslední velikost obchodu za minutu.
- numTrades – počet obchodů – počet obchodů za minutu.
- volume – součet velikosti – součet velikostí obchodů za minutu.
- pvolume – součet cen – součet cen za minutu, nezbytné pro průměrnou cenu.
- turnover – součet cena*velikost – celkový objem obchodů za minutu.
- avgPrice – pvolume%numTrades – průměrná cena za minutu.
- avgSize – volume%numTrades – průměrná velikost obchodu za minutu.
- vwap – turnover%volume – vážená průměrná cena podle velikosti obchodu za minutu.
- cumVolume – součet objem – kumulovaná velikost obchodů za celé období.
Hned prodiskutujme jeden nezřejmý aspekt – jak inicializovat tyto sloupce poprvé a pro každou následující minutu. Některé sloupce jako firstPrice je třeba vždy inicializovat hodnotou null, jejich hodnota není definována. Jiné jako volume je třeba vždy nastavovat na 0. Existují také sloupce, které vyžadují kombinovaný přístup – například cumVolume je třeba kopírovat z předchozí minuty, zatímco pro první je třeba nastavit hodnotu na 0. Nastavíme všechny tyto parametry pomocí datového typu slovník (analogie záznamu):
// list ! list – создать словарь, 0n – float null, 0N – long null, `sym – тип символ, `sym1`sym2 – список символов
initWith:`sym`time`high`low`firstPrice`lastPrice`firstSize`lastSize`numTrades`volume`pvolume`turnover`avgPrice`avgSize`vwap`cumVolume!(`;00:00;0n;0n;0n;0n;0N;0N;0;0;0.0;0.0;0n;0n;0n;0);
aggCols:reverse key[initWith] except `sym`time; // список всех вычисляемых колонок, reverse объяснен ниже
Přidal jsem sym a time do slovníku pro pohodlí, nyní initWith – to je hotový řádek z finální agregované tabulky, kde zbývá nastavit správné sym a time. Může být použita k přidání nových řádků do tabulky.
aggCols budeme potřebovat při vytváření agregující funkce. Seznam je třeba invertovat kvůli zvláštnostem pořadí výpočtů výrazu v Q ( zprava doleva). Cílem je zajistit výpočet ve směru od high k cumVolume, protože některé sloupce závisí na předchozích.
Sloupce, které je třeba kopírovat do nové minuty z předchozí, sloupec sym byl přidán pro pohodlí:
rollColumns:`sym`cumVolume;
Nyní rozdělíme sloupce do skupin podle toho, jak je třeba je aktualizovat. Můžeme identifikovat tři typy:
- Akumulátory (volume, turnover,..) – musíme přičíst přicházející hodnotu k předchozí.
- Se zvláštním bodem (high, low, ..) – první hodnota v minutě se bere z přicházejících dat, ostatní se počítají pomocí funkce.
- Ostatní. Vždy se počítají pomocí funkce.
Definujme proměnné pro tyto třídy:
accumulatorCols:`numTrades`volume`pvolume`turnover;
specialCols:`high`low`firstPrice`firstSize;
Pořadí výpočtů
Agregovanou tabulku budeme aktualizovat ve dvou krocích. Pro efektivitu nejprve zúžíme příchozí tabulku tak, aby tam zůstal jeden řádek pro každý symbol a minutu. To, že všechny naše funkce jsou inkrementální a asociativní, nám zaručuje, že výsledek tohoto dodatečného kroku se nezmění. Tabulku by šlo zúžit pomocí selektu:
select high:max price, low:min price … by sym,time.minute from table
Tento způsob má nevýhodu – soubor vypočítaných sloupců je stanoven předem. Naštěstí je v Q selekt realizován také jako funkce, do které lze dynamicky vkládat vytvořené argumenty:
?[table;whereClause;byClause;selectClause]
Nebudu podrobně popisovat formát argumentů, v našem případě budou netriviální pouze výrazy by a select a měly by být slovníky typu columns!expressions. Takto lze definovat zhušťující funkci:
selExpression:`high`low`firstPrice`lastPrice`firstSize`lastSize`numTrades`volume`pvolume`turnover!parsing each ("max price";"min price";"first price";"last price";"first size";"last size";"count i";"sum size";"sum price";"sum price*size"); // each je funkce map v Q pro jeden seznam
preprocess:?[;();`sym`time!`sym`time.minute;selExpression];
Pro srozumitelnost jsem použil funkci parse, která převádí řetězec s Q výrazem na hodnotu, která může být předána do funkce eval a která je potřebná v funkčním selectu. Také si všimněme, že preprocess je definován jako projekce (tj. funkce s částečně určenými argumenty) funkce select, jeden argument (tabulka) chybí. Pokud aplikujeme preprocess na tabulku, získáme zhuštěnou tabulku.
Druhým krokem je aktualizace agregované tabulky. Nejprve napíšeme algoritmus v pseudokódu:
for each sym in inputTable
idx: index řádku v agregované tabulce pro sym+currentTime;
aggTable[idx;`high]: aggTable[idx;`high] | inputTable[sym;`high];
aggTable[idx;`volume]: aggTable[idx;`volume] + inputTable[sym;`volume];
…
V Q je obvyklé místo cyklů používat funkce map/reduce. Ale protože Q je vektorový jazyk a všechny operace můžeme v klidu aplikovat na všechny symboly najednou, tak v prvním přístupu se můžeme obejít úplně bez cyklu a provádět operace se všemi symboly najednou:
idx:calcIdx inputTable;
row:aggTable idx;
aggTable[idx;`high]: row[`high] | inputTable`high;
aggTable[idx;`volume]: row[`volume] + inputTable`volume;
…
Ale můžeme jít ještě dál, v Q existuje jedinečný a výjimečně mocný operator – operator obecného přiřazení. Umožňuje změnit množinu hodnot v složité struktuře dat pomocí seznamu indexů, funkcí a argumentů. V našem případě vypadá takto:
idx:calcIdx inputTable;
rows:aggTable idx;
// .[target;(idx0;idx1;..);function;argument] ~ target[idx 0;idx 1;…]: function[target[idx 0;idx 1;…];argument], v našem případě je funkcí přiřazení
.[aggTable;(idx;aggCols);:;flip (row[`high] | inputTable`high;row[`volume] + inputTable`volume;…)];
Bohužel, pro přiřazení do tabulky je potřebný seznam řádků, a ne sloupců, a musíme transponovat matici (seznam sloupců na seznam řádků) pomocí funkce flip. Pro velkou tabulku je to nákladné, takže místo toho použijeme obecné přiřazení na každý sloupec zvlášť, pomocí funkce map (která vypadá jako apostrof):
[aggTable;;:;]'[(idx;)each aggCols; (row[`high] | inputTable`high;row[`volume] + inputTable`volume;…)];
Znovu používáme projekci funkce. Všimněte si také, že v Q je vytváření seznamu také funkce a můžeme ji vyvolat pomocí funkce each(map), abychom získali seznam seznamů.
Aby soubor počítaných sloupců nebyl pevně stanoven, vytvoříme výrazy výše dynamicky. Nejprve definujeme funkce pro výpočet každého sloupce pomocí proměnných row a inp pro odkazování na agregované a vstupní údaje:
aggExpression:`high`low`firstPrice`lastPrice`firstSize`lastSize`avgPrice`avgSize`vwap`cumVolume!
("row[`high]|inp`high";"row[`low]&inp`low";"row`firstPrice";"inp`lastPrice";"row`firstSize";"inp`lastSize";"pvolume%numTrades";"volume%numTrades";"turnover%volume";"row[`cumVolume]+inp`volume");
Některé sloupce jsou zvláštní, jejich první hodnota by neměla být počítána funkcí. Můžeme definovat, že je první podle sloupce row[`numTrades] – pokud je 0, je hodnota první. V Q existuje výběrová funkce — ?[Boolean list;list1;list2] – která vybírá hodnotu ze seznamu 1 nebo 2 podle podmínky v prvním argumentu:
// high -> ?[isFirst;inp`high;row[`high]|inp`high]
// @ - тоже обобщенное присваивание для случая когда индекс неглубокий
@[`aggExpression;specialCols;{[x;y]"?[isFirst;inp`",y,";",x,"]"};string specialCols];
Tady jsem vyvolal obecné přiřazení s mou funkcí (výraz ve složených závorkách). Předává se do ní aktuální hodnota (první argument) a další argument, který předávám ve 4. parametru.
Samostatně přidáme akumulační sloupce, protože pro ně je funkce stejná:
// volume -> row[`volume]+inp`volume
aggExpression[accumulatorCols]:{"row[`",x,"]+inp`",x } each string accumulatorCols;
Toto je běžné přiřazení pro Q, jen přiřazuji okamžitě seznam hodnot. Nakonec vytvoříme hlavní funkci:
// ":",/:aggExprs ~ map[{":",x};aggExpr] => ":row[`high]|inp`high" присвоим вычисленное значение переменной, потому что некоторые колонки зависят от уже вычисленных значений
// string[cols],'exprs ~ map[,;string[cols];exprs] => "high:row[`high]|inp`high" завершим создание присваивания. ,’ расшифровывается как map[concat]
// ";" sv exprs – String from Vector (sv), соединяет список строк вставляя “;” посредине
updateAgg:value "{[aggTable;idx;inp] row:aggTable idx; isFirst_0=row`numTrades; .[aggTable;;:;]'[(idx;)each aggCols;(",(";"sv string[aggCols],'":",/:aggExpression aggCols),")]}";
Tímto výrazem dynamicky vytvářím funkci ze řetězce, který obsahuje výrazy, které jsem uvedl výše. Výsledek bude vypadat takto:
{[aggTable;idx;inp] rows:aggTable idx; isFirst_0=row`numTrades; .[aggTable;;:;]'[(idx;)each aggCols ;(cumVolume:row[`cumVolume]+inp`cumVolume;… ; high:?[isFirst;inp`high;row[`high]|inp`high])]}
Pořadí výpočtu sloupců je inverzní, protože v Q je pořadí výpočtu zprava doleva.
Nyní máme dvě hlavní funkce potřebné pro výpočty, zbývá přidat trochu infrastruktury a služba je hotova.
Konečné kroky
Máme funkce preprocess a updateAgg, které dělají veškerou práci. Ale je také nutné zajistit správný přechod mezi minutami a vypočítat indexy pro agregaci. Nejdříve definujme funkci init:
init:{
tradeAgg:: 0#enlist[initWith]; // vytváříme prázdnou typizovanou tabulku, enlist převádí slovník na tabulku, a 0# znamená vzít 0 prvků z ní
currTime::00:00; // začneme s 0, :: znamená, že přiřazení do globální proměnné
currSyms::`u#`symbol$(); // `u# - převádí seznam na strom, pro urychlení hledání prvků
offset::0; // index v tradeAgg, kde začíná aktuální minuta
rollCache:: `sym xkey update `u#sym from rollColumns#tradeAgg; // cache pro poslední hodnoty roll sloupců, tabulka s klíčem sym
}
Také definujeme funkci roll, která změní aktuální minutu:
roll:{[tm]
if[currTime>tm; :init[]]; // pokud jsme překročili půlnoc, jednoduše zavoláme init
rollCache,::offset _ rollColumns#tradeAgg; // aktualizujeme cache – vezmeme roll sloupce z aggTable, zkrátíme, vložíme do rollCache
offset::count tradeAgg;
currSyms::`u#`$();
}
Budeme potřebovat funkci pro přidání nových symbolů:
addSyms:{[syms]
currSyms,::syms; // přidáme do seznamu známých
// přidáme do tabulky sym, time a rollColumns využívající obecné přiřazení.
// Funkce ^ nastaví výchozí hodnoty pro roll sloupce, pokud symbol není v cache. value flip tabulka vrací seznam sloupců v tabulce.
`tradeAgg upsert @[count[syms]#enlist initWith;`sym`time,cols rc;:;(syms;currTime), (initWith cols rc)^value flip rc:rollCache ([] sym: syms)];
}
A nakonec, funkce upd (tradiční název této funkce pro Q služby), která je volána klientem k přidání dat:
upd:{[tblName;data] // tblName nepotřebujeme, ale obvykle služba zpracovává několik tabulek
tm:exec distinct time from data:() xkey preprocess data; // preprocess & calc time
updMinute[data] each tm; // přidáme data pro každou minutu
};
updMinute:{[data;tm]
if[tm<>currTime; roll tm; currTime::tm]; // změníme minutu, pokud je to nutné
data:select from data where time=tm; // filtrování
if[count msyms:syms where not (syms:data`sym)in currSyms; addSyms msyms]; // nové symboly
updateAgg[`tradeAgg;offset+currSyms?syms;data]; // aktualizujeme agregovanou tabulku. Funkce ? hledá index prvků seznamu vpravo v seznamu vlevo.
};
A to je vše. Tady je kompletní kód našeho služby, jak bylo slíbeno, jen pár řádků:
initWith:`sym`time`high`low`firstPrice`lastPrice`firstSize`lastSize`numTrades`volume`pvolume`turnover`avgPrice`avgSize`vwap`cumVolume!(`;00:00;0n;0n;0n;0n;0N;0N;0;0;0.0;0.0;0n;0n;0n;0);
aggCols:reverse key[initWith] except `sym`time;
rollColumns:`sym`cumVolume;
accumulatorCols:`numTrades`volume`pvolume`turnover;
specialCols:`high`low`firstPrice`firstSize;
selExpression:`high`low`firstPrice`lastPrice`firstSize`lastSize`numTrades`volume`pvolume`turnover!parse each ("max price";"min price";"first price";"last price";"first size";"last size";"count i";"sum size";"sum price";"sum price*size");
preprocess:?[;();`sym`time!`sym`time.minute;selExpression];
aggExpression:`high`low`firstPrice`lastPrice`firstSize`lastSize`avgPrice`avgSize`vwap`cumVolume!("row[`high]|inp`high";"row[`low]&inp`low";"row`firstPrice";"inp`lastPrice";"row`firstSize";"inp`lastSize";"pvolume%numTrades";"volume%numTrades";"turnover%volume";"row[`cumVolume]+inp`volume");
@[`aggExpression;specialCols;{"?[isFirst;inp`",y,";",x,"]"};string specialCols];
aggExpression[accumulatorCols]:{"row[`",x,"]+inp`",x } each string accumulatorCols;
updateAgg:value "{[aggTable;idx;inp] row:aggTable idx; isFirst_0=row`numTrades; .[aggTable;;:;]'[(idx;)each aggCols;(",(";"sv string[aggCols],'":",\/aggExpression aggCols),")]}"; \/ '
init:{
tradeAgg::0#enlist[initWith];
currTime::00:00;
currSyms::`u#`symbol$();
offset::0;
rollCache:: `sym xkey update `u#sym from rollColumns#tradeAgg;
};
roll:{[tm]
if[currTime>tm; :init[]];
rollCache,::offset _ rollColumns#tradeAgg;
offset::count tradeAgg;
currSyms::`u#`$();
};
addSyms:{[syms]
currSyms,::syms;
`tradeAgg upsert @[count[syms]#enlist initWith;`sym`time,cols rc;:;(syms;currTime),(initWith cols rc)^value flip rc:rollCache ([] sym: syms)];
};
upd:{[tblName;data] updMinute[data] each exec distinct time from data:() xkey preprocess data};
updMinute:{[data;tm]
if[tm<>currTime; roll tm; currTime::tm];
data:select from data where time=tm;
if[count msyms:syms where not (syms:data`sym)in currSyms; addSyms msyms];
updateAgg[`tradeAgg;offset+currSyms?syms;data];
};
Testování
Otestujeme výkon služby. K tomu ji spustíme v samostatném procesu (vložit kód do souboru service.q) a zavoláme funkci init:
q service.q –p 5566
q)init[]
V jiném terminálu spusťte druhý Q proces a připojte se k prvnímu:
h:hopen `:host:5566
h:hopen 5566 \/\ pokud jsou oba na stejném hostiteli
Nejprve vytvoříme seznam symbolů – 10000 kusů a přidáme funkci pro vytvoření náhodné tabulky. Ve druhém terminálu:
syms:`IBM`AAPL`GOOG,-9997?`8
rnd:{[n;t] ([] sym:n?syms; time:t+asc n#til 25; price:n?10f; size:n?10)}
Do seznamu symbolů jsem přidal tři skutečné, aby bylo snazší je vyhledávat v tabulce. Funkce rnd vytváří náhodnou tabulku s n řádky, kde se čas mění od t do t+25 milisekund.
Nyní můžeme zkusit poslat data do služby (přidáme prvních deset hodin):
{h (`upd;`trade;rnd[10000;x])} each `time$00:00 + til 60*10
Můžete zkontrolovat ve službě, že tabulka byla aktualizována:
c 25 200
select from tradeAgg where sym=`AAPL
-20#select from tradeAgg where sym=`AAPL
Result:
sym|time|high|low|firstPrice|lastPrice|firstSize|lastSize|numTrades|volume|pvolume|turnover|avgPrice|avgSize|vwap|cumVolume
--|--|--|--|--|--------------------------------
AAPL|09:27|9.258904|9.258904|9.258904|9.258904|8|8|1|8|9.258904|74.07123|9.258904|8|9.258904|2888
AAPL|09:28|9.068162|9.068162|9.068162|9.068162|7|7|1|7|9.068162|63.47713|9.068162|7|9.068162|2895
AAPL|09:31|4.680449|0.2011121|1.620827|0.2011121|1|5|4|14|9.569556|36.84342|2.392389|3.5|2.631673|2909
AAPL|09:33|2.812535|2.812535|2.812535|2.812535|6|6|1|6|2.812535|16.87521|2.812535|6|2.812535|2915
AAPL|09:34|5.099025|5.099025|5.099025|5.099025|4|4|1|4|5.099025|20.3961|5.099025|4|5.099025|2919Nyní provedeme zátěžové testování, abychom zjistili, kolik dat může služba zpracovat za minutu. Připomínám, že jsme nastavili interval pro aktualizace na 25 milisekund. Služba by tedy měla (průměrně) stíhat alespoň 20 milisekund na aktualizaci, aby dal uživatelům čas na dotaz na data. Zadejte následující do druhého procesu:
tm:10:00:00.000
stressTest:{[n] 1 string[tm]," "; times,::h ({st:.z.T; upd[`trade;x]; .z.T-st};rnd[n;tm]); tm+:25}
start:{[n] times::(); do[4800;stressTest[n]]; -1 " "; `min`avg`med`max!(min times;avg times;med times;max times)}
4800 – to jsou dvě minuty. Můžeme zkusit nejprve spustit pro 1000 řádků každých 25 milisekund:
start 1000
V mém případě je výsledek kolem několika milisekund na aktualizaci. Takže ihned zvýším počet řádků na 10 000:
start 10000
Result:
min| 00:00:00.004
avg| 9.191458
med| 9f
max| 00:00:00.030
Opět nic zvláštního, a to je 24 milionů řádků za minutu, 400 tisíc za sekundu. Více než 25 milisekund, aktualizace se zdržela jen 5krát, očividně při změně minuty. Zvětšíme na 100 000:
start 100000
Result:
min| 00:00:00.013
avg| 25.11083
med| 24f
max| 00:00:00.108
q)sum times
00:02:00.532
Jak vidíme, služba sotva zvládá, ale přesto se jí daří udržet na hladině. Takový objem dat (240 milionů řádků za minutu) je extrémně vysoký, v těchto případech je obvyklé spouštět několik klonů (nebo dokonce desítky klonů) služby, z nichž každý zpracovává pouze část symbolů. Nicméně, výsledek je působivý pro interpretovaný jazyk, který je primárně zaměřen na uchovávání dat.
Může se zdát, že čas roste nelineárně s velikostí každé aktualizace. Důvodem je, že kompresní funkce je vlastně funkce C, která funguje mnohem efektivněji než updateAgg. Od určité velikosti aktualizace (kolem 10 000) dosahuje updateAgg svého limitu a její doba běhu již nezávisí na velikosti aktualizace. Díky předběžnému kroku Q je služba schopna zpracovávat takové objemy dat. To podtrhuje, jak je důležité při práci s velkými daty vybírat správný algoritmus. Dalším bodem je správné ukládání dat do paměti. Kdyby data nebyla ukládána sloupcově nebo nebyla uspořádána podle času, setkali bychom se s něčím, co se nazývá TLB cache miss – chybějící adresa stránky paměti v cache adres procesoru. Hledání adresy zabere zhruba 30krát více času v případě neúspěchu a při rozptýlených datech může výrazně zpomalit službu.
Závěr
V tomto článku jsem ukázal, že databáze KDB+ a Q jsou vhodné nejen pro ukládání velkých dat a jejich jednoduchý přístup pomocí SELECT, ale také pro vytváření datových zpracovatelských služeb, které jsou schopny zpracovávat stovky milionů řádků/gigabajty dat i v jednom jednotlivém procesu Q. Jazyk Q umožňuje velmi stručně a efektivně realizovat algoritmy související se zpracováním dat díky své vektorové povaze, vestavěnému interpretátorovi dialektu SQL a velmi úspěšnému souboru knihovních funkcí.
Poznamenám, že výše uvedené je pouze část možností Q, má i další jedinečné vlastnosti. Například nesmírně jednoduchý IPC protokol, který stírá hranici mezi jednotlivými procesy Q a umožňuje spojit stovky těchto procesů do jedné sítě, která může být rozložena na desítkách serverů po celém světě.
Zdroj: habr.com
