SQL, NoSQL, ja miksi aloitin Kanta DB:n

SQL on edelleen oletusvalinta syystÀkin


Jos rakentaisin tÀnÀÀn tavanomaista Python-palvelua, aloittaisin luultavasti PostgreSQL:llÀ, SQLAlchemyllÀ ja Alembicilla. PostgreSQL antaa minulle oikeat UUID:t, JSONB:n, taulukot, transaktiot, rajoitteet ja erinomaisen indeksoinnin. SQLAlchemy kuvaa suurimman osan tÀstÀ siististi Pythoniin. Alembic pitÀÀ skeemamuutokset eksplisiittisinÀ ja versioituina.

TÀllÀ pÀÀsee melko pitkÀlle ennen kuin mikÀÀn alkaa ÀrsyttÀÀ minua.


 mutta mallissa on reunaehtoja

Tietokannan skeema ja Python-tietorakenne eivÀt ole aivan sama asia.

SQLAlchemyllÀ voin mÀÀritellÀ tyypitettyjÀ malleja, kÀyttÀÀ uuid.UUID:tÀ suoraan PostgreSQL:n UUID-sarakkeita vasten, kuvata JSONB:n Python-sÀiliöihin ja pitÀÀ suurimman osan rutiinimuunnoksista poissa omasta koodistani. TÀmÀ on huomattavasti parempaa kuin jokaisen tietokanta-arvon kÀsitteleminen merkkijonona tai SQL:n manuaalinen kasaaminen.

SiitÀ huolimatta ORM-mallista tulee erÀÀnlainen erityisolio. Se kantaa sarakkeita, relaatioita, session kÀyttÀytymistÀ ja pysyvyyden sÀÀntöjÀ. Jos muu ohjelma haluaa tavallisia sovellusrakenteita, joko annan tietokanta-asioiden levitÀ ulospÀin tai lisÀÀn uuden muunnoskerroksen.

Ongelma nÀkyy selvemmin, kun skeema muuttuu.

KentÀn lisÀÀminen Python-rakenteeseen tuntuu triviaalilta. Sarakkeen lisÀÀminen pysyviin tietoihin tarkoittaa mallin muuttamista ja migraation luomista. Monimutkaisemmat muutokset vaativat tietomuunnoksia ja yhteensopivuuspÀÀtöksiÀ. Alembic hoitaa tÀmÀn jÀrkevÀsti, mutta minun on silti yllÀpidettÀvÀ historiaa rakenteellisista muutoksista vain jotta vanhat rivit voivat muuttua uusiksi riveiksi.

TÀmÀ ei ole PostgreSQL:n vika. Tietokanta on sitoutunut skeemaan, joten sen muuttamisella on seurauksensa.

Historia luo toisen kerroksen. Jos haluan tietÀÀ, kuka muutti arvoa, milloin hÀn muutti sen tai miltÀ tietue nÀytti viime tiistaina, minun on mallinnettava se. Voin lisÀtÀ audit-tauluja, triggereitÀ, aikaleimasarakkeita tai event sourcing -kerroksen. PostgreSQL voi tukea kaikkia nÀitÀ melko hyvin.

Mutta tavallinen rivi edustaa edelleen mitÀ on olemassa nyt. Historia jÀÀ joksikin, jonka rakennan sen ympÀrille.

Vaihtoehtoja SQL:lle

EntÀpÀ hyvÀ kuppi NoSQL:ÀÀ

MongoDB poistaa osan kitkasta

MongoDB siirtÀÀ datan muodon paljon lÀhemmÀksi sitÀ muotoa, jota kÀytÀn ohjelmassa.

Voin tallentaa sisÀkkÀisiÀ dokumentteja suoraan, lisÀtÀ kenttiÀ muuttamatta taulua ja antaa vanhempien ja uudempien tietueiden elÀÀ rinnakkain sovelluksen ymmÀrtÀessÀ molemmat. TÀmÀ tekee usein skeeman kehittymisestÀ vÀhemmÀn seremoniallista. Sen sijaan ettÀ migraoisin koko tietokannan ensin, voin joskus pÀivittÀÀ vanhoja dokumentteja niitÀ lukiessani tai muuttaessani.

