Inkrementální zálohy MySQL

Zálohování databází je výrazně komplikovanější než zálohování normálních souborů. Už jen fakt, že samotné zkopírování dat databáze je obvykle k ničemu a i kdyby to jako záloha fungovalo, tak to stejně není žádná velká výhra. Obecně možností, jak zálohovat SQL databázi je několik:

  • správné zkopírování samotných datových souborů - soubory se zkopírují jako normální soubory, jen je potřeba dodržet nějaký další postup, aby to ve finále fungovalo a nic tam nechybělo. Je to poměrně rychlé, nicméně v záloze máte pak akorát nějaký binární bordel, se kterým nejde udělat nic jiného, než ho obnovit tak jak prostě byl - a to po celých databázích. A také nejspíše na stejnou verzi databázového systému jako byl ten předtím, protože to jinak nejspíše nebude fungovat.
  • snapshoty filesystému/LVM - pokud něco takového máte, tak se dá záloha podobného typu jako předchozí, ale asi jednodušším způsobem. Má to stejné háčky jako předchozí verze a hádám, že asi i nějaké další - tipuji, že bude potřeba mít data, indexy a transakční log na stejném oddílu; například.
  • online replikace - databáze se po celou dobu mirroruje na další databázový stroj, s minimálním zpožděním. Ať tedy hlavní server hebne kdykoliv, na druhém je (skoro) aktuální kopie databáze připravená k okamžitému použití (což se dá i zautomatizovat). Ovšem toto řešení má také háček - replikace úplně stejně ochotně replikuje i příkazy jako DELETE FROM a DROP TABLE - před lidskou chybou tedy stejně neuchrání.
  • SQL dumpy - obsah databáze se dá vydumpovat do souboru, který obsahuje jednotlivé SQL příkazy. Je to tedy offline záloha, podobně jako první dvě možnosti, ale výsledkem je textový soubor, ve kterém je i okem vidět, co za data tam vlastně jsou, dá se teoreticky z něj importovat i jen určitá část a dá se přenášet i mezi verzemi databázového systému (tedy, většinou alespoň do novějších verzí). Nevýhodou oproti prvním dvěma způsobům je ten, že jeho pořízení a hlavně obnovení zpět trvá déle.
Pokud se chceme chránit i proti té lidské blbosti, budeme potřebovat i nějakou offline zálohu, tedy možnost 1, 2 nebo 4. A bez problému se to dá nakombinovat i s online replikací. Ovšem vzniká tady ještě jeden další problém. Mějme pro příklad databázi, která má velikost 50GB. Do databáze data postupně přibývají nějakou lidskou činností - tedy databáze stále roste, ale pomalu. Proti nechtěnému mazání uděláme zálohu každých 24 hodin - pokud se tedy někde něco pokazí, máme maximálně 24 hodin stará data, kde to bylo ještě v pořádku. A protože ne vždy si problému všimneme hned, budeme ty zálohy skladovat třeba 3 měsíce. Pokud tedy například budeme zálohovat vždy ve 2 ráno, tak jsme takhle nyní schopni vrátit databázi do stavu, v jakém byla ve 2 hodiny ráno v kterémkoliv dni až čtvrt roku zpětně. Ale je vážně potřeba skladovat 3*30 50GB, když v poslední záloze je jen o pár MB dat více, než v té první?

O zálohování (nejen) MySQL databází je toho napsáno poměrně dost, nicméně o tom, jak se vyhnout mnohonásobnému ukládání stejných dat už toho tolik popsáno není. Tak jsem začal bádat sám a po projití několika slepých uliček jsem našel něco, co se tváří, že funguje docela pěkně :-) Jistě, je to námaha ušetřená za ušetření pár GB, které stojí pakatel (ikdyž po záplavách v Thajsku to volné místo celkem podražilo :), ale pokud velikost databází bude ještě větší a chceme je skladovat po delší dobu, tak mi přijde vážně zbytečné zaplácat hromady GB místa mnohonásobně redundantními daty.

Zálohování běžných souborů jsem řešil pomocí systému bacula. Nabízelo se, aby i databáze jím byla řešena a bylo to alespoň trochu jednotné. Výsledný script, co jsem vytvořil, je tedy napsán tak, aby šel pouštět z baculy (a pro baculu vyreprodukuje požadované soubory, které potom bacula spokojeně odzálohuje). Databázové servery jsem měl k dispozici dva, kde na druhém byla replikovaná kopie prvního serveru. Offline zálohy jsem potom řešil pomocí SQL dumpů. Ty jsou sice ze všech řešení asi nejpomalejší, ale obnovu v případě pádu hlavního serveru nemusím dělat, protože jeho funkci zastane sekundární server (a případy obnovy, když se někde něco smaže až tolik nehoří). A samotná tvorbu dumpů pouštím na replikované kopii, kde nic jiného neběží, takže nějaké vytěžování serveru zde také nevadí.

