Operativna analitika u mikroservisnoj arhitekturi: pomoći i savjetovati Postgres FDW

Mikroservisna arhitektura, kao i sve u ovom svijetu, ima svoje prednosti i mane. Neki procesi postaju lakši, dok drugi postaju složeniji. U korist brzine promjena i bolje skalabilnosti, mora se praviti određene žrtve. Jedna od njih je kompliciranje analitike. Dok se u monolitu sva operativna analitika može svesti na SQL upite prema analitičkoj replici, u mikroservisnoj arhitekturi svaka usluga ima svoju bazu i čini se da se jednim upitom ne može proći (a možda i može?). Za one koje zanima kako smo riješili problem operativne analitike u našoj tvrtki i kako smo naučili živjeti s tim rješenjem — dobrodošli.

Operativna analitika u mikroservisnoj arhitekturi: pomoći i savjetovati Postgres FDW
Zovem se Pavel Sivaš, u DomKlika radim u timu koji je zadužen za održavanje analitičkog skladišta podataka. Naša aktivnost se može okarakterizirati kao inženjering podataka, ali zapravo, spektar zadatka je mnogo širi. Tu su standardni zadaci za inženjering podataka ETL/ELT, podrška i adaptacija alata za analizu podataka kao i razvoj vlastitih alata. Konkretno, za operativno izvještavanje odlučili smo se pretvarati da imamo monolit i dati analitičarima jednu bazu u kojoj će biti svi potrebni podaci.

Uglavnom smo razmatrali razne opcije. Mogli smo izgraditi potpuno skladište — čak smo to i pokušali, ali, da budem iskren, nismo uspjeli uskladiti dovoljno česte promjene u logici s prilično sporim procesom izgradnje skladišta i unošenja promjena (ako je nekome to uspjelo, neka javi u komentarima kako). Mogli smo reći analitičarima: „Dečki, učite Python i idite u analitičke replike“, ali to je dodatni zahtjev za zapošljavanje, i činilo se da bi to trebalo izbjeći, ako je moguće. Odlučili smo pokušati koristiti tehnologiju FDW (Foreign Data Wrapper): u suštini, to je standardni dblink koji postoji u SQL standardu, ali s puno prikladnijim sučeljem. Na temelju nje napravili smo rješenje koje je na kraju opstalo, na njemu smo se zaustavili. Njegovi detalji su tema zasebnog članka, a možda i nekoliko njih, jer se želi pričati o mnogočemu: od sinhronizacije shema baza do upravljanja pristupom i anonymizacije osobnih podataka. Također treba napomenuti da ovo rješenje nije zamjena za stvarne analitičke baze i skladišta, već rješava samo specifičan zadatak.

Na visokoj razini, to izgleda ovako:

Operativna analitika u mikroservisnoj arhitekturi: pomoći i savjetovati Postgres FDW
Postoji baza podataka PostgreSQL, gdje korisnici mogu pohraniti svoje radne podatke, a najvažnije — toj bazi su putem FDW povezane analitičke replike svih servisa. To omogućuje pisanje upita na nekoliko baza, nezavisno od toga što one bile: PostgreSQL, MySQL, MongoDB ili nešto drugo (datoteka, API, ako slučajno nema odgovarajućeg wrappera, možemo napisati vlastiti). Pa, izgleda super! Idemo se razići?

Da je sve završilo tako brzo i jednostavno, vjerojatno ne bi bilo ni ovog članka.

Važno je jasno shvatiti kako PostgreSQL obrađuje upite na udaljenim serverima. To se čini logičnim, međutim, često se na to ne obraća pažnja: PostgreSQL dijeli upit na dijelove koji se izvršavaju na udaljenim serverima neovisno, prikuplja te podatke, a konačne izračune vrši već sam, stoga će brzina izvršavanja upita uvelike ovisiti o tome kako je napisan. Također treba napomenuti: kada podaci dolaze s udaljenog servera, oni već nemaju indekse, nemaju ništa što bi pomoglo planerima, stoga možemo pomoći i uputiti ga samo mi sami. O tome bi želeo govoriti detaljnije.

Jednostavni upit i plan s njim

Da bismo pokazali kako PostgreSQL izvršava upit na tablici s 6 milijuna redaka na udaljenom serveru, pogledajmo jednostavan plan.

