SQL, NoSQL e por que eu comecei o Kanta DB

SQL ainda é o padrão por um motivo…

Se eu estivesse construindo um serviço convencional em Python hoje, provavelmente começaria com PostgreSQL, SQLAlchemy e Alembic. O PostgreSQL me dá UUIDs reais, JSONB, arrays, transações, constraints e excelente indexação. O SQLAlchemy mapeia a maior parte disso de forma limpa para o Python. O Alembic mantém as mudanças de schema explícitas e versionadas.

Isso me leva bem longe antes que algo comece a me incomodar.

… mas o modelo tem arestas

Um schema de banco de dados e uma estrutura de dados em Python não são exatamente a mesma coisa.

Com o SQLAlchemy posso definir modelos tipados, usar uuid.UUID diretamente contra colunas UUID do PostgreSQL, mapear JSONB para containers Python e manter a maioria das conversões rotineiras fora do meu próprio código. Isso é consideravelmente melhor do que tratar cada valor do banco como uma string ou montar SQL manualmente.

Ainda assim, o modelo do ORM se torna um tipo especial de objeto. Ele carrega colunas, relacionamentos, comportamento de sessão e regras de persistência. Se o resto do programa quer estruturas simples de aplicação, eu ou deixo as preocupações do banco de dados se espalharem para fora, ou adiciono mais uma camada de conversão.

O problema fica mais visível quando o schema muda.

Adicionar um campo a uma estrutura Python parece trivial. Adicionar uma coluna a dados persistidos significa alterar o modelo e criar uma migration. Mudanças mais complexas exigem transformações de dados e decisões de compatibilidade. O Alembic lida com isso de forma sensata, mas eu ainda preciso manter um histórico de mudanças estruturais apenas para que linhas antigas possam se tornar linhas novas.

Isso não é uma falha do PostgreSQL. O banco de dados se comprometeu com um schema, então alterá-lo tem consequências.

O histórico cria outra camada. Se eu quiser saber quem alterou um valor, quando o alterou, ou como o registro estava na terça-feira passada, preciso modelar isso. Posso adicionar tabelas de auditoria, triggers, colunas de timestamp ou uma camada de event sourcing. O PostgreSQL suporta tudo isso muito bem.

Mas a linha normal ainda representa o que existe agora. O histórico continua sendo algo que eu construo em torno dela.

Alternativas ao SQL

Que tal uma boa xícara de NoSQL

MongoDB remove um pouco do atrito

O MongoDB aproxima muito o formato dos dados do formato que uso no programa.

Posso armazenar documentos aninhados diretamente, adicionar campos sem alterar uma tabela e deixar registros antigos e novos coexistirem enquanto a aplicação entende ambos. Isso frequentemente torna a evolução do schema menos cerimonial. Em vez de migrar o banco inteiro primeiro, às vezes posso atualizar documentos antigos quando os leio ou modifico.

O schema ainda existe. Meu código ainda espera certos campos com certos significados. O MongoDB simplesmente me dá mais liberdade sobre quando impor esse acordo.

Redis me dá peças excelentes

O Redis é maravilhosamente prático.

Se eu preciso de um cache, fila, contador, sorted set, lock distribuído ou estado compartilhado efêmero, o Redis geralmente tem uma resposta compacta.

Claro que quando meus dados vêm em JSON ou outro formato estruturado, também cabe completamente a mim quebrá-los nessas primitivas do Redis, ou simplesmente fazer um dump deles como um todo, ignorando todas as ferramentas refinadas.

Push e pull

Me avise quando alguém mexer nos meus dados

O Firebase começa pela sincronização

O Firebase segue um caminho mais direto. Seus bancos de dados tratam atualizações ao vivo do cliente como parte do produto.

Posso anexar um listener aos dados e fazer o cliente receber mudanças conforme acontecem. Comportamento offline e reconexão também pertencem ao mesmo sistema, em vez de aparecerem depois como um projeto de WebSocket.

Isso é atraente.

Temos todas as mudanças

O MongoDB tem a resposta forte e embutida para isso. Os Change Streams me permitem observar uma collection, um banco de dados ou todo o deployment e receber inserts, updates, deletes etc. Os updates normalmente incluem os campos alterados, e cada evento carrega um resume token, então posso reconectar e continuar de onde parei, desde que o oplog ainda contenha aquele ponto.

O PostgreSQL também pode expor mudanças reais do banco através de logical decoding e replicação. Os Change Streams do Redis oferecem uma solução semelhante nesse domínio.

Sistemas de change-data-capture constroem pipelines muito capazes sobre isso, mas aí precisamos fazer parse de comandos SQL ou comandos Redis e acompanhar o estado por conta própria.

Publish e subscribe

O PostgreSQL e o Redis também oferecem canais de mensagens à moda antiga.

Eu poderia usar LISTEN/NOTIFY ou PUB/SUB e emitir uma notificação na mesma transação que modifica os dados. Isso evita parte da feiura de um message bus desconectado.