Skeema on silti olemassa. Koodini odottaa edelleen tiettyjÀ kenttiÀ tietyillÀ merkityksillÀ. MongoDB antaa minulle vain enemmÀn vapautta sen suhteen, milloin valvon tÀtÀ sopimusta.

Redis antaa minulle erinomaisia palasia

Redis on ihmeellisen kÀytÀnnöllinen.

Jos tarvitsen vÀlimuistin, jonon, laskurin, jÀrjestetyn joukon, hajautetun lukon tai tilapÀisen jaetun tilan, RedisillÀ on yleensÀ kompakti vastaus.

Tietysti kun datani tulee JSON-muodossa tai muussa jÀsennellyssÀ muodossa, on myös tÀysin minun tehtÀvÀni pilkkoa se noiksi Redis-primitiiveiksi, tai vain dumpata se sisÀÀn kokonaisena sivuuttaen kaikki hienot työkalut.

Push ja pull

Kerro minulle, kun joku koskee dataani

Firebase alkaa synkronoinnista

Firebase ottaa suoremman reitin. Sen tietokannat pitÀvÀt reaaliaikaisia asiakaspÀivityksiÀ osana tuotetta.

Voin kiinnittÀÀ kuuntelijan dataan ja saada asiakkaan vastaanottamaan muutokset niiden tapahtuessa. Offline-kÀyttÀytyminen ja uudelleenyhdistÀminen kuuluvat myös samaan jÀrjestelmÀÀn sen sijaan ettÀ ne ilmestyisivÀt myöhemmin WebSocket-projektina.

TÀmÀ on houkuttelevaa.

MeillÀ on kaikki muutokset

MongoDB:llÀ on vahva sisÀÀnrakennettu vastaus tÀhÀn. Change Streams antavat minun seurata kokoelmaa, tietokantaa tai koko asennusta ja vastaanottaa lisÀyksiÀ, pÀivityksiÀ, poistoja jne. PÀivitykset sisÀltÀvÀt normaalisti muuttuneet kentÀt, ja jokainen tapahtuma kantaa mukanaan jatkotunnisteen (resume token), joten voin yhdistÀÀ uudelleen ja jatkaa siitÀ mihin jÀin niin kauan kuin oplog sisÀltÀÀ vielÀ tuon pisteen.

PostgreSQL voi myös paljastaa varsinaiset tietokantamuutokset logical decoding:in ja replikoinnin kautta. Redisin Change Streams tarjoavat samanlaisen ratkaisun omassa maailmassaan.

Change-data-capture -jÀrjestelmÀt rakentavat erittÀin kyvykkÀitÀ putkia tÀmÀn pÀÀlle, mutta silloin meidÀn tÀytyy jÀsentÀÀ SQL-lauseita tai Redis-komentoja ja pitÀÀ kirjaa tilasta itse.

Julkaise ja tilaa

PostgreSQL ja Redis tarjoavat myös oldskool-viestikanavat.

Voisin kÀyttÀÀ LISTEN/NOTIFY:tÀ tai PUB/SUB:ia ja lÀhettÀÀ ilmoituksen samasta transaktiosta, joka muuttaa dataa. TÀmÀ vÀlttÀÀ osan erillisen viestivÀylÀn rumuudesta.

Mutta minun on silti luotava ja vastaanotettava ilmoitusviestejÀ pÀÀttÀÀkseni, mitÀ lukea ja mitÀ lÀhettÀÀ. Ja mukana tulee kilpailutilanteita varsinaisen muutoksen ja ilmoituksen vÀlillÀ.

Omaan ongelmaani tÀmÀ tuntui aloittamiselta melko kaukana alhaalta sen abstraktion alapuolelta, jonka oikeastaan halusin.

En halunnut pelkÀstÀÀn tietÀÀ, ettÀ tila oli muuttunut.
Halusin itse muutoksen, sekÀ tilan ennen ja jÀlkeen.

PitÀisikö minun tehdÀ oma?

KÀytettyÀni kaikkia yllÀ olevia ja huomattuani aina ORM:n leviÀmisen koodipohjaani sietÀmÀttömÀksi, aloin vihdoin työstÀÀ ideaa, jota olin hiljaa kehittÀnyt vuosien ja vuosien ajan.

TehdÀ oma tietokanta. TiedÀttehÀn, sellainen, jota ne aina kehottavat olemaan edes yrittÀmÀttÀ.

