WP_Cron

Důležité pro pochopení akcí naplánovaných pomocí WP_Cron je to, že WP_Cron není cron takový, jaký známe ze světa serverů – spolehlivý pracovník, který v daný čas udělá to, co mu řeknete. V případě WP_Cron se nelze spoléhat na to, že se naplánovaná akce spustí v čas. Nicméně se můžete spolehnout na to, že se nespustí dříve.

Je to dáno tím, že WordPressu se snaží veškerou svou funkcionalitu poskytnout i na tom nejlevnějším sdíleném hostingu ihned po světoznámé 5 minutové instalaci.

WP_Cron je systém postaven čistě na záznamu v MySQL databázi, systému zámků využívajícího Transients API (tedy buď databázi či objektovou cache), HTTP požadavcích a PHP. V takovém setupu jsou proto stále přítomny timeouty, PHP memory limit či max-execution time.

Základ pro funkcionalitu, kterou WP_Cron nabízí tedy není zcela ideální, ale i tak je WP_Cron užitečným pomocníkem – pokud tedy víme jak se k němu chovat a co od něj očekávat. To vše se pokusím aspoň nastínit v tomto článku.

Jak naplánovat akci ze svého kódu

Na webu prakticky okamžitě naleznete obstojný tutorial o tom, jak nějakou událost naplánovat. Já budu v dalším výkladu spoléhat na to, že jste si tu práci již dali a níže uvádím jen kód, který by měl být výsledkem takových tutoriálů. Poté přejdu rovnou k popisu mechanismu, který tento kód spouští. Začínáme tedy tam, kde tutoriály končí.

//následující funkce je spouštěna nějakou uživatelskou akcí, typicky odesláním formuláře
function my_example_function_called_as_a_result_of_user_action( ... ) {
    $post_id = 2; //typicky dynamicky získaná proměnná.
    $variable = 'Hello WP_Cron World!';
    wp_schedule_single_event( time(), 'trigger_my_cron_function', array( $post_id, $variable ) );
}

add_action( 'trigger_my_cron_function', 'run_my_cron_function', 20, 2 );

function run_my_cron_function( $post_id, $variable ) {
    update_post_meta( intval( $post_id ), 'cron_set_variable', sanitize_text_field( $variable ) );
}

Pokud vám ukázka kódu nic neříká, je nejvyšší čas si přečíst některý z tutoriálů, či se podívat do kodexu

Jak WordPress plánuje události

Pokaždé, když je z kódu zavolána funkce wp_schedule_single_event, dojde k aktualizaci záznamu v tabulce wp_options. Jedním ze záznamů v této tabulce jsou naplánované události – uložené jsou všechny pěkně pohromadě v option s názvem cron podobně serializovaného pole s údajem o čase na místě klíče a naplánovaných volání funkce v hodnotě záznamu pole. Zde je podoba cron option na mé lokální instalaci založené na VVV získaná pomocí WP_CLI a příkazu shell

$ wp shell
wp> get_option( 'cron' );
array(4) {
  [1451331840]=>
  array(1) {
    ["wp_maybe_auto_update"]=>
    array(1) {
      ["40cd750bba9870f18aada2478b24840a"]=>
      array(3) {
        ["schedule"]=>
        string(10) "twicedaily"
        ["args"]=>
        array(0) {
        }
        ["interval"]=>
        int(43200)
      }
    }
  }
  [1451332812]=>
  array(1) {
    ["wp_scheduled_delete"]=>
    array(1) {
      ["40cd750bba9870f18aada2478b24840a"]=>
      array(3) {
        ["schedule"]=>
        string(5) "daily"
        ["args"]=>
        array(0) {
        }
        ["interval"]=>
        int(86400)
      }
    }
  }
  [1451333427]=>
  array(3) {
    ["wp_version_check"]=>
    array(1) {
      ["40cd750bba9870f18aada2478b24840a"]=>
      array(3) {
        ["schedule"]=>
        string(10) "twicedaily"
        ["args"]=>
        array(0) {
        }
        ["interval"]=>
        int(43200)
      }
    }
    ["wp_update_plugins"]=>
    array(1) {
      ["40cd750bba9870f18aada2478b24840a"]=>
      array(3) {
        ["schedule"]=>
        string(10) "twicedaily"
        ["args"]=>
        array(0) {
        }
        ["interval"]=>
        int(43200)
      }
    }
    ["wp_update_themes"]=>
    array(1) {
      ["40cd750bba9870f18aada2478b24840a"]=>
      array(3) {
        ["schedule"]=>
        string(10) "twicedaily"
        ["args"]=>
        array(0) {
        }
        ["interval"]=>
        int(43200)
      }
    }
  }
  ["version"]=>
  int(2)
}

