SQLite gelijktijdigheid en waarom het belangrijk is
Deze pagina is vertaald door PageTurner AI (beta). Niet officieel goedgekeurd door het project. Een fout gevonden? Probleem melden →
SQLite is een krachtige database-engine, maar door zijn ontwerp kent het beperkingen die niet over het hoofd mogen worden gezien.
Jellyfin gebruikt al jaren een SQLite-database voor het opslaan van de meeste gegevens, maar heeft op veel systemen ook problemen ondervonden. In deze blogpost leg ik uit hoe we deze beperkingen aanpakken en hoe ontwikkelaars die SQLite gebruiken dezelfde oplossingen kunnen toepassen.
Dit wordt een technische blogpost bedoeld voor ontwikkelaars en iedereen die wil leren over gelijktijdigheid.
Bovendien zou Jellyfins implementatie van vergrendeling voor SQLite vrij eenvoudig in een andere EF Core-toepassing geïmplementeerd kunnen worden als je hetzelfde probleem tegenkomt.
- JPVenson
De uitgangssituatie
SQLite is een op bestanden gebaseerde database-engine die binnen je applicatie draait en waarmee je gegevens in een relationele structuur kunt opslaan. Over het algemeen biedt het je applicatie de mogelijkheid gestructureerde gegevens als één bestand op te slaan zonder afhankelijk te zijn van een andere applicatie. Dit heeft uiteraard ook een prijskaartje. Als je applicatie dit bestand volledig beheert, moet worden aangenomen dat je applicatie de enige eigenaar is, en dat niemand er aan zal prutsen terwijl je gegevens wegschrijft.
Een applicatie die SQLite als database wil gebruiken, moet dus de enige zijn die er toegang toe heeft. Met deze vaststelling rijst een belangrijke gedachte: als slechts één schrijfbewerking tegelijk op een bestand mag plaatsvinden, moet deze regel ook gelden voor bewerkingen binnen dezelfde applicatie.
De WAL-modus
SQLite heeft een functie die deze beperking probeert te omzeilen: het Write-Ahead-Log (WAL). Het WAL is een apart bestand dat fungeert als logboek van bewerkingen die op een SQLite-bestand moeten worden toegepast. Hierdoor kunnen meerdere parallelle schrijfbewerkingen plaatsvinden en in de WAL-wachtrij worden geplaatst. Wanneer een ander deel van de applicatie gegevens wil lezen, leest het uit de feitelijke database, scant dan het WAL voor wijzigingen en past deze al lezend toe. Dit is geen waterdichte oplossing; er zijn nog steeds scenario's waarin WAL vergrendelingsconflicten niet voorkomt.
SQLite-transacties
Een transactie moet twee dingen garanderen. Wijzigingen binnen een transactie kunnen ongedaan worden gemaakt, ofwel bij problemen of wanneer de applicatie besluit dat dit moet, en optioneel kan een transactie ook andere lezers blokkeren van het lezen van gegevens die binnen de transactie worden gewijzigd. Hier wordt het interessant en komen we bij de echte reden waarom ik deze blogpost schrijf. Om onbekende redenen rapporteert de SQLite-engine op sommige systemen die Jellyfin draaien bij een transactie dat de database is vergrendeld, en in plaats van te wachten tot de transactie is opgelost, weigert de engine te wachten en crasht hij gewoon. Dit lijkt een niet ongebruikelijk probleem te zijn en er zijn veel meldingen over te vinden.
Wat dit probleem zo vervelend maakt, is dat het niet consistent optreedt. Tot nu toe hebben we slechts één teamlid bij wie dit (enigszins) consistent is te reproduceren, wat het een nog vervelender bug maakt. Uit rapporten blijkt dat dit probleem voorkomt op alle besturingssystemen, ongeacht schijfsnelheden en met of zonder virtualisatie. We hebben dus geen bepalende factor geïdentificeerd die zelfs maar bijdraagt aan de kans dat het probleem optreedt.
De Jellyfin-factor
Nu de algemene theorie over SQLite-gedrag is vastgesteld, moeten we ook kijken naar de specifieke manier waarop Jellyfin SQLite gebruikt. Tijdens normale werking op een aanbevolen opstelling (niet-netwerkopslag en bij voorkeur SSD) is het ongebruikelijk dat er problemen optreden, maar de manier waarop Jellyfin de SQLite-db tot versie 10.11 gebruikt is zeer suboptimaal. In versies vóór 10.11 had Jellyfin een bug in zijn parallelle taaklimiet die leidde tot exponentieel overmatig plannen van bibliotheekscanbewerkingen, waardoor de database-engine werd bestookt met duizenden parallelle schijfacties waar een SQLite-engine simpelweg niet tegen kan. Hoewel de meeste SQLite-engine-implementaties herhaalgedrag hebben, hebben ze ook timeouts en controles om eindeloos wachten te voorkomen, dus als we de engine genoeg belasten, faalt hij gewoon met een fout. Dat, samen met zeer langlopende en eerlijk gezegd ongeoptimaliseerde transacties, kon ertoe leiden dat de database simpelweg overbelast raakte en begaf.
De oplossing
Sinds we de codebase naar EF Core zelf hebben verplaatst, hebben we de tools om hier daadwerkelijk iets aan te doen, omdat EF Core ons een gestructureerd abstractieniveau biedt. EF Core ondersteunt een manier om in te pluggen op elke commando-uitvoering of transactie door Interceptors te creëren. Met een interceptor kunnen we eindelijk het eenvoudige idee realiseren om simpelweg niet parallel naar de database te schrijven, op een transparante manier voor de aanroeper. Het overkoepelende idee is om meerdere strategieën voor vergrendeling te hanteren. Omdat alle niveaus van synchronisatie onvermijdelijk ten koste gaan van prestaties, willen we dit alleen doen wanneer het echt noodzakelijk is. Daarom heb ik gekozen voor drie vergrendelingsstrategieën:
-
Geen vergrendeling
-
Optimistische vergrendeling
-
Pessimistische vergrendeling
Standaard doet de "geen vergrendeling"-strategie precies wat de naam impliceert: niets. Dit is de standaardinstelling omdat uit mijn onderzoek blijkt dat dit voor 99% van de gevallen geen probleem is, en elke interactie op dit niveau de hele applicatie vertraagt.
Zowel de optimistische als pessimistische strategieën gebruiken twee interceptors – één voor transacties en één voor commando's – en overschrijven SaveChanges in JellyfinDbContext.
Optimistische vergrendelingsstrategie
Optimistische vergrendeling betekent dat we aannemen dat de betreffende operatie zal slagen en eventuele problemen achteraf afhandelen. In essentie komt dit neer op "Proberen en opnieuw proberen..." voor een vast aantal keren, totdat we ofwel slagen of volledig falen. Dit laat nog steeds de mogelijkheid open dat we uiteindelijk niet kunnen schrijven, maar de geïntroduceerde overhead is veel kleiner dan bij de pessimistische strategie.
Het werkingsprincipe is simpel: telkens wanneer twee operaties proberen naar de database te schrijven, zal er altijd één winnen. De andere zal falen, even wachten, en dan enkele keren opnieuw proberen.
Jellyfin gebruikt de Polly-bibliotheek om het opnieuw proberen uit te voeren en zal alleen operaties opnieuw proberen die geblokkeerd zijn door dit specifieke probleem.
Pessimistische vergrendelingsstrategie
Pessimistische vergrendeling blokkeert altijd wanneer er naar SQLite geschreven moet worden. In feite wacht Jellyfin bij elke gestarte transactie of schrijfbewerking via EF Core tot alle andere leesoperaties zijn voltooid, en blokkeert dan alle andere operaties – of ze nu lezen of schrijven zijn – tot de betreffende schrijfactie is uitgevoerd. Dit betekent echter dat Jellyfin slechts één enkele schrijfactie naar de database kan uitvoeren, zelfs als dit technisch gezien niet nodig zou zijn.
In theorie zou een applicatie zonder problemen moeten kunnen lezen van tabel "Alice" terwijl er geschreven wordt naar tabel "Bob", maar om alle mogelijke bronnen van vergrendelingsproblemen door gelijktijdigheid uit te sluiten, staat Jellyfin in deze modus slechts één enkele schrijfactie op de database toe. Hoewel dit absoluut de meest stabiele werking garandeert, zal het ongetwijfeld ook de traagste zijn.
Jellyfin gebruikt een ReaderWriterLockSlim om de operaties te vergrendelen. Dit betekent dat we een onbeperkt aantal gelijktijdige leesacties toestaan, terwijl slechts één schrijfactie ooit mag plaatsvinden.
De toekomstige Slimme vergrendelingsstrategie
In de toekomst kunnen we overwegen beide strategieën te combineren om het beste van beide werelden te bereiken.
Het resultaat
Eerste tests toonden aan dat we met beide strategieën groot succes hadden in het aanpakken van het onderliggende probleem. Hoewel we nog niet zeker weten waarom dit alleen op sommige systemen gebeurt terwijl andere werken, hebben gebruikers die voorheen Jellyfin niet konden gebruiken nu tenminste een optie.
Toen ik dit onderwerp onderzocht, vond ik talloze meldingen over het hele internet met dezelfde fout, maar niemand kon een sluitende verklaring geven voor wat hier echt gebeurt. Er zijn vergelijkbare voorstellen gedaan om het te verhelpen, maar er was geen "kant-en-klare" oplossing die alle verschillende gevallen afhandelde, of alleen code die enorme aanpassingen vereiste voor elke EF Core-query. Jellyfins implementatie van de vergrendelingsstrategieën zou een copy-paste-oplossing moeten zijn voor iedereen met dezelfde problemen, omdat het gebruikmaakt van interceptors en de aanroeper geen weet heeft van de daadwerkelijke vergrendelingslogica.
Succes ermee,
- JPVenson