
Hösten 2019 intrĂ€ffade en efterlĂ€ngtad hĂ€ndelse i iOS-teamet för Mail.ru Cloud. Den primĂ€ra databasen för bestĂ€ndig lagring av appens tillstĂ„nd blev ganska exotisk för mobilvĂ€rlden. (LMDB). Nedan presenteras en detaljerad översikt i fyra delar. Först kommer vi att diskutera orsakerna till detta otraditionella och svĂ„ra val. Sedan gĂ„r vi vidare till de tre pelarna i LMDB:s arkitektur: minnesmappade filer, B+-trĂ€d och copy-on-write-metoden för att implementera transaktionshantering och multiversionering. Slutligen, som en extra bonus â den praktiska delen. HĂ€r kommer vi att titta pĂ„ hur man kan designa och implementera ett databas-schema med flera tabeller ovanpĂ„ ett lĂ„gnivĂ„ key-value API, inklusive en indexering.
InnehÄll
3.1.
3.2.
3.3.
4.1.
4.2.
4.3.
1. Motivation för implementering
En gÄng runt 2015 blev vi oroade över att mÀta hur ofta grÀnssnittet i vÄr applikation laggar. Vi sysslade inte med detta utan anledning. Vi fick fler klagomÄl om att applikationen ibland slutade reagera pÄ anvÀndarens handlingar: knappar svarade inte, listor rullades inte osv. Om mekaniken bakom mÀtningarna vet jag. pÄ AvitoTech, sÄ jag ger hÀr endast en översikt av siffrorna.

MÀtresultaten blev en kall dusch för oss. Det visade sig att problemen som orsakades av lÄsningar var mycket fler Àn andra problem. Innan vi insÄg detta var den viktigaste tekniska kvalitetsindikatorn kraschfrihet, men efterÄt flyttades fokus. Genom att bygga
en dashboard med lĂ„sningar en kvantitativ och tvĂ„ trĂ„dar en artikel pĂ„ Habra . Đ ŃĐ°ĐŒĐșĐ°Ń Đ¶Đ” ŃĐ”ĐșŃŃĐ”ĐłĐŸ ĐżĐŸĐČĐ”ŃŃĐČĐŸĐČĐ°ĐœĐžŃ Ń ĐŸŃŃ ĐżĐŸĐŽŃĐ”ŃĐșĐœŃŃŃ ŃĐ” аŃпДĐșŃŃ ŃĐ”ŃĐ”ĐœĐžŃ, ĐșĐŸŃĐŸŃŃĐ” ĐżĐŸĐČлОŃлО ĐœĐ° ĐČŃĐ±ĐŸŃ Đ±Đ°Đ·Ń ĐŽĐ°ĐœĐœŃŃ .â
Aktörmodellen för systemorganisation innebÀr att flerkapacitet blir dess andra vÀsen. Modellens objekt gillar att korsa trösklar mellan trÄdar. Och de gör detta inte bara ibland och nÄgonstans, utan praktiskt taget stÀndigt och överallt.

Databasen Àr en av de grundlÀggande komponenterna i den presenterade skissen. Dess huvudsakliga uppgift Àr att genomföra makromönstret. . Om den i företagsvÀrlden anvÀnds för att organisera datasynergi mellan tjÀnster, sÄ handlar det i en aktörsarkitektur om data mellan trÄdar. DÀrför behövde vi en sÄdan databas vars anvÀndning i en flerkapacitetmiljö inte orsakar ens minimala svÄrigheter. Detta innebÀr i synnerhet att de objekt som erhÄlls frÄn den minst ska vara trÄdsÀkra, och idealiskt sett helt oförÀnderliga. Som bekant kan de senaste anvÀndas samtidigt frÄn flera trÄdar utan att behöva anvÀnda nÄgra lÄs, vilket gynnar prestandan.
Den andra viktiga faktorn som pÄverkade valet av databas var vÄrt moln-API. Det inspirerades av synkroniseringsmetoden som antagits i git. Precis som det siktade vi pÄ , vilket ser mer Àn rimligt ut för molnkunder. Det förutsattes att de bara skulle ladda ner det kompletta tillstÄndet av molnet en gÄng, och att synkroniseringen i de flesta fall skulle ske genom att applicera förÀndringar. TyvÀrr Àr denna möjlighet fortfarande endast teoretisk, och i praktiken har kunderna inte lÀrt sig att arbeta med patchar. Det finns en rad objektiva orsaker till detta, som för att inte förlÀnga inledningen lÄter vi vara osagda. Just nu Àr det mycket mer intressant att se de lÀrande resultaten av lektionen om vad som hÀnder nÀr API:et sa "A", men dess konsument inte sa "B".
SÄ om du förestÀller dig git som vid körning av kommandot pull, istÀllet för att applicera patchar pÄ det lokala snapshots, jÀmför dess fullstÀndiga tillstÄnd med serverns fullstÀndiga tillstÄnd, sÄ fÄr du en ganska klar bild av hur synkronisering sker i molnklienter. Det Àr inte svÄrt att gissa att för att genomföra detta mÄste man allokera tvÄ DOM-trÀd i minnet med meta-information om alla server- och lokala filer. Om en anvÀndare lagrar 500 000 filer i molnet, krÀvs det att Äterskapa och förstöra tvÄ trÀd med 1 miljon noder för hans synkronisering. Varje nod Àr en aggregat som innehÄller en graf av underobjekt. I detta ljus blev resultaten av profileringen vÀntade. Det visade sig att Àven utan att beakta algoritmerna för sammanslagning Àr sjÀlva proceduren för att skapa och sedan förstöra ett stort antal smÄ objekt kostsam. Situationen förvÀrras av det faktum att den grundlÀggande synkroniseringsoperationen ingÄr i ett stort antal anvÀndarscenarier. Som en följd av detta faststÀller vi det andra viktiga kriteriet vid valet av databas - möjligheten att utföra CRUD-operationer utan dynamisk allokering av objekt.
Andra krav Àr mer traditionella och deras lista ser helt ut som följande.
- TrÄdsÀkerhet.
- Multiprocessor. Detta motiveras av önskan att anvÀnda samma databasinstans för att synkronisera tillstÄndet inte bara mellan trÄdar utan Àven mellan huvudapplikationen och iOS-tillÀgg.
- Möjlighet att representera lagrade entiteter som immutabla objekt.
- FrÄnvaro av dynamiska allokeringar inom ramen för CRUD-operationer.
- Stöd för transaktioner med grundlÀggande egenskaper : atomiskhet, konsekvens, isolering och tillförlitlighet.
- Hastighet i de mest populÀra fallen.
Ett bra val med denna uppsĂ€ttning krav har varit och förblir SQLite. Men i samband med att studera alternativ snubblade jag över en bok . Under her guidance, a benchmark was written comparing the performance of different databases in real cloud scenarios. The result exceeded even the most ambitious expectations. In the most popular cases â obtaining a cursor for a sorted list of all files and a sorted list of all files for a given directory â LMDB proved to be 10 times faster than SQLite. The choice became obvious.

2. Positioning of LMDB
LMDB is a very small library (only 10K lines) that implements the most fundamental layer of databases â storage.

