backendin infrastruktuurin skaalaaminen hyperkasvun hallitsemiseksi on yksi doordashin monista jännittävistä haasteista. Vuoden 2019 puolivälissä kohtasimme merkittäviä skaalaushaasteita ja toistuvia katkoksia, joihin liittyi selleri ja RabbitMQ, kaksi teknologiaa, jotka antavat virtaa järjestelmälle, joka käsittelee asynkronista työtä mahdollistaen alustamme kriittiset toiminnot, mukaan lukien tilaus-ja Dasher-tehtävät.
ratkaisimme ongelman nopeasti yksinkertaisella, Apache Kafkapohjaisella asynkronisella tehtävänkäsittelyjärjestelmällä, joka pysäytti katkokset jatkaessamme iterointia vankalla ratkaisulla. Meidän alkuperäinen versio toteutettu pienin joukko ominaisuuksia tarvitaan mahtuu suuri osa nykyisten selleri tehtäviä. Kun tuotanto, jatkoimme tuen lisää selleri ominaisuuksia samalla käsitellä uusia ongelmia, jotka syntyivät käytettäessä Kafka.
ongelmat, joita kohtasimme sellerin ja RabbitMQ: n
RabbitMQ: n ja Celerin käytössä, olivat kriittisiä osia infrastruktuuristamme, joka toimi yli 900 eri asynkronisessa tehtävässä Doordashissa, mukaan lukien tilausten kassalla, kauppiaiden tilausten välityksessä ja Dasherin sijainnin käsittelyssä. Doordashin ongelmana oli, että RabbitMQ oli usein menossa alas liiallisen kuormituksen vuoksi. Jos tehtävien käsittely laski, DoorDash meni tehokkaasti alas ja tilauksia ei voitu suorittaa, mikä johti liikevaihdon menetykseen kauppiaille ja Dashers, ja huono kokemus kuluttajille. Ongelmia oli seuraavilla rintamilla:
- saatavuus: kysynnän aiheuttamat katkokset heikensivät saatavuutta.
- skaalautuvuus: RabbitMQ ei kyennyt skaalautumaan liiketoimintamme kasvun myötä.
- Havainnoitavuus: RabbitMQ tarjosi rajalliset Mittarit ja Sellerityöntekijät olivat läpinäkymättömiä.
- toiminnan tehokkuus: näiden komponenttien uudelleenkäynnistäminen oli aikaa vievä, manuaalinen prosessi.
miksi asynkroninen tehtävänkäsittelyjärjestelmämme ei ollut kovin käytettävissä
tämä suurin ongelmamme olivat katkokset, ja niitä tuli usein silloin, kun kysyntä oli huipussaan. RabbitMQ menisi alas kuormituksen, liiallisen yhteyden Kirnun ja muista syistä. Tilaukset pysähtyisivät, ja meidän pitäisi käynnistää järjestelmämme uudelleen tai joskus jopa ottaa käyttöön kokonaan uusi välittäjä ja manuaalisesti failover toipuaksemme katkoksesta.
sukeltaessamme syvemmälle saatavuusongelmiin löysimme seuraavat alaongelmat:
- Celerin avulla käyttäjät voivat ajoittaa tehtäviä tulevaisuudessa lähtölaskennalla tai ETA: lla. Näiden laskujen runsas käyttö johti huomattavaan kuormituksen lisääntymiseen välittäjällä. Osa katkoksistamme liittyi suoraan työtehtävien lisääntymiseen laskujen myötä. Päätimme lopulta rajoittaa laskennan käyttöä toisen järjestelmän hyväksi, joka meillä oli käytössä töiden aikatauluttamiseen tulevaisuudessa.
- äkilliset ruuhkat jättäisivät RabbitMQ: n heikentyneeseen tilaan, jossa tehtävien kulutus oli huomattavasti odotettua pienempi. Kokemuksemme mukaan tämä voitiin ratkaista vain RabbitMQ bounce. RabbitMQ on käsite Virtauksen ohjaus, jossa se vähentää nopeutta yhteyksiä, jotka julkaisevat liian nopeasti, jotta jonot voivat pysyä. Virtauksen säätely oli usein, mutta ei aina, mukana näissä saatavuushajoissa. Kun Flow Control käynnistyy, julkaisijat näkevät sen verkon latenssina. Verkon latenssi vähentää vasteaikoja; jos latenssi kasvaa ruuhkahuippujen aikana, merkittävät hidastukset voivat johtaa siihen, että cascade pyyntöjen kasaantuessa ylävirtaan.
- python uWSGI-verkkotyöntekijöillämme oli Harakiri-niminen ominaisuus, joka pystyi tappamaan kaikki aikalisän ylittäneet prosessit. Katkosten tai hidastusten aikana harakiri johti yhteyden kirnuamiseen RabbitMQ-välittäjiin, kun prosesseja toistuvasti lopetettiin ja käynnistettiin uudelleen. Kun tuhansia verkkotyöntekijöitä on käynnissä milloin tahansa, mikä tahansa harakirin laukaissut hitaus puolestaan lisää hitautta entisestään lisäämällä RabbitMQ: hon ylimääräistä kuormaa.
- tuotannossa koimme useita tapauksia, joissa tehtävän käsittely sellerin kuluttajissa loppui, vaikka merkittävää kuormitusta ei ollut. Tutkimuksemme eivät tuottaneet todisteita resurssirajoituksista, jotka olisivat pysäyttäneet käsittelyn, ja työntekijät jatkoivat käsittelyä, kun heidät oli irtisanottu. Tämä ongelma ei koskaan juuri aiheuttanut, vaikka epäilemme ongelma selleri työntekijät itse eikä RabbitMQ.
kaiken kaikkiaan näitä saatavuusongelmia ei voitu hyväksyä, sillä korkea luotettavuus on yksi tärkeimmistä tavoitteistamme. Koska nämä katkokset maksoivat meille paljon menetettyjen tilausten ja uskottavuuden kannalta, tarvitsimme ratkaisun, joka puuttuisi näihin ongelmiin mahdollisimman pian.
miksi perintöratkaisumme ei skaalautunut
seuraavaksi suurin ongelma oli mittakaava. DoorDash kasvaa nopeasti ja saavutimme nopeasti nykyisen ratkaisumme rajat. Meidän oli löydettävä jotain, joka pysyisi jatkuvan kasvun vauhdissa, sillä perintöratkaisussamme oli seuraavat ongelmat:
osuminen pystysuuntaiseen skaalausrajaan
käytimme suurinta saatavilla olevaa yhden solmun RabbitMQ-ratkaisua, joka oli saatavilla. Ei ollut polkua skaalata pystysuunnassa enää ja olimme jo alkaneet työntää, että solmu sen rajoja.
korkean käytettävyyden tila rajoitti kapasiteettiamme
replikaation vuoksi, ensisijainen-toissijainen korkean käytettävyyden (HA) tila vähensi läpimenoa verrattuna yhden solmun vaihtoehtoon, jättäen meille vielä vähemmän liikkumatilaa kuin vain yhden solmun ratkaisu. Meillä ei ollut varaa vaihtaa tuotantotehoa saatavuuteen.
toiseksi primaari-sekundaarinen HA-tila ei käytännössä vähentänyt katkoksiemme vakavuutta. Failovers kesti yli 20 minuuttia loppuun ja usein juuttua vaativat manuaalista toimintaa. Myös viestit katosivat usein prosessin aikana.
meiltä loppui nopeasti pääntila, kun DoorDash jatkoi kasvuaan ja työnsi tehtävänkäsittelymme äärirajoilleen. Tarvitsimme ratkaisun, joka voisi skaalautua horisontaalisesti jalostustarpeidemme kasvaessa.
sellerin ja RabbitMQ: n rajallinen havainnoitavuus
tieto siitä, mitä missä tahansa järjestelmässä tapahtuu, on olennaista sen käytettävyyden, skaalautuvuuden ja toiminnallisen eheyden varmistamiseksi.
suunnistaessamme edellä kuvattuja asioita huomasimme, että:
- meitä rajoitti pieni joukko RabbitMQ-mittareita käytössämme.
- meillä oli rajallinen näkyvyys Sellerityöläisiin itseensä.
meidän oli kyettävä näkemään reaaliaikaiset mittarit järjestelmämme kaikista osa-alueista, mikä tarkoitti sitä, että myös havaittavuuden rajoitteisiin oli puututtava.
operational efficiency challenges
meillä oli myös useita ongelmia RabbitMQ: n kanssa:
- jouduimme usein pettämään RabbitMQ-solmumme uuteen ratkaistaksemme havaitsemamme jatkuvan rappeutumisen. Tämä operaatio oli manuaalinen ja aikaa vievä mukana oleville insinööreille, ja se oli usein tehtävä myöhään illalla, ruuhka-aikojen ulkopuolella.
- doordashissa ei ollut omia selleri-tai RabbitMQ-asiantuntijoita, joiden varaan voisimme tukeutua suunnittelemaan skaalausstrategiaa tälle teknologialle.
RabbitMQ: n toimintaan ja ylläpitoon käytetty suunnitteluaika ei ollut kestävää. Tarvitsimme jotain, joka vastaa paremmin nykyisiä ja tulevia tarpeitamme.
mahdollisia ratkaisuja sellerin ja RabbitMQ: n ongelmiimme
edellä kuvattujen ongelmien kanssa pohdimme seuraavia ratkaisuja:
- Vaihda sellerin välittäjä RabbitMQ: sta Redis: iin tai Kafkaan. Näin voisimme jatkaa sellerin käyttöä erilaisella ja mahdollisesti luotettavammalla taustatietoaineistolla.
- lisää monen välittäjän tuki Django-sovellukseemme, jotta kuluttajat voisivat julkaista N eri välittäjille perustuen mihin tahansa logiikkaan, jonka halusimme. Tehtävien käsittely saa shared poikki useita välittäjiä, joten jokainen välittäjä kokee murto-osan alkuperäisestä kuormituksesta.
- Päivitä uudempiin versioihin selleristä ja RabbitMQ: sta. Uudemmat versiot selleri ja RabbitMQ odotettiin korjata luotettavuusongelmia, ostaa meille aikaa, koska olimme jo talteen komponentteja meidän Django monolith rinnakkain.
- siirtyä Kafkan tukemaan mukautettuun ratkaisuun. Tämä ratkaisu vaatii enemmän vaivaa kuin muut vaihtoehdot listasimme, mutta on myös enemmän mahdollisuuksia ratkaista jokainen ongelma meillä oli kanssa legacy ratkaisu.
jokaisella vaihtoehdolla on hyvät ja huonot puolensa:
Option | Cons | |
Redis meklarina |
|
|
Kafka välittäjänä |
|
|
Monivälittäjät |
horisontaalinen skaalautuvuus |
|
päivitysversiot |
|
|
Custom Kafka-ratkaisu |
|
|
strategiamme Kafkan käyttöönotossa
ottaen huomioon vaaditun järjestelmän käytettävyyden, me laati meidän onboarding strategia perustuu seuraaviin periaatteisiin maksimoida Luotettavuusedut lyhyessä ajassa. Strategiaan kuului kolme vaihetta:
- lyömällä maajuoksu: Halusimme hyödyntää rakentamamme ratkaisun perusteita iteroidessamme sen muita osia. Vertaamme tätä strategiaa kilpa-auton ajamiseen samalla, kun vaihdamme uuden Polttoainepumpun.
- suunnitteluvalinnat kehittäjien saumattomaksi hyväksymiseksi: halusimme minimoida kaikkien kehittäjien turhat ponnistelut, jotka ovat voineet aiheutua erilaisen käyttöliittymän määrittelystä.
- Incremental rollout with zero downtime: Sen sijaan, että isoa räikeää julkaisua testattaisiin ensimmäistä kertaa luonnossa suuremmalla epäonnistumisriskillä, keskityimme toimittamaan pienempiä itsenäisiä ominaisuuksia, joita voidaan erikseen testata luonnossa pidemmän ajan kuluessa.
maahanlasku
siirtyminen Kafkaan merkitsi suurta teknistä muutosta pinossamme, mutta kipeästi kaivattua. Meillä ei ollut aikaa tuhlata, koska joka viikko olimme menettämässä liiketoimintaa johtuen epävakautta meidän perintö RabbitMQ ratkaisu. Ensisijaisena tavoitteenamme oli luoda minimum viable product (MVP), joka tuo meille väliaikaista vakautta ja antaa meille liikkumavaraa, jota tarvitaan iterointiin ja valmistautumiseen kattavampaan ratkaisuun laajemmalla käyttöönotolla.
meidän MVP koostui tuottajista, jotka julkaisivat task Fully Qualified Names (fqns) ja marinoituja argumentteja Kafkalle samalla kun kuluttajamme lukivat näitä viestejä, toivat tehtävät FQN: stä ja toteuttivat ne synkronisesti määriteltyjen argumenttien kanssa.
kuva 1: Minimal Viable Product(MVP) – arkkitehtuuri, jonka päätimme rakentaa, sisälsi väliaikaisen valtion, jossa julkaisisimme toisensa poissulkevia tehtäviä sekä perintöosille (red dashed lines) että uusille järjestelmille (green solid lines), ennen lopullista tilaa, jossa lakkaisimme julkaisemasta tehtäviä RabbitMQ: lle.
Suunnitteluvalinnat kehittäjien saumattomaan hyväksymiseen
joskus kehittäjien hyväksyminen on suurempi haaste kuin kehittäminen. Teimme tämän helpommaksi toteuttamalla kääreen sellerin @task annotationille, joka dynaamisesti reititti tehtävälausuntoja jompaankumpaan järjestelmään dynaamisesti konfiguroitavien ominaisuuslippujen perusteella. Nyt samaa rajapintaa voitaisiin käyttää tehtävien kirjoittamiseen molemmille järjestelmille. Kun nämä päätökset oli tehty, insinööriryhmien ei tarvinnut tehdä lisätöitä integroidakseen uuteen järjestelmään, lukuun ottamatta yhden ominaisuuslipun käyttöönottoa.
halusimme ottaa järjestelmämme käyttöön heti, kun MVP on valmis, mutta se ei vielä tukenut kaikkia samoja ominaisuuksia kuin selleri. Celerin avulla käyttäjät voivat määrittää tehtäviään parametreilla tehtävähuomautuksessa tai tehtävän lähettämisen yhteydessä. Jotta voisimme käynnistää nopeammin, loimme valkolistan yhteensopivista parametreista ja päätimme tukea pienintä määrää ominaisuuksia, joita tarvitaan tukemaan suurinta osaa tehtävistä.
kuva 2: Nostimme nopeasti tehtävämäärää Kafkapohjaiseen MVP: hen aloittaen ensin matalan riskin ja matalan prioriteetin tehtävistä. Osa näistä oli tehtäviä, jotka juoksivat ruuhka-ajan ulkopuolella, mikä selittää yllä kuvatun mittarin piikit.
kuten kuvassa 2 näkyy, kahden edellä mainitun päätöksen myötä käynnistimme MVP: n kahden viikon kehitystyön jälkeen ja saavutimme 80%: n vähennyksen RabbitMQ-tehtäväkuormassa vielä viikon kuluttua käynnistämisestä. Käsittelimme ensisijainen ongelma katkoksia nopeasti, ja aikana hankkeen tukivat enemmän esoteerisia ominaisuuksia, jotta jäljellä olevien tehtävien suorittamiseen.
inkrementaalinen käyttöönotto, nollaseisokit
kyky vaihtaa Kafka-klustereita ja vaihtaa RabbitMQ: n ja Kafkan välillä dynaamisesti ilman liiketoimintavaikutuksia oli meille erittäin tärkeä. Tämä kyky auttoi meitä myös erilaisissa toiminnoissa, kuten klusterin kunnossapidossa, kuorman irtoamisessa ja vähittäisessä muutossa. Tämän käyttöönoton toteuttamiseksi käytimme dynaamisia ominaisuuslippuja sekä viestin lähettämistasolla että viestin kulutuspuolella. Täyden dynaamisuuden hinta täällä oli pitää työläislaivastomme toiminnassa kaksinkertaisella kapasiteetilla. Puolet laivastosta oli omistettu RabbitMQ: lle ja loput Kafkalle. Työntekijäkannan pyörittäminen kaksinkertaisella kapasiteetilla verotti ehdottomasti infrastruktuuriamme. Yhdessä vaiheessa kehitimme jopa täysin uuden Kubernetes-rykelmän-kaikkien työntekijöidemme majoittamiseksi.
kehityksen alkuvaiheessa tämä joustavuus palveli meitä hyvin. Kun luotimme enemmän uuteen järjestelmäämme, tutkimme keinoja vähentää infrastruktuurimme kuormitusta, kuten useiden kuluttavien prosessien suorittamista työntekijää kohti. Kun siirryimme eri aiheista yli, pystyimme aloittamaan vähentää työntekijöiden määrä RabbitMQ säilyttäen pieni varakapasiteetti.
mikään ratkaisu ei ole täydellinen, iteroi tarpeen mukaan
MVP: n ollessa tuotannossa meillä oli tarvittava pääntila iteroida ja kiillottaa tuotteemme. Pisteytimme jokaisen puuttuvan sellerin ominaisuuden niiden tehtävien määrän mukaan, jotka käyttivät sitä auttaakseen meitä päättämään, mitkä toteutetaan ensin. Ominaisuuksia, joita käytettiin vain muutamissa tehtävissä, ei toteutettu räätälöidyssä ratkaisussamme. Sen sijaan kirjoitimme tehtävät uudelleen, jotta emme käyttäisi kyseistä ominaisuutta. Tällä strategialla siirsimme lopulta kaikki tehtävät pois selleristä.
Kafkan käyttö toi mukanaan myös uusia ongelmia, jotka vaativat huomiotamme:
- head-of-the-line-esto, joka johti tehtävien käsittelyn viivästymiseen
- käyttöönottojen laukaisema osioiden tasapainottaminen, joka myös johti viiveisiin
Kafkan Pää-of-the-line-esto-ongelma
Kafkan aiheet on jaettu siten, että yksi kuluttaja (kuluttajaryhmää kohden) lukee viestit sille määrätyille osioille siinä järjestyksessä kuin ne saapuivat. Jos yhden osion viestin käsittely kestää liian kauan, se hidastaa kaikkien sen takana olevien viestien kulutusta kyseisessä osiossa, kuten alla olevassa kuvassa 3 nähdään. Tämä ongelma voi olla erityisen tuhoisa, jos kyseessä on erittäin tärkeä aihe. Haluamme pystyä jatkamaan viestien käsittelyä osion sisällä siinä tapauksessa, että viive tapahtuu.
kuva 3: Kafkan head-of-the-line-esto-ongelmassa hidas viesti osiossa (punaisella) estää kaikkia sen takana olevia viestejä joutumasta käsiteltäviksi. Muut osiot jatkaisivat käsittelyä odotetusti.
vaikka parallelismi on pohjimmiltaan Python-ongelma, tämän ratkaisun käsitteet soveltuvat myös muihin kieliin. Ratkaisumme, joka on kuvattu alla olevassa kuvassa 4, oli yhden Kafka-kuluttajaprosessin ja useiden tehtävien suoritusprosessien rakentaminen työntekijää kohti. Kafka-kuluttaja-prosessi vastaa viestien hakemisesta Kafkalta ja niiden asettamisesta paikalliseen jonoon, jota tehtävien suoritusprosessit lukevat. Se jatkaa kuluttamista, kunnes paikallinen jono osuu käyttäjän määrittelemään kynnykseen. Tämä ratkaisu mahdollistaa viestien virtaamisen osiossa ja hidas viesti pysäyttää vain yhden tehtävän suoritusprosessin. Kynnys rajoittaa myös lennon aikana lähetettävien viestien määrää paikallisessa jonossa (joka voi kadota järjestelmän kaatuessa).
kuva 4: Estoton Kafka-työntekijämme koostuu paikallisesta viestijonosta ja kahdentyyppisistä prosesseista: Kafka-kuluttaja prosessi ja useita tehtävä-toimeenpanija prosesseja. Vaikka Kafka-kuluttaja voi lukea useista osioista, yksinkertaisuuden vuoksi kuvaamme vain yhden. Tämä kaavio osoittaa, että hitaasti käsittelevä viesti (punaisella) estää vain yhden tehtävän suorittajan, kunnes se on valmis, kun taas toiset tehtävän suorittajat jatkavat sen takana olevien viestien käsittelyä.
käyttöönoton häiriö
otamme Django-sovelluksen käyttöön useita kertoja päivässä. Yksi haittapuoli ratkaisussamme, jonka huomasimme, on se, että käyttöönotto käynnistää Kafkan osiotehtävien tasapainottamisen. Huolimatta siitä, että eri kuluttajaryhmiä käytettiin aihekohtaisesti tasapainottamisen soveltamisalan rajoittamiseen, käyttöönotto aiheutti silti hetkellisen hidastumisen sanomankäsittelyssä, koska tehtävien kulutuksen oli loputtava tasapainottamisen aikana. Hidastukset voivat olla hyväksyttäviä useimmissa tapauksissa, kun suoritamme suunniteltuja julkaisuja, mutta ne voivat olla katastrofaalisia, kun esimerkiksi teemme hätäjulkaisua hotfix-vialle. Seurauksena olisi kaskadisen jalostuksen hidastuminen.
uudemmat versiot Kafkasta ja asiakkaista tukevat lisääntyvää osuuskuntamuotoista tasapainottamista, mikä vähentäisi huomattavasti tasapainottamisen toiminnallista vaikutusta. Asiakkaittemme päivittäminen tukemaan tällaista tasapainottamista olisi ratkaisumme eteenpäin. Valitettavasti inkrementaalinen osuuskunnan tasapainottaminen ei ole vielä tuettu valitsemassamme Kafka-asiakkaassa.
avain voittaa
tämän projektin päätyttyä saavutimme merkittäviä parannuksia käytettävyydessä, skaalautuvuudessa, havaittavuudessa ja hajautuksessa. Nämä voitot olivat ratkaisevia liiketoimintamme jatkuvan kasvun varmistamiseksi.
ei enää toistuvia katkoksia
lopetimme toistuvat katkokset lähes heti, kun aloimme toteuttaa tätä Kafka-tapaista lähestymistapaa. Käyttökatkot johtivat erittäin huonoihin käyttökokemuksiin.
- toteuttamalla vain pienen osan eniten käytetyistä Selleriominaisuuksista MVP: ssämme pystyimme toimittamaan työkoodin tuotantoon kahdessa viikossa.
- MVP: n ollessa käytössä pystyimme vähentämään merkittävästi RabbitMQ: n ja sellerin kuormitusta jatkaessamme ratkaisumme kovettamista ja uusien ominaisuuksien käyttöönottoa.
tehtävänkäsittely ei ollut enää kasvun rajoittava tekijä
Kafkan ollessa arkkitehtuurimme ytimessä rakensimme tehtävänkäsittelyjärjestelmän, joka on hyvin saatavilla ja vaakasuunnassa skaalautuva, jolloin DoorDash ja sen asiakkaat voivat jatkaa kasvuaan.
Massively augmented observability
koska kyseessä oli kustomoitu ratkaisu, pystyimme leipomaan enemmän mittareita lähes jokaisella tasolla. Jokainen jono, työntekijä ja tehtävä oli täysin havainnoitavissa hyvin rakeisella tasolla tuotanto-ja kehitysympäristöissä. Tämä lisääntynyt havaittavuus oli valtava voitto paitsi tuotantomielessä myös kehittäjien tuottavuuden kannalta.
operatiivinen hajauttaminen
havaittavuuden parannusten myötä pystyimme templatoimaan hälytyksemme Terraformimoduuleiksi ja osoittamaan yksiselitteisesti omistajat jokaiseen aiheeseen ja epäsuorasti kaikkiin 900-Plus tehtäviin.
työtehtävien käsittelyjärjestelmän yksityiskohtainen käyttöopas antaa kaikille insinööreille mahdollisuuden debugata toiminnallisia ongelmia aiheineen ja työntekijöineen sekä suorittaa Kafka-klusterin kokonaishallintatoimintoja tarpeen mukaan. Päivittäinen toiminta on itsepalvelua ja Infrastruktuuritiimimme tukea tarvitaan harvoin.
johtopäätös
yhteenvetona osuimme RabbitMQ: n skaalauskykymme kattoon ja jouduimme etsimään vaihtoehtoja. Vaihtoehtona oli kustomoitu Kafkapohjainen ratkaisu. Vaikka on olemassa joitakin haittoja Kafka, löysimme useita kiertoteitä, kuvattu edellä.
kun kriittiset työnkulut ovat vahvasti riippuvaisia asynkronisesta tehtävien käsittelystä, skaalautuvuuden varmistaminen on äärimmäisen tärkeää. Kun sinulla on samanlaisia ongelmia, ota rohkeasti mallia strategiastamme, joka antoi meille 80% tuloksesta ja 20% ponnisteluista. Tämä strategia on yleisesti ottaen taktinen lähestymistapa, jolla voidaan nopeasti lieventää luotettavuusongelmia ja ostaa kipeästi kaivattua aikaa järeämmälle ja strategisemmalle ratkaisulle.
kiitokset
kirjoittajat haluavat kiittää Clement Fangia, Corry Hainesia, Danial Asifia, Jay Weinsteinia, Luigi Tagliamontea, Matthew Angeria, Shaohua Zhouta ja Yun-Yu Cheniä osallistumisesta tähän projektiin.
kuva: tian kuan on Unsplash