Ukládání do wp_options, namísto použití transients API či jiného dočasného mechanismu je zvoleno proto, že tyto záznamy musí zůstat zachovány až do té doby, dokud nedojde k jejich zpracování. V případě, že by na webu byla použita externí cache využívaly by zmíněné transients tuto cache a ta nám nikdy negarantuje minimální dobu, po kterou je záznam dostupný, pouze maximální – tedy tu, od kdy už záznam dostupný nebude.

autoload options, cron a object cache

Skutečnost, že WP_Cron využívá wp_options má ještě minimálně jeden háček, který je nutné brát v potaz. WordPress využívá options pro skladování informací o svém nastavení, uživatelských rolí, nastavení šablony, pro transients a stejná tabulka také slouží často jako úložiště dat pro pluginy. Protože některá z těchto nastavení jsou důležitá při každém načtění WordPressu a některá nikoli, WordPress využívá sloupeček autoload pro uchovávání informace o tom, které options mají být automaticky z databáze načteny v samotném začátku načítání aplikace. WP_Cron je jednou z takových autoloaded options:

$ wp shell
wp> global $wpdb
wp> $wpdb->get_results( "SELECT * FROM {$wpdb->options} WHERE option_name = 'cron'" );
array(1) {
  [0]=>
  object(stdClass)#82 (4) {
    ["option_id"]=>
    string(3) "103"
    ["option_name"]=>
    string(4) "cron"
    ["option_value"]=>
    string(828) "a:4:{i:1451331840;a:1:{s:20:"wp_maybe_auto_update";a:1:{s:32:"40cd750bba9870f18aada2478b24840a";a:3:{s:8:"schedule";s:10:"twicedaily";s:4:"args";a:0:{}s:8:"interval";i:43200;}}}i:1451332812;a:1:{s:19:"wp_scheduled_delete";a:1:{s:32:"40cd750bba9870f18aada2478b24840a";a:3:{s:8:"schedule";s:5:"daily";s:4:"args";a:0:{}s:8:"interval";i:86400;}}}i:1451333427;a:3:{s:16:"wp_version_check";a:1:{s:32:"40cd750bba9870f18aada2478b24840a";a:3:{s:8:"schedule";s:10:"twicedaily";s:4:"args";a:0:{}s:8:"interval";i:43200;}}s:17:"wp_update_plugins";a:1:{s:32:"40cd750bba9870f18aada2478b24840a";a:3:{s:8:"schedule";s:10:"twicedaily";s:4:"args";a:0:{}s:8:"interval";i:43200;}}s:16:"wp_update_themes";a:1:{s:32:"40cd750bba9870f18aada2478b24840a";a:3:{s:8:"schedule";s:10:"twicedaily";s:4:"args";a:0:{}s:8:"interval";i:43200;}}}s:7:"version";i:2;}"
    ["autoload"]=>
    string(3) "yes"
  }
}

Proč to říkám. Říkám to proto, že funkce wp_load_alloptions využívá WP_Cache a ta může být poháněná pomocí externí objektové cache a to může mít svá úskalí. Například defaultní maximální velikost objektu skladovaného v Memcache je 1M. Může se tak velice snadno stát, že tenhle 1M záznamy WP_Cron zaplníte a externí objektová cache v případě alloptions přestane fungovat. A přitom je alloptions často jedním z prvních důvodů, proč se člověk k Memcache či jiné objektové cache dostane.

Kdy WordPress vykoná naplánovanou událost

Jak jsem již zmínil, WP_Cron je jakýsi kvazi cron, nikoli cron v pravém slova smyslu. To, jakým způsobem je realizován v rámci WordPressu není nic neobvyklého a vlastně dost lidí překvapí, že využívá stejný mechanismu, jako vše ostatní – filtry a akce.