The diagram shown demonstrates that comparing LMDB with SQLite, which also implements higher levels, is not really correct, much like comparing SQLite with Core Data. It would be more fair to mention similar storage engines as equal competitors â BerkeleyDB, LevelDB, Sophia, RocksDB, etc. There are even projects where LMDB acts as a component of the storage engine for SQLite. The first such experiment was conducted in 2012 by the Howard Chu . LumoSQL presented it at LinuxConfAu. , who were strongly dissatisfied with BerkeleyDB as the foundation of their project. Building on a modest library
btree , Howard Chu was able to create one of the most popular alternatives today. He dedicated a very great presentation to this history and the internal structure of LMDB . A good example of conquering storage was shared by Leonid Yurev (aka ) from Positive Technologies in his talk at Highload 2015 . In it, he discusses LMDB in the context of a similar task of implementing ReOpenLDAP, while LevelDB underwent comparative criticism. As a result of this implementation, Positive Technologies even spawned a rapidly developing fork with very attractive features, optimizations, and LMDB is often used as a storage as is. For example, the Mozilla Firefox browser .
it for a number of needs, and starting from version 9, Xcode it over SQLite for index storage. Đ”ĐłĐŸ SQLite ĐŽĐ»Ń Ń ŃĐ°ĐœĐ”ĐœĐžŃ ĐžĐœĐŽĐ”ĐșŃĐŸĐČ.
Motorn har gjort sig bemÀrkt inom mobilutveckling. SpÄr av dess anvÀndning kan hittas i iOS-klienten för Telegram. LinkedIn har tagit det ett steg lÀngre och valt LMDB som standardlagring för sitt egenutvecklade cache-ramverk, Rocket Data, om vilket det rapporterades i en artikel 2016.
LMDB slÄss framgÄngsrikt om en plats i solen i det utrymme som lÀmnades av BerkeleyDB efter att det hamnade under Oracles kontroll. Biblioteket Àlskas för sin hastighet och tillförlitlighet Àven jÀmfört med liknande alternativ. Som bekant finns det inga gratis luncher, och det Àr vÀrt att betona det trade-off som man kommer att stöta pÄ vid valet mellan LMDB och SQLite. Schemat ovan visar tydligt hur den ökade hastigheten uppnÄs. För det första betalar vi inte för extra abstraktionslager ovanför den diskbaserade lagringen. Klart Àr att en bra arkitektur alltid krÀver dem, och de kommer oundvikligen att dyka upp i applikationens kod, men de kommer att vara mycket mer smidiga. De kommer inte att ha funktioner som inte efterfrÄgas av just den specifika applikationen, exempelvis stöd för SQL-frÄgor. För det andra blir det möjligt att optimalt genomföra mappning av applikationsoperationer till disklagringsfrÄgor. Om SQLite 3. Tre pelare av LMDB
Nu nÀr vi har sett pÄ LMDB frÄn fÄgelperspektiv Àr det dags att gÄ djupare. De följande tre avsnitten kommer att handla om de grundlÀggande pelarna som arkitekturen för lagringen vilar pÄ:
Minneskartlagrade filer som mekanism för diskÄtkomst och synkronisering av interna datask strukturer.
- B+-trÀd som struktur för de lagrade data.
- Copy-on-write som metod för att sÀkerstÀlla ACID-egenskaper vid transaktioner och multiversionering.
- 3.1. Pelare nummer 1. Minneskartlagrade filer
3.1. Pelare nummer 1. Minneskartlagrade filer
De visade minnesfilerna Àr en sÄ viktig arkitektonisk komponent att de till och med nÀmns i namnet pÄ lagringssystemet. FrÄgor kring caching och synkronisering av Ätkomst till den lagrade informationen överlÀmnas helt och hÄllet till operativsystemet. LMDB innehÄller inga cachar. Detta Àr ett medvetet beslut frÄn författarens sida, eftersom att lÀsa data direkt frÄn de visade filerna möjliggör att mÄnga hörn kan skÀras i implementeringen av motorn. Nedan ger jag en lÄngt ifrÄn fullstÀndig lista över nÄgra av dessa.
- Att upprÀtthÄlla datakonsistens i lagringssystemet nÀr flera processer arbetar med det blir operativsystemets ansvar. I nÀsta avsnitt kommer denna mekanik att diskuteras i detalj med bilder.
- Avsaknaden av cachar befriar LMDB frÄn overheadkostnader relaterade till dynamisk allokering. Att lÀsa data Àr i praktiken en enkel installation av en pekare till rÀtt adress i det virtuella minnet och inte mer. Det lÄter som science fiction, men i kÀllkoden för lagringssystemet Àr alla anrop till salloc koncentrerade till en funktion för lagringskonfiguration.
- Avsaknaden av cachar innebÀr Àven att det inte finns nÄgra lÄs relaterade till synkronisering av deras Ätkomst. LÀsare, av vilka det kan finnas godtyckligt mÄnga samtidigt, stöter inte pÄ en enda mutex pÄ sin vÀg till data. Detta resulterar i att lÀshastigheten har idealisk linjÀr skalbarhet med avseende pÄ antalet CPU:er. I LMDB genomgÄr endast modifierande operationer synkronisering. Det kan bara finnas en skribent vid varje given tidpunkt.
- Minst mÀngd caching- och synkroniseringslogik befriar koden frÄn extremt komplexa typer av fel relaterade till arbete i en flertrÄdad miljö. Vid Usenix OSDI 2014 presenterades tvÄ intressanta studier av databaser: och . Dessa kan ge information om sÄvÀl den exceptionella tillförlitligheten hos LMDB som den nÀstan felfria implementeringen av ACID-egenskaper för transaktioner, som övertrÀffar den i SQLite.
- Minimalismen i LMDB gör det möjligt för maskinens representation av dess kod att helt rymmas i CPU:ns L1-cache med de dÀrav följande prestandakarakteristikena.
TyvÀrr Àr det inte sÄ enkelt med minnessatta filer i iOS som man skulle önska. För att kunna diskutera de relaterade nackdelarna mer medvetet behöver vi Äterkalla de allmÀnna principerna för hur denna mekanism implementeras i operativsystem.
AllmÀn information om minnessatta filer
Varje körbart program associerar operativsystemet med en enhet som kallas process. Varje process fÄr ett sammanhÀngande adressintervall, dÀr den placerar allt den behöver för sitt arbete. I de lÀgsta adresserna finns sektioner med kod och hÄrdkodade data och resurser. DÀrefter följer ett vÀxande block av dynamiskt adressutrymme, som vi kÀnner till som heap. HÀr finns adresser till enheter som dyker upp under programmets körning. Högst upp finns minnesomrÄdet som anvÀnds av applikationens stack. Den vÀxer och krymper, med andra ord har dess storlek ocksÄ en dynamisk natur. För att stacken och heapen inte ska krocka och störa varandra har de separerats Ät olika hÄll i adressutrymmet. Mellan de tvÄ dynamiska sektionerna uppe och nere finns ett gap. Adresser i detta mellanrum anvÀnds av operativsystemet för att associera olika enheter med processen. I synnerhet kan det sÀtta en viss sammanhÀngande uppsÀttning adresser - en fil pÄ disken. En sÄdan fil kallas en minnessatt fil.
Det tilldelade adressutrymmet för processen Àr enormt. Teoretiskt Àr antalet adresser begrÀnsat endast av storleken pÄ pekaren, som bestÀms av systemets bitgenomgÄng. Om det skulle motsvara den fysiska minnet en-till-en skulle den första processen förbruka all RAM, och nÄgon form av multitasking skulle inte kunna ske.
Men av vÄr erfarenhet vet vi att moderna operativsystem kan köra hur mÄnga processer som helst samtidigt. Detta Àr möjligt eftersom de endast pÄ pappret tilldelar processerna en stor mÀngd minne, medan de i verkligheten endast laddar den del av minnet som efterfrÄgas hÀr och nu in i den fysiska minnet. DÀrför kallas minnet som Àr associerat med processen virtuellt.

Operativsystemet organiserar virtuell och fysisk minne i form av sidor av en viss storlek. NÀr en viss sida av virtuell minne har efterfrÄgats, laddar operativsystemet den i det fysiska minnet och upprÀttar en koppling mellan dem i en sÀrskild tabell. Om det inte finns nÄgra lediga platser kopieras en tidigare laddad sida till disken, och den efterfrÄgade tar dess plats. Denna procedur, som vi snart kommer tillbaka till, kallas swapping. Bilden nedan illustrerar den beskrivna processen. DÀr har sida A med adress 0 laddats och placerats i sidan av huvudminnet med adress 4. Detta faktum har Äterspeglats i kopplingstabellen i cell nummer 0.

Historien för de visade filerna i minnet Ă€r exakt densamma. Logiskt sett placeras de till synes kontinuerligt och helt i det virtuella adresseringsutrymmet. Men i det fysiska minnet kommer de in sida för sida och endast pĂ„ begĂ€ran. Modifiering av sĂ„dana sidor synkroniseras med filen pĂ„ disken. PĂ„ sĂ„ sĂ€tt kan man utföra filinmatning/utmatning, genom att bara arbeta med byte i minnet â alla förĂ€ndringar kommer automatiskt att överföras av kĂ€rnan till den ursprungliga filen.
â
Bilden nedan visar hur LMDB synkroniserar sitt tillstÄnd nÀr den arbetar med en databas frÄn olika processer. Genom att mappa den virtuella minnen av olika processer pÄ samma fil, förbinder vi de facto operativsystemet att transitivt synkronisera vissa block av deras adresseringsutrymmen, som LMDB refererar till.
â

