Kešování výsledku funkcí v PL/Perl

Z PostgreSQL
Skočit na navigaci Skočit na vyhledávání

Žádné jiné aplikace nedokáží vygenerovat tak ohromnou zátěž jako www aplikace. Je to dáno jednak vlastnostmi protokolu HTTP, jednak dostupností www aplikací. Neoptimálně napsané aplikace dokáží položit libovolnou databázi na libovolném hardware. Naopak dobře navržené aplikace si vystačí i s levným a na slušném vybavení poskytují dostatečnou rezervu výkonu jako ochrany před špičkovým zatížením. V prvé řadě jde o minimalizaci počtu dotazů do databáze. Zejména těch pomalých (to jsou už dotazy nad 50ms). Řešením je intenzivní využívání cache. Všechno co lze, je třeba umístit do cache. Příklad z praxe. Prodejní web zaměřený na literaturu obsahoval na každé stránce žebříček nejprodávanějších knih. Bez použití cache se při každm vykreslení stránky pustil dotaz obsahující sekvenční čtení tabulek prodeje a knih doplnění požadavkem na třídění. Tento relativně jednoduchý dotaz (trvající cca 20ms) v reálném provozu dokonale vyřadil celý server z provozu. V jednu chvíli jej spouštělo 500-1000 uživatelů. Proto se používají techniky, které uživatele do jisté míry šidí. Jen ve výjimečných případech musí všichni uživatelé vidět aktuální data. Zrovna u žebříčku prodejnosti bohatě stačí aktualizovat data několikrát denně. Pětková verze PHP obsahují nástroje, kterými lze vybudovat a udržovat datovou cache. Ty se použily také v případě elektronického knihkupectví s celkem očekávaným efektem. Místo nejnovějšího hw se mohl bezproblémově použít několik let starý server.

Datové cache můžeme používat i na databázové úrovni. První obvyklé řešení jsou materializované pohledy aktualizované triggery. Jedná se o relativně jednoduché řešení, které udržuje agregovaná data 100% aktuální. To relativně je ovšem na místě. Napsat opravdu robustní řešení není až tak snadné, a k tomu ještě v řadě případů potřebujeme explicitní zamykání, které se negativně projeví na průchodnosti (výkonu) databáze. Pokud nepotřebujeme vždy aktuální data, tak nejjednodušším řešením jsou materializované pohledy aktualizované cronem. Nevýhodou tohoto řešení je závislost na externí službě (cron). Při jejím selhání budou materializované pohledy neaktuální (nad rámec předpokládané neaktuálnosti). Tuto nevýhodu nemá použití cache na výstup z uložené procedury.

V PostgreSQL lze použít cache v prostředí PL/Perl, kde je k dispozici pole $_SHARED, kam můžeme ukládat libovolné hodnoty (i tabulky). Uložit tabulku do této cache mne napadlo až poté, co jsem viděl cache v Oracle 11g, která je ovšem mnohem sofistikovanější, bezpečnější a efektivnější (znevalidní obsah cache při změně dat, lze nastavit limity pro cache, cache je sdílena všemi uživateli). Cache PL/Perlu je omezena na session - což by nemělo vadit, pokud používáte nějakou techniku poolování. Data v PL/Perlu nejsou uložena příliš efektivně, takže do cache není vhodné ukládat velké tabulky.

Následující příklady počítají s následujícím datovým modelem:

CREATE TABLE Books(
  id serial PRIMARY KEY,
  name VARCHAR(20));

CREATE TABLE Sale(
  book_id integer REFERENCES Books(id),
  inserted timestamp DEFAULT(CURRENT_TIMESTAMP)
);

INSERT INTO Books VALUES(1,'Dracula');
INSERT INTO Books VALUES(2,'Nosferatu');
INSERT INTO Books VALUES(3,'Bacula');

INSERT INTO Sale VALUES(1, '2007-10-11');
INSERT INTO Sale VALUES(2, '2007-10-12');
INSERT INTO Sale VALUES(2, '2007-10-13');
INSERT INTO Sale VALUES(3, '2007-10-10');

Funkce Top10Books vrátí tabulku 10 nejprodávanějších knih v daném měsíci. Řádky jsou očíslované. Je to triviální funkce v PL/pgSQL. Na malé testovací množině trvá cca 2ms. Pokud ale testovací množina obsahovala dvacet tisíc prodejů během jednoho měsíce, to je docela realistické, tak její provedení trvá cca 120ms. Což už je příliš, pokud by se měla volat, při vykreslení každé stránky. Výsledkem je malá tabulka (obsahuje 2 sloupce, max 10 řádek), tudíž je ideální kandidát pro kešování.

-- Top10 is on every page                                                                                                                                                                                                        
CREATE OR REPLACE FUNCTION Top10Books(IN date, OUT ordr integer, OUT name varchar(20))
RETURNS SETOF RECORD
AS $$
BEGIN
  ordr := 0;
  FOR name IN SELECT b.name
                  FROM Books b
                       JOIN
                       Sale s
                       ON b.id = s.book_id
                 WHERE s.inserted BETWEEN date_trunc('month', $1)
                                      AND date_trunc('month', $1) + interval '1month' - interval '1day'
                 GROUP BY b.name
                 ORDER BY count(*) DESC
                 LIMIT 10
  LOOP
    ordr := ordr + 1;
    RETURN NEXT;
  END LOOP;
  RETURN;
END;
$$ LANGUAGE plpgsql;

Přepsáním do perlu nic nezískáme. Funkce trvá stejně dlouho.

