SQL, NoSQL ja miksi aloitin Kanta DB:n
SQL on silti oletusarvo syystä…
Jos rakentaisin tänään tavallisen Python-palvelun, aloittaisin todennäköisesti PostgreSQL:llä, SQLAlchemylla ja Alembicillä. PostgreSQL tarjoaa minulle oikeat UUID-tunnisteet, JSONB:n, järjestelmään, transaktiot, rajoitukset ja erinomaisen indeksoinnin. SQLAlchemy mappaa suurimman osan näistä selkeästi Pythoniin. Alembic pitää skema-muutokset selkeinä ja versionhallinnassa.
Se menee aika pitkälle ennen kuin mikään alkaa ärsyttämään minua.
… mutta mallilla on reunoja
Tietokannan skema ja Pythonin tietorakenne eivät ole aivan sama asia.
SQLAlchemy-kirjaston avulla voin määritellä tyypitettyjä malleja ja käyttää niitä uuid.UUID Suoraan PostgreSQL:n UUID-sarakkeita vastaan, kartoittaa JSONB:tä Python-konteksteihin ja pitää useimpien rutiininomaiset muunnokset poissa omasta koodistani. Se on huomattavasti parempi kuin kohdella jokaista tietokannan arvoa merkkijonona tai koota SQL-kyselyjä manuaalisesti.
Silti ORM-mallista tulee erityinen objekti. Se sisältää sarakkeita, suhteita, istuntojen käytöstä ja pysyvyysääntöjä. Jos ohjelman muut osat tarvitsevat tavallisia sovellusrakenteita, joko annan tietokantahaasteiden levinneen ulospäin tai lisään toisen muuntamisvaiheen.
Ongelma tulee näkyvämmäksi, kun skema muuttuu.
Kentän lisääminen Python-rakenteeseen tuntuu triviaalilta. Sarakkeen lisääminen pysyviin tietoihin tarkoittaa mallin muuttamista ja migrationin luomista. Monimutkaisemmat muutokset vaativat tietojen muuntamista ja yhteensopivuuspäätöksiä. Alembic käsittelee asiaa järkevästi, mutta minun on silti ylläpidettävä rakenteellisten muutosten historiaa, jotta vanhoista riveistä voi tulla uusia rivejä.
Kyseessä ei ole PostgreSQL:n vika. Tietokanta on sitoutunut jonkin tiettyyn skemaan, joten sen muuttamisella on seurauksia.
Historia luo toisen tason. Jos haluan tietää, kuka muutti arvoa, milloin se muutettiin tai miltä tietue näytti viime tiistaina, minun tarvitsee mallintaa se. Voin lisätä tarkastustaulukoita, triggereitä, aikaleima-sarakkeita tai tapahtumalähteiden kerrosta. PostgreSQL pystyy tukemaan niitä kaikkia hyvin.
Mutta normaali rivi edustaa silti sitä, mitä on olemassa nyt. Historia pysyy jotain, mitä rakennan sen ympärille.
SQL:n vaihtoehdot