En viktig aspekt Àr att LMDB som standard modifierar datafilen genom systemanropet write, medan sjÀlva filen visas i lÀslÀge (read-only). Denna metod har tvÄ viktiga konsekvenser.
Den första konsekvensen Àr gemensam för alla operativsystem. Dess kÀrna ligger i att lÀgga till skydd mot oavsiktlig skada av databasen frÄn felaktig kod. Som bekant har körbara instruktioner i processen frihet att komma Ät data frÄn vilket stÀlle som helst i dess adressutrymme. Samtidigt, som vi just pÄminde om, innebÀr visning av en fil i lÀs- och skrivlÀge att varje instruktion dessutom kan modifiera den. Om den gör detta av misstag, till exempel genom att försöka skriva över ett arrayelement med ett icke-existerande index, kan den av misstag Àndra den fil som Àr kopplad till den adressen, vilket leder till korruption av databasen. Om filen dÀremot visas i lÀslÀge kommer försöket att Àndra det motsvarande adressutrymmet att orsaka en krasch med en signal. SIGSEGV, och filen förblir intakt.
Den andra konsekvensen Àr specifik för iOS. Varken författaren eller nÄgra andra kÀllor nÀmner den specifikt, men utan den skulle LMDB vara oanvÀndbar pÄ detta mobila operativsystem. Denna frÄga Àgnas nÀsta avsnitt Ät.
Specifikt för minnesavbildade filer i iOS
Under WWDC 2018 hölls en fantastisk presentation . I den beskrivs att i iOS hör alla sidor i det fysiska minnet till en av tre typer: dirty, compressed och clean.

Clean memory â detta Ă€r en samling sidor som kan avlastas frĂ„n det fysiska minnet utan problem. Data som finns dĂ€r kan laddas om vid behov frĂ„n dess ursprungliga kĂ€llor. Read-only memory-mapped filer faller just i denna kategori. iOS har ingen rĂ€dsla för att nĂ€r som helst avlasta avbildade sidor frĂ„n minnet, eftersom de garanterat Ă€r synkroniserade med filen pĂ„ disken.
â
Dirty memory inkluderar alla modifierade sidor oavsett var de ursprungligen lÄg. SÀrskilt sÄ kommer Àven memory-mapped filer som Àndrats genom skrivning till den associerade virtuella minnet att klassificeras sÄ. Genom att öppna LMDB med flaggan MDB_WRITEMAP, kan man personligen försÀkra sig om detta efter att ha gjort Àndringar.
NÀr en app börjar ta upp för mycket fysisk minne komprimerar iOS dess smutsiga sidor. Den totala mÀngden minne som upptas av de smutsiga och komprimerade sidorna kallas appens minnesfotavtryck. NÀr den nÄr en viss tröskel, tar systemdemonen OOM killer över och tvingar den att avslutas. Detta Àr en skillnad mellan iOS och stationÀra operativsystem. Till skillnad frÄn dem tillÄter iOS inte att minnesfotavtrycket minskas genom att byta sidor frÄn det fysiska minnet till disken. Anledningarna Àr endast spekulationer. Kanske Àr sjÀlva processen att flytta sidor till och frÄn disken för energikrÀvande för mobila enheter, eller sÄ sparar iOS resurser i form av att skriva om celler pÄ SSD:er, eller sÄ var systemdesignernas allmÀnna prestandakrav otillfredsstÀllande med tanke pÄ att allt kontinuerligt byts. Oavsett vad Àr faktum ett faktum.
Den goda nyheten, som nÀmnts tidigare, Àr att LMDB som standard inte anvÀnder mmap-mekanismen för att uppdatera filer. Detta innebÀr att de visade data klassificeras av iOS som rent minne och bidrar inte till minnesfotavtrycket. Detta kan bekrÀftas med hjÀlp av ett Xcode-verktyg som heter VM Tracker. PÄ skÀrmdumpen nedan visas det virtuella minnet för iOS-appen Obcloud under drift. Vid starten initialiserades tvÄ LMDB-instans. Den första fick tillstÄnd att mappa sin fil till 1GiB virtuellt minne, den andra till 512MiB. Trots att bÄda lagringarna tar en viss mÀngd resident minne bidrar ingen av dem till det smutsiga storleken.

Och nu Àr det dags för de dÄliga nyheterna. Tack vare bytte av sidor i 64-bitars stationÀra operativsystem kan varje process ta upp sÄ mycket virtuell adresseringsutrymme som det finns ledigt utrymme pÄ hÄrddisken för sin potentiella swap. ErsÀttningen av swapning med kompression i iOS sÀnker radikalt det teoretiska maxvÀrdet. Nu mÄste alla aktiva processer fÄ plats i det primÀra (dvs. RAM) minnet, och allt som inte fÄr plats riskerar att avslutas. Detta nÀmns ocksÄ i den ovannÀmnda. .ts Som ett resultat begrÀnsar iOS strikt storleken pÄ minnet som kan tilldelas via mmap. HÀr Àr Man kan se pÄ de empiriska grÀnserna för minnesvolymer som kunnat allokeras pÄ olika enheter med hjÀlp av detta systemanrop. PÄ de mest moderna iOS-smartphones har det allokerats 2 gigabyte, medan de toppmoderna versionerna av iPad har fÄtt 4. I praktiken mÄste man sjÀlvklart hÄlla sig till de lÀgsta understödda enhetsmodellerna, dÀr det ser ganska dystert ut. VÀrre Àr det att nÀr man tittar pÄ applikationens minnestillstÄnd i VM Tracker, kan man upptÀcka att LMDB inte Àr den enda som Àr ute efter minnesmappat minne. Bra delar tas av systemallokatorer, resursfiler, bildbehandlingsramverk och andra mindre rovdjur.
UtifrÄn experimenten i Molnet har vi kommit fram till följande kompromissvÀrden för tilldelat LMDB-minne: 384 megabyte för 32-bitars enheter och 768 för 64-bitars enheter. Efter att denna volym har förbrukats börjar alla modifierande operationer att avslutas med koden MDB_MAP_FULL. SÄdana fel observerar vi i vÄr övervakning, men de Àr tillrÀckligt fÄ för att vi i detta skede kan bortse frÄn dem.
En icke-uppenbar orsak till överdriven minnesanvÀndning frÄn lagringen kan vara lÄnglivade transaktioner. För att förstÄ hur dessa tvÄ fenomen hÀnger ihop, kan vi titta pÄ de tvÄ andra pelarna i LMDB.
3.2. Pelare â2. B+-trĂ€d
För att emulera tabeller ovanpÄ ett nyckel-vÀrde-lager krÀvs att följande operationer finns i dess API:
- Infoga en ny post.
- Söka efter en post med angiven nyckel.
- Ta bort en post.
- Iterera över nyckelintervall i sorterad ordning.
Den enklaste dat strukturen som lÀtt kan implementera alla fyra operationerna Àr ett binÀrt söktrÀd. Varje nod representerar en nyckel som delar upp alla undergrupper av barnnycklar i tvÄ undertrÀd. Till vÀnster samlas de som Àr mindre Àn förÀldern, och till höger de som Àr större. Att fÄ en ordnad uppsÀttning av nycklar uppnÄs genom en av de klassiska trÀdgenomgÄngarna.
BinÀrtrÀd har tvÄ grundlÀggande nackdelar som förhindrar dem frÄn att vara effektiva som diskdata-strukturer. För det första Àr graden av deras balansering oförutsÀgbar. Det finns en betydande risk att fÄ trÀd dÀr höjden pÄ olika grenar kan skilja sig avsevÀrt, vilket försÀmrar den algoritmiska sökkostnaden jÀmfört med det förvÀntade. För det andra gör riklig korsreferens mellan noder att binÀrtrÀd saknar lokalitet i minnet. NÀra noder (med avseende pÄ deras kopplingar) kan befinna sig pÄ helt olika sidor i det virtuella minnet. Som en följd kan det, Àven för att enkelt gÄ igenom nÄgra angrÀnsande noder i trÀdet, krÀvas att man besöker ett jÀmförbart antal sidor. Detta Àr ett problem Àven nÀr vi diskuterar effektiviteten hos binÀrtrÀd som en in-memory datastruktur, eftersom den stÀndiga rotationen av sidor i processorns cache Àr en kostsam affÀr. NÀr det kommer till frekvent Ätkomst av sidor kopplade till noder frÄn disken, blir situationen Ànnu vÀrre .
B-trÀd, som Àr en evolution av binÀrtrÀd, löser de problem som nÀmndes i föregÄende stycke. För det första Àr de sjÀlvbalanserande. För det andra delar varje nod upp antalet underliggande nycklar inte i 2, utan i M ordnade delmÀngder, dÀr M kan vara ganska stort, ordnar sig i flera hundra eller till och med tusen.
Genom detta:
- Finns det ett stort antal redan ordnade nycklar i varje nod och trÀden blir mycket lÄga.
- TrÀdet fÄr egenskapen av lokalitet i minnet, eftersom nÀra vÀrdena nycklar naturligtvis placeras nÀra varandra pÄ en eller angrÀnsande noder.
- Antalet transit-noder minskar nÀr man gÄr ner i trÀdet under en sökoperation.
- Antalet lÀsta mÄlnoder minskar vid range-frÄgor, eftersom varje nod redan innehÄller ett stort antal ordnade nycklar.