Mas eu ainda preciso criar e receber mensagens de notificação para decidir o que ler e o que enviar. E isso vem com race conditions entre a mudança real e a notificação.

Para o meu problema, parecia começar muito abaixo da abstração que eu realmente queria.

Eu não queria apenas saber que o estado tinha mudado.
Eu queria a própria mudança, e o estado antes e depois.

Talvez eu devesse fazer o meu próprio?

Tendo usado tudo isso, e sempre achando a invasão do ORM sobre meu código insuportável, finalmente comecei a trabalhar numa ideia que vinha desenvolvendo em silêncio por anos e anos.

Fazer meu próprio banco de dados. Sabe, aquela coisa que sempre te dizem para nem tentar.

Deixar o log ser o banco de dados

Esse se tornou o ponto de partida do Kanta.

Em vez de armazenar o estado mais recente como registro primário e adicionar o histórico ao redor, eu queria armazenar mudanças.

Um objeto começa com estado vazio. Cada operação posterior registra apenas o que mudou, junto com um timestamp e quaisquer outros metadados que pertençam àquela mudança.

O estado atual vem da aplicação do log. Agora o histórico não precisa mais de um schema próprio. As consultas não precisam mais encontrar o timestamp mais recente porque trabalham sobre o objeto de estado em qualquer momento dado.

Uma única estrutura do início ao fim

O Msgspec me dá estruturas de dados tipadas e compactas com serialização muito rápida. Semelhante a dataclasses ou Pydantic, ele lida com estruturas aninhadas e tipos nativos comuns como UUIDs, enums e datetimes sem transformar os objetos em entidades de ORM. Definir suas estruturas de dados fica simples assim:

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

Isso significa que posso usar o mesmo tipo de objeto em todo o programa. Posso serializá-lo. Posso enviá-lo pela rede. Posso persistir suas mudanças. Posso reconstruí-lo do outro lado.

Não preciso de uma classe para minha aplicação, outra para o SQLAlchemy, outro schema para serialização, e pequenas funções de conversão mediando entre todas elas.

Está vivo

Eu montei rapidamente um logger JSONL simples, uma mudança por linha, com um registro de mudança jsondiff nela. Não era um formato binário como eu preferiria, mas é algo absurdamente simples de editar e depurar.

As leituras são simplesmente leituras de variáveis Python, muito mais rápidas que Redis ou qualquer banco de dados externo!

As escritas exigiram reflexão adicional. Em vez de dizer ao banco o que mudar, editaríamos o estado e o banco persistiria um registro de mudança.

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

Note que não há async with nem await ali, embora estejamos trabalhando com Python assíncrono. A transação é imediata e síncrona. Você não precisa de nenhum lock ou sincronização ao redor dela. Os estados antes e depois são armazenados e comparados para produzir o diff da mudança. Em caso de erro, o estado anterior é restaurado, revertendo tudo que já foi feito naquela transação.

As mudanças são persistidas em disco por uma thread em background, num arquivo append-only que é facilmente reparado em caso de qualquer corrupção por queda de energia ou crashes.

Foi mais ou menos nesse ponto que o Kanta deixou de parecer um experimento de banco de dados e começou a parecer um modelo coerente sobre o qual construir.

Por que não experimentar em produção?

Depois de testar em meus próprios projetos, rapidamente o conectei a uma aplicação mais séria, que tinha dados mais pesados e muitos usuários. Isso respondeu meus receios sobre possíveis problemas de performance, porque afinal estávamos de fato usando JSON como banco de dados, e numa estrutura de logdb que outros, que eu saiba, não usavam desde os primórdios da computação.

Como esperado, reexecutar grandes changesets na inicialização da aplicação ficou lento com o tempo, então adicionei linhas de snapshot completo para evitar as longas reexecuções desde a revisão inicial. Depois disso, a performance superou todas as minhas necessidades.

Mas o ponto principal não é performance, é simplicidade. Ao assumir que nossa aplicação pode viver em um único processo worker e manter o estado completo em memória, removemos a maioria dos problemas que vêm com o banco de dados típico.

Devido à estrutura de log, retroceder o histórico vem de graça. Eu já desfiz até uma série de transações no meio do histórico — para resgatar um usuário que tinha deletado parte do projeto e depois feito outras alterações.

Este site também é construído sobre o Kanta (Pagerite CMS).

Migrations

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

Ferramentas

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.

O modelo de processo único remove uma enorme quantidade de maquinário, mas não remove o fato de que software muda.

Ninguém nunca gostou de migrations. Elas são o fardo de fazer qualquer mudança de manutenção sobre dados. Adicionar mais uma migration SQL ou mais um fallback legado e branch de atualização no Mongo é trabalho demais, então você prefere evitar essas mudanças.

Aqui novamente o msgspec faz grande parte do trabalho pesado. Se queremos adicionar ou remover um campo, basta colocar o novo campo nas estruturas de dados com um valor padrão, ou remover qualquer campo antigo. Ele migrará silenciosamente para o novo formato. Se o formato sendo carregado não corresponder às estruturas, recebemos um erro dizendo o que está errado e onde.