MongoDB poistaa joitakin esteitä
MongoDB siirtää tietojen muodon paljon lähemmäs sitä muotoa, jota käytän ohjelmassani.
Voin säilyttää nestettyjä asiakirjoja suoraan, lisätä kenttiä muuttamatta taulukkoa ja antaa vanhojen ja uusien tietueiden olla rinnakkaisia, kun sovellus ymmärtää molemmat. Tämä tekee skemaevoluutiosta usein vähemmän muodollisen. Sen sijaan, että siirtäisin koko tietokannan ensin, voin joskus päivittää vanhoja asiakirjoja lukiessani tai muuttaessani niitä.
Schema on edelleen olemassa. Koodini odottaa edelleen tiettyjä kenttiä tietyillä merkityksillä. MongoDB antaa minulle yksinkertaisesti enemmän vapautta määrittää, milloin noudatan tätä sopimusta.
Redis antaa minulle erinomaisia kappaleita
Redis on ihmeellisen käytännöllinen.
Jos tarvitsen välimuistia, jonotusta, laskuria, lajiteltua settiä, hajautettua lukitusta tai hetkellistä jaettua tilaa, Redisillä on yleensä kompakti ratkaisu.
Tietenkin, kun tietoni tulee JSON-muodossa tai muussa strukturoidussa muodossa, on täysin minun päätökseni pilkkoa se näihin Redis-primitiiveihin tai vain syöttää se kokonaisuutena huomioimatta kaikkia hyödyllisiä työkaluja.
Työntämistä ja vetämistä
Ilmoita minulle, kun joku koskee tietojani
Firebase alkaa synkronoinnista
Firebase käyttää suoremman reitin. Sen tietokannat käsittelevät reaaliaikaiset asiakkaiden päivitykset osana tuotetta.
Voin liittää kuuntelijan tietoihin ja antaa asiakkaan vastaanottaa muutokset niiden tapahtuessa. Offline-käytös ja uudelleen yhdistäminen kuuluvat myös samaan järjestelmään sen sijaan, että ne esiintyisivät myöhemmin WebSocket-projektina.
Se on houkuttelevaa.
Meillä on kaikki muutokset
MongoDB:llä on tähän vahva sisäänrakennettu ratkaisu. Muutosvirrat mahdollistavat kokoelman, tietokannan tai koko käyttöönoton seuraamisen ja lisäysten, päivitysten, poistojen jne. vastaanottamisen. Päivitykset sisältävät tavallisesti muutetut kentät, ja jokainen tapahtuma sisältää jatko-merkin, jotta voin muodostaa yhteyden uudelleen ja jatkaa siitä, mihin jäin, kunhan oplog sisältää yhä kyseisen pisteen.
PostgreSQL voi myös paljastaa varsinaiset tietokannan muutokset loogisen dekoodauksen ja replikoinnin kautta. Redis Change Streams tarjoaa samanlaisen ratkaisun tällä alalla.
Muutostietojen tallennusjärjestelmät rakentavat tämän päälle hyvin kykeneviä putkistoja, mutta silloin meidän tarvitsee analysoida SQL-lauseita tai Redis-komentoja ja seurata tilaa itse.
Julkaise ja tilaa
PostgreSQL ja Redis tarjoavat myös vanhanaikaisia viestikanavia.
Voisin käyttää LISTEN/NOTIFY- tai PUB/SUB-toimintoja ja lähettää ilmoituksen samasta tapahtumasta, joka muuttaa tietoja. Näin vältytään joiltakin liittymättömän viestinvälitysjärjestelmän epäkäytelisyyksiltä.
Mutta minun on silti luotava ja vastaanotettava ilmoitusviestejä päättääkseni, mitä lukea ja mitä lähettää. Ja siihen liittyy kilpailutilanteita varsinaisen muutoksen ja ilmoituksen välillä.
Ongelmani kanssa tuntui siltä, että aloitin melko kaukana siitä abstraktiotasosta, jota oikeastaan halusin.
En halunnut pelkästään tietää, että tila oli muuttunut.
Halusin itse muutoksen ja sen ennen ja jälkeen olevan tilan.
Ehkä minun pitäisi tehdä omani?
Käytettyäni kaikkea yllä mainittua ja huomattuani, että ORM alkoi hiipiä koodipohjaani ja muuttua sietämättömäksi, aloin vihdoin työskennellä idean eteen, jota olin hiljaa kehittänyt vuosien ajan.
Luoda oma tietokantani. Tiedätkö, jotain, mitä he aina sanovat, ettei sinun edes kannata yrittää.
Annettakoon lokin olla tietokanta
Siitä tuli Kantan lähtökohta.
Sen sijaan, että säilyttäisin uusin tilanne pääasiallisena tietueena ja lisäisin siihen historiaa, halusin säilyttää muutokset.
Objekti alkaa tyhjänä. Jokainen myöhempi toiminto kirjaa vain sen, mikä on muuttunut, yhdessä aikaleiman ja muiden kyseiseen muutokseen liittyvien metatietojen kanssa.
Nykytila johtuu lokin soveltamisesta. Nyt historia ei enää tarvitse omaa skemaansa. Kyselyjen ei enää tarvitse löytää uusinta aikaleimaa, koska ne toimivat tila-objektin kanssa minkä tahansa hetken aikana.
Yksi rakenne kautta matkan
Msgspec antaa minulle kirjoitettuja, kompakteja tietorakenteita, jotka voidaan serialoida hyvin nopeasti. Samalla tavoin kuin dataclasses tai Pydantic, se käsittelee nestettyjä rakenteita ja tavallisia alkuperäisiä tyyppejä, kuten UUID-tunnisteita, luetteloita ja päivämääriä muuttamatta objekteja ORM-entiteeteiksi. Tietorakenteiden määrittäminen käy näin yksinkertaiseksi:
class Data(msgspec.Struct):
servername: str
users: dict[UUID, User]
Se tarkoittaa, että voin käyttää samaa objektityyppiä koko ohjelman ajan. Voin serialoida sen. Voin lähettää sen verkon kautta. Voin säilyttää sen muutokset. Voin rakentaa sen uudelleen toisella puolella.
En tarvitse yhtä luokkaa sovellukselleni, toista SQLAlchemylle, toista serialisointia varten ja pieniä muuntotoimintoja, jotka toimivat kaikkien näiden välittäjinä.
Se elää!
Rakensin nopeasti yksinkertaisen JSONL-lokeerajan, jossa jokainen rivi sisältää yhden muutoksen, ja siihen lisättiin jsondiff-muutosrekisteri. Se ei ole sellainen binäärimuoto kuin olisin ehkä halunnut, mutta sitä on helppo muokata ja debugoida.
Lukemiset ovat yksinkertaisesti Python-muuttujista luettuja arvoja, paljon nopeammin kuin Redisistä tai mistään ulkoisesta tietokannasta!
Kirjoittaminen vaati lisäajattelua. Sen sijaan, että kertoisimme tietokannalle, mitä muuttaa, voisimme muokata tilaa, ja tietokanta säilyttäisi muutosrekisterin.
with kanta.transaction(action="new_user"):
data.users[uuid7()] = User(...)
Huomaa, ettei siellä ole async with Tai await Siellä, vaikka työskentelemme asynkronisella Pythonilla. Transaktiio on välitön ja synkroninen. Et tarvitse lukitusjärjestelyjä tai synkronointia sen ympärille. Sitä edeltävät ja sitä seuraavat tilat tallennetaan ja verrataan, jotta muutos voidaan tunnistaa. Virheen sattuessa edellinen tila palautetaan, ja kaikki kyseisessä transaktiossa jo tehty työ peruutetaan.
Muutokset tallennetaan levyllä taustaksi toimivan langan avulla append-only-tiedostoon, joka voidaan helposti korjata mahdollisten sähkökatkojen tai kaatumisten aiheuttamassa korruptiossa tapauksessa.
Se oli suunnilleen se hetki, jolloin Kanta lakkasi näyttämästä tietokantakokeelta ja alkoi näyttämään johdonmukaiselta mallilta, jonka pohjalta voisi rakentaa jotain.
Miksi en kokeilisi sitä tuotannossa?
Kun olin kokeillut sitä omilla projekteillani, kytkin sen nopeasti vakavampaan sovellukseen, jossa oli suurempia tietomääriä ja useampia käyttäjiä. Tämä ratkaisi pelkoni mahdollisista suoritusongelmista, sillä käytimmehän JSONia tietokannassa, ja logdb-rakennetta, jota kukaan ei ollut käyttänyt tietojenkäsittelyn alkuvuosien jälkeen.
Todellakin, suurten muutosjoukkojen toistaminen sovelluksen käynnistyessä hidasti ajan mittaan, joten lisäsin täydelliset pikakuvausrivit välttääkseen pitkiä toistoja alkuperäisestä versiosta. Sen jälkeen suorituskyky on ylittänyt kaikki tarpeeni.
Mutta pääasia ei ole suorituskyky, vaan yksinkertaisuus. Olettamalla, että sovelluksemme pystyy toimimaan yhdessä työprosessissa ja säilyttämään koko tilan muistissaan, poistamme suurimman osan tyypillisen tietokannan aiheuttamista ongelmista.
Logirakenteen ansiosta historian uudelleenpyörittäminen on ilmaista. Olen jopa peruuttanut sarjan transaktiot historian keskellä - pelastaakseni käyttäjän, joka oli poistanut osan projektista ja tehnyt sen jälkeen lisämuutoksia.
Tämä verkkosivusto on myös rakennettu Kantaan (Pagerite CMS).
Migraatiot

