Larm som går av en gång, inte en gång per server
Ett av våra driftlarm – "låga krediter" för en byggares publicerade app – ska nå oss högst en gång om dagen. En eftermiddag kom det fem gånger. Orsaken: varje kopia av vårt API mindes vilka larm den redan skickat i sitt eget minne, så två servrar skickade det två gånger, och varje deploy raderade det minnet och lät dem skicka igen. Samma mönster gjorde att ett bakgrundsjobb utförde sitt arbete två gånger. Båda samordnar nu via en delad lagring, och båda faller på den säkra sidan om lagringen inte går att nå.
Symtomet
Mythex skickar larm till teamet när något behöver tittas på: en tjänst är nere, ett jobb har stannat, eller en byggares kreditsaldo är lågt medan appen är publicerad. Varje larm har ett dedupe-fönster – en period under vilken samma larm bara skickas en gång. För larmet om låga krediter är fönstret 24 timmar.
Fem kopior av samma larm under en eftermiddag betydde att fönstret inte höll. Värre: de kom parvis, ungefär 11 minuter från varandra.
Varför det hände
Vårt larmpaket höll sin dedupe i minnet: en tabell med "larmnyckel → när det senast skickades", inne i den körande processen. Det fungerar när det finns en process som aldrig startas om. Produktionen har varken eller:
- Två API-repliker. Produktionen kör två kopior av API:et. Var och en hade sin egen tabell, så var och en skickade larmet en gång. Det är parvisheten.
- Deploys. Varje deploy ersätter båda replikerna med nya processer och tomma tabeller. Efter en deploy gick larmet ut igen, som om det aldrig skickats.
Glappet på ungefär 11 minuter mellan de två larmen i varje par kom från jobbet som höjer larmet. Det körs på båda replikerna och tar ett lås, så replikerna vinner det om och om igen, och var och en höjde larmet när det var dess tur.
Det fanns en andra, tystare bugg i samma kod. För att tabellen inte skulle växa i all evighet rensade den sig själv när den nådde 500 nycklar – den raderade varje post äldre än 15 minuter, oavsett dess fönster. Under belastning kunde en 24-timmars dedupe bara vara en kvart.
Lösningen
En delad post per larm
Innan API:et skickar ett larm claimar det nu larmets dedupe-nyckel i Redis – en delad minnesbaserad lagring som båda replikerna pratar med och som inte startar om när API:et gör det. Claimet är en enda operation: "sätt den här nyckeln bara om den inte finns, och låt den löpa ut efter fönstret". Den replik som claimar först skickar larmet; den andra hittar nyckeln upptagen och håller tyst. En ny process efter en deploy hittar fortfarande nyckeln.
Nyckeln och fönstret kommer från samma regel som larmpaketet använder, nu delad mellan dem, så de två lagren inte kan vara oense om vad som räknas som "samma larm".
Släpp igenom, stäng inte
Det viktigaste beslutet var vad som ska ske när Redis inte går att nå. Vi valde att skicka larmet ändå. Om Redis är nere, eller inte svarar inom 1,5 sekunder, går larmet ut. Ett dubblerat larm är brus. Ett bortfallet larm är någon som inte blir informerad.
De allvarligaste larmen, P0, hålls aldrig tillbaka av dedupe alls.
Varje post behåller sitt eget fönster
I larmpaketets egen dedupe i minnet lagrar varje post nu när dess fönster tar slut, och rensningen tar bara bort poster vars fönster är över. En 24-timmarsnyckel finns kvar i 24 timmar, oavsett hur många andra larm som kommer.
Samma bugg, på ett annat ställe
Samma mönster hade också fördubblat ett bit bakgrundsarbete: ett avstämningsjobb som håller vår bild av varje arbetsytas status i takt med vår sandlådeleverantörs. Varje varv listar varje sandlåda på kontot – tusentals i produktionen, ungefär fem sekunder – plus varje körande eller pausad projekt. Båda replikerna körde det var tvåminutersperiod.
Dess skrivningar är idempotenta – att köra dem två gånger ger samma resultat som en gång – så ingenting gick sönder. Det var bara dubbelt så mycket arbete. Nu claimas varje tvåminutersfönster i Redis på samma sätt som våra användningsmätare redan claimade sina, och bara den replik som claimar kör varvet. Om Redis inte går att nå körs varvet ändå: att göra arbetet två gånger är ofarligt, att inte göra det är det inte.
Så testade vi det
Testerna efterliknar den verkliga uppsättningen. De laddar larmmodulen två gånger, som två separata "repliker", pekar båda mot en delad lagring, höjer samma larm från var och en, och laddar sedan en tredje, nyövrad kopia för att representera "efter en deploy" och höjer det igen. Exakt ett larm skickas. Andra tester kontrollerar att ett annat byggarlarm fortfarande går ut, att ett P0 skickas från båda replikerna, och att larmet fortfarande skickas när lagringen är nere eller hänger längre än tidsgränsen. Rensningsfixen har ett eget test: en 24-timmarsnyckel överlever 600 andra larm som kommer 20 minuter senare.
För avstämningsjobbet ger två repliker som tickar samtidigt, plus ett tredje tick i samma fönster, ett enda listningsanrop.
Det vi tog med oss
- Tillstånd i minnet är per process. Allt som måste gälla över servrar – "skickat en gång", "kört en gång" – måste finnas någonstans de delar.
- Deploys är omstarter. Tillstånd som bara överlever till nästa omstart överlever inte en vanlig arbetsdag.
- Bestäm åt vilket håll det ska falla. För larm ska en nedsläckt delad lagring betyda fler larm, inte färre.
- Leta efter mönstret, inte bara förekomsten. Larmet var det högljudda fallet; det dubblerade avstämningsjobbet var samma bugg, som tyst kostade arbete.
Inget av detta syns inifrån Mythex. För mer om hur vi håller plattformen stabil, se hur vi fick deploys ner till noll 502-fel och swap-filen som saknades. Om du är ny på idén att köra fler än en kopia av en tjänst täcker vår guide om vad en server är grunderna.