Annetaan login olla tietokanta

SiitÀ tuli Kannan lÀhtökohta.

Sen sijaan ettÀ tallentaisin viimeisimmÀn tilan ensisijaisena tietueena ja lisÀisin historian sen ympÀrille, halusin tallentaa muutoksia.

Objekti alkaa tyhjÀstÀ tilasta. Jokainen myöhempi operaatio tallentaa vain sen, mikÀ muuttui, yhdessÀ aikaleiman ja muun muutokseen kuuluvan metadatan kanssa.

Nykyinen tila tulee login soveltamisesta. Nyt historia ei enÀÀ tarvitse omaa skeemaansa. Kyselyjen ei enÀÀ tarvitse etsiÀ tuoreinta aikaleimaa, koska ne toimivat tilalla millÀ tahansa hetkellÀ.

Yksi rakenne alusta loppuun

Msgspec antaa minulle tyypitettyjÀ, kompakteja tietorakenteita erittÀin nopealla serialisoinnilla. Kuten dataclassit tai Pydantic, se kÀsittelee sisÀkkÀiset rakenteet ja yleiset natiivityypit kuten UUID:t, enumit ja datetimet muuttamatta olioita ORM-entiteeteiksi. Tietorakenteiden mÀÀrittelystÀ tulee nÀin yksinkertaista:

class Data(msgspec.Struct):
    servername: str
    users: dict[UUID, User]   

TÀmÀ tarkoittaa, ettÀ voin kÀyttÀÀ samanlaista oliota koko ohjelman lÀpi. Voin serialisoida sen. Voin lÀhettÀÀ sen verkon yli. Voin pysyvÀistÀÀ sen muutokset. Voin rekonstruoida sen toisella puolella.

En tarvitse yhtÀ luokkaa sovellukselleni, toista SQLAlchemylle, toista skeemaa serialisoinnille ja pieniÀ muunnosfunktioita vÀlittÀmÀssÀ niiden kaikkien vÀlillÀ.

Se elÀÀ

Hakkasin kasaan yksinkertaisen JSONL-lokittajan, yksi muutos per rivi, mukana jsondiff-muutostietue. Se ei ollut binÀÀriformaatti kuten olisin ehkÀ halunnut, mutta se on jotain naurettavan yksinkertaista muokata ja debugata.

Lukemiset ovat yksinkertaisesti lukemisia Python-muuttujista, paljon nopeampia kuin Redis tai mikÀ tahansa ulkoinen tietokanta!

Kirjoitukset vaativat lisÀajattelua. Sen sijaan ettÀ kertoisimme tietokannalle, mitÀ muuttaa, me muokkaisimme tilaa ja tietokanta tallentaisi muutostietueen.

with kanta.transaction(action="new_user"):
    data.users[uuid7()] = User(...)

Huomaa, ettei siinÀ ole async with:iÀ tai await:ia, vaikka työskentelemme async-Pythonin kanssa. Transaktio on vÀlitön ja synkroninen. Et tarvitse mitÀÀn lukkoja tai synkronointia sen ympÀrille. Tilat ennen ja jÀlkeen tallennetaan ja niitÀ verrataan muutos-diffin tuottamiseksi. Virheen sattuessa edellinen tila palautetaan, peruttaen kaiken mitÀ transaktiossa on jo tehty.

Muutokset pysyvÀistetÀÀn levylle taustasÀikeen toimesta append-only-tiedostoon, joka on helppo korjata jos virrankatkaisu tai kaatuminen aiheuttaa vaurioita.

Suunnilleen tuossa kohtaa Kanta lakkaa nÀyttÀmÀstÀ tietokantakokeilulta ja alkoi nÀyttÀÀ yhtenÀiseltÀ mallilta, jonka varaan rakentaa.

Miksi en kokeilisi tuotannossa?

Kokeiltuani sitÀ omissa projekteissani kytkin sen nopeasti vakavampaan sovellukseen, jossa oli raskaampaa dataa ja monta kÀyttÀjÀÀ. TÀmÀ vastasi pelkoihini mahdollisista suorituskykyongelmista, koska loppujen lopuksi kÀytimme itse asiassa JSON:a tietokantana, ja logdb-rakenteessa, jota tietÀÀkseni kukaan ei ollut kÀyttÀnyt sitten tietojenkÀsittelyn varhaisten aikojen.