Tak, nyní jsem v situaci, že vím, že chci dělat SQL dumpy a vím, že je chci nějak diffovat, abych neukládal stále to samé. Zbývá jen jak na to. Pomocí mysql_dump se dá vytvořit SQL dump databáze - s vhodně nakombinovanými parametry třeba tak, že pro každou databázi uděláme jeden soubor a nebudeme mít vše nahňácané v jednom souboru. mysql_dump také (od nějaké verze) má dva formáty, jak dump vypadá - respektive umí dělat něco, čemu říká extended insert. Rozdíl je ten, že extended insert se snaží jedním insert příkazem vložit více dat - tedy za klasickým INSERT INTO blablabla je několik závorek obsahující jednotlivé hodnoty, až do maximální velikosti příkazu. Pokud to zapnuto není, je INSERT INTO v dumpu vloženo pro každý řádek tabulky. Extended insert má tedy mnoho výhod - jednak celkový dump je výrazně menší (přece jenom text "INSERT INTO tabulka" zopakovaný pro 10 milionů záznamů něco sežere) a dále jeho obnova bude rychlejší - provede se menší počet operací (insertů).

Nicméně po pár neúspěšných pokusích s binárním diffováním jsem se uchýlil ke starému, známému diffu (do doby než mě to omrzelo, ale o tom až později). Diff funguje velice pěkně, ale s extended inserty se kamarádit nebude. Ona totiž taková docela velká tabulky má v dumpu s extended inserty poměrně málo řádků, protože co jeden insert, to jeden řádek. A jelikož diff diffuje po řádcích, tak na tohle nebude vůbec fungovat. Pokud se do takové tabulky pár dat přidá, tak se těch jejich všech pár řádků změní a efekt diffu je v čudu (patch bude naopak větší, než kompletní dump). Tak jsem se uchýlil k vypnutí extended insertů. Obětoval jsem rychlejší čas pro obnovu (přece se ta obnova nebude dělat nijak zvlášť často) a obětoval jsem velikost dumpu (koneckonců, když se dump ještě zkomprimuje, tak rozdíl ve velikosti bude minimální, protože stále se opakující slova jdou zkomprimovat dobře. Tak jsem to zkusil a experimentálně zjistil, že je to stejně k ničemu, protože když se všemohoucímu diffu předhodí dva soubory o velikosti třeba 10GB, tak se stejně dočkáte akorát hlášky o tom, že už není dostatek paměti na to, kolik si chce diff alokovat. Takže pokus hezký, ale nevyšel...

Nastal tedy čas zase zapnout extended inserty a najít nějaké diffovátko, které umí diffovat binárně (tj ne po řádcích, ale po bajtech) a které to umí udiffovat rychle a bez použití paměti velikosti několikanásobku vstupního souboru. A tehdy po neúspěšných pokusech s wdiff, bsdiff a dalšími jsem se dostal k xdelta3.

xdelta3 funguje jako binární diff - je mu tedy jedno, jaké jsou v porovnávaných souborech konce řádků apod. A navíc diffuje doopravdy rychle a bez nutnosti spousty paměti (zkoušeno na souborech o velikosti řádově 10GB). Jistou nevýhodou je to, že výsledný diff je také binární soubor, takže už si ho nelze textovým editorem přečíst. No, nicméně teď tedy co dál. Máme zálohovací systém, co umí zálohovat soubory, máme nástroj, co umí vydumpovat databázi do SQL dumpů a máme nástroj, který ze dvou libovolných souborů udělá binární patch. Zbývá tedy skript, co to nějak zautomatizuje:

  1.  

Teď tedy jak to ještě funguje:

  • Skript se spouští jenom s jedním parametrem a to typem zálohy - jak jsem zmínil, chtěl jsem databázi zálohovat baculou a bacula umí spouštět skripty a ještě jim říct, co je to za zálohu. Rozdíly jsou zde jenom dva - pokud děláme plnou zálohu (parametr Full), tak začínáme načisto. Smažeme tedy všechny dumpy a patche, co teď máme a uděláme je znovu. V ostatních případech si dumpy necháme, uděláme nový, uděláme diff a diff předhodíme baculovi.
  • Skript includuje soubor /etc/sqlbackup.conf, ve kterém je pár potřebných nastavení. Soubor vypadá takto:
    1.  
    Proměnné MYSQL_USER a MYSQL_PASSWORD nastavují jméno a heslo uživatele v MySQL, pod kterým se bude provádět dump. Je poměrně vhodné, aby tento uživatel mohl číst doopravdy vše, jinak je ta záloha celkem k ničemu. Složka BACKUP_DIR slouží k uchovávání posledního SQL dumpu databáze - zde se bude SQL dump vytvářet a archivovat. Ve složce EXPORT_DIR potom skončí soubory, které je potřeba odzálohovat pryč. Při používání inkrementálních záloh se soubory zde kupí - při první záloze je zde uložen celý dump databáze a s každou další zálohou zde potom přibyde patch oproti poslednímu dumpu (bacula při inkrementální záloze zálohuje znovu pouze změněné/nové soubory). Při další plné záloze je obsah složky smazán.
  • V prvním kroku se z MySQL zjistí seznam databází. Ten se následně prochází a pro každou databázi se provádí nějaká akce. Díky tomu jsou dumpy rozdělené podle jednotlivých databází a ne jako celek v jednom souboru.
  • Napřed se celá databáze v současném stavu vydumpuje do souboru. Za povšimnutí stojí parametr --single-transaction - ten by měl zaručit, že celý dump poběží v jedné transakci a tedy výsledek bude konzistentní. Nicméně transakce umí jenom engine InnoDB, takže pokud používáte něco jiného, tak to úplně dobře fungovat nebude. Ještě se místo dá použít jiný parametr, který databázi zamkne - to funguje i na jiných enginech. Akorát že v tomhle případě do dokončení dumpu nikdo s databází ani nehne - je to tedy použitelné na replikované kopii databáze, která se fláká a není používaná (a jste si naprosto jisti, že nenastal failover!), ale rozhodně ne na té produkční.
  • Pokud už fulldump předtím existoval, tak můžeme vytvářet patch. Pomocí xdelta3 tedy z nového a minulého dumpu vyrobíme patch, pro jistotu si z nového dumpu vypočítáme SHA1 otisk a patch spolu s otiskem zabalíme a strčíme do exportní složky. Starý dump potom smažeme a necháme si jen ten poslední.
  • Pokud předchozí dump neexistuje, tak strčíme do exportní složky celý dump. Já je v tomto kroku navíc komprimuji, aby nezabíraly zbytečně místo. Na druhou stranu nutno říci, že bzip2 s parametrem -9 je celkem brutální a na velkých souborech to komprimuje celkem dlouho. V téhle chvíli se dá komprimace buď vypnout nebo alespoň snížit - pokud neoplýváte spoustou CPU času a málem prostoru pro zálohy.

Tak, zálohy máme hotové, teď ještě zbývá scénář pro případnou krizi - co dělat, když je potřeba obnova. Vezmeme tedy v úvahu úvodní případ, kdy zálohy budeme provádět jednou za 24 hodin a budeme chtít zálohy uchovávat měsíc - tedy až 31 dní zpětně od současného stavu. Plnou zálohu budeme provádět vždy prvního dne v měsíci a každý další den inkrementální (inkrementální = jen patch oproti předchozí verzi). (Z téhle konfigurace nakonec vyjde, že ty samotné data pro tyto požadavky držíte 2 měsíce. Pokud to vadí, tak lze problém vyřešit například zkrácením intervalu provádění plných záloh). Tak, zálohy hezky máme a najednou budeme chtít obnovit jednu konkrétní databázi do stavu, v jakém byla dne 20.11.2011.

V první řadě je potřeba si ze zálohovacího systému vytahat relevantní soubory k této databázi. Dumpy jsou pojmenované podle databáze, takže stačí vytáhnout jen soubory s tímto jménem. K obnovení stavu k 20.11. bude potřeba plná záloha ze dne 1.11.2011 a následně 19 patchů, kteří byly odzálohováni po tomto souboru. Uvedený skript vše komprimoval, takže to teď hezky rozbalíme (tar xjf pro zmíněný případ). V plné záloze je jen jeden soubor - celý dump databáze, jak vypadala první den v měsíci. U patchů jsou soubory dva - samotný patch a crc soubor, obsahující SHA1 otisk dumpu, jak vypadal těsně po vydumpování z databáze. Nyní se tedy pustíme do aplikování patchů. Patche je potřeba aplikovat postupně jak šly za sebou - to by ale nemělo být tak těžké; ve jménu souboru je datum a čas pořízení, navíc v takovém formátu, že abecední pořadí podle jména souboru odpovídá chronologickému. Patch se aplikuje pomocí příkazu:   Tohle bude třeba aplikovat 19x (v každém dalším kole samozřejmě na novy_dump.sql) nebo můžete využít mergnutí patchů - teda napřed se sjednotí všechny patche do jednoho a ten se potom aplikuje (viz xdelta3 --help). Na konci tohohle procesu zbyde celý SQL dump databáze tak, jak vypadala 20.11.2011. Zbývá ještě poslední kontrola - spusťte sha1sum na nově vzniknutý dump a porovnejte ho s crc souborem s posledním patchem. Pokud hodnoty sedí, máte vyhráno (a pokud ne, tak máte někde dost velký problém...).

Když se na ten zápisek dívám zpětně, tak to vypadá celé dost složité. Nicméně ve skutečnosti to tak hrozné není - však ten script, co to celé dělá, má jen pár řádek. Obnovování je o něco komplikovanější, na druhou stranu by k němu nikterak často nemělo docházet (na zálohu pro případ katastrofy je ta replikace beztak lepší). A pokud zálohujete databáze, jejichž objemy se dostávají ke stovkám GB a denně se v nich mění jen pár dat, ušetří se tím spousta místa.