Distribuovaný zámek u dlouhodobě běžících PHP procesů

Narazili jsme na situaci, kdy jsme potřebovali vytvořit sdílený zámek napříč servery, napříč datacentry a s minimem zásahu do aplikace. Určitě Vás hned napadlo velké množství řešení, my jsme k problému ale přistoupili trochu jinak a nejdříve popíšeme všechny okolnosti a pak i samotné řešení.

Aplikace a prostředí

V rámci aplikace zpracováváme velké množství zpráv na pozadí. Z pohledu softwarového stacku se o to stará kód napsaný v PHP, zprávy se ukládají do RabbitMQ.

Dlouhá doba zpracování

Důležitá informace je, že zpracování jedné zprávy trvá od jednotek minut, klidně i přes 24 hodin.

Závislosti aplikace

V aplikaci máme naprosté minimum závislostí - spojení se drží pouze do RabbitMQ a dále využívá REST API pro výměnu dat. Hlavní část práce se dělá lokálně, kdy aplikace zpracovává data a připravuje výsledek.

Workflow:

  • Aplikace dostane zprávu z RabbitMQ
  • Dotáže se API na získání více informacích o práci, kterou má provést
  • Zpracuje zprávu (tato část trvá nejdéle) a je to část, kde je po celou dobu vytížený CPU na 100 %
  • Odešle API informace s výsledkem
  • Potvrdí zprávu v RabbitMQ

Duplicitní zprávy ve frontě

Stává se nám ale, že se jedna zpráva dostane do fronty duplicitně - a předem říkáme, že toto chování je zcela chtěné. Pokud jednu práci provedeme několikrát, tak se výsledek dalšího zpracování jen zahodí. Trápí nás to ale z pohledu výkonu/plýtvání zdroji.

Škálování

Pro zpracování zpráv používáme jak lokální servery v našich datacentrech, tak i cloudové instance různě po Evropě. Výkon vždy škálujeme podle aktuální ceny zdrojů a počtu zpráv ve frontě. V době psaní tohoto textu zprávy zpracovává 438 fyzických serverů v celkem 6 datacentrech. Na každém serveru navíc běží několik procesů pro zpracování, abychom servery opravdu vytížili na 100 %.

Řešení

Sdílený zámek pomocí redisu/databáze

Jako první řešení jsme samozřejmě zvážili sdílený zámek pomocí redisu a databáze. Aplikace před samotným zpracováním zprávy vytvoří spojení do nějaké sdílené databáze, kde si bude držet zámek.

Tady hned vyskakuje otázka - kód je v PHP (tzn. bez podpory vláken a tím i heartbeatu) a spojení bychom potřebovali držet hodiny, než se zpráva zpracuje.

Toto řešení jsme tedy zavrhli kvůli tomu, že se takto dlouhá spojení bez přenosu dat se rozhodně budou rozpadat.

Sdílený zámek pomocí redisu/databáze - druhý pokus

V dalším kroku jsme tedy zvážili, že bychom původní řešení rozšířili. Do databáze bychom si jen uložili, že se zpráva aktuálně zpracovává, a pokud by jiný server dostal ke zpracování stejnou práci, tak by věděl, že ke zpracování zprávy již dochází jinde.

U této varianty bychom do zprávy museli přidat ještě unikátní identifikátor pro každou zprávu, abychom byli schopni rozlišit, kdy se aplikace snaží zpracovat duplicitní zprávu a kdy naopak došlo k novému doručení jednou zpracovávané zprávy, aby nedocházelo k zahazování zpráv, které jsou doručené vícenásobně. K tomu dochází běžně kvůli různým výpadkům, restartům serverů a podobně.

Toto řešení má pro nás několik krásných vlastností:

  • Nedržíme žádná dlouhá spojení
  • Zámek můžeme implementovat i v rámci REST API, takže nemusíme přidávat žádné další závislosti do aplikace.

Nevýhodou je, že musíme na konci práce zámek také pečlivě uvolnit/smazat, protože kdybychom chtěli tu samou práci zpracovat znovu - např. kvůli špatnému zpracování nebo úpravě v kódu, tak by byla automaticky odmítnutá jako duplicitní.

Řešení pomocí RabbitMQ

Spojení do RabbitMQ už máme, tak jsme si říkali, jestli se nedá využít k vyřešení našeho problému.

Zámek jsme implementovali lokálně na filesystému pomocí flock. K identifikaci úkolu používáme stejný unikátní identifikátor zprávy jako v předchozím řešení. Jasně - filesystém není sdílený mezi servery, takže tímto omezíme pouze zpracování zpráv, které se spustí vícenásobně na jednom serveru.

Na každý server jsme přidali další RabbitMQ consumer, který odpovídá na zprávy s požadavkem na kontrolu zámku. Pokud nalezne lokální zámek, který opravdu drží nějaký z procesů, tak pomocí RPC (standardní AMQP RPC pattern s hlavičkami reply_to a correlation_id) odešle informaci, že se daný úkol již zpracovává na tomto serveru.

Z pohledu RabbitMQ pak používáme pro tento účel fronty s automatickým mazáním, zprávy navíc mají krátké TTL a exchange používáme typu fanout - tzn. zpráva se doručí do všech aktuálně existujících front (= všem připojeným consumerům).

Zde je potřeba zmínit, že server, který se ptá, musí počkat na odpověď, jež může dorazit s drobným zpožděním. Teprve když žádná odpověď nepřijde, ví, že úlohu může zpracovat. Timeout jsme nejdříve nastavili na 5 vteřin, nyní je již snížený na 2 vteřiny. V porovnání s dobou zpracování samotné úlohy, která je běžně v hodinách, je to zanedbatelné.

Zajímavým vedlejším efektem tohoto řešení je, že flock je svázaný s file handle procesu - pokud proces spadne (OOM killer, tvrdý restart serveru) nebo doběhne, operační systém zámek automaticky uvolní. Odpadá nám tak nevýhoda předchozího řešení, kde jsme museli zámek po zpracování ručně mazat.

Závěr

Určitě Vás napadla otázka, jak je to se spojením do RabbitMQ, které také musíme držet hodiny a má stejný problém jako první řešení. V PHP se pro zpracování zpráv přímo k RabbitMQ nepřipojujeme a používáme k tomu rabbitmq-cli-consumer v Go, který velmi dobře řeší heartbeat a udržování spojení.

Díky implementaci tohoto zámku jsme ušetřili 10 % výkonu celé farmy a k duplicitnímu zpracování zpráv již nedochází. A jako příjemný bonus je, že celá úprava kódu byla rychlá.