Odstranění Zpracování Úloh Výpadky Nahradí RabbitMQ s Apache Kafka Bez Prostojů

Škálování backend infrastruktury zvládnout hyper-růst je jedním z mnoha zajímavých problémů z práce u DoorDash. V polovině roku 2019, jsme čelili výrazné škálování problémy a časté výpadky zahrnující Celer a RabbitMQ, dvě technologie pohání systém, který zpracovává asynchronní práce umožňující kritické funkce naší platformy, včetně pořadí pokladny a Dasher úkoly.

tento problém jsme rychle vyřešili jednoduchým asynchronním systémem zpracování úloh Apache Kafka, který zastavil naše výpadky, zatímco jsme pokračovali v iteraci na robustním řešení. Naše původní verze implementovala nejmenší sadu funkcí potřebných k přizpůsobení velké části stávajících celerových úkolů. Jednou v produkci, jsme nadále přidat podporu pro více Celer funkcí při řešení nových problémů, které vznikly při použití Kafka.

problémů pomocí Celer a RabbitMQ

RabbitMQ a Celer byly mise kritické části naší infrastruktury, který poháněl přes 900 různých asynchronní úkoly na DoorDash, včetně pořadí, pokladna, objednávky obchodníka převodovka, a Dasher místo zpracování. Problém, kterému DoorDash čelil, byl, že RabbitMQ často klesal kvůli nadměrnému zatížení. Pokud zpracování úlohy šla dolů, DoorDash účinně šel dolů a objednávky nemohla být dokončena, což vede k ztráty příjmů pro naše obchodníky a Dasherovi, a špatná zkušenost pro naše zákazníky. Čelili jsme problémům na následujících frontách:

  • dostupnost: výpadky způsobené poptávkou Snížená dostupnost.
  • škálovatelnost: RabbitMQ nemohl škálovat s růstem našeho podnikání.
  • pozorovatelnost: RabbitMQ nabízel omezené metriky a pracovníci celeru byli neprůhlední.
  • provozní efektivita: restartování těchto komponent bylo časově náročné, ruční proces.

Proč naše asynchronní zpracování úloh systému nebyla k dispozici vysoce

největší problém, jsme čelili, byly výpadky, a oni často přišel, když poptávka byla na svém vrcholu. RabbitMQ by šel dolů kvůli zatížení, nadměrnému víření připojení, a další důvody. Objednávky by byly zastaveny a museli bychom restartovat náš systém nebo někdy dokonce vychovávat zcela nového makléře a ručně failover, abychom se zotavili z výpadku.

při potápění hlouběji do problémů s dostupností jsme našli následující dílčí problémy:

  • celer umožňuje uživatelům plánovat úkoly v budoucnu s odpočítáváním nebo ETA. Naše těžké používání těchto odpočítávání vedlo ke znatelnému zvýšení zatížení makléře. Některé naše výpadky přímo souvisely s nárůstem úkolů s odpočítáváním. Nakonec jsme se rozhodli omezit používání odpočítávání ve prospěch jiného systému, jsme měli na místě pro plánování práce v budoucnosti.
  • náhlé výbuchy provozu by zanechaly RabbitMQ ve zhoršeném stavu, kdy byla spotřeba úkolů výrazně nižší, než se očekávalo. Podle našich zkušeností by to mohlo být vyřešeno pouze odrazem RabbitMQ. RabbitMQ má koncept řízení toku, kde sníží rychlost připojení, která se publikují příliš rychle, aby fronty mohly držet krok. Řízení toku bylo často, ale ne vždy, podílí se na těchto degradacích dostupnosti. Když začne řízení toku, vydavatelé to efektivně vidí jako latenci sítě. Latence sítě snižuje naše doby odezvy; pokud se latence zvyšuje během špičkového provozu, může dojít k výraznému zpomalení této kaskády, protože požadavky se hromadí proti proudu.
  • naši weboví pracovníci python uWSGI měli funkci nazvanou harakiri, která umožnila zabít všechny procesy, které překročily časový limit. Během výpadků nebo zpomalení vedlo harakiri ke spojení s brokery RabbitMQ, protože procesy byly opakovaně zabíjeny a restartovány. S tisíci webovými pracovníky běžícími v daném okamžiku, jakákoli pomalost, která vyvolala harakiri, by zase přispěla ještě více k pomalosti přidáním dalšího zatížení do RabbitMQ.
  • ve výrobě jsme zažili několik případů, kdy se zpracování úkolů v konzumentech celeru zastavilo, a to i při absenci významného zatížení. Naše vyšetřovací úsilí nepřineslo důkazy o jakýchkoli omezeních zdrojů, která by zastavila zpracování, a pracovníci pokračovali ve zpracování, jakmile byli odrazeni. Tento problém nebyl nikdy způsoben kořenem, i když máme podezření na problém samotných pracovníků celeru a ne RabbitMQ.