Vezměme si modelový problém. Není to problém čistě akademický, ale něco, co skutečně vývojáře trápí.

Naplánujeme publikaci článku na půlnoc. Klikneme tedy na tlačítko “Schedule”. Záznam o této budoucí akci je zapsán do tabulky wp_options a WordPress vás přesměruje zpět na výpis článků. To je vše, nic dalšího se nekoná a WordPress i server spí.

A spí tak dlouho, dokud jej nikdo nenavštíví. A dokud WordPress spí, nemůže vykonávat žádné akce. WordPress vzbudíte tím, že navštívíte nějakou stránku – ať již na frontendu či v administraci. Ale pozor, v případě frontendu se musí jednat o necachovanou stránku. Pokud tedy web pro nepřihlášené uživatele cachujete pomocí nějaké full page cache, je nutné se přihlásit.

No a pokud vy, ani nikdo jiný web kolem půlnoci nenavštíví (a nebo navštíví, ale je mu předložena cachovaná verze), může se dost dobře stát, že druhý den ráno si vzpomenete, že jste na půlnoc měli naplánovanou publikaci článku a rádi byste se podívali na komentáře.

Ale jelikož jste prvním návštěvníkem webu od doby, kdy jste dokončili článek a naplánovali jej, žádné komentáře nečekejte. WordPress se právě probudil a hodlá dohnat vše, co zameškal. Včetně publikace vašeho článku, která nastane až krátce poté, co jste web navštívli. Již bychom měli mít představu o tom, proč nedošlo k publikaci článku o půlnoci – protože se na web nikdo nepodíval nebo se podíval na cachovaný frontend a WordPress samotný tedy spal. Proč ale nedojde k publikaci článku ihned ve chvíli, kdy se přihlásím do administrace?

WP_Cron a výkon

Jelikož WP_Cron není odbavován přesně v čas, kdy to bylo naplánováno, může se stát, že na pořadu je v danou dobu více akcí, které již měly proběhnout, nicméně kvůli tomu, že web nikdo nenavštívil, neproběhly. Kdyby se WordPress snažil okamžitě vše zpracovat, došlo by k tomu, že by takové načtení stránky trvalo nesnesitelně dlouho a dost pravdědopodobně by došlo k timeoutu požadavku. Tomu ale WordPress předchází.

Asynchronní PHP

Asynchronní PHP je něco, co by mnoho vývojářů velmi ocenilo. Ovšem ještě stále nejsme tak daleko, abychom jej mohli plně využívat na libovolném hostingu. A tak WP_Cron využívá techniky, která spoléhá na HTTP requesty.

Nejspíš jste si všimli, že v rootu vaší WordPress instalace naleznete soubor wp-cron.php. Ten hraje klíčovou roli v tom, jak WordPress spouští cron tak, aby neblokoval načítání stránky pro daného návštěvníka.

Vždy když navštívíte necachovanou verzi stránky,

// WP Cron
if ( !defined( 'DOING_CRON' ) )
	add_action( 'init', 'wp_cron' );

WordPress vyšle HTTP požadavek na http://example.com/wp-cron.php?doing_wp_cron a přidá k tomu nějaké parametry.

Relevantní kód: https://github.com/WordPress/WordPress/blob/77e365efbf2e499e2ed11d29c101ea466cf1ceed/wp-includes/cron.php#L352

Důležité je, že se jedná o tzv. non-blocking HTTP request. Tedy WordPress odešle požadavek, ale již nečeká na odpověď. Ta jej vůbec nezajímá a načítání vaší stránky může nerušeně pokračovat.

Jak WordPress vykonává naplánované akce

WordPress vykonává naplánované akce tak, že vybere již zmíněný záznam z tabulky wp_options (cron) obsahující serializované pole z databáze a prochází celé pole, položku po položce a vždy porovnává časový záznam s aktuálním časem. Pokud je naplánovaný čas v minulosti je tato položka z pole odebrána a zavolá se funkce, která je vedle času v dané položce uložena a předají se ji uložené argumenty.

