Optimalizace počítání investorských poplatků v Zonky.cz

Martin Swiech
zonky-developers
Published in
5 min readMar 13, 2018

Čas od času se v Zonky potýkáme s tím, že nám něco začne svým objemem pomalu, ale jistě přerůstat přes hlavu. Investorské poplatky mi začaly rozsvěcovat červené světýlko v hlavě, neboť neutále se zvyšující počet poskytnutých půjček zvyšuje také počet jednotlivých investic investorů, ke kterým je nutno počítat a evidovat mikročástky investorských poplatků pro každý jednotlivý den.

Situace se měla zhruba následovně:
Každá půjčka, kterou Zonky poskytuje, je zafinancována množstvím investorů — tedy jejich jednotlivými investicemi. Ke každé takovéto investici počítáme každý den denní poplatek z investice. Do toho výpočtu vstupuje množství parametrů (například částka nesplacené jistiny na půjčce; zda je aktuální splátka po splatnosti a jak dlouho; má/nemá půjčka odloženou splátku; případná sleva z poplatků). Tyto napočtené poplatky se musí nejen napočítat, ale i evidovat a na konci měsíce archivovat. V době před nasazením zde popisovaných úprav zabíraly tabuky v DB zhruba tolik místa:

daily_fee (denní poplatek) cca 25GB
daily_fee_archive cca 57GB

Pozn.: měsíční přírůstek v archivu se samozřejmě nerovnal celé velikosti daily_fee, protože se jednak nearchivují úplně všechny sloupečky a jednak velikost daily_fee je provozní velikost včetně indexů a je zvetšená o alokované místo vzniké při manipulaci s daty (operace DELETE při archivaci).

Toto pravda nejsou zatím příliš hrozivá čísla, ale krom toho, že to přinášelo spoustu provozního nekomfortu, tak hrozilo, že při našem současném tempu růstu překročíme rychle nějakou únosnou mez a náprava pak bude o to složitější.

Hledání řešení

Návrhů jak situaci řešit bylo mnoho. Nakonec zvítězil ten, který těžil z toho, že napočtený denní poplatek pro jedenu investici se typicky změní jen 1x za měsíc. A sice v době, kdy přijde splátka půjčky. Samozřejmě existují i další případy, kdy dojde ke změně částky poplatku, ale jejich výskyt je velmi řidký. Nastíněné řešení tedy bylo:

Pokud se dnešní poplatek vůči včerejšímu nezměnil, tak neukládej nový záznam, ale jen inkrementuj počet dní, po které poplatek platí. Takže místo 31 záznamů za měsíc pro jednu investici, by se typicky vyskytly pouhé 2 záznamy za měsíc. Nová struktura se bude jmenovat daily_fee_incremental (a daily_fee_incremental_archive).

úsporu místa ilustruje tento obrázek

Přičemž bychom neztratili žádnou informaci. A pro extremní případy je dokonce možno pomocí jednoduchého selectu nechat rozpadnout záznamy v nové tabulce na řádky po jednotlivých dnech:

select i.investment_id, (i.start_day + d.d -1)::date as date, i.amount
from daily_fee_incremental as i,
generate_series(1, i.days) as d(d)
where …..

Myšlenka je to jednoduchá, ale bylo potřeba ověřit, zda bude funkční. Připravil jsem tedy několik verzí základní implementace (proof of concept), které se lišily v detailu, jak technicky bude probíhat ta inkrementace počtu dní (e.g.: by update; by table recreate; by update with index drop before and create after).

Na těch implementacích jsem začal spouštět syntetické testy s desetinásobným počet půjček a investic, než máme teď. Zajímalo mě, kolik bude evidence poplatků zabírat místa v DB a hlavně, jak se s tím popere naše DB PostgreSQL, která není moc ideání pro velké UPDATE operace. Výsledky byly uspokojivé. A také se ukázalo, že složitost, kterou bych zavlekl recreate variantami, nestojí za to. Při desetinásobku aktivních půjček to nakonec dopadlo takto:

daily_fee_incremental cca 20GB

Což i při desetinásobném množství dat je dokonce i o chlup méně, než původní stav na aktuálních počtech investic.

Pozn. s cvičením: Optimalizace jsem hnal až do té míry, že jsem se naučil, že v PostgreSQL dost záleží na pořadí sloupců v tabulce. Zkuste si schválně v PostgreSQL vytvořit tyto tabulky:

create table t1(a bigint, b smallint, c bigint, d smallint);
create table t2(a bigint, c bigint, b smallint, d smallint);

Mohlo by se zdát, že když je jejich struktura identická, tak i po naplnění bude jejich velikost stejná.
Ovšem, když si to zkusímte, zjistíme, že to tak není:

insert into t1 (a,b,c,d) select 1,2,3,4 from generate_series(1,1000000);
insert into t2 (a,b,c,d) select 1,2,3,4 from generate_series(1,1000000);

Výsledek:
\dt+ t*
t1: 57MB
t2: 50MB

Výsledek si můžeme zkusit nastínit i bez reálného insertu:

select pg_column_size((1,2,3,4)::t1); — vysledek: 50
select pg_column_size((1,3,2,4)::t2); — vysledek: 44

Je to dáno tím, že některé datové typy musí být v PostgreSQL zarovnány na určitá místa. Obecně mi to vycházelo tak, že v PostgreSQL je dobré začít velkými datovými typy s fixní velikostí, pak přejít na menší a nakonec umístit datové typy s variabilní velikostí.

Vypadá to tedy, že s tímto řešením si na nějakou dobu vystačíme a po čase můžeme například začít uvažovat o vytržení celého systému počítání a ukládání poplatků do samostatné microservice a např. provozovat to na jiné DB apod.

Implementace

Po návrhu řešení a konzultacích s kolegy jsem se tedy pustil do implementace. Nějaký POC už jsem měl a z něj jsem mohl vyjít.

  • Doimplementoval jsem tedy všechny dopady (některé triviální, některé složité).
  • Tabulku daily_fee_incremental jsem rozdělil na partitions podle měsíce, do kterého záznamy spadají. To abych mohl po zaarchivování provést jednoduše drop celé partition a nikoliv spouštět velmi drahou a neefektivní operaci DELETE.
  • Zoptimalizoval jsem jeden z kroků denního napočítání poplatků, který byl naimplementovám velmi neefektivně přes aplikační logiku, do SQL (je to sice drobnost, ale 3 minuty toho kroku jsem smrsknul do zhruba 3 sekund).
  • Datovou migraci jsem připravil tak, že při release appky se migrovala jen daily_fee (archiv jsem zmigroval mimoreleasově).
  • Opravil jsem všechny integrační testy, které bylo nutno opravit a vytvořil navíc jeden další, který cíleně pokrýval novou implementaci nápočtu denní poplatků.
  • Udělal jsem ručně spouštěný regresní test nad reálnou anonymizovanou databází. Tím jsem provedl simulaci běhu všech stěžejních jobů souvisejících s poplatky za období od 28.1. do 3.3. (v druhé variantě pak do 24.2.). Jednou jsem spustil obě varianty nad starou verzí aplikace a podruhé nad refaktorovanou verzí. Oba nápočty vedly v konečném důsledku k navlas stejným poplatkům.
  • Pro release jsem napsal rollback plan pro případ, že by něco selhalo. Sepsal jsem také postup pro zapnutí logování příkazů `RAISE NOTICE` v PostgreSQL pro sledování procesu migračního scriptu.
  • Napsal jsem health check pro sledování toho, že v době, kdy nebeží denní job počítání poplatků, tak pracovní tabulka daily_fee_work musí být prázdná a cookbook pro případ, že tomu tak není.

Závěr

Úspora místa:
daily_fee (25GB) -> daily_fee_incremental (2GB)
daily_fee_archive (57GB) -> daily_fee_archive_incremental (2GB)

Zrychlení některých jobů:
FeeDebitJob 46 min. -> 2 min.
DailyFeeArchiveJob 32 min. -> 5 min.

Retrospektiva (drobné osobní pracovní poučení pro příště)

  • Proaktivně do pull-requestu zasvětit kolegy o něco dříve, než jsem to udělal.
  • Pro spoustu práce použít např. nějaký silnější virtuální stroj (dedikovaný pro osobní práci) na AWS a nesnažit se spouštět a testovat vše na lokálním pracovním počítači. Jednak jsem dost zápasil s diskovou kapacitou a jednak jeden běh ručního regresního testu byl na celý den.
    (Několik dní střídavě hučel můj notebook položený na mikrovlnce, když jsem byl u přítelkyně. Ta to glosovala slovy, že přijímám mimozemský signál z vesmíru. :D No a když pak záhadně zmizela oblíbená psí hračka “superman”, tak bylo jasné, že si pro něj ti mimozemšťani přisli.)

Další prostor pro optimalizace

  • Nejspíše by se dal zrychlit běh denního jobu půjček (tedy samotný nápočet DPD a denních poplatků, nejen jejich uložení).
  • Odlívat archiv poplatků někam úplně mimo PostgreSQL.
  • Vytrhnout celý subsystém počítání poplatků do samostatné a škálovatelné microservice.

--

--