celkově byly všechny tyto problémy s dostupností pro nás nepřijatelné, protože vysoká spolehlivost je jednou z našich nejvyšších priorit. Protože tyto výpadky byly nás stojí hodně, pokud jde o nepřijaté objednávky a důvěryhodnost jsme potřebovali řešení, které by tyto problémy řešit co nejdříve.

proč naše starší řešení nezměnilo

dalším největším problémem bylo měřítko. DoorDash rychle roste a my jsme rychle dosáhli limitů našeho stávajícího řešení. Potřebovali jsme najít něco, co by držet krok s naší pokračující růst od našich starších řešení následujících problémů:

Bít vertikální škálování mezní

byli Jsme pomocí největšího k dispozici jeden uzel RabbitMQ řešení, které bylo k dispozici pro nás. Neexistovala žádná cesta k vertikálnímu měřítku a my jsme již začali tlačit tento uzel na jeho hranice.

V režimu Vysoké Dostupnosti omezena naše schopnost

Vzhledem k replikace, primární-sekundární Vysokou Dostupnost (HA) režim snižuje propustnost v porovnání s jeden uzel možnost, opouští nás s ještě menší prostor než jen jeden uzel řešení. Nemohli jsme si dovolit obchodovat propustnost za dostupnost.

za druhé, primární-sekundární režim HA v praxi nesnížil závažnost našich výpadků. Selhání trvalo více než 20 minuty na dokončení a často by uvízly vyžadující ruční zásah. Zprávy byly často ztraceny v procesu, jakož.

rychle nám docházela světlá výška, protože DoorDash stále rostl a tlačil naše zpracování úkolů na své limity. Potřebovali jsme řešení, které by se mohlo horizontálně škálovat, jak rostly naše potřeby zpracování.

Jak Celer a RabbitMQ nabízí omezené pozorovatelnost

Vědět, co se děje v každém systému je zásadní pro zajištění jeho dostupnosti, škálovatelnosti a provozní integrity.

Když jsme procházeli výše uvedenými problémy, všimli jsme si, že:

  • byli jsme omezeni na malou sadu metrik RabbitMQ, které máme k dispozici.
  • Měli jsme omezenou viditelnost do samotných pracovníků celeru.

potřebovali jsme vidět metriky v reálném čase všech aspektů našeho systému, což znamenalo, že je třeba řešit i omezení pozorovatelnosti.

provozní efektivitu výzvy

Jsme také čelí několik problémů s operačním RabbitMQ:

  • často Jsme museli převzetí služeb při selhání našich RabbitMQ uzlu do nového vyřešit přetrvávající degradace jsme pozorovali. Tato operace byla manuální a časově náročná pro zúčastněné inženýry a často se musela provádět pozdě v noci, mimo špičku.
  • v DoorDash nebyli žádní interní odborníci na celer nebo RabbitMQ, o které bychom se mohli opřít, abychom pomohli navrhnout strategii škálování pro tuto technologii.

technický čas strávený provozem a údržbou RabbitMQ nebyl udržitelný. Potřebovali jsme něco, co by lépe vyhovovalo našim současným i budoucím potřebám.

Potenciální řešení našich problémů s Celerem a RabbitMQ

S problémy nastíněné výše, jsme zvažovali následující řešení:

  • Změnit Celer makléř z RabbitMQ, aby Redis nebo Kafka. To by nám umožnilo pokračovat v používání celeru, s jiným a potenciálně spolehlivějším datovým úložištěm.
  • Přidejte podporu více makléřů do naší aplikace Django, aby spotřebitelé mohli publikovat N různým makléřům na základě jakékoli logiky, kterou jsme chtěli. Zpracování úkolů se rozdělí mezi více makléřů, takže každý makléř zažije zlomek počátečního zatížení.
  • upgradujte na novější verze celeru a RabbitMQ. Novější verze Celer a RabbitMQ měly vyřešit problémy se spolehlivostí, dává nám čas, jak jsme byli již vyrábějí komponenty z našeho Django monolit paralelně.
  • migrovat na vlastní řešení podporované Kafkou. Toto řešení vyžaduje více úsilí než ostatní možnosti, které jsme uvedli, ale také má větší potenciál vyřešit každý problém, který jsme měli se starším řešením.