Työkalut
Kanta tarjoaa myös pienen komentoriviliittymän tietokantojen suoraan tarkistamiseen. Sillä voi toistaa tietokannan, tarkastella sen historian valittuja alueita, ladata sovelluksen varsinaiset tietotyypit ja suorittaa tarvittaessa migraatioita sekä viedä tulokseksi saadun tilan JSON-muodossa. Hyvä työkalu on parempi kuin sen käsittely tekstitiedostona.
Yksiprosessimalli poistaa suuren osan koneistosta, mutta se ei poista sitä tosiasiaa, että ohjelmisto muuttuu.
Kukaan ei ole koskaan pitänyt migraatioista. Ne ovat taakka, kun tehdään tietojen ylläpitomuutoksia. Toisen SQL-migrationin tai toisen Mongo-legacy-varajärjestelmän lisääminen ja päivitysversio on liian vaivalloinen, joten niitä muutoksia kannattaa välttää.
Tässäkin msgspec tekee suurimman osan raskaasta työstä. Jos haluamme lisätä tai poistaa kentän, laitamme uuden kentän tietorakenteisiin oletusarvolla tai poistamme vanhan kentän. Se siirtyy hiljaa uuteen muotoon. Jos laden muoto ei vastaa rakenteita, saamme virheen, joka kertoo, mikä ja missä on vialla.
Siisti ja yksinkertainen, muttei riittävä tietokannalle.
Silloin tällöin haluamme nimetä kentän uudelleen, muuttaa tietotyyppejä tai jopa uudelleenjärjestellä koko tietojoukon ja ehkä hakea uusia ulkoisia tietoja samalla (olen tehnyt sen). Tämä vaatii varsinaisen migraatiotoiminnon, joka tietää, mitä se tekee.
def migrate_v1(d: dict) -> None:
"""Rename counter to total"""
d["total"] = d["counter"]
Muoto on yksinkertainen: tietokannan versionumero tulee suoraan funktion nimestä. Funktio käsittelee tavallista sanakirjaformaattia, joten meidän ei tarvitse ylläpitää vanhemman version msgspec.Struct-rakenteita. Dokumentointiteksti antaa lokitiedon siitä, mitä tehtiin.
Jotta vältyisimme täyttämästä koko ohjelmamme näillä, migraatiot voidaan sijoittaa omaan Python-moduuliin, johon viittaamme yksinkertaisesti Python-moduulin polulla määritellessämme tietokannan:
kanta = Kanta("foo.kantadb", migrations="foo.migrations")
Muutossarjan metatiedot ja pikakuvaukset sisältävät versionumeron, jota ne edustavat. Sovellamme kaikkia löytyneitä migraatiotoimintoja ylöspäin kyseisestä versiosta alkaen ja suoritamme migraation loppuun asti MSGSPEC-muunnoksen. Jos jokin näistä aiheutti muutoksia, kirjaamme ja säilytämme suoritetut migraatiot ja otamme pikakuvauksen jälkeen.
Vanhimmat migraatiotoiminnot voidaan myös poistaa, kun tällaisia versioita ei enää tarvitse tukea, jolloin koko migraatiojärjestelmä pysyy helposti hallittavana.
Levyn muodossa
Oletusmuoto pysyy tarkoituksellisesti yksinkertaisena: JSON-tiedostot, yksi rivi kerrallaan. Msgspec koodaa bittijonoja ja muita tyyppejä siihen.
Ennen julkaisua halusin kuitenkin käsitellä kritiikkiä, jota varmasti antaisin itsekin binäärisen tuen puutteellisuudesta, toteuttamalla sen vaihtoehtona.
Vaikka JSONL:n avulla voimme etsiä rivinvaihdoksset tiedoston lopusta löytääksemme pikakuvauksen, binääridata saattaa hämmentää analysoijaa. Voimme lukea tiedoston alusta alkaen täysin deterministisellä lähestymistavalla, mutta jos tietokanta olisi suuri, haluaisimme etsiä sen läheltä loppua. Halusin myös säilyttää sen liitettävissä olevana, joten emme voineet yksinkertaisesti kirjoittaa pikakuvauksen offsetia alkuun.
Ratkaisu on kaksiosainen:
- Arvaamaton satunnaisnumero synkronointisana
- Blake3-hash integriteetin todentamiseksi
Muutospaketit itsessään on tällä hetkellä koodattu MessagePack-muodossa, jota tuetaan helposti msgspec-kirjastossa.
Haluaisin korvata sen jollakin, joka on lähempänä Protocol Buffereita. Siinä ei tarvitse säilyttää kenttien nimiä ja tyyppejä ollenkaan. Hyödynnetään sitä, että msgspec tietää jo rakenteen ja tyypit rakenteistani, ja sen pitäisi pystyä johdamaan kompaktin binäärimuodon samoista tiedoista.
Se säilyttäisi omaisuuden, josta välitän eniten: yksi tietorakenteen määritelmä sen sijaan, että käytettäisiin Python-mallia, ORM-mallia ja serialisointiskemaalia.
Tietokanta on vain puolet valtiosta
Autoritaarisen tilan säilyttäminen muistissa tekee jotain muuta vaikeaksi jättää huomiotta: useimmat interaktiiviset sovellukset säilyttävät jo toista kopiota jossakin muualla.
Selaimella on myös sellainen.
Kun tietokanta itse tallentaa järjestelmällisen muutosvirran, saman virran käyttäminen synkronointiin on ilmeinen seuraava askel.
Kauppojen synkronointi WebSocketin kautta
FastAPI-sovellus lähettää muutokset WebSocketin kautta ja soveltaa saapuvat muutokset takaisin tietokantaan.
Jokaisella asiakkaalla ylläpidämme varmuuskopiota, joka on viimeksi nähty palvelimen tila, sekä asiakkaan nykyinen työskentelytila sen alkuperäisessä Vue Pinia -tallennustilassa. Tai Svelten tai Reactin vastaavissa ratkaisuissa.
Kauppa on yhdistävä lenkki, joka tarjoaa reaktiivisuutta aina tietokannasta käyttöliittymään ja takaisin.
Asiakas voi työskennellä offline-tilassa tai samanaikaisesti muiden asiakkaiden kanssa, ja muutokset yhdistetään, kun ne päätyvät palvelimelle. Tätä varten käytetään kolmenvälisen yhdistämisen automaattista ratkaisua. Yhdistettynä ylikirjoitukseen ja validointiin/hylkäämiseen – emme todellakaan halua, että yhdistämiskonfliktit vaativat manuaalista Git-tyyppistä ratkaisua.
Tällä hetkellä kyseinen koneisto on upotettu sovelluksiin eikä sitä ole pakattu osaksi Kantaa. Seuraava vaihe on erottaa se erilliseksi Kanta-yhteensopivaksi synkronointimoduuliksi, johon kuuluu myös sovittimia JavaScript-etusivuille.
Se on luultavasti toinen artikkeli.
Kokeile sitä
Kaikilla näillä suunnitteluvalinnoilla on merkitystä vain, jos ne tekevät tavallisesta sovelluskoodista yksinkertaisempaa.
Kantan kanssa tietokanta ei ole jotain, jota kysyn toisen mallin kautta. Määrittelen haluamani tilan, avaan sen ja työskentelen tavallisten Python-objektien kanssa. Lukemiset ovat vain lukemisia:
user = data.users[user_id]
Kun haluan muuttaa jotakin, teen kyseisen muutoksen transaktiossa:
with kanta.transaction(action="update"):
data.users[user_id].name = "Alice"
Välissä ei ole kyselykieltä, ei ORM-objektia, joka tarvitsisi muuntaa takaisin sovellusmalliini, eikä erillistä tarkastuskoodia, joka tarvitsisi muistaa. Transaktiot, historia, peruutukset ja pysyvyys tulevat kaikki samasta toiminnosta.
uv add kanta
Lisää se projektiisi UV:n kanssa tai lue lisää osoitteessa git.zi.fi.

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())