Kuten arvata saattoi, suurten muutosjoukkojen toistaminen sovelluksen kÀynnistyksessÀ muuttui hitaaksi ajan myötÀ, joten lisÀsin tÀydet snapshot-rivit vÀlttÀÀkseni pitkÀt toistot alun revisiosta. Sen jÀlkeen suorituskyky on ylittÀnyt kaikki tarpeeni.

Mutta pÀÀasia ei ole suorituskyky, vaan yksinkertaisuus. Ottamalla oletukseksi, ettÀ sovelluksemme voi elÀÀ yhdessÀ worker-prosessissa ja yllÀpitÀÀ koko tilan muistissaan, poistamme suurimman osan ongelmista, jotka liittyvÀt tyypilliseen tietokantaan.

Lokirakenteen ansiosta historian kelailu taaksepĂ€in tulee ilmaiseksi. Olen jopa perunut sarjan transaktioita historian keskeltĂ€ — pelastaakseni kĂ€yttĂ€jĂ€n, joka oli poistanut osan projektista ja tehnyt sen jĂ€lkeen vielĂ€ lisĂ€muutoksia.

TÀmÀkin verkkosivusto on rakennettu Kannan pÀÀlle (Pagerite CMS).

Migraatiot

image
Inspect any slice through your database, grepping for the data you need

Työkalut

Kanta also has a small CLI for inspecting databases directly. It can replay a database, inspect selected ranges of its history, load the application’s actual data types and run migrations when needed, and dump the resulting state as JSON. A good tool also beats treating it as a text file.

Yhden prosessin malli poistaa valtavasti koneistoa, mutta se ei poista sitÀ tosiasiaa, ettÀ ohjelmistot muuttuvat.

Kukaan ei ole koskaan pitÀnyt migraatioista. Ne ovat se taakka, jonka minkÀ tahansa yllÀpitomuutoksen tekeminen dataan aiheuttaa. Toinen SQL-migraatio tai toinen Mongo-legacy-varaus ja pÀivityshaara on aivan liikaa vaivaa, joten mieluummin vÀltÀt nÀmÀ muutokset.

TÀssÀkin msgspec tekee suurimman osan raskaasta työstÀ. Jos haluamme lisÀtÀ tai poistaa kentÀn, laita uusi kenttÀ tietorakenteisiin oletusarvolla, tai poista mikÀ tahansa vanha kenttÀ. Se migrautuu ÀÀnettömÀsti uuteen muotoon. Jos ladattava muoto ei tÀsmÀÀ rakenteiden kanssa, saamme virheen, joka kertoo mikÀ ja missÀ on vialla.

SiistiÀ ja yksinkertaista, mutta ei riittÀvÀÀ tietokannalle.

Silloin tÀllöin haluamme nimetÀ kentÀn uudelleen, muuttaa tietotyyppejÀ tai suorastaan uudelleenjÀrjestÀÀ koko datan, mahdollisesti hakeaksemme samalla uutta ulkoista dataa (olen tehnyt sen). TÀmÀ vaatii varsinaisen migraatiofunktion, joka tietÀÀ mitÀ tekee.

def migrate_v1(d: dict) -> None:
    """Rename counter to total"""
    d["total"] = d["counter"]

Muoto on yksinkertainen: tietokannan revisioluku tulee suoraan funktion nimestÀ. Funktio manipuloi tavallista dict-muotoa, jotta meidÀn ei tarvitse yllÀpitÀÀ vanhempien versioiden msgspec.Structeja. Docstring antaa lokitetun kuvauksen siitÀ, mitÀ tehtiin.

VÀlttÀÀksemme muun ohjelman sotkemisen nÀillÀ, migraatiot voidaan laittaa omaan Python-moduuliinsa, johon viitataan yksinkertaisesti Python-moduulipolulla tietokantaa mÀÀriteltÀessÀ:

kanta = Kanta("foo.kantadb", migrations="foo.migrations")