každá možnost má své klady a zápory:

Možnost Klady Zápory
Redis jako zprostředkovatel
  • Lepší dostupnost s ElasticCache a multi-AZ podpory
  • Lepší broker pozorovatelnost s ElasticCache jako zprostředkovatel
  • Zlepšení provozní efektivity
  • In-house provozní zkušenosti a zkušenosti s Redis
  • makléře swap je rovnou foward jako podporované možnost v Celeru
  • Harakiri připojení konve nemá výrazně snížit Redis výkon
  • Nekompatibilní s Redis clusteru režimu
  • Jeden node Redis není měřítko vodorovně
  • Celer pozorovatelnost vylepšení
  • Toto řešení se nezabývá pozorovány problém, kde Celer pracovníků zastavil zpracování úkolů
Kafka jako zprostředkovatel
  • Kafka může být velmi k dispozici
  • Kafka je horizontálně škálovatelná
  • Lepší pozorovatelnost s Kafky jako zprostředkovatel
  • Zlepšení provozní efektivity
  • DoorDash měl v domě Kafka znalosti
  • makléře swap je rovnou foward jako podporované možnost v Celeru
  • Harakiri připojení konve nemá výrazně snížit Kafka výkon
  • Kafka není podporován Celer
  • neřeší pozorovány problém, kde Celer pracovníci zastavit zpracování úkolů
  • celer pozorovatelnost vylepšení
  • Přes in-house zkušenosti, jsme neměli provozovat Kafka v rozsahu, v DoorDash.
Více makléři
  • Lepší dostupnost
  • Horizontální škálovatelnost
  • Žádné zlepšení pozorovatelnost
  • Žádné provozní účinnosti
  • Neřeší pozorovány problém, kde Celer pracovníci zastavit zpracování úkolů
  • neřeší problém s harakiri-indukované připojení máselnice
Upgrade verze
  • Může zlepšit problém, kde RabbitMQ stane uvízl ve zhoršeném stavu
  • Může zlepšit problém, kde Celer pracovníků
  • získáme prostor k realizaci dlouhodobé strategie
  • Není zaručeno, že opravit naše pozorovány chyby
  • není okamžitě opravit naše problémy s dostupnost, škálovatelnost, pozorovatelnost, a provozní účinnost
  • Novější verze RabbitMQ a Celer vyžaduje novější verze Pythonu.
  • neřeší problém s harakiri-indukované připojení máselnice
Vlastní Kafka řešení
  • Kafka může být velmi k dispozici
  • Kafka je horizontálně škálovatelná
  • Lepší pozorovatelnost s Kakfa jako zprostředkovatel
  • Zlepšení provozní efektivity
  • In-house Kafka znalosti
  • makléře změna je rovnou foward
  • Harakiri připojení konve nemá výrazně snížit Kafka výkon
  • Řeší pozorované problém, kde Celer pracovníci zastavit zpracování úkolů
  • Vyžaduje více práce realizovat, než všechny ostatní možnosti
  • Přes in-house zkušenosti, jsme neměli provozovat Kafka v rozsahu, v DoorDash

Naše strategie pro onboarding Kafka

Vzhledem k naší požadované system uptime, vymysleli jsme naše onboarding strategii založenou na následujících principech maximalizovat spolehlivost výhody v co nejkratším čase. Tato strategie zahrnovala tři kroky:

  • zasažení terénu: Chtěli jsme využít základy řešení, které jsme budovali, když jsme iterovali na jiných částech. Tuto strategii přirovnáváme k řízení závodního automobilu při výměně nového palivového čerpadla.
  • možnosti návrhu pro bezproblémové přijetí vývojáři: chtěli jsme minimalizovat zbytečné úsilí ze strany všech vývojářů, které mohly vyplynout z definování jiného rozhraní.
  • Inkrementální zavádění s nulovými prostoji: Místo velké honosné vydání testované v divočině poprvé s vyšší šanci na selhání, jsme se zaměřili na přepravu menších nezávislých funkcí, které by mohly být individuálně testovány v přírodě po delší dobu.