Teoreticky se tedy může stát, že mezi odstraněním položky z cronu a vykonáním akce dojde k timeoutu či vyčerpání PHP memory limitu. V takovém případě je akce navždy ztracena a již se nikdy nevykoná. Je to ovšem lepší varianta, než aby se naplánovaná akce spustila opakovaně.

Vždy běží jen jeden proces

Jak jsem zmínil, vždy, když někdo navštíví necachovanou stránku, je odeslán dotaz na wp-cron.php. Nebýt systému zámků, docházelo by k tomu, že vedle sebe poběží dva a více konkurenčních procesů zpracovávajících záznam o naplánovaných akcích. To by opět vedlo v problémům – ať již výkonnostním, tak i funkčním, jelikož by se některé akce vykonávali vícekrát, než bylo původně myšleno.

Systém zámků jsem již nastínil v úvodu. WP_Cron využívá transients API (ať jsou již ukládané do databáze, či do object-cache). Pokud existuje specifický transients záznam, wp-cron.php svou činnost ukončí a nepustí se do zpracování naplánovaných akcí. Čili velké množství requestů na http://example.com/wp-cron.php?doing_wp_cron končí velmi záhy.

Relevantní kód:

Nasazení podpory opravdového cronu

Nicméně pokud je váš web hojně navštěvován, může i velké množství HTTP requestů spouštějícíh wp-cron.php způsobit problémy na straně serveru. A zde se již dostáváme k technice, která umožňuje zajistit, že WP_Cron se bude vykonávat periodicky i tehdy, pokud web nikdo nenavštíví (nebo je vhodně a silně cachován) a navíc zajistí, že návštěva necachované stránky nevytvoří nový HTTP požadavek na wp-cron.php, tedy na server.

WordPress umožňuje mechanismus HTTP requestů vypnout pomocí konstanty DISABLE_WP_CRON.

//file: wp-config.php
define(‘DISABLE_WP_CRON’, true);

Pokud je definována s hodnotou true ve vašem wp-config.php, žádné požadavky na wp-crong.php se neprovádí. To ovšem znamená, že se žádná z naplánovaných akcí nikdy neprovede.

Je totiž třeba dalšího kroku. A sice nastavení opravdového cronu tak, aby odesílal HTTP požadavky na wp-cron.php:

wget -q -O - http://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1

Podrobnější návod jak na to určitě naleznete na googlu.

Závěrem

Tento článek by měl pomoci vysvětlit mechanismus stojícím za WP_Cron a to, co tato konkrétní implementace způsobuje. Určitě jste se všichni někdy setkali s “Missed Schedule” hláškou v administraci označující příspěvek, který nebyl doposud publikován, ačkoli měl být. Důvody pro existenci tohoto fenoménu by vám nyní již měly být jasné.

Určitě doporučuji podívat se nyní do souboru wp-cron.php. To se v něm odehrává by vám nyní mělo být jasnější.

Pokud je pro vás pravidelnost publikování příspěvků či provádění některých akcí, zkuste popřemýšlet o podpoře WP_Cronu skrze reálný cron. U více navštěvovaných webů tak navíc můžete ulehčit vašim serverům.

Zrádné počty zobrazení příspěvku

K napsání tohoto příspěvku mě inspiroval blogpost “Admin-ajax.php zpomaluje stránky”, který správně uvádí proč byl daný web pomalý a jak byl problém, aspoň částečně, odstraněn. Proč je problém odstraněn jen částečně rozvádím ke konci tohoto příspěvku.

Vývojář se čas od času setká s poždavkem na zobrazování počtu zobrazení příspěvku na frontentu. Ovšem taková feature je celkem zrádná. V čem je problém?

Na otázku kam s daty si většinou vývojář celkem rychle odpoví, že přeci logicky patří do post_meta.

Tím velmi jednoduše získá místo, kam své údaje může ukládat. Pokud navíc nechce skladovat žádné další údaje o tom, kdo kdy odkud a jak se na web podíval, bude se jednat jen o číslo a takové číslo moc místa v databázi nezabírá. Jednoduché.

První nástřel fungování může vypadat takto:

//Uložíme shlédnutí do databáze
add_action( 'wp_head', function() {
    if ( is_single() ) {
        //získáme uložená data
        $pageviews = get_post_meta( get_the_ID(), 'myawesome_pageview', true );
        //zvětšíme o jedno
        $pageviews = intval( $pageviews ) + 1;
        //znovu uložíme
        update_post_meta( get_the_ID(), 'myawesome_pageview', $pageviews );
    }
}, 10, 0 );

//funkce pro zobrazování počtu shlédnutí
add_filter( 'the_content', function( $content ) {
    if ( ! is_single() ) {
        return $content;
    }
    $content .=  "\nZobrazeno: " . get_post_meta( get_the_ID(), 'myawesome_pageview', true ) 'x';
}, 10, 0 );

Těhle pár řádků kódu dělá přesně to, co vývojář řekl. Ukládá a zobrazuje počty načtení příspěvku.

Jak na page level cache

Pokud ovšem web, na kterém je takový kód přítomný, používá nějaký cachovací plugin, nebude nepřihlášenému uživateli (tedy většině) kód fungovat, jelikož se akce wp_head nespustí, nic se neuloží a zobrazovat se bude stále stejný počet zobrazení uložený spolu s celou stránkou ve statickém HTML.

Ovšem i tento problém lze celkem jednoduše vyřešit pomocí AJAXu:

<?php
add_action( 'wp_ajax_add_pageview', 'myawesome_pageview_add_pageview' );
add_action( 'wp_ajax_nopriv_add_pageview', 'myawesome_pageview_add_pageview' );

//Uložíme shlédnutí do databáze
function myawesome_pageview_add_pageview() {
    $post_id = $_REQUEST['post_id'];
    //získáme uložená data
    $pageviews = get_post_meta( $post_id, 'myawesome_pageview', true );
    //zvětšíme o jedno
    $pageviews = intval( $pageviews ) + 1;
    //znovu uložíme
    update_post_meta( $post_id, 'myawesome_pageview', $pageviews );
    die( $pageviews );
}

//funkce pro zobrazování počtu shlédnutí
function myawesome_pageview( $post_id = null ) {
    if ( false === empty( $post_id ) && true === is_singular() ) {
        $post_id = get_the_ID();
    } else {
        return;
    }
    echo '<div id="myawesome_pageviews">'.get_post_meta( $post_id, 'myawesome_pageview', true ).'</div>';
}

add_action( 'wp_head', function(){
    if ( ! is_singular() )
        return;
    $ajax_nonce = wp_create_nonce( "my-special-string" );
?>
<script type="text/javascript">
jQuery(document).ready(function($){
	var data = {
		action: 'add_pageview',
		security: '<?php echo $ajax_nonce; ?>',
		post_id: '<?php echo get_post_ID(); ?>'
	};
	$.post( '<?php echo admin_url( "admin-ajax.php" ); ?>' , data, function(response) {
		$( "#myawesome_pageviews" ).html( response );
	});
});
</script>
<?php
} );

Tohle řešení bude fungovat i s libovolným cachovacím pluginem. Ovšem nastane jiný problém. A to s výkonem. Ten sice existoval již předtím, nicméně zůstal skryt cachovacím pluginem.

Co se nyní děje při požadavku na wp-admin/admin-ajax.php

WordPress, k tomu aby mohl fungovat, musí načíst jádro, pluginy, šablony, options z databáze a další data. To neplatí jen a pouze o necachovaném pageview, ale také o AJAX requestech, které se v podobně POST requestu určitě necachují.

Takže každé načtení stránky na daném webu produkuje nyní dva requesty. Jeden cachovaný a druhý přímo mířící na origin – tedy váš server, kde se WordPress načítá s veškerou parádou. Takový AJAX request nebude blokovat načtení stránky, uživatel se dostane k obsahu teoreticky celkem rychle a na ten počet zobrazení v hlavičce článku si holt počká. Ale server bude přetížen.

Tohle je ta část problému, jejíž řešení je uvedeno v článku odkazovaném v úvodu. Nicméně to není vše.

Nekončící zápisy do databáze

Už zmiňované necachované requesty na server jsou špatné, ale je to ještě horší. Daný AJAX request nejen, že načítá data z databáze, on také data zapisuje. To znamená, že co načtení stránky, to zápis do databáze. V malých objemech to sever nejspíš ustojí, ale v případě nějakého spiku v návštěvnosti se ukáže, kdo používá MyISAM a kdo InnoDB jako úložiště dat pro MySQL.