explain analyze verbose  
SELECT count(1)
FROM fdw_schema.table;

Aggregate  (cost=418383.23..418383.24 rows=1 width=8) (actual time=3857.198..3857.198 rows=1 loops=1)
  Output: count(1)
  ->  Foreign Scan on fdw_schema."table"  (cost=100.00..402376.14 rows=6402838 width=0) (actual time=4.874..3256.511 rows=6406868 loops=1)
        Output: "table".id, "table".is_active, "table".meta, "table".created_dt
        Remote SQL: SELECT NULL FROM fdw_schema.table
Planning time: 0.986 ms
Execution time: 3857.436 ms

Korištenje upute VERBOSE omogućava da se vidi upit koji će biti poslan na udaljeni poslužitelj i rezultati koje ćemo dobiti za daljnju obradu (linija RemoteSQL).

Idemo malo dalje i dodajmo u naš upit nekoliko filtara: jedan po boolean polju, jedan po uključivanju timestamp u intervalu i jedan po jsonb.

explain analyze verbose
SELECT count(1)
FROM fdw_schema.table 
WHERE is_active is True
AND created_dt BETWEEN CURRENT_DATE - INTERVAL '7 month' 
AND CURRENT_DATE - INTERVAL '6 month'
AND meta->>'source' = 'test';

Aggregate  (cost=577487.69..577487.70 rows=1 width=8) (actual time=27473.818..25473.819 rows=1 loops=1)
  Output: count(1)
  ->  Foreign Scan on fdw_schema."table"  (cost=100.00..577469.21 rows=7390 width=0) (actual time=31.369..25372.466 rows=1360025 loops=1)
        Output: "table".id, "table".is_active, "table".meta, "table".created_dt
        Filter: (("table".is_active IS TRUE) AND (("table".meta ->> 'source'::text) = 'test'::text) AND ("table".created_dt >= (('now'::cstring)::date - '7 mons'::interval)) AND ("table".created_dt <= ((('now'::cstring)::date)::timestamp with time zone - '6 mons'::interval)))
        Rows Removed by Filter: 5046843
        Remote SQL: SELECT created_dt, is_active, meta FROM fdw_schema.table
Planning time: 0.665 ms
Execution time: 27474.118 ms

Ovdje leži trenutak na koji treba obratiti pažnju prilikom pisanja upita. Filtar se nije prenio na udaljeni poslužitelj, što znači da PostgreSQL mora izvući svih 6 milijuna redaka kako bi ih kasnije lokalno filtrirao (linija Filter) i izvršio agregaciju. Ključ uspjeha je pisanje upita tako da se filtri prenose na udaljeni stroj, a mi dobivamo i agregiramo samo potrebne retke.

To je neka booleanska glupost

S boolean poljima - sve je jednostavno. U izvornoj upitu problem se javljao zbog operatora is. Ako ga zamijenimo s =, dobit ćemo sljedeći rezultat:

explain analyze verbose
SELECT count(1)
FROM fdw_schema.table
WHERE is_active = True
AND created_dt BETWEEN CURRENT_DATE - INTERVAL '7 month' 
AND CURRENT_DATE - INTERVAL '6 month'
AND meta->>'source' = 'test';

Aggregate  (cost=508010.14..508010.15 rows=1 width=8) (actual time=19064.314..19064.314 rows=1 loops=1)
  Output: count(1)
  -→  Foreign Scan on fdw_schema."table"  (cost=100.00..507988.44 rows=8679 width=0) (actual time=33.035..18951.278 rows=1360025 loops=1)
        Output: "table".id, "table".is_active, "table".meta, "table".created_dt
        Filter: ((("table".meta -→> 'source'::text) = 'test'::text) AND ("table".created_dt >= (('now'::cstring)::date - '7 mons'::interval)) AND ("table".created_dt <= ((('now'::cstring)::date)::timestamp with time zone - '6 mons'::interval)))
        Rows Removed by Filter: 3567989
        Remote SQL: SELECT created_dt, meta FROM fdw_schema.table WHERE (is_active)
Planning time: 0.834 ms
Execution time: 19064.534 ms

Kao što vidite, filtriranje se prebacilo na udaljeni server, a vrijeme izvođenja se smanjilo sa 27 na 19 sekundi.