zásah do terénu

přechod na Kafku představoval zásadní technickou změnu v našem zásobníku, která však byla velmi potřebná. Neměli jsme čas ztrácet čas, protože každý týden jsme ztratili podnikání kvůli nestabilitě našeho dědictví RabbitMQ řešení. Naše první a nejdůležitější prioritou bylo vytvořit minimální životaschopný produkt (MVP), aby nám přinesl prozatímní stabilitu a dát nám prostor potřebný pro iteraci a připravit se na více komplexní řešení s širší přijetí.

Naše MVP se skládala z výrobců, které jsou zveřejněny úkol Plně Kvalifikované Názvy (FQNs) a nakládané argumenty Kafka zatímco naše spotřebitele, přečtěte si ty zprávy, importované úkoly z FQN a popravili synchronně s uvedenými argumenty.

Minimální Životaschopný Produkt(MVP) architektury jsme se rozhodli postavit jako průběžný státu, kde bychom publikování vzájemně se vylučující úkoly k oběma dědictví (červené přerušované čáry) a nové systémy (zelené pevné linky), před konečným státu, kde bychom se zastavit publikování úkoly RabbitMQ.1

Obrázek 1: Minimální Životaschopný Produkt(MVP) architektury jsme se rozhodli postavit jako průběžný státu, kde bychom publikování vzájemně se vylučující úkoly k oběma dědictví (červené přerušované čáry) a nové systémy (zelené pevné linky), před konečným státu, kde bychom se zastavit publikování úkoly RabbitMQ.

možnosti návrhu pro bezproblémové přijetí vývojáři

někdy je adopce vývojářů větší výzvou než vývoj. Usnadnili jsme to implementací obalu pro anotaci @task Celery, která dynamicky směrovala podání úkolů do obou systémů na základě dynamicky konfigurovatelných příznaků funkcí. Nyní lze stejné rozhraní použít k psaní úkolů pro oba systémy. S těmito rozhodnutími, v místě, inženýrské týmy měli dělat žádné další práce na integraci s novým systémem, blokování provádění jedné funkce vlajky.

chtěli Jsme nasadit náš systém, jakmile se naše MVP byl připraven, ale to ještě podporovat všechny stejné funkce jako Celer. Celer umožňuje uživatelům konfigurovat své úkoly s parametry v anotaci úkolu nebo při odeslání úkolu. Abychom mohli spustit rychleji, vytvořili jsme whitelist kompatibilních parametrů a rozhodli jsme se podporovat nejmenší počet funkcí potřebných k podpoře většiny úkolů.

Jsme rychle vyrovnali úkol objem Kafka založené na MVP, počínaje s nízkým rizikem a nízkou prioritní úkoly první. Některé z nich byly úkoly, které probíhaly mimo špičku, což vysvětluje hroty metriky zobrazené výše.

Obrázek 2: Rychle jsme zvýšili objem úkolů na MVP založený na Kafce, počínaje úkoly s nízkým rizikem a nízkou prioritou. Některé z nich byly úkoly, které probíhaly mimo špičku, což vysvětluje hroty metriky zobrazené výše.

Jak je vidět na Obrázku 2, se dvěma rozhodnutí výše, jsme spustili naše MVP po dvou týdnech rozvoj a dosaženo 80% snížení v RabbitMQ úkol načíst další týden po spuštění. Primární problém výpadků jsme řešili rychle a v průběhu projektu jsme podporovali stále více esoterických funkcí, které umožňovaly plnění zbývajících úkolů.

Inkrementální zavádění, nulové prostoje

schopnost dynamicky přepínat Kafkovy klastry a přepínat mezi RabbitMQ a Kafkou bez obchodního dopadu byla pro nás nesmírně důležitá. Tato schopnost nám také pomohla v různých operacích, jako je údržba clusteru, uvolňování zátěže a postupná migrace. K implementaci tohoto zavádění jsme využili dynamické příznaky funkcí jak na úrovni odesílání zpráv, tak na straně spotřeby zpráv. Náklady jsou plně dynamické tady bylo udržet náš pracovník flotila běží na dvojnásobné kapacity. Polovina této flotily byla věnována RabbitMQ a zbytek Kafkovi. Provoz dělnické flotily na dvojnásobnou kapacitu rozhodně zdaňoval naši infrastrukturu. V jednu chvíli jsme dokonce roztočili úplně nový klastr Kubernetes, jen abychom ubytovali všechny naše pracovníky.