I LMDB anvÀnds en av variationerna av B-trÀd som kallas B+-trÀd för datalagring. PÄ schemat ovan visas tre typer av noder som finns i det.
- I toppen finns roten (root). Den materialiserar inget annat Àn konceptet av en databas inom lagringsutrymmet. Inom en instans av LMDB kan flera databaser skapas som delar det mappade virtuella adressutrymmet mellan sig. Varje databas börjar med sin egen rot.
- PÄ den lÀgsta nivÄn finns bladen (leaf). Det Àr de som enbart innehÄller nyckel-vÀrde-paren som lagras i databasen. Det Àr just detta som Àr den sÀrskilda egenskapen hos B+-trÀd. Om ett vanligt B-trÀd lagrar vÀrdedelarna i noder pÄ alla nivÄer, lagrar B+-varianten dem endast pÄ den lÀgsta nivÄn. NÀr vi har faststÀllt detta faktum, kommer vi hÀrnÀst att kalla den undergrupp av trÀdet som anvÀnds i LMDB för ett enkelt B-trÀd.
- Mellan roten och bladen finns 0 eller fler tekniska nivÄer med navigationsnoder (branch). Deras uppgift Àr att dela upp det sorterade mÀngden nycklar mellan bladen.
Fysiskt Àr noder block av minne med en fördefinierad lÀngd. Deras storlek Àr ett multipel av sidstorleken i operativsystemet, som vi nÀmnde tidigare. Nedan visas en struktur av en nod. I headern finns metadata, dÀr den mest uppenbara för exemplet Àr en kontrollsumma. DÀrefter finns information om offsetar för var datacellerna Àr belÀgna. Som data kan antingen nycklar representeras, om vi pratar om navigationsnoder, eller hela nyckel-vÀrde-par i fallet med bladen. Mer om strukturen av sidor kan lÀsas i arbetet .

NÀr vi har analyserat det interna innehÄllet i nodsidorna kommer vi att representera B-trÀdet av LMDB förenklat pÄ följande sÀtt.

Sidor med noder Àr placerade sekventiellt pÄ disken. Sidor med högre nummer Àr placerade nÀrmare slutet av filen. Den sÄ kallade meta-sidan (meta page) innehÄller information om offsetarna, dÀr man kan hitta rötterna till alla trÀd. NÀr LMDB öppnar filen skannar den sida för sida frÄn slutet till början för att hitta en giltig meta-sida och genom den hitta befintliga databaser.

Nu nÀr vi har en förstÄelse för den logiska och fysiska strukturen av datalagring kan vi gÄ vidare till att diskutera den tredje pelaren i LMDB. Det Àr genom den hÀr som alla modifieringar av lagret sker transaktionellt och isolerat frÄn varandra, vilket ger databasen egenskapen av flerversionering.
3.3. Pelare nr 3. Copy-on-write
Vissa operationer med B-trÀd involverar en serie förÀndringar i dess noder. Ett exempel Àr att lÀgga till en ny nyckel i en nod som redan har nÄtt sin maximala kapacitet. I sÄdana fall mÄste noden, för det första, delas i tvÄ, och för det andra, en lÀnk till den nya, avknoppade undernoden lÀggas till i dess förÀlder. Denna procedur kan potentiellt vara mycket farlig. Om, av nÄgon anledning (krasch, strömavbrott, etc.), endast en del av Àndringarna i serien intrÀffar, förblir trÀdet i ett inkonsekvent tillstÄnd.
En av de traditionella lösningarna för att sĂ€kerstĂ€lla databasens motstĂ„ndskraft mot fel Ă€r att lĂ€gga till en extra disklagringsstruktur bredvid B-trĂ€det â en transaktionslogg, ocksĂ„ kĂ€nd som write-ahead log (WAL). Det Ă€r en fil dĂ€r den tĂ€nkta operationen skrivs innan modifieringen av B-trĂ€det genomförs. SĂ„ledes, om korruption upptĂ€ckts under sjĂ€lvdiagnosen, konsulterar databasen loggen för att Ă„terstĂ€lla sig sjĂ€lv.
LMDB har valt en annan metod för att sÀkerstÀlla motstÄndskraft mot fel, som kallas copy-on-write. KÀrnan i denna metod Àr att istÀllet för att uppdatera data pÄ en befintlig sida, kopieras hela sidan först och alla modifieringar görs sedan i kopian.

För att de uppdaterade data ska vara tillgÀngliga mÄste lÀnken till den aktuella noden Àndras i dess förÀldranod. Eftersom detta ocksÄ krÀver modifiering, kopieras förÀldern ocksÄ först. Processen fortsÀtter rekursivt upp till roten. De sista Àndringarna sker pÄ metasidan.

Om det under uppdateringsprocessen skulle intrÀffa en ovÀntad avslutning av processen, kommer antingen en ny meta-sida inte att skapas, eller sÄ kommer den inte att sparas pÄ disken förrÀn den Àr komplett, och dess kontrollsumma kommer att vara ogiltig. I bÄda dessa fall kommer de nya sidorna att vara ouppnÄeliga, medan de gamla inte kommer att pÄverkas. Detta befriar LMDB frÄn att behöva föra en skriv-efter-logg för att upprÀtthÄlla datakonsistens. Den faktiska datalagringsstrukturen pÄ disken, som beskrivs ovan, tar samtidigt pÄ sig denna funktion. Avsaknaden av en transaktionslogg i tydlig bemÀrkelse Àr en av LMDB:s sÀrdrag som sÀkerstÀller en hög lÀshastighet.

Den resulterande strukturen, kallad append-only B-tree, sÀkerstÀller naturligt transaktionsisolering och multi-versionedness. I LMDB Àr varje öppen transaktion kopplad till den aktuella rot-strukturen av trÀdet. SÄ lÀnge transaktionen inte Àr fullbordad, kommer sidorna i det relaterade trÀdet aldrig att Àndras eller ÄteranvÀndas för nya versioner av data. DÀrmed kan man arbeta med exakt det dataset som var aktuellt nÀr transaktionen öppnades, Àven om lagringsutrymmet samtidigt fortsÀtter att uppdateras aktivt. Detta Àr kÀrnan i multi-versionedness, vilket gör LMDB till en idealisk datakÀlla för vÄra Àlskade. UICollectionViewGenom att öppna en transaktion behöver man inte öka appens minnesanvÀndning hastigt genom att snabbt hÀmta aktuella data till nÄgon in-memory-struktur, av rÀdsla för att bli lÀmnad utan nÄgot. Denna funktion skiljer LMDB frÄn SQLite, som inte kan stoltsera med en sÄdan total isolering. Om man i det senare öppnar tvÄ transaktioner och tar bort en post inom en av dem, kommer samma post inte att kunna nÄs inom den andra kvarvarande.
Nackdelen Àr den potentiellt mycket större konsumtionen av virtuell minne. PÄ bilden visas hur databasstrukturen kommer att se ut om den modifieras samtidigt med 3 öppna lÀs-transaktioner som tittar pÄ olika versioner av databasen. Eftersom LMDB inte kan ÄteranvÀnda noder som nÄs frÄn rötterna kopplade till aktuella transaktioner, finns det inget annat alternativ för lagringen Àn att lÀgga till en fjÀrde rot i minnet och Äterigen klona de förÀnderliga sidorna.

