MySQL: Migrering til ElasticSearch

Vi flyttede fritekst-søgningen ud af MySQL og opnåede mange fordele med denne migrering til ElasticSearch. Hastighed og stabilitet var bare to af gevinsterne ved migreringen.

Søgning i store mængder data udvikler sig – både for hvad der kvalificerer sig som store mængder, men også hvilke værktøjer der er mest velegnede til opgaven. ElasticSearch har set en stor opblomstring, så det er naturligt at undersøge hvad platformen kan gøre for os.

Det kan sagtens lade sig gøre at komme i gang uden at skulle investere andet end din egen tid (meget af platformen er Open Source). Når det er sagt er det også godt at vide, at bag teknologien står en virksomhed der både udbyder kurser, hosting og betalt adgang til bl.a. en række analyse og visualiseringsværktøjer, hvis du har brug for det.

Hos Overskrift begynder vi at gribe ud efter nye værktøjer, når vi ser et begyndende behov, men også når omtalen af teknologi nærmer sig områder vi opererer i. I dette tilfælde lå en stor del af vores data på en ældre MySQL-platform og de seneste 3 måneders data var fuld-tekst indekseret i en MyISAM tabel. Den ældre version af MySQL begyndte at knage, og selvom databasen havde masser af dejlig RAM, kunne vi, pga. versionen af MySQL, ikke anvende Fuldtekst-indexes på den nyere InnoDB tabel-type. Vi så altså på et database-opdateringsprojekt, men tænkte også om vi evt. kunne finde noget teknologi der skalerede bedre; Her faldt vores opmærksomhed på ElasticSearch.

Hvad kan ElasticSearch, som MySQL ikke kan?

ElasticSearch er meget anderledes, for udviklere der kommer fra primært at arbejde med MySQL. Tingene hedder noget andet: Du skal f.eks. vende dig til, at hvad du tidligere kendte som en tabel i MySQL nu hedder et index, og data skal indsættes og fremsøges ved at sende og modtage JSON-dokumenter til databasen istedet for SQL. Det er forholdsvis ligetil at abstrahere simple tabelopslag ud i funktionskald.

Den rigtig god nyhed for os var, at søgesproget i MySQL fulltext-indexes og ElasticSearch ligner hinanden utroligt meget, så kundernes søgestrenge skulle ikke skrives om. Vi kunne omskrive blot omskrive applikationens model-kode, for at bibeholde interfacet til søgninger i de relevante data.

Migrering til ElasticSearch betyder også indbygget skalering

ElasticSearch er bygget til skalérbarhed. Hvis du sætter en server op på dit netværk, har du hurtigt database-storage, men sætter du endnu en server (node) op, begynder den at aflaste den første server og du har et cluster – det giver dog mest mening når dit cluster består af mindst 3 noder. Kan en node “se” en anden node på netværket, kan den videresende opslag fra din applikation til den anden node. Typisk vil en node være master – og fungere som load-balancer for andre noder i clusteret, men den opgave kan altså også overtages af andre noder. Skaléringen kan altså både give større sikkerhed mod hardware-nedbrud, men også bedre performance.

Migrering fra SQL til JSON

Arbejder du med MySQL (eller andre relationelle databaser) ved du, at data gemmes i rækker i tabeller, og for at gøre søgning i disse data hurtigere oprettes et index, der ligesom et index i en bog gør, at du hurtigt kan finde information baseret på udvalgte kolonner eller værdier i din tabels rækker af data. Skal du hente alle records fra en tabel i MySQL, og se dem med det højeste id først, kan det f.eks. se således ud:

SELECT * FROM overskrift_2019_11
ORDER BY id DESC;

Samme slags opslag i ElasticSearch kunne se således ud:

GET overskrift_2019_11/_search
{
"query": {
"match_all": {}
},
"sort": {
"id": "desc"
}
}

Altså et JSON dokument, der angiver at vi gerne vil se på tabellen indexet overskrift_2019_11. JSON-dokumentet sendes med et GET-request til ElasticSearch, som svarer noget a’la:

{
   "took" : 28,
   "timed_out" : false,
   "_shards" : {
     "total" : 5,
     "successful" : 5,
     "skipped" : 0,
     "failed" : 0
   },
   "hits" : {
     "total" : 3095276,
     "max_score" : null,
     "hits" : [
       {
         "_index" : "overskrift_2019_11",
         "_type" : "posts",
         "_id" : "486678534",
         "_score" : null,
         "_source" : {
           "id" : 486678534,
           "item_url" : "http://www.example.com/test",
           "title" : "Kommunen er blandt de første til at matche 6. klasser med SOSU-uddannelse",
           "description" : "Som de første i landet har Kommunen lavet et 4-dages brobygningsforløb, hvor alle 6. klasser prøver kræfter med omsorgsfagene. Opstarten har været så stor en succes, at forløbet fortsætter. I et nyt samarbejde mellem ...

Altså også et JSON-dokument som svar. Udover data fortæller ElasticSearch heri bl.a. også at opslaget tog 28 millisekunder, og at der findes 3.095.276 “records” der matcher. Skal man søge efter en specifik tekst, bliver forespørgslen lidt mere kompliceret.

GET overskrift_2019_11/_search
{
"query": {
"bool": {
"must": {
"query_string": {
"query": "+banjo +guitar*",
"fields": ["title","description","author"],
"type": "cross_fields"
}
}
}
},
"sort": {
"id": "desc"
}
}

Når man ser svartider falde fra flere sekunder til få milisekunder uden tabel-låse på hvert opslag, og først har abstraheret JSON-formatet væk, begynder det altså at give ekstremt god mening – især som afløser for et monolitisk MySQL fuldtekst-index. Og du har måske fornemmet her, at vores tilgang til ordet migrering ikke helt følger den gængse opfattelse.

Pragmatisk migrering

ElasticSearch er nemlig ekstremt god til skaléring og lynhurtig tekstsøgning. Vi har dog en applikation, som har mange data-afhængigheder mellem forskellige entiteter. Skulle alle tabeller og data med i denne migrering til ElasticSearch, ville vi se på en meget stor kodeomskrivning af vores applikation, så vi valgte i stedet at afhjælpe det mest presserende problem: Svartiderne på tekstsøgninger fra den mest aktive tabel (med ca. 150.000 INSERT’s pr. dag og mange opslag).

For at få svartider ned på tværs af hele applikationen, eliminerede vi altså fulltext-indekset på vores indholds tabel og kopierer nye data til ElasticSearch så snart de er indlæst i MySQL. Alle MySQL-opslag, der brugte fuld-tekst indekset blev skrevet om til ElasticSearch queries.

Planlægning af strukturer

De data vi arbejder med, er naturligt periode-opdelte. Når kunderne skal bruge dem, er de som regel interesserede i data i månedsintervaller. I andre tilfælde foretages udtræk ofte pr. år. Meget gamle data er ikke super interessante, og vi vil gerne kunne offloade “gamle” data (læs slette indexet) når de ikke længere er relevante. På MySQL ligger disse data opdelt i:

  • en tabel for de seneste 3 måneder med fulltext index, og
  • en stor arkiv-tabel (uden indexes) for alt ældre indhold.

På ElasticSearch opretter vi et index pr. måned med månedens data, og afgrænser adgangen til f.eks. de seneste 3 måneders data med et alias, der altid peger på de indexes, der dækker de seneste 3 måneder. Alias’et hedder altså posts_recent og er et alias for de seneste 3 måneder’s indexes:

overskrift_2019_10
overskrift_2019_11
overskrift_2019_12

Når vi træder ind i en ny måned omdefineres indexet til at pege på de seneste 3 måneder. I MySQL ville det svare til at gemme data i en tabel pr. måned og benytte et view, som en SELECT … UNION … på de 3 seneste måneders tabeller. Det eneste vi skal være opmærksomme på er, at gen-definere vores alias’er, når vi træder ind i en ny måned.

Migrering af MySQL 5 til MySQL 8

Her kan du læse hvordan vi gennemførte en migrering af en stor MySQL 5 database til MySQL 8 med minimal nedetid, fuldt roll-back scenarie og fuld backup hele vejen.

Der er en række fordele ved MySQL 8 fremfor MySQL 5. Hos Overskrift havde vi brug for en del af disse fordele. Vi var også ved at slå hovedet mod loftet i de begrænsninger, der er indbygget i den aldrende MyISAM tabeltype.

Når databasecrashes begynder at blive en tilbagevendende begivenhed, er det på tide at stoppe op. I “gamle dage” (MySQL 5.5) understøttede tabeltypen InnoDB ikke full-text indexes, så vi havde en del data gemt i MyISAM-tabeller. Det viste sig problematisk ved store datamængder der ofte voksede, blev opdateret og slettet. Da planlægningen startede, var tanken at migrere til MySQL version 5.7, da MySQL 8 var meget ny på daværende tidspunkt.

Samtidig researchede vi fritekst-søgeteknologier og endte med at alle data skulle migreres til MySQL 8. Den tunge fritekst-søgning skulle dog foregå på ElasticSearch fremadrettet – en gennemprøvet, men dog ny teknologi for os. Søgninger der tidligere kunne tage flere sekunder med MySQL tog et tilsvarende antal millisekunder i ElasticSearch. Den svære del af øvelsen var, at migrere det hele uden nedetid.

Hvad der ikke virkede

Vi har arbejdet en del med database backup via MySQL replikering; En process der registrerer alle loggede dataopdateringer og lader en anden MySQL database læse og udføre de samme opdateringer. Det forudsætter at begge databaser har et fælles udgangspunkt – f.eks. et databasedump fra originaldatabasen. På den måde har vi en backup af databasen kørende (slave), som maksimalt er få sekunder bagud fra originaldatabasen (master). Hvis vi kunne lave en komplet kopi af MySQL 5.5 databasen, ville vi altså kunne replikere den til MySQL 8. Derefter består skiftet i en simpel opdatering af databasens DNS-registrering.

MEN replikering virker kun med maksimalt en en minor version i afvigelse. Replikering fra version 5.5 til 5.6 er OK, men version 5.5 til 5.7 er no-go. Vi skulle altså etablere en kæde fra MySQL 5.5 til 5.6 til 5.7 til MySQL 8.0. Replikering mellem databaser af forskellig version er også mere følsom overfor små versions-afhængige afvigelser (og skulle håndbæres og genstartes ofte). Denne fremgangsmåde hjalp os heller ikke af med de aldrende MyISAM tabeller.

Normal kan MySQL selv opdatere data-filerne fra en tidligere version, hvis man bare kopierer dem ind i den korrekte data-folder. Succesraten falder når man springer over flere minor versioner. Så er vi tilbage ved at skulle producere en kæde af MySQL servere, der kan håndtere datafiler. Det er heller ikke muligt at beregne, hvor lang tid datamigreringen i så fald ville tage.

Alt i alt var selve processen overtænkt, og endte med at være usikker fordi den var kompliceret. Vi valgte at skrue ned for kompleksiteten.

Kravene til migrering af MySQL databasen

  • Der måtte ikke findes MyISAM tabeller i databasen efter opdateringen, så alle MyISAM tabeller skulle konverteres til InnoDB
  • Data der var fulltext-indekseret skulle kopieres til ElasticSearch
  • Nedetiden skulle holdes på et minimum. Tests forud for den rigtige migrering skulle godtgøre at migreringen var mulig at gennemføre under et præcist estimat.

Forberedelser

Den nye produktionsdatabase skal have en backup-server klar, som vi hele tiden kan anvende i tilfælde af nedbrud. Ligesom tidligere, har vi en langsommere og billigere server klar til at overtage, hvis den nye produktionsdatabase skulle gå ned. Den kører samme version (8) af MySQL og replikerer fra produktionsserveren. Det var vigtigt at den også replikerede før migreringsprocessen, så vi dermed fik eftervist at den kunne følge med. Hvis master serveren er for hurtigt i forhold til slaven (ja – det kalder man det), kan den komme langt bagud. Det er faktisk muligt at den er så langsom at den ikke kan følge med, og at replikeringstiden aldrig falder. I sådanne tilfælde vil backup serveren være ubrugelig, og skal bestykkes med hurtigere diske og mere RAM.

Replikeringsserveren var i gang med at replikere fra produktionsserveren, selv før tabellerne var oprettet på den nye produktionsserver. Derved kunne vi blot kaste et blik på backupserveren en gang imellem, og observere at replikeringstiden faldt.

Hvad der virkede

Vi endte med at script’e alt, så oprettelser og dataoverførsler var automatiserede. Scripts blev produceret ved heftig brug af mysqldump-kommandoen og opdelt i 4 “klumper”:

  1. Tabel-strukturer uden indexes (produceres i god tid før migrering)
  2. Data fra alle tabeller (produceres ca. et døgn før migreringen)
  3. Nye data (delta) fra tabeller med høj opdateringsfrekvens (produceres under migrering)
  4. Index-oprettelse på tabellerne (produceres i god tid før migreringen, køres på ny database når alle data er overført)

Oprettelsen af alle tabellers strukturer (pkt. 1) går stærkt (få sekunder), og kan afvikles på den nye database forud for migreringen. Inden den afvikles omskrives alle forekomster af MyISAM til InnoDB. Dermed er strukturen på plads med de korrekte tabeltyper.

Data blev dumpet fra den gamle MySQL 5 server (pkt. 2) og streamet via netværket direkte til MySQL 8-serveren. Mål-serveren er desuden bestykket med SSD-disks i RAID, mens den gamle server havde fysiske (dog stadig hurtige diske i RAID). Denne dataoverførsel var den største og varede længst – ca. 10 timer, hvilket er en hel del når man tænker på at det er rå data, som endnu ikke er indekseret, der blev sendt over netværket. Rationalet var at data, så tidligt som muligt, skulle flyttes til den nye produktionsserver, da ydelsen af SSD-diskene på den nye server gjorde den nye database mange faktorer hurtigere end den gamle server og at behandling af større datamængder i processen altid skulle ske her.

Delta-scriptet var det mest komplicerede. For alle tabeller undersøgte det de maksimale id’er på alle tabellerne, og kunne derfor nøjes med at kopiere evt. nye data over netværket til de nye tabeller med almindelige INSERT-statements. Delta-scriptet kunne køres igen og igen, og jo kortere intervaller det blev afviklet med, desto hurtigere var data fuldt opdaterede på mål-serveren. Delta-scriptet var ca. 2 timer om at overføre et døgns data-delta, så kørt før migreringen af MySQL blev sat i gang, blev scriptet kørt, for på den måde at minimere nedetiden under selve migreringen.

Migreringsplanen blev nedskrevet punkt for punkt med estimater for hvert punkt, og var gennemført flere gange før den skarpe migrering af MySQL databasen.

Selve migreringen bestod altså (let forsimplet) af at:

  1. lukke alle services ned, som anvendte databasen
  2. køre delta-scriptet igen (denne gang tog det kun ca. 10 minutter, da data-bevægelserne var meget begrænsede)
  3. køre index-scriptet til oprettelse af indexes på mål-databasen (hvilket tog ca. en halv time, da vi havde fjernet alle komplekse full-text indexes)
  4. rette dns-registreringen for databasen
  5. genstarte alle services

I alt blev den store database migreret med en samlet nedetid på ca. 45 minutter, selvom den største del af data tog mere end 10 timer at flytte.

Roll-back planen

For hvert trin i migreringsprocessen blev der ligeledes nedskrevet et eller flere roll-back trin, således at det samlede system hurtigt kan komme tilbage i produktion på den gamle server. I dette tilfælde drejede det sig dog primært om at starte services op igen, før rettelsen i dns-registreringen.

Konklusion

Forud for migreringen anvendte vi replikeringsteknologier, forsøgte at ændre tabeltyper på MyISAM-tabeller, der var vel belæsset med data. Vi kunne ikke rigtig se hvordan vi kunne klare os uden de håndjern som MySQL 5.5 og dermed MyISAM-kravet for at understøtte fulltext-indexes, men der opstod en vis forløsning da vi begyndte at insistere på at forsimple processen.

MySQL logger ikke noget videre forståeligt, når den først crasher, men ved at tilvælge udelukkende at bruge den nyeste og mest stabile tabel type, og ved at flytte fritekst-søgning væk fra den relationelle database, som til gengæld er god til nøgle-indekserede tabeller, fik vi sænket kompleksiteten. I sidste ende betød det, at vi kunne designe en migreringsproces, som var replikérbar, som kunne estimeres ret præcist og som kunne rulles tilbage hvis noget skulle vise sig at gå galt.

I et andet indlæg beskriver jeg migreringen af data til ElasticSearch.


Har du brug for hjælp til migrering af MySQL databaser? Læs evt. hvilke andre teknologier vi har mest erfaring med.

Ring til Jens Ulrik på 21 24 88 85 eller skriv en e-mail til jul@nuit.dk.