Važno je napomenuti da se operator is razlikuje od operatora = time što može raditi s vrijednošću Null. To znači da is not True u filtrima ostavlja vrijednosti False i Null, dok != True ostavlja samo vrijednosti False. Stoga, prilikom zamjene operatora is not treba u filtrima proslijediti dva uvjeta s operatorom OR, na primjer, WHERE (col != True) OR (col is null).

S boolean vrijednostima smo riješili, idemo dalje. A za sada vraćamo filter po boolean vrijednosti u prvobitno stanje kako bismo samostalno razmotrili utjecaj drugih promjena.

timestamptz? hz

Uopće, često je potrebno eksperimentirati s tim kako ispravno napisati upit u kojem sudjeluju udaljeni serveri, a zatim tražiti objašnjenje zašto se stvari događaju na određeni način. Na Internetu se može pronaći vrlo malo informacija o tome. Tako, u eksperimentima, otkrili smo da se filter po fiksnom datumu odlično izvršava na udaljenom serveru, dok, kada želimo postaviti datum dinamički, na primjer, now() ili CURRENT_DATE, to se ne događa. U našem primjeru dodali smo filter kako bi stupac created_at sadržavao podatke tačno prije 1 mjesec (BETWEEN CURRENT_DATE - INTERVAL ‘7 month’ AND CURRENT_DATE - INTERVAL ‘6 month’). Što smo u ovom slučaju poduzeli?

objašnjenje analiza detaljno
SELECT count(1)
FROM fdw_schema.table 
WHERE is_active is True
AND created_dt >= (SELECT CURRENT_DATE::timestamptz - INTERVAL '7 mjeseci') 
AND created_dt >'source' = 'test';

Agregat  (cijena=306875.17..306875.18 redaka=1 širina=8) (stvarno vrijeme=4789.114..4789.115 redaka=1 petlji=1)
  Izlaz: count(1)
  InitPlan 1 (vrati $0)
    ->  Rezultat  (cijena=0.00..0.02 redaka=1 širina=8) (stvarno vrijeme=0.007..0.008 redaka=1 petlji=1)
          Izlaz: ((('sada'::cstring)::date)::timestamp with time zone - '7 mjeseci'::interval)
  InitPlan 2 (vrati $1)
    ->  Rezultat  (cijena=0.00..0.02 redaka=1 širina=8) (stvarno vrijeme=0.002..0.002 redaka=1 petlji=1)
          Izlaz: ((('sada'::cstring)::date)::timestamp with time zone - '6 mjeseci'::interval)
  ->  Strani pregled na fdw_schema."table"  (cijena=100.02..306874.86 redaka=105 širina=0) (stvarno vrijeme=23.475..4681.419 redaka=1360025 petlji=1)
        Izlaz: "table".id, "table".is_active, "table".meta, "table".created_dt
        Filter: (("table".is_active IS TRUE) AND (("table".meta ->> 'source'::text) = 'test'::text))
        Redovi uklonjeni filtriranjem: 76934
        Udaljeni SQL: SELECT is_active, meta FROM fdw_schema.table WHERE ((created_dt >= $1::timestamp with time zone)) AND ((created_dt < $2::timestamp with time zone))
Vrijeme planiranja: 0.703 ms
Vrijeme izvođenja: 4789.379 ms

Upravo smo savjetovali planerima da unaprijed izračunaju datum u podupitu i prenesu već pripremljenu varijablu u filtar. I taj savjet dao nam je odličan rezultat, upit je postao brži gotovo 6 puta!

Opet, ovdje je važno biti pažljiv: tip podataka u podupitu mora biti identičan onom polju po kojem filtriramo, inače planer odlučuje da su tipovi različiti i da je prvo potrebno izvući sve podatke, a zatim ih lokalno filtrirati.

Vratimo filtar po datumu na izvorno stanje.

Freddy protiv Jsonb

U suštini, bool polja i datumi su već dovoljno ubrzali naš upit, međutim, ostao je još jedan tip podataka. Borba s filtriranjem po njemu, iskreno, još uvijek nije završena, premda su tu zabilježeni određeni uspjesi. Dakle, ovako smo uspjeli prenijeti filtar po jsonb polju na udaljeni server.