Legal e simples, mas não suficiente para um banco de dados.

De tempos em tempos queremos renomear um campo, mudar tipos de dados ou reestruturar completamente os dados, possivelmente buscando novos dados externos no processo (já fiz isso). Isso requer uma função de migration real que sabe o que está fazendo.

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

O formato é simples: o número da revisão do banco vem diretamente do nome da função. A função manipula o formato dict simples para que não precisemos manter msgspec.Structs de versões antigas. A docstring fornece a descrição registrada do que foi feito.

Para evitar poluir o resto do nosso programa com elas, as migrations podem ficar em seu próprio módulo Python, ao qual simplesmente nos referimos pelo caminho do módulo Python ao definir o banco:

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

Os metadados do changeset e os snapshots contêm o número da versão que representam, e aplicamos todas as funções migrate encontradas para cima a partir dessa versão, e para completar a migration fazemos a conversão msgspec em qualquer caso. Se algo disso resultar em mudanças, registramos e armazenamos as migrations feitas, e fazemos snapshot depois.

As funções de migration mais antigas também podem ser removidas, quando tais versões não precisarem mais ser suportadas, mantendo todo esse sistema de migration gerenciável.

Formato em disco

O formato padrão permanece deliberadamente sem sofisticação: registros JSON, um por linha. O Msgspec codifica bytes e outros tipos nele.

Antes de publicar, eu queria endereçar uma crítica que eu certamente faria sobre a falta de suporte binário, implementando isso como uma opção.

Enquanto com JSONL podemos procurar quebras de linha a partir do fim do arquivo para encontrar um snapshot, os dados binários poderiam confundir o parser. Podemos ler desde o início do arquivo para uma abordagem totalmente determinística, mas se o banco fosse grande preferiríamos buscar perto do fim. Eu também queria mantê-lo append only, então não poderíamos simplesmente escrever no início o offset do snapshot.

A solução é dupla:

  • Nonce aleatório imprevisível como syncword
  • Hash Blake3 para verificação de integridade

Os payloads das mudanças em si são atualmente codificados com MessagePack, que é prontamente suportado no msgspec.

Eu gostaria de substituir isso por algo mais próximo de Protocol Buffers, não armazenando nomes e tipos de campos de forma alguma, aproveitando que o msgspec já conhece a estrutura e os tipos dos meus structs, e como tal deveria ser capaz de derivar uma representação binária compacta dessa mesma informação.

Isso manteria a propriedade que mais me importa: uma única definição da estrutura de dados, em vez de um modelo Python mais um modelo ORM mais um schema de serialização.

O banco de dados é só metade do estado

Manter o estado autoritativo em memória torna outra coisa difícil de ignorar: a maioria das aplicações interativas já mantém outra cópia em outro lugar.

O navegador também tem uma.

Uma vez que o próprio banco registra um fluxo ordenado de mudanças, usar esse mesmo fluxo para sincronização se torna um próximo passo óbvio.

Sincronizando stores via WebSocket

A aplicação FastAPI envia mudanças por um WebSocket e aplica as mudanças recebidas de volta ao banco.

Em cada cliente, mantemos uma cópia shadow, que é o último estado do servidor visto, e o estado de trabalho atual do cliente em sua store nativa Vue Pinia. Ou os equivalentes em Svelte ou React.

A store é o elo de ligação que oferece reatividade do banco de dados até a interface do usuário e de volta.

O cliente pode trabalhar offline ou concorrentemente com outros clientes, e as mudanças são mescladas quando chegam ao servidor, usando um three-way merge com resolução automática. Combinado com sobrescrita e validação/rejeição — definitivamente não queremos que conflitos de merge exijam solução manual estilo git.

Atualmente tenho esse maquinário embutido nas aplicações em vez de empacotado como parte do Kanta. O próximo passo é extraí-lo para um módulo de sincronização separado compatível com Kanta, com adaptadores também para os frontends Javascript.

Isso provavelmente é outro artigo.

Dê uma volta

Todas essas escolhas de design só importam se tornarem o código comum de aplicação mais simples.

Com o Kanta, o banco de dados não é algo que eu consulto através de outro modelo. Eu defino o estado que quero, abro-o, e trabalho com objetos Python normais. Leituras são apenas leituras:

user = data.users[user_id]

Quando quero mudar algo, faço essa mudança dentro de uma transação:

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

Não há linguagem de consulta no meio, nenhum objeto ORM para traduzir de volta ao meu modelo de aplicação, e nenhum código de auditoria separado para lembrar. A transação, o histórico, o rollback e a persistência vêm todos da mesma operação.

uv add kanta

Adicione ao seu projeto com uv ou leia mais em git.zi.fi.

image
A demo vai deliberadamente um pouco além do hello world. Ela cria um banco com uma versão do modelo de dados, modifica-o através de várias transações, depois abre o mesmo banco com um modelo mais novo, roda uma migration, demonstra rollback, e continua trabalhando normalmente. Esse é mais ou menos o tipo de ciclo de vida que eu queria tornar entediante.
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())