Det kan vara bra att pÄminna sig om avsnittet om minnesmappade filer. Den extra konsumtionen av virtuell minne borde egentligen inte bekymra oss mycket, eftersom den inte bidrar till applikationens minnesfotavtryck. Samtidigt har det noterats att iOS Àr mycket snÄl med tilldelning av detta, och vi kan inte, som pÄ en server eller dator, utan vidare tillhandahÄlla LMDB med ett omrÄde pÄ 1 terabyte och helt ignorera denna egenskap. SÄ lÄngt som möjligt bör vi strÀva efter att göra transaktionernas livslÀngd sÄ kort som möjligt.
4. Utformning av databasstruktur ovanpÄ key-value API
Vi börjar analysen av API:t med att granska de grundlÀggande abstraktionerna som tillhandahÄlls av LMDB: miljön och databaser, nycklar och vÀrden, transaktioner och markörer.
Kommentarer till kodlistningar
Alla funktioner i LMDB:s offentliga API returnerar resultatet av sitt arbete i form av en felkod, men i alla följande listningar har kontrollen över detta utelÀmnats för att öka kortfattadheten. I praktiken har vi helt och hÄllet anvÀnt vÄr C++-wrapper , dÀr fel materialiseras i form av C++-undantag.
Som det snabbaste sÀttet att ansluta LMDB till ett projekt för iOS eller macOS föreslÄr jag min CocoaPod .
4.1. GrundlÀggande abstraktioner
Miljö (environment)
Struktur MDB_env Àr behÄllaren för LMDB:s interna tillstÄnd. Funktionen med prefix mdb_env möjliggör konfiguration av vissa av dess egenskaper. I det enklaste fallet kan initialisering av motorn se ut sÄ hÀr.
mdb_env_create(env);â
mdb_env_set_map_size(*env, 1024 * 1024 * 512)â
mdb_env_open(*env, path.UTF8String, MDB_NOTLS, 0664);I Mail.ru:s Cloud-applikation Àndrade vi standardvÀrdena för endast tvÄ parametrar.
Den första av dem Ă€r storleken pĂ„ det virtuella adressutrymmet som filsystemet tilldelas. TyvĂ€rr kan det specifika vĂ€rdet, Ă€ven pĂ„ samma enhet, variera kraftigt frĂ„n gĂ„ng till gĂ„ng. För att ta hĂ€nsyn till denna egenskap hos iOS vĂ€ljs den maximala lagringskapaciteten dynamiskt. Börjar frĂ„n ett visst vĂ€rde halveras det successivt tills funktionen mdb_env_open inte returnerar ett resultat som Ă€r olika frĂ„n ENOMEM. I teorin finns det en motsatt vĂ€g â att först tilldela motorn en minimal mĂ€ngd minne och sedan, vid mottagna fel, öka det. Men det Ă€r mycket mer komplicerat. Orsaken Ă€r att proceduren för minnes omallokering (remap) med hjĂ€lp av funktionen MDB_MAP_FULLmdb_env_set_map_size ogiltigförklarar alla entiteter (pekare, transaktioner, nycklar och vĂ€rden) som tidigare fĂ„tts frĂ„n motorn. Att ta hĂ€nsyn till denna vĂ€ndning i koden skulle leda till betydande komplexitet. Om det virtuella minnet dock Ă€r mycket viktigt för dig, kan det vara en anledning att titta pĂ„ en fork som kommit lĂ„ngt fram, dĂ€r en av de utlovade funktionerna Ă€r "automatisk justering av databasens storlek i realtid". Den andra parametern, vars standardvĂ€rde inte passade oss, reglerar mekaniken för trĂ„dsĂ€kerhet. TyvĂ€rr finns det minst ett problem med trĂ„d lokal lagring i iOS 10. PĂ„ grund av detta öppnas lagringen i det ovanstĂ„ende exemplet med flaggan
MDB_NOTLS . Dessutom behövdes ocksÄ enfork , för att ta bort variabler med denna attribut i den. Databasen Àr en separat instans av ett B-trÀd, som vi diskuterade tidigare. Dess öppnande sker inom en transaktion, vilket först kan verka lite konstigt.
Databaser
MDB_txn *txn;â MDB_dbi dbi;â mdb_txn_begin(env, NULL, MDB_RDONLY, &txn);â mdb_dbi_open(txn, NULL, MDB_CREATE, &dbi);â mdb_txn_abort(txn);
I sjÀlva verket Àr en transaktion i LMDB en lagringselement, och inte en specifik databas. Denna konceptualisering möjliggör atomÀra operationer pÄ entiteter som finns i olika databaser. I teorin öppnar detta möjligheten att modellera tabeller som olika databaser, men jag valde en annan vÀg som beskrivs i detalj nedan.Nycklar och vÀrden
MDB_val
Struktur MDB_val modellera konceptet av bÄde nyckel och vÀrde. Lagringen har ingen som helst förstÄelse för deras semantik. För den Àr det bara en array av byte med en viss storlek. Maximal storlek för nyckeln Àr 512 byte.
typedef struct MDB_val {â
size_t mv_size;â
void *mv_data;â
} MDB_val;ââMed hjĂ€lp av en komparator ordnar lagringen nycklarna i stigande ordning. Om man inte ersĂ€tter den med en egen kommer den som anvĂ€nds vara standardkomparatorn, som sorterar dem byte-för-byte i leksikografisk ordning.â
Transaktioner
Transaktionssystemet beskrivs ingÄende i , sÄ hÀr kommer jag kort att Äterge deras huvudegenskaper:
- Stöd för alla grundlÀggande egenskaper : atomicitet, konsistens, isolering och tillförlitlighet. Jag kan inte lÄta bli att nÀmna att det finns en bugg gÀllande hÄllbarhet pÄ macOS och iOS som har ÄtgÀrdats i MDBX. Mer information kan lÀsas i deras .
- TillvÀgagÄngssÀttet för flervalsverksamhet beskrivs med schemat "en skribent / flera lÀsare". Skribenter blockerar varandra, men blockerar inte lÀsarna. LÀsarna blockerar varken skribenter eller varandra.
- Stöd för inbÀddade transaktioner.
- Stöd för flerversionersystem.
Flerversionersystemet i LMDB Àr sÄ bra att jag vill visa det i aktion. FrÄn koden nedan kan vi se att varje transaktion arbetar med den version av databasen som var aktuell vid tidpunkten för dess öppnande, helt isolerad frÄn alla efterföljande förÀndringar. Initieringen av lagringen och tillÀggandet av en testpost erbjuder inget intressant, sÄ dessa ritualer har lÀmnats under en spoiler.
TillÀgg av testpost
MDB_env *env;
MDB_dbi dbi;
MDB_txn *txn;
mdb_env_create(&env);
mdb_env_open(env, ".\/testdb", MDB_NOTLS, 0664);
mdb_txn_begin(env, NULL, 0, &txn);
mdb_dbi_open(txn, NULL, 0, &dbi);
mdb_txn_abort(txn);
char k = 'k';
MDB_val key;
key.mv_size = sizeof(k);
key.mv_data = (void *)&k;
int v = 997;
MDB_val value;
value.mv_size = sizeof(v);
value.mv_data = (void *)&v;
mdb_txn_begin(env, NULL, 0, &txn);
mdb_put(txn, dbi, &key, &value, MDB_NOOVERWRITE);
mdb_txn_commit(txn);MDB_txn *txn1, *txn2, *txn3;
MDB_val val;
// Ăppnar 2 transaktioner, var och en av dem tittar
// pÄ databasens version med en post.
mdb_txn_begin(env, NULL, 0, &txn1); // lÀs- och skrivlÀge
mdb_txn_begin(env, NULL, MDB_RDONLY, &txn2); // endast lÀsning
// Inom ramen för den första transaktionen tar vi bort en existerande post frÄn databasen.
mdb_del(txn1, dbi, &key, NULL);
// BekrÀftar borttagningen.
mdb_txn_commit(txn1);
// Ăppnar den tredje transaktionen som tittar pĂ„
// den aktuella versionen av databasen, dÀr posten inte lÀngre finns.
mdb_txn_begin(env, NULL, MDB_RDONLY, &txn3);
// Vi bekrÀftar att posten med den efterfrÄgade nyckeln inte lÀngre existerar.
assert(mdb_get(txn3, dbi, &key, &val) == MDB_NOTFOUND);
// Avslutar transaktionen.
mdb_txn_abort(txn3);
// Vi ser till att vi fortfarande kan hitta posten med nyckeln inom den andra transaktionen, som öppnades medan
// posten fanns i databasen.
assert(mdb_get(txn2, dbi, &key, &val) == MDB_SUCCESS);
// Kontrollerar att vi fÄr giltiga data och inte bara skrÀp.
assert(*(int *)val.mv_data == 997);
// Avslutar transaktionen som Àven om den arbetar med en förÄldrad, Àr en konsekvent databas.
mdb_txn_abort(txn2);Jag rekommenderar att du ocksÄ försöker utföra samma trick med SQLite och se vad som hÀnder.
Multiversionering tillför mycket trevliga fördelar för iOS-utvecklare. Med hjÀlp av denna egenskap kan man enkelt och utan besvÀr reglera hastigheten för uppdatering av datakÀllan för skÀrmkontroller, med hÀnsyn till anvÀndarupplevelsen. LÄt oss ta en funktion i appen Mail.ru Cloud som exempel, nÀmligen automatisk nedladdning av innehÄll frÄn det systemmedialivet. Med en bra anslutning kan klienten lÀgga till flera bilder pÄ servern varje sekund. Om vi efter varje uppladdning uppdaterar UICollectionView med mediekontot i anvÀndarens moln, kan vi glömma bort 60 fps och smidig rullning under denna process. För att förhindra frekventa skÀrmutdragningar mÄste vi pÄ nÄgot sÀtt begrÀnsa hastigheten för databasens Àndringar i grunden. UICollectionViewDataSource.
Om databasen inte stödjer fler versioner och endast tillÄter arbete med den aktuella versionen, mÄste en stabil tidsbild av data skapas genom att kopiera den antingen till en in-memory datastuktur eller till en temporÀr tabell. BÄda dessa metoder Àr mycket kostsamma. I fallet med en in-memory lagring fÄr vi kostnader bÄde för minnet, som orsakas av lagring av konstruerade objekt, och för tiden, kopplad till överflödiga ORM-konverteringar. NÀr det gÀller temporÀra tabeller Àr det Ànnu dyrare, vilket endast har mening i icke-triviala fall.
Fler versioner av LMDB löser problemet med att upprĂ€tthĂ„lla en stabil datakĂ€lla pĂ„ ett mycket elegant sĂ€tt. Det rĂ€cker med att öppna en transaktion och voilĂ â sĂ„ lĂ€nge vi inte avslutar den, Ă€r vĂ„r dataset garanterat fixerat. Logiken för hastigheten av dess uppdatering ligger nu helt och hĂ„llet i hĂ€nderna pĂ„ presentationsskiktet, utan betydande resurskostnader.
Kursorer
Kursorer tillhandahÄller en mekanism för ordnad iterering över nyckel-vÀrde-par genom att traversera ett B-trÀd. Utan dem skulle det vara omöjligt att effektivt modellera tabeller i databasen, som vi nu gÄr vidare till.
4.2. Modellering av tabeller
Egenskapen av ordning i nycklarna möjliggör att strukturera över grundlÀggande abstraktioner en högre nivÄ som en tabell. Vi ska övervÀga denna process med exemplet av huvudtabellen för en molnkund, dÀr information om alla anvÀndarens filer och mappar Àr cachad.
Tabellschema
Ett av de vanliga scenarierna som tabellstrukturen med mapptrÀd mÄste anpassas för Àr att hÀmta alla element inom en given katalog. En bra datamodell för effektiva frÄgor av denna typ Àr . För att implementera den ovanpÄ ett key-value lagringssystem mÄste nycklarna för filer och mappar sorteras sÄ att de grupperas baserat pÄ tillhörighet till förÀlder-katalogen. Dessutom, för att visa innehÄllet i katalogen i en bekant Windows-stil (först mappar, sedan filer, och bÄda sorterade alfabetiskt), Àr det nödvÀndigt att inkludera motsvarande ytterligare fÀlt i nyckeln.
PÄ bilden nedan visas hur representationen av nycklar i form av en byte-array kan se ut, baserat pÄ den uppsatta uppgiften. Först placeras bytes med identifikatorn för den överordnade katalogen (röda), sedan med typen (gröna) och till sist med namnet (blÄa). NÀr de sorteras av LMDB:s standardkomparator i lexikografisk ordning, organiseras de pÄ det önskade sÀttet. Sekventiell genomgÄng av nycklar med samma röda prefix ger oss de relaterade vÀrdena i den ordning de ska presenteras i anvÀndargrÀnssnittet (till höger), utan att krÀva nÄgon ytterligare efterbehandling.