objašnjenje analiza detaljno
SELECT count(1)
FROM fdw_schema.table 
WHERE is_active is True
AND created_dt BETWEEN CURRENT_DATE - INTERVAL '7 mjeseci' 
AND CURRENT_DATE - INTERVAL '6 mjeseci'
AND meta @> '{"source":"test"}'::jsonb;

Agregat  (cijena=245463.60..245463.61 redaka=1 širina=8) (stvarno vrijeme=6727.589..6727.590 redaka=1 petlji=1)
  Izlaz: count(1)
  ->  Strani pregled na fdw_schema."table"  (cijena=1100.00..245459.90 redaka=1478 širina=0) (stvarno vrijeme=16.213..6634.794 redaka=1360025 petlji=1)
        Izlaz: "table".id, "table".is_active, "table".meta, "table".created_dt
        Filter: (("table".is_active IS TRUE) AND ("table".created_dt >= (('sada'::cstring)::date - '7 mjeseci'::interval)) AND ("table".created_dt  '{"source": "test"}'::jsonb))
Vrijeme planiranja: 0.747 ms
Vrijeme izvođenja: 6727.815 ms

Umjesto operatora filtriranja, potrebno je koristiti operator prisutnosti jednog jsonb u drugom. 7 sekundi umjesto izvornih 29. Ovo je za sada jedini uspješan način prijenosa filtera po jsonb na udaljeni server, ali ovdje je važno uzeti u obzir jednu ograničenja: koristimo verziju baze 9.6, međutim do kraja travnja planiramo završiti posljednje testove i preći na verziju 12. Kada se ažuriramo, javit ćemo kako je to utjecalo, jer promjena ima mnogo na koje se računa: json_path, novo ponašanje CTE, push down (postojeći od verzije 10). Stvarno želim što prije isprobati.

Finish him

Provjerili smo kako svaka promjena utječe na brzinu upita zasebno. Sada pogledajmo što će se dogoditi kada budu sva tri filtera ispravno napisana.

explain analyze verbose
SELECT count(1)
FROM fdw_schema.table 
WHERE is_active = True
AND created_dt >= (SELECT CURRENT_DATE::timestamptz - INTERVAL '7 month') 
AND created_dt  '{"source":"test"}'::jsonb;

Aggregate  (cost=322041.51..322041.52 rows=1 width=8) (actual time=2278.867..2278.867 rows=1 loops=1)
  Output: count(1)
  InitPlan 1 (returns $0)
    ->  Result  (cost=0.00..0.02 rows=1 width=8) (actual time=0.010..0.010 rows=1 loops=1)
          Output: ((('now'::cstring)::date)::timestamp with time zone - '7 mons'::interval)
  InitPlan 2 (returns $1)
    ->  Result  (cost=0.00..0.02 rows=1 width=8) (actual time=0.003..0.003 rows=1 loops=1)
          Output: ((('now'::cstring)::date)::timestamp with time zone - '6 mons'::interval)
  ->  Foreign Scan on fdw_schema."table"  (cost=100.02..322041.41 rows=25 width=0) (actual time=8.597..2153.809 rows=1360025 loops=1)
        Output: "table".id, "table".is_active, "table".meta, "table".created_dt
        Remote SQL: SELECT NULL FROM fdw_schema.table WHERE (is_active) AND ((created_dt >= $1::timestamp with time zone)) AND ((created_dt  '{"source": "test"}'::jsonb))
Planning time: 0.820 ms
Execution time: 2279.087 ms

Da, upit izgleda složenije, to je nužna cijena, ali brzina izvršenja iznosi 2 sekunde, što je više od 10 puta brže! I to govorimo o jednostavnom upitu prema relativno malom skupu podataka. Na stvarnim upitima dobivali smo poboljšanje do nekoliko stotina puta.

Zaključimo: ako koristite PostgreSQL s FDW, uvijek provjerite šaljete li svi filteri na udaljeni server, i bit će vam sreće… barem dok ne dođete do spojeva između tablica iz različitih poslužitelja. No to je već priča za još jedan članak.

Hvala na pažnji! Bit ću sretan čuti pitanja, komentare, kao i priče o vašem iskustvu u komentarima.

Izvor: habr.com

Kupite pouzdan hosting za web stranice s DDoS zaštitom, VPS VDS servere 🔥 Kupite pouzdan hosting za web stranice s DDoS zaštitom, VPS VDS servere | ProHoster