CREATE OR REPLACE FUNCTION Top10BooksPerl(IN date, OUT ordr integer, OUT name varchar(20))
RETURNS SETOF RECORD
AS $$
    if (not defined($_SHARED{plan_for_top10books}))
    {
       $_SHARED{plan_for_top10books} = spi_prepare(
               'SELECT b.name                                                                                                                                                                                                    
                  FROM Books b                                                                                                                                                                                                   
                       JOIN                                                                                                                                                                                                      
                       Sale s                                                                                                                                                                                                    
                       ON b.id = s.book_id                                                                                                                                                                                       
                 WHERE s.inserted BETWEEN date_trunc(\'month\', $1)                                                                                                                                                              
                                      AND date_trunc(\'month\', $1) + interval \'1month\' - interval \'1day\'                                                                                                                    
                 GROUP BY b.name                                                                                                                                                                                                 
                 ORDER BY count(*) DESC                                                                                                                                                                                          
                 LIMIT 10' , 'DATE');
    }
    my $row;
    my $i = 0;
    my $sth = spi_query_prepared($_SHARED{plan_for_top10books}, $_[0]);
    while (defined ($row = spi_fetchrow($sth))) {
        return_next({
            ordr => ++$i,
            name => $row->{name}
        });
    }
    return;
$$ LANGUAGE plperlu;

Teprve s využitím cache získáme zásadní urychlení - očekávané. Funkce má druhý IN parametr, který určuje, zda-li cache má být aktualizována, nebo zda-i se má použít obsah cache. Díky použití cache není žádny rozdíl v rychlosti mezi voláním funkce s malou testovací množinou nebo s velkou testovací množinou.

CREATE OR REPLACE FUNCTION Top10BooksCached(IN date, IN bool, OUT ordr integer, OUT name varchar(20))
RETURNS SETOF RECORD
AS $$
    return $_SHARED{tableof_top10book}
        if (defined ($_SHARED{tableof_top10book}) and not (defined($_[1]) and $_[1] eq "t"));
    if (not defined($_SHARED{plan_for_top10books}))
    {
       $_SHARED{plan_for_top10books} = spi_prepare(
               'SELECT b.name                                                                                                                                                                                                    
                  FROM Books b                                                                                                                                                                                                   
                       JOIN                                                                                                                                                                                                      
                       Sale s                                                                                                                                                                                                    
                       ON b.id = s.book_id                                                                                                                                                                                       
                 WHERE s.inserted BETWEEN date_trunc(\'month\', $1)                                                                                                                                                              
                                      AND date_trunc(\'month\', $1) + interval \'1month\' - interval \'1day\'                                                                                                                    
                 GROUP BY b.name                                                                                                                                                                                                 
                 ORDER BY count(*) DESC                                                                                                                                                                                          
                 LIMIT 10' , 'DATE');
    }
    my $row;
    my $i = 0;
    my $heap;
    my $sth = spi_query_prepared($_SHARED{plan_for_top10books}, $_[0]);
    while (defined ($row = spi_fetchrow($sth))) {
        push @$heap, {ordr => ++$i, name => $row->{name}}
    }
    $_SHARED{tableof_top10book} =  $heap ;
    return $_SHARED{tableof_top10book};
$$ LANGUAGE plperlu;

Modifikací předešlého, je funkce s cache omezenou časem. Např. budu chtít, aby se cache aktualizovala vždy po pěti minutách (300sec).

CREATE OR REPLACE FUNCTION Top10BooksCached(IN date, IN integer, OUT ordr integer, OUT name varchar(20))
RETURNS SETOF RECORD
AS $$
    return $_SHARED{tableof_top10book}
        if (defined ($_SHARED{tableof_top10book}) 
                and defined($_SHARED{actualised_top10book}) 
                and ($_SHARED{actualised_top10book} + $_[1] > time));
    if (not defined($_SHARED{plan_for_top10books}))
    {
       $_SHARED{plan_for_top10books} = spi_prepare(
               'SELECT b.name                                                                                                                                                                                                    
                  FROM Books b                                                                                                                                                                                                   
                       JOIN                                                                                                                                                                                                      
                       Sale s                                                                                                                                                                                                    
                       ON b.id = s.book_id                                                                                                                                                                                       
                 WHERE s.inserted BETWEEN date_trunc(\'month\', $1)                                                                                                                                                              
                                      AND date_trunc(\'month\', $1) + interval \'1month\' - interval \'1day\'                                                                                                                    
                 GROUP BY b.name                                                                                                                                                                                                 
                 ORDER BY count(*) DESC                                                                                                                                                                                          
                 LIMIT 10' , 'DATE');
    }
    my $row;
    my $i = 0;
    my $heap;
    my $sth = spi_query_prepared($_SHARED{plan_for_top10books}, $_[0]);
    while (defined ($row = spi_fetchrow($sth))) {
        push @$heap, {ordr => ++$i, name => $row->{name}}
    }
    $_SHARED{tableof_top10book} =  $heap ;
    $_SHARED{actualised_top10book} = time;
    return $_SHARED{tableof_top10book};
$$ LANGUAGE plperlu;

Použití:

postgres=# select * from Top10BooksCached(current_date, 300);
 ordr |   name
------+-----------
    1 | Nosferatu
    2 | Bacula
    3 | Dracula
(3 rows)

Time: 128,965 ms
postgres=# select * from Top10BooksCached(current_date, 300);
 ordr |   name
------+-----------
    1 | Nosferatu
    2 | Bacula
    3 | Dracula
(3 rows)

Time: 11,911 ms