Muutosjoukkojen metadata ja snapshotit sisÀltÀvÀt versionumeron, jota ne edustavat, ja sovellamme kaikkia löydettyjÀ migraatiofunktioita ylöspÀin tuosta versiosta, ja migraation viimeistelemiseksi teemme msgspec-muunnoksen joka tapauksessa. Jos jokin tÀstÀ tuotti muutoksia, lokitamme ja tallennamme tehdyt migraatiot, ja otamme snapshotin jÀlkeen.

Vanhimmat migraatiofunktiot voidaan myös poistaa, kun kyseisiÀ versioita ei enÀÀ tarvitse tukea, pitÀen koko tÀmÀn migraatiojÀrjestelmÀn hallittavana.

Formaatti levyllÀ

Oletusformaatti pysyy tarkoituksella yksinkertaisena: JSON-tietueita, yksi per rivi. Msgspec koodaa tavut ja muut tyypit siihen.

Ennen julkaisua halusin vastata kritiikkiin, jonka varmasti itsekin esittÀisin binÀÀrituen puutteesta, toteuttamalla sen vaihtoehtona.

Kun JSONL:ssÀ voimme etsiÀ rivinvaihtoja tiedoston lopusta löytÀÀksemme snapshotin, binÀÀridata saattaisi sekoittaa parserin. Voimme lukea tiedoston alusta tÀysin deterministisellÀ lÀhestymistavalla, mutta jos tietokanta olisi suuri, haluaisimme hakea lÀheltÀ loppua. Halusin myös pitÀÀ sen append-only:nÀ, joten emme voineet yksinkertaisesti kirjoittaa alkuun snapshotin offsettia.

Ratkaisu on kaksiosainen:

  • Arvaamaton satunnainen nonce syncwordina
  • Blake3-tiiviste eheyden varmistukseen

Itse muutospayloadit on tÀllÀ hetkellÀ koodattu MessagePackilla, jota msgspec tukee valmiiksi.

Haluaisin korvata sen jollain lÀhempÀnÀ Protocol Buffersia, tallentamatta kenttien nimiÀ ja tyyppejÀ lainkaan, hyödyntÀen sitÀ, ettÀ msgspec jo tietÀÀ structieni rakenteen ja tyypit, ja sellaisenaan pitÀisi pystyÀ johtamaan kompakti binÀÀriesitys tuosta samasta tiedosta.

TÀmÀ sÀilyttÀisi ominaisuuden, josta vÀlitÀn eniten: yksi tietorakenteen mÀÀritelmÀ, sen sijaan ettÀ olisi Python-malli plus ORM-malli plus serialisointiskeema.

Tietokanta on vain puoli statea

Kun auktoritatiivinen tila pidetÀÀn muistissa, toinen asia on vaikea sivuuttaa: useimmat interaktiiviset sovellukset pitÀvÀt jo toista kopiota jossain muualla.

Selaimessakin on yksi.

Kun tietokanta itse tallentaa jÀrjestetyn muutosvirran, saman virran kÀyttÀminen synkronointiin muuttuu ilmeiseksi seuraavaksi askeleeksi.

Storejen synkronointi WebSocketin yli

FastAPI-sovellus lÀhettÀÀ muutoksia WebSocketin yli ja soveltaa saapuvat muutokset takaisin tietokantaan.

Jokaisella asiakkaalla yllÀpidÀmme varjokopiota, eli viimeksi nÀhtyÀ palvelintilaa, ja asiakkaan nykyistÀ työtilaa sen omassa Vue Pinia -storessa. Tai Svelte- tai React-vastaavissa.

Store on yhdistÀvÀ lenkki, joka tarjoaa reaktiivisuuden koko matkan tietokannasta kÀyttöliittymÀÀn ja takaisin.

Asiakas voi työskennellĂ€ offlinessa tai samanaikaisesti muiden asiakkaiden kanssa, ja muutokset yhdistetÀÀn kun ne saapuvat palvelimelle, kĂ€yttĂ€en kolmitie-yhdistĂ€mistĂ€ automaattisella ratkaisulla. YhdistettynĂ€ ylikirjoitukseen ja validointiin/hylkÀÀmiseen — emme todellakaan halua, ettĂ€ merge-konfliktit vaativat manuaalista git-tyylistĂ€ ratkaisua.

TÀllÀ hetkellÀ tÀmÀ koneisto on upotettuna sovelluksiin sen sijaan ettÀ se olisi paketoituna osaksi Kantaa. Seuraava askel on irrottaa se erilliseksi Kanta-yhteensopivaksi synkronointimoduuliksi, sovittimilla myös Javascript-frontendeille.