během počáteční fáze vývoje nám tato flexibilita dobře sloužila. Jednou jsme měli větší důvěru v náš nový systém, jsme se podívali na způsoby, jak snížit zatížení naší infrastruktury, jako je běh více náročné procesy na pracovníka stroj. Jak jsme přecházeli různá témata, byli jsme schopni začít snižovat počty pracovníků pro RabbitMQ při zachování malé rezervní kapacity.

žádné řešení není dokonalé, iterujte podle potřeby

s naším MVP ve výrobě jsme měli světlou výšku potřebnou k iteraci a leštění našeho produktu. Každou chybějící celerovou funkci jsme zařadili podle počtu úkolů, které nám pomohly rozhodnout, které z nich implementovat jako první. Funkce používané pouze několika úkoly nebyly v našem vlastním řešení implementovány. Místo toho jsme tyto úkoly přepsali, abychom tuto konkrétní funkci nepoužívali. S touto strategií jsme nakonec přesunuli všechny úkoly z celeru.

Pomocí Kafka představil také nové problémy, které vyžadují naši pozornost:

  • Head-of-line blokování což mělo za následek zpracování úloh zpoždění
  • Nasazení vyvolalo oddíl rovnováhy, což mělo za následek zpoždění

Kafka hlavu-of-the-line blokování problém

Kafka témata jsou rozdělena tak, že jeden spotřebitel (za skupinu spotřebitelů) čte zprávy pro své přidělené oddíly v pořadí, v jakém dorazila. Pokud zpracování zprávy v jednom oddílu trvá příliš dlouho, zastaví spotřebu všech zpráv za ním v tomto oddílu, Jak je vidět na obrázku 3 níže. Tento problém může být obzvláště katastrofální v případě tématu s vysokou prioritou. Chceme být schopni pokračovat ve zpracování zpráv v oddílu v případě, že dojde ke zpoždění.

v Kafkově hlavičkovém problému blokování pomalá zpráva v oddílu (červeně) blokuje zpracování všech zpráv za ním. Ostatní oddíly by se dále zpracovávaly podle očekávání.

obrázek 3: V Kafkově problému s blokováním head-of-the-line blokuje pomalá zpráva v oddílu (červeně) zpracování všech zpráv za ním. Ostatní oddíly by se dále zpracovávaly podle očekávání.

zatímco paralelismus je v zásadě problémem Pythonu, koncepty tohoto řešení jsou použitelné i pro jiné jazyky. Naše řešení, znázorněné na obrázku 4, níže, bylo umístit jeden Kafka-spotřebitelský proces a více procesů provádění úkolů na pracovníka. Proces Kafka-spotřebitel je zodpovědný za načítání zpráv z Kafky a jejich umístění do místní fronty, která je čtena procesy provádění úkolů. Pokračuje ve spotřebě, dokud místní fronta nedosáhne uživatelem definované prahové hodnoty. Toto řešení umožňuje tok zpráv v oddílu a pomalá zpráva zastaví pouze jeden proces provádění úkolů. Prahová hodnota také omezuje počet zpráv za letu v místní frontě (které se mohou ztratit v případě selhání systému).

obrázek 4: Náš neblokující Kafka Worker se skládá z lokální fronty zpráv a dvou typů procesů: kafka-spotřebitelský proces a více procesů vykonávajících úkoly. Zatímco kafka-spotřebitel může číst z více oddílů, pro jednoduchost zobrazíme jen jeden. Tento diagram ukazuje, že zpráva s pomalým zpracováním (červeně) blokuje pouze jednoho vykonavatele úloh, dokud jej nedokončí, zatímco ostatní zprávy za ním v oddílu budou nadále zpracovávány jinými vykonavateli úloh.

obrázek 4: náš neblokující pracovník Kafka se skládá z místní fronty zpráv a dvou typů procesů: kafka-spotřebitelský proces a více úkolů-vykonavatel procesů. Zatímco kafka-spotřebitel může číst z více oddílů, pro jednoduchost zobrazíme jen jeden. Tento diagram ukazuje, že zpráva s pomalým zpracováním (červeně) blokuje pouze jednoho vykonavatele úloh, dokud jej nedokončí, zatímco ostatní zprávy za ním v oddílu budou nadále zpracovávány jinými vykonavateli úloh.