Serialisering av nycklar och vÀrden
Det finns mĂ„nga metoder för att serialisera objekt. Eftersom vi inte hade nĂ„got annat krav Ă€n hastighet valde vi den snabbaste metoden â dumpning av minnet som anvĂ€nds av instansen av C-sprĂ„kets struktur. SĂ„ledes kan nyckeln till en katalogelement modelleras med följande struktur NodeKey.
typedef struct NodeKey {â
EntityId parentId;â
uint8_t type;â
uint8_t nameBuffer[256];â
} NodeKey;För att spara NodeKey i lagret mÄste i objektet MDB_val pekar pekaren pÄ data adressen för början av strukturen, och dess storlek berÀknas med funktionen sizeof.
MDB_val serialize(NodeKey * const key) {
return MDB_val {
.mv_size = sizeof(NodeKey),
.mv_data = (void *)key
};
}I det första kapitlet om kriterier för val av databas nĂ€mnde jag att minimalisering av dynamisk allokering Ă€r en viktig faktor. Funktionen serialize visar hur man i fallet med LMDB kan undvika dem helt vid insĂ€ttning av nya poster i databasen. Den inkommande byte-arrayen frĂ„n servern transformeras först till stackstrukturer, och sedan dumpas de trivialt i lagret. Med tanke pĂ„ att det heller inte finns nĂ„gra dynamiska allokeringar inom LMDB, kan man uppnĂ„ en fantastisk situation i termer av iOS â att arbeta med data pĂ„ hela deras vĂ€g frĂ„n nĂ€tverket till disken med enbart stackminne!
Sortering av nycklar med en binÀr komparator
Relation of the key order is set by a special function called a comparator. Since the engine knows nothing about the semantics of the bytes contained within them, the default comparator has no choice but to sort the keys in lexicographical order, resorting to byte-by-byte comparison. Using it for sorting structures is akin to shaving with a cleaver. Nevertheless, in simple cases, I find this method acceptable. The alternative is described a bit further below, and here I will note a couple of bumps along this path.
The first thing to remember is the representation of primitive data types in memory. For example, on all Apple devices, integer variables are stored in the format . This means that the least significant byte will be on the left, and sorting whole numbers by using byte-by-byte comparison will not work. For instance, attempting to do this with a set of numbers from 0 to 511 will yield the following result.
// value (hex dump)
000 (0000)
256 (0001)
001 (0100)
257 (0101)
...
254 (fe00)
510 (fe01)
255 (ff00)
511 (ff01)To solve this problem, whole numbers must be stored in the key in a format suitable for byte-by-byte comparators. The necessary transformation can be accomplished with functions from the hton* family (in particular, htons for the two-byte numbers in the example).
The representation format of strings in programming is, as is known, entirely . If the semantics of the strings and the encoding used for their representation in memory suggest that more than one byte can correspond to a character, then it is better to abandon the idea of using the default comparator right away.
The second thing to keep in mind is the of the compiler for structure fields. Due to this, there may be bytes with garbage values in memory between fields, which, of course, breaks byte-wise sorting. To eliminate the garbage, you either need to declare the fields in a strictly defined order, keeping the alignment rules in mind, or use the attribute packed.
Sorting keys with an external comparator
The logic of comparing keys may prove too complex for a binary comparator. One of many reasons is the presence of technical fields within the structures. I will illustrate their occurrence using the already familiar key for a directory element.
typedef struct NodeKey {â
EntityId parentId;â
uint8_t type;â
uint8_t nameBuffer[256];â
} NodeKey;Trots sin enkelhet konsumerar den i de flesta fall för mycket minne. Buffertnamnet tar upp 256 byte, Àven om genomsnittligt fil- och mappnamn sÀllan överstiger 20-30 tecken.
Ett av de standardverktyg för att optimera storleken pÄ en post bestÄr av att "beskurna" den till den faktiska storleken. Grundidén Àr att innehÄllet i alla fÀlt av variabel lÀngd lagras i bufferten i slutet av strukturen, medan deras lÀngder lagras i separata variabler. I enlighet med detta tillvÀgagÄngssÀtt transformeras nyckeln NodeKey pÄ följande sÀtt.
typedef struct NodeKey {
EntityId parentId;
uint8_t type;
uint8_t nameLength;
uint8_t nameBuffer[256];
} NodeKey;Vid serialisering anges inte storleken pÄ hela strukturen, utan storleken pÄ alla fÀlt med fast lÀngd plus storleken av den faktiskt anvÀnda delen av bufferten. sizeof MDB_val serialize(NodeKey * const key) { return MDB_val { .mv_size = offsetof(NodeKey, nameBuffer) + key->nameLength, .mv_data = (void *)key }; }
Som ett resultat av den utförda refaktoriseringen fick vi en betydande minskning av det minnesutrymme som upptas av nycklar. Men pÄ grund av det tekniska fÀltetnameLength , Àr den standard binÀra komparatorn inte lÀngre lÀmplig för jÀmförelse av nycklar. Om vi inte byter ut den mot vÄr egen, kommer namnlÀngden att bli en mer prioriterad faktor vid sortering Àn sjÀlva namnet.LMDB tillÄter att varje databas fÄr sin egen nyckeljÀmförelsefunktion. Detta görs med hjÀlp av funktionen
mdb_set_compare strÀngt före databasen öppnas. Av uppenbara skÀl kan den inte Àndras under databasens livstid. Som indata fÄr komparatorn tvÄ nycklar i binÀrt format och returnerar pÄ utdata resultatet av jÀmförelsen: mindre (-1), större (1) eller lika (0). Pseudokoden för ser ut sÄ hÀr. NodeKey int compare(MDB_val * const a, MDB_val * const b) { NodeKey * const aKey = (NodeKey * const)a->mv_data; NodeKey * const bKey = (NodeKey * const)b->mv_data; return // ... }
SÄ lÀnge som alla nycklar i databasen har samma typ Àr det fullt legitimt att obetingat konvertera deras byte-representation till typ av den tillÀmpade nyckelstrukturen. Det finns en nyans hÀr, men den kommer att behandlas lite senare i avsnittet "LÀsa poster".Serialisering av vÀrden
ĐĄĐ”ŃОалОзаŃĐžŃ Đ·ĐœĐ°ŃĐ”ĐœĐžĐč
Med nycklarna till LMDB:s lagrade poster arbetar man extremt intensivt. JÀmförelsen mellan dem sker i samband med varje applikationsoperation, och hastigheten hos komparatorn avgör prestandan för hela lösningen. I en perfekt vÀrld skulle den standard binÀra komparatorn vara tillrÀcklig för att jÀmföra nycklar, men om man mÄste anvÀnda sin egen, bör deserialiseringsprocessen av nycklar vara sÄ snabb som möjligt.
VÀrdedelen av posten (vÀrdet) intresserar databasen i stort sett inte. Omvandlingen frÄn byterepresentation till objekt sker endast nÀr det redan krÀvs av applikationskoden, till exempel för att visas pÄ skÀrmen. Eftersom detta sker relativt sÀllan, Àr kraven pÄ hastigheten för denna procedur inte lika kritiska, och i dess implementation har vi mycket större frihet att fokusera pÄ anvÀndarvÀnlighet. Till exempel, för serialisering av metadata om Ànnu icke-inlÀsta filer anvÀnder vi NSKeyedArchiver.
NSData *data = serialize(object);â
MDB_val value = {â
.mv_size = data.length,â
.mv_data = (void *)data.bytesâ
};Det finns dock fall nÀr prestandan fortfarande har betydelse. Till exempel, nÀr man sparar metainformation om filstrukturen i anvÀndarens moln anvÀnder vi fortfarande samma dump av objektens minne. Det unika med uppgiften att skapa deras serialiserade representation Àr det faktum att delarna av en katalog modelleras med en hierarki av klasser.

För att implementera detta pÄ C-sprÄket flyttas specifika fÀlt frÄn arvklasser till separata strukturer, och deras koppling till basen faststÀlls genom fÀltet av typen union. Det aktuella innehÄllet i unionen anges genom en teknisk attributtyp.
typedef struct NodeValue {â
EntityId localId;â
EntityType type;â
union {â
FileInfo file;â
DirectoryInfo directory;â
} info;â
uint8_t nameLength;â
uint8_t nameBuffer[256];â
} NodeValue;âLĂ€gg till och uppdatera poster
De serialiserade nycklarna och vÀrdena kan lÀggas till lagringen. För detta anvÀnds funktionen mdb_put.
// key Đž value ĐžĐŒĐ”ŃŃ ŃОп MDB_valâ
mdb_put(..., &key, &value, MDB_NOOVERWRITE);Under the configuration stage, the storage can allow or deny storing multiple records with the same key. If key duplication is prohibited, when inserting a record, it can be determined whether updating an existing record is permissible or not. If overwriting can only occur due to a code error, it can be safeguarded by setting a flag. NOOVERWRITE.
Reading Records
The function designed for reading records in LMDB is mdb_get. If the key-value pair has been previously represented by dumped structures, this procedure appears as follows.
NodeValue * const readNode(..., NodeKey * const key) {
MDB_val rawKey = serialize(key);
MDB_val rawValue;
mdb_get(..., &rawKey, &rawValue);
return (NodeValue * const)rawValue.mv_data;
}The presented listing demonstrates how serialization through dumped structures helps avoid dynamic allocations not only when writing but also when reading data. The pointer obtained from the function mdb_get points exactly to the address in virtual memory where the database stores the byte representation of the object. In effect, we achieve a kind of ORM that provides very high reading speeds almost for free. Despite the beauty of the approach, it is necessary to remember several associated peculiarities.
- For readonly transactions, the pointer to the value-structure will remain valid only until the transaction is closed. As previously noted, the B-tree pages on which the object resides remain unchanged due to the copy-on-write principle as long as at least one transaction refers to them. However, once the last associated transaction is completed, those pages can be reused for new data. If it is necessary for the objects to outlive the transaction that created them, they will still have to be copied.
- For readwrite transactions, the pointer to the obtained value-structure will remain valid only until the first modifying operation (writing or deleting data).
- Despite the fact that the structure
NodeValueis not complete but trimmed (see the subsection âOrdering keys with an external comparatorâ), one can safely access its fields through the pointer. Just do not dereference it! - Under no circumstances should the structure be modified via the obtained pointer. All changes must be made only through the method
mdb_put. However, even with the desire to do so, it will not be possible, since the memory area where this structure is located is mapped in readonly mode. - Remapping the file to the address space of the process with the aim, for example, of increasing the maximum storage size using the function
ogiltigförklarar alla entiteter (pekare, transaktioner, nycklar och vÀrden) som tidigare fÄtts frÄn motorn. Att ta hÀnsyn till denna vÀndning i koden skulle leda till betydande komplexitet. Om det virtuella minnet dock Àr mycket viktigt för dig, kan det vara en anledning att titta pÄ en fork som kommit lÄngt fram,completely invalidates all transactions and the related entities in general, and the pointers to the read objects in particular.
Finally, another feature is so insidious that revealing its essence cannot simply fit into another point. In the chapter on B-trees, I provided a schematic representation of its pages in memory. From it, it follows that the address of the beginning of the buffer with serialized data can be completely arbitrary. Because of this, the pointer to them, obtained in the structure MDB_val and converted to a pointer to the structure, is generally not aligned. At the same time, the architectures of some chips (in the case of iOS, this is armv7) require that any data address be a multiple of the size of the machine word or, in other words, the bitness of the system (for armv7, this is 32 bits). In other words, an operation like *(int *foo)0x800002 is equated to an escape and leads to execution with the verdict EXC_ARM_DA_ALIGN. Such a sad fate can be avoided in two ways.
The first is to copy the data in advance to a deliberately aligned structure. For example, on a custom comparator, this will be reflected as follows.
int compare(MDB_val * const a, MDB_val * const b) {
NodeKey aKey, bKey;
memcpy(&aKey, a->mv_data, a->mv_size);
memcpy(&bKey, b->mv_data, b->mv_size);
return
// ...
}An alternative approach is to notify the compiler in advance that the structures with key and value may be unaligned using the attribute aligned(1). On ARM, the same effect can be achieved using the packed attribute. Considering that it also contributes to the optimization of the space occupied by the structure, I see this method as preferable, even though it leads to increased costs for data access operations.
typedef struct __attribute__((packed)) NodeKey {
uint8_t parentId;
uint8_t type;
uint8_t nameLength;
uint8_t nameBuffer[256];
} NodeKey;Range queries
För iteration genom en uppsÀttning av poster i LMDB finns det en abstraktion av en kursör. Hur man arbetar med den kommer vi att titta pÄ med exemplet av vÄr bekanta anvÀndartabell med metadata.
Inom ramen för att visa en lista över filer i en katalog mÄste vi hitta alla nycklar som Àr associerade med dess underliggande filer och mappar. I de föregÄende avsnitten har vi sorterat nycklarna NodeKey sÄ att de i första hand Àr ordnade efter identifieraren för den överordnade katalogen. Tekniskt sett innebÀr uppgiften att fÄ innehÄllet i en mapp att stÀlla in kursorn pÄ den övre grÀnsen för nyckelgruppen med en given prefix och sedan iterera till den nedre grÀnsen.

Den övre grÀnsen kan hittas direkt genom en sekventiell sökning. För att göra detta stÀlls kursorn i början av hela nyckellistan i databasen och inkrementeras tills den under den hittar nyckeln med identifieraren för den överordnade katalogen. Denna metod har tvÄ uppenbara nackdelar:
- Den linjÀra sökkomplexiteten, Àven om det Àr kÀnt att i trÀd, och speciellt i B-trÀd, kan det genomföras pÄ logaritmisk tid.
- Det Àr onödigt att hÀmta alla sidor som föregÄr den sökta nyckeln till huvudminnet, vilket Àr mycket kostsamt.
Lyckligtvis finns det i LMDB API ett effektivt sÀtt att initialt positionera kursorn. För detta behöver vi skapa en sÄdan nyckel vars vÀrde kommer vara mindre Àn eller lika med nyckeln pÄ den övre grÀnsen av intervallet. Till exempel, med avseende pÄ listan i bilden ovan kan vi skapa en sÄdan nyckel dÀr fÀltet parentId Àr lika med 2, medan alla andra fÀlt Àr fyllda med nollor. Denna delvis ifyllda nyckel matare in i funktionen mdb_cursor_get anges operationen MDB_SET_RANGE.
NodeKey upperBoundSearchKey = {â
.parentId = 2,â
.type = 0,â
.nameLength = 0â
};â
MDB_val value, key = serialize(upperBoundSearchKey);â
MDB_cursor *cursor;â
mdb_cursor_open(..., &cursor);â
mdb_cursor_get(cursor, &key, &value, MDB_SET_RANGE);Om den övre grÀnsen för nyckelgruppen har hittats, itererar vi sedan över den tills vi antingen hittar en nyckel med en annan parentId, eller nycklarna Àr slut.
do {â
rc = mdb_cursor_get(cursor, &key, &value, MDB_NEXT);â
// bearbetning...â
} while (MDB_NOTFOUND != rc && // kontrollera slutet av tabellenâ
IsTargetKey(key)); // kontrollera slutet av nyckelgruppenââDet Ă€r trevligt att mdb_cursor_get-itereringen inte bara ger oss nyckeln utan ocksĂ„ vĂ€rdet. Om vi behöver kontrollera fĂ€lten i vĂ€rdedelen av posten för att uppfylla urvals kriterierna, sĂ„ Ă€r de helt och hĂ„llet tillgĂ€ngliga utan extra Ă„tgĂ€rder.
4.3. Modellering av relationer mellan tabeller
Vid detta tillfÀlle har vi kunnat granska alla aspekter av design och arbete med en engÄngstabell databas. Man kan sÀga att en tabell Àr en uppsÀttning sorterade poster som bestÄr av homogena nyckel-vÀrde-par. Om man förestÀller sig nyckeln som en rektangel och dess relaterade vÀrde som en parallelepiped, fÄr man en visuell schema av databasen.
â
![]()
Men i verkliga livet Àr det sÀllan möjligt att klara sig med sÄ fÄ resurser. Ofta krÀvs det i databasen, för det första, att ha flera tabeller, och för det andra, att genomföra urval i en ordning som skiljer sig frÄn den primÀra nyckeln. FrÄgorna om att skapa dem och koppla dem samman Àr vad det hÀr sista avsnittet handlar om.
IndextablÄer
I det molnbaserade programmet finns det en sektion "Galleri". I den visas mediainnehÄll frÄn hela molnet, sorterat efter datum. För att optimalt genomföra en sÄdan urval behöver man skapa en ytterligare tabell med en ny typ av nycklar bredvid den huvudsakliga tabellen. I dessa kommer det att finnas ett fÀlt med datumet för filens skapande, vilket kommer att fungera som den primÀra sorteringskriteriet. Eftersom de nya nycklarna refererar till samma data som nycklarna i den huvudsakliga tabellen, kallas de indexnycklar. PÄ bilden nedan Àr de markerade i orange.

För att sÀrskilja nycklarna frÄn olika tabeller inom en enda databas har ett ytterligare tekniskt fÀlt, tableId, lagts till alla nycklar. Genom att göra detta till det mest prioriterade för sortering, uppnÄr vi gruppering av nycklar först efter tabeller och sedan inom tabellerna enligt sina egna regler.
Indexnyckeln refererar till samma data som den primÀra nyckeln. En rak implementation av denna egenskap genom att associera den med en kopia av vÀrdedelen frÄn den primÀra nyckeln Àr inte optimalt frÄn flera perspektiv:
- FrÄn perspektivet av upptagen utrymme, eftersom metadata kan vara ganska rika.
- FrÄn perspektivet av prestanda, eftersom vid uppdatering av metadata mÄste noderna skriva om pÄ tvÄ nycklar.
- Ur kodstödets perspektiv, sÄ fort vi glömmer att uppdatera data för en av nycklarna, fÄr vi en svÄrupptÀckbar bugg av datainkonsistens i lagret.
Vi gÄr vidare för att diskutera hur vi kan ÄtgÀrda dessa brister.
Organisering av relationer mellan tabeller
För att koppla ihop indexmönstret med huvudtabellen Àr mönstret som passar bra "nyckel som vÀrde". Som namnet antyder Àr en kopia av vÀrdet av den primÀra nyckeln vÀrde-delen av indexposten. Denna metod eliminerar alla de nÀmnda bristerna kopplade till lagring av en kopia av vÀrde-delen av den primÀra posten. Den enda kostnaden Àr att för att hÀmta vÀrdet med den indexnyckeln, behöver vi göra 2 förfrÄgningar till databasen istÀllet för en. Schemat för den resulterande databasen ser ut som följer.

Ănnu ett mönster för att organisera relationer mellan tabeller Ă€r "överskott nyckel". KĂ€rnan Ă€r att lĂ€gga till ytterligare attribut i nyckeln, som inte behövs för sortering, utan för att Ă„terskapa den relaterade nyckeln. I Mail.ru:s Cloud-applikation finns det verkliga exempel pĂ„ dess anvĂ€ndning, men för att undvika att gĂ„ för djupt in i kontexten av specifika iOS-ramverk, kommer jag att ge ett pĂ„hittat, men tydligare exempel.
I de molnbaserade mobilklienterna finns det en sida dÀr alla filer och mappar visas som anvÀndaren har gett tillgÄng till andra personer. Eftersom det finns relativt fÄ sÄdana filer, men mycket specifik information om deras offentlighet (vem som har fÄtt tillgÄng, med vilka rÀttigheter etc.), skulle det vara oekonomiskt att tynga vÀrde-delen av posten i huvudtabellen med detta. Men om man vill visa sÄdana filer offline, behöver man förvara den nÄgonstans. En naturlig lösning Àr att skapa en separat tabell för den. I diagrammet nedan har dess nyckel prefixen "P", och platsinnehÄllaren "propname" kan ersÀttas med ett mer konkret vÀrde "offentlig information".

All unique metadata for which a new table was created is contained in the value part of the record. At the same time, we do not wish to duplicate the data about files and folders that are already stored in the main table. Instead, redundant data in the form of the fields 'node ID' and 'timestamp' is added to the key 'P'. Thanks to them, we can construct an index key that allows us to obtain the primary key, which can finally retrieve the node's metadata.
Slutsats
We evaluate the results of implementing LMDB positively. After its introduction, the number of application hangs decreased by 30%.

The results of the work carried out have resonated beyond the iOS team. Currently, one of the main sections 'Files' in the Android application has also switched to using LMDB, while other parts are on the way. The C language, in which the key-value storage is implemented, proved to be a good support for initially creating a cross-platform wrapper around it in C++. A code generator was used for seamless integration of the resulting C++ library with the platform code in Objective-C and Kotlin. from Dropbox, but that's a different story.
KĂ€lla: habr.com