Se on luultavasti toinen artikkeli.

Kokeilehan

Kaikilla nÀillÀ suunnitteluvalinnoilla on vÀliÀ vain, jos ne tekevÀt tavallisesta sovelluskoodista yksinkertaisempaa.

Kannan kanssa tietokanta ei ole jotain, jota kyselen toisen mallin kautta. MÀÀrittelen haluamani tilan, avaan sen ja työskentelen tavallisten Python-olioiden kanssa. Lukemiset ovat vain lukemisia:

user = data.users[user_id]

Kun haluan muuttaa jotain, teen muutoksen transaktion sisÀllÀ:

with kanta.transaction(action="update"):
    data.users[user_id].name = "Alice"

VÀlissÀ ei ole kyselykieltÀ, ei ORM-oliota kÀÀnnettÀvÀksi takaisin sovellusmalliini, eikÀ erillistÀ audit-koodia muistettavaksi. Transaktio, historia, rollback ja pysyvyys tulevat kaikki samasta operaatiosta.

uv add kanta

LisÀÀ se projektiisi uv:lla tai lue lisÀÀ osoitteessa git.zi.fi.

kuva
Demo menee tahallaan hieman hello worldia pidemmÀlle. Se luo tietokannan yhdellÀ versiolla datamallista, muokkaa sitÀ useiden transaktioiden kautta, avaa sitten saman tietokannan uudemmalla mallilla, ajaa migraation, demonstroi rollbackin ja jatkaa normaalia työskentelyÀ. TÀmÀ on suunnilleen sitÀ elinkaarta, jonka halusin tehdÀ tylsÀksi.
uv run --with kanta demo.py

demo.py

import asyncio, msgspec
from kanta import Kanta, configure_logging

# For demonstration purposes, we use "original v0" and "modified v1" in this same script
# Normally your app would only have the latest supported data model

class Data(msgspec.Struct):  # type: ignore - intentionally redefined later
    users: dict[str, dict] = {}
    counter: int = 0

kanta_v0 = Kanta("demo.kantadb", Data())

@kanta_v0.bootstrap
def bootstrap(data: Data) -> None:
    """Create the initial admin user."""
    data.users["userid001"] = {"name": "Alice", "role": "admin"}


# Redefinition to simulate new version
class Data(msgspec.Struct):
    users: dict[str, dict] = {}
    total: int = 0  # Replaces old counter field
    lang: str = "en"  # New field

def migrate_v1(d: dict) -> None:
    """Rename counter to total"""
    d["total"] = d["counter"]

kanta_v1 = Kanta("demo.kantadb", Data(), migrations="demo")

@kanta_v1.logfmt
def resolve_user(value: str, path: str, state: dict) -> str | None:
    """Resolve user ids to names from the database state itself."""
    if path != "$user" and not path.startswith("users."):
        return None
    return state.get("users", {}).get(value, {}).get("name")


async def main() -> None:
    print("Database creation with v0 schema and basic transactions:\n")
    # Open and close automatically; you can also `await kanta.open()` instead
    async with kanta_v0 as kanta:
        with kanta.transaction(action="create", user="userid001") as data:
            data.users["userid002"] = {"name": "Bob", "role": "user"}

        with kanta.transaction(action="update", user="userid001") as data:
            data.users["userid002"]["role"] = "editor"
            data.counter = 1

        # Display-only extra string, appended after the action.
        with kanta.transaction(action="export", user="userid002", extra="(we are still v0)") as data:
            data.counter = 2

    print("\nA new data model, migrations and logfmt pretty names:\n")
    async with kanta_v1 as kanta:
        with kanta.transaction(action="update", user="userid002", extra="(new version)") as data:
            data.total += 1

        try:
            with kanta.transaction(action="reset", user="userid001") as data:
                data.total = 99
                raise ValueError("simulated failure")
        except ValueError:
            print(f"\nReset rolled back: {data.total=} (we can always read data without tx)\n")

        with kanta.transaction(action="delete", user="userid002") as data:
            data.users.pop("userid001", None)  # del if exists

if __name__ == "__main__":
    configure_logging(debug=True)
    asyncio.run(main())