narušení nasazení

nasazujeme naši aplikaci Django několikrát denně. Jednou z nevýhod našeho řešení, které jsme si všimli, je, že nasazení spouští vyvážení přiřazení oddílů v Kafce. Navzdory použití jiné skupiny spotřebitelů na téma k omezení rozsahu vyvážení, nasazení stále způsobilo okamžité zpomalení zpracování zpráv, protože spotřeba úkolů se během vyvážení musela zastavit. Zpomalení může být přijatelné ve většině případů, když provádíme plánovaná vydání,ale může být katastrofální, když například provádíme nouzové vydání opravy opravy chyb. Důsledkem by bylo zavedení kaskádového zpomalení zpracování.

novější verze Kafky a klientů podporují Inkrementální kooperativní vyvážení, což by masivně snížilo provozní dopad vyvážení. Upgradování našich klientů na podporu tohoto typu vyvážení by bylo naším řešením do budoucna. Inkrementální kooperativní rebalancování bohužel u našeho vybraného Kafkova klienta zatím není podporováno.

Key wins

se závěrem tohoto projektu jsme realizovali významná zlepšení, pokud jde o provozuschopnost, škálovatelnost, pozorovatelnost a decentralizaci. Tato vítězství byla zásadní pro zajištění dalšího růstu našeho podnikání.

Žádné další opakované výpadky

Jsme zastavili opakované výpadky téměř hned, jak jsme začali vyvalit tento zvyk Kafka přístup. Výpadky vedly k extrémně špatným zkušenostem uživatelů.

  • prováděcí jen malou podmnožinou z nejčastěji používaných Celer funkce v našich MVP jsme byli schopni doručit pracovní kód do výroby za dva týdny.
  • se zavedeným MVP jsme byli schopni výrazně snížit zatížení RabbitMQ a celeru, protože jsme pokračovali v tvrdnutí našeho řešení a implementaci nových funkcí.

Úkol zpracování již není limitujícím faktorem pro růst

S Kafky v centru našeho hotelu, jsme postavili zpracování úloh systém, který je vysoce dostupné a horizontálně škálovatelný, což umožňuje DoorDash a své zákazníky, aby i nadále jejich růst.

masivně rozšířená pozorovatelnost

protože se jednalo o vlastní řešení, dokázali jsme péct ve více metrikách téměř na všech úrovních. Každá fronta, pracovník, a úkol byl plně pozorovatelný na velmi granulární úrovni ve výrobních a vývojových prostředích. Tato zvýšená pozorovatelnost byla obrovskou výhrou nejen ve výrobním smyslu, ale také z hlediska produktivity vývojářů.

Provozní decentralizace

S pozorovatelnost vylepšení, jsme byli schopni templatize naše upozornění, jak Terraform moduly a explicitně přiřadit majitele, aby každý téma a, implicitně, všechny 900-plus úkoly.

podrobný návod pro zpracování úloh systému je informace přístupná pro všechny inženýry ladění operačního problémy s jejich témata a pracovníků, jakož i provádět celkovou Kafka cluster-řízení operací, jak je potřeba. Každodenní operace jsou samoobslužné a podpora je zřídka potřebná od našeho týmu infrastruktury.

závěr

abychom to shrnuli, narazili jsme na strop naší schopnosti škálovat RabbitMQ a museli jsme hledat alternativy. Alternativou, se kterou jsme šli, bylo vlastní Kafkovo řešení. I když existují určité nevýhody používání Kafky, našli jsme řadu řešení popsaných výše.

když kritické pracovní postupy silně spoléhají na asynchronní zpracování úloh, je zajištění škálovatelnosti nanejvýš důležité. Když se setkáte s podobnými problémy, neváhejte se inspirovat naší strategií, která nám poskytla 80% výsledku s 20% úsilí. Tato strategie, v obecném případě, je taktický přístup k rychle zmírnit problémy se spolehlivostí a koupit bolestně zapotřebí čas pro více robustní a strategické řešení.

Poděkování

autoři by rádi poděkovali Clement Fang, Corry Haines, Danial Asif, Jay Weinstein, Luigi Tagliamonte, Matthew Hněv, Shaohua Zhou, a Yun-Yu Chen za přispění tohoto projektu.

fotografie od tian kuan na Unsplash

Napsat komentář

Vaše e-mailová adresa nebude zveřejněna.