Zatímco stále ještě nejpoužívanější MyISAM zamyká při zápisu celé tabulky, InnoDB zamyká jen řádky. InnoDB pomůže hlavně v tom, že administrátor či editoři nebudou mít problémy s vytvářením novým příspěvků, kvůli tomu, že by tabulka wp_post_meta byla zamknutá kvůli všem těch zápisům z pageviews pluginu. Ale problém to stejně neřeší.

Když jsem nakousl MySQL, tak cítím, že je vhodné také zmínit pro nefunguje cache, kterou má MySQL zabudovanou. MySQL cachuje dotazy a v případě identického dotazu dokáže vrátit data bez toho, aniž by dotaz vykonala. Ovšem MySQL tuto cache maže vždy, když dojde ke změně v relevantních tabulkách. Takže i jednoduché čtení z databáze se nám začne prodražovat.

Nepomůže změnit cahovací plugin

Věřím, že řada provozovatelů webu na WordPressu se začne poohlížet po lepším cachovacím pluginu. Neznám podrobně všechny page level cachovací pluginy, ale dokážu si představit že mažou cache pro stránku v případě updatu relevantní post_meta. Aspoň tak tomu je u různých object cache pluginů, které znám lépe.

Uživatel se dost možná dočte o něčem, čemu se říká object cache a zkusí nějaký ten redis či memcache nasadit. Ovšem kýženého výsledku se opět nedosáhne. Jednoduše proto, že object cache pro daný post, který obsahuje nejen údaje z tabulky wp_posts, ale také z tabulky wp_post_meta, se bude s každým zápisem do databáze mazat, tedy vždy, když někdo spustí daný AJAX request – tedy navštíví danou stránku. Takže vlastně pořád. To je opět stejný problém, jako s MySQL cachí.

Opět se vracím k problému a řešení popsaném ve zmiňovaném blogpostu. Ten správně odstranil AJAX request, ovšem plugin/šablona použitá na webu není tak “elegantní”, pokud se o eleganci ještě vůbec dá hovořit, a neprovádí čtení i zápis v rámci jednoho requestu, nýbrž ve dvou. “Zapisovací” request je v době psaní tohoto článku stále ještě aktivní a vesele zapisuje do databáze, znovu a znovu.

Správné řešení?

Jediné, co můžeme provést je celý takovýto návrh smést ze stolu.

Statistiky jako pageviews patří na systémy, které jsou na nápor dat připraveny. A můžeme si vybírat. Jmenujme aspoň Google Analytics, Piwik či Jetpack a jeho modul Stats.

Pluginy na zobrazování počtu shlédnutí daného příspěvku poté budou muset využít API zmíněných nástrojů a získat tak kýžená data. Jaké pluginy to jsou a jak obecně na to je nad rámec tohoto článku, jehož cílem je především poučit o tom, proč se s page/post views pluginy většinou spálíte a proč.

PS: Původně jsem nechtěl žádný plugin jmenovat a veškerý kód jsem napsal jen ilustračně (aniž bych jej testoval), nicméně když jsem zjistil, že plugin WP-PostViews má více jak 200000 stažení a jeho kód je velmi podobný tomu, co jsem zde nastínil, nedalo mi to. Schválně se na kód pluginu podívejte.

Dokumentace Best Practice pro WordPress

WordPress dlouho postrádal aktivitu podobnou příručce PHP The Right Way, kterou má k dispozici komunita PHP vývojářů. Ještě nedávno platilo, a obávám se, že stále ještě platí a nějakou dobu platit bude, že kdo se chtěl ponořit do WordPressu, musel začít studiem kodexu, prokousávat se zdrojovým kódem (vedle tradičního trac je tento již nějakou dobu také na GitHubu, což potěší určitě ty, kteří SVN opovrhují) a studovat řadu tutoriálů, mezi kterými navíc musel vybírat ty, které jsou kvalitní, správné a aktuální. Situace se ovšem, a hodí se říci i naštěstí, pomalu mění k lepšímu.

Continue reading Dokumentace Best Practice pro WordPress