SQL, NoSQL e Porque eu comecei o Kanta DB

SQL ainda é o padrão por uma razão…

Se eu estivesse a construir um serviço Python convencional hoje, provavelmente começaria com PostgreSQL, SQLAlchemy e Alembic. PostgreSQL fornece-me UUIDs reais, JSONB, arrays, transações, restrições e excelente indexação. SQLAlchemy mapeia a maior parte disso de forma limpa para Python. Alembic mantém as alterações de esquema explícitas e versionadas.

Isso vai bastante longe antes que qualquer coisa comece a irritar-me.

… mas o modelo tem bordas

Um esquema de base de dados e uma estrutura de dados Python não são exactamente a mesma coisa.

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

Ainda assim, o modelo ORM torna-se um tipo especial de objeto. Ele contém colunas, relações, comportamento de sessão e regras de persistência. Se o resto do programa quiser estruturas de aplicação simples, eu deixo as preocupações com a base de dados espalharem-se para fora ou adiciono outra camada de conversão.

O problema torna-se mais visível quando o esquema muda.

Adicionar um campo a uma estrutura Python parece trivial. Adicionar uma coluna a dados persistentes significa alterar o modelo e criar uma migração. Alterações mais envolvidas necessitam de transformações de dados e decisões de compatibilidade. O Alembic lida com isto de forma sensata, mas eu ainda tenho de manter um histórico de alterações estruturais apenas para que as linhas antigas possam tornar-se novas linhas.

Isso não é uma falha no PostgreSQL. A base de dados comprometeu-se com um esquema, por isso, alterá-lo tem consequências.

A história cria outra camada. Se eu quiser saber quem alterou um valor, quando o alterou ou como o registo se apresentava na terça-feira passada, preciso de modelar isso. Posso adicionar tabelas de auditoria, gatilhos, colunas de timestamp ou uma camada de event-sourcing. O PostgreSQL pode suportar tudo isto muito bem.

Mas a linha normal ainda representa o que existe agora. A história continua a ser algo que eu construo em torno dela.

Alternativas ao SQL

Que tal uma boa chávena de NoSQL

O MongoDB remove alguma fricção

O MongoDB move a forma dos dados muito mais perto da forma que eu uso no programa.

Eu posso armazenar documentos aninhados diretamente, adicionar campos sem alterar uma tabela, e deixar registos mais antigos e mais novos coexistirem enquanto a aplicação compreende ambos. Isso muitas vezes torna a evolução do esquema menos cerimonial. Em vez de migrar toda a base de dados primeiro, por vezes, posso atualizar documentos antigos quando os leio ou modifico.

O esquema ainda existe. O meu código ainda espera determinados campos com determinados significados. O MongoDB simplesmente dá-me mais liberdade sobre quando eu imponho esse acordo.

O Redis dá-me peças excelentes

O Redis é maravilhosamente prático.

Se eu precisar de um cache, fila, contador, conjunto ordenado, bloqueio distribuído ou estado partilhado efémero, o Redis geralmente tem uma resposta compacta.

Claro que, quando os meus dados vêm em JSON ou noutro formato estruturado, também cabe inteiramente a mim dividi-los nessas primitivas Redis, ou simplesmente despejá-los como um todo, ignorando todas as ferramentas sofisticadas.

Empurrando e puxando

Deixe-me saber quando alguém tocar nos meus dados

Firebase começa a partir da sincronização

O Firebase segue um caminho mais direto. As suas bases de dados tratam as atualizações de clientes em tempo real como parte do produto.

Eu posso anexar um ouvinte aos dados e fazer com que o cliente receba alterações à medida que elas ocorrem. O comportamento offline e a reconexão também pertencem ao mesmo sistema, em vez de aparecerem mais tarde como um projeto WebSocket.

Isso é atraente.

Nós temos todas as mudanças

O MongoDB tem uma forte resposta integrada para isto. Os Change Streams permitem-me monitorizar uma coleção, base de dados ou implementação completa e receber inserções, atualizações, eliminações, etc. As atualizações normalmente incluem os campos alterados, e cada evento carrega um token de resumo, para que eu possa reconectar e continuar de onde parei, desde que o oplog ainda contenha esse ponto.

O PostgreSQL também pode expor alterações reais da base de dados através de descodificação lógica e replicação. Os Redis Change Streams oferecem uma solução semelhante nesse domínio.

Os sistemas de captura de dados de alteração constroem pipelines muito capazes para além disso, mas depois precisamos de analisar as instruções SQL ou os comandos Redis e de manter o controlo do estado nós mesmos.

Publicar e subscrever

O PostgreSQL e o Redis também oferecem canais de mensagens oldskool.

Eu poderia usar LISTEN/NOTIFY ou PUB/SUB e emitir uma notificação da mesma transacção que modifica os dados. Isso evita algumas das inconveniências de um barramento de mensagens não relacionado.

Mas eu ainda tenho de criar e receber mensagens de notificação para decidir o que ler e o que enviar. E isso vem com condições de corrida entre a alteração real e a notificação.

Para o meu problema, parecia que estava a começar muito abaixo da abstração que 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 deva fazer o meu próprio?

Tendo utilizado tudo o que foi mencionado acima, e sempre achando que o ORM se infiltrava na minha base de código, tornando-se insuportável, finalmente comecei a trabalhar numa ideia que tinha desenvolvido silenciosamente durante anos e anos.

Para fazer a minha própria base de dados. Sabes, algo que eles sempre te dizem para nem tentares.

Deixe o log ser a base de dados

Isso tornou-se o ponto de partida para Kanta.

Em vez de armazenar o estado mais recente como o registo principal e adicionar história em torno dele, eu queria armazenar alterações.

Um objeto começa com um estado vazio. Cada operação posterior regista apenas o que mudou, juntamente com uma marca de tempo e quaisquer outros metadados que pertençam a essa alteração.

O estado atual vem da aplicação do log. Agora, a história já não precisa do seu próprio esquema. As consultas já não precisam de encontrar o timestamp mais recente, porque elas funcionam no objeto de estado em qualquer momento dado.

Uma estrutura do início ao fim

O Msgspec fornece-me estruturas de dados digitados e compactos com uma 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 ORM. Definir as suas estruturas de dados torna-se assim tão simples:

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 as suas alterações. Posso reconstruí-lo do outro lado.

Eu não preciso de uma classe para a minha aplicação, outra para SQLAlchemy, outro esquema para serialização, e pequenas funções de conversão a mediar entre todos eles.

Está vivo

Eu criei um logger JSONL simples, com uma alteração por linha, com um registo de alterações jsondiff nele. Não era um formato binário como eu poderia ter preferido, mas é algo muito simples de editar e depurar.

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

Esperava-se que os escritos exigissem uma reflexão adicional. Em vez de dizer ao banco de dados o que mudar, nós iríamos editar o estado e o banco de dados iria persistir um registro de alteração.

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

Repare que não há async with Ou await Lá dentro, embora estejamos a trabalhar em Python assíncrono. A transacção é imediata e síncrona. Não necessita de quaisquer bloqueios ou sincronização em torno dela. Os estados antes e depois são armazenados e comparados para produzir a diferença de alteração. Em caso de erro, o estado anterior é restaurado, revertendo tudo o que já foi feito nessa transacção.

As alterações são persistidas no disco por um thread em segundo plano, num ficheiro apenas para anexos que é facilmente reparado em caso de qualquer corrupção devida a perda de energia ou falhas.

Esse foi aproximadamente o ponto em que Kanta deixou de parecer uma experiência de base de dados e começou a parecer um modelo coerente sobre o qual construir.

Por que não experimentar isso na produção?

Depois de testar com os meus próprios projetos, rapidamente o implementei numa aplicação mais séria que tinha dados mais pesados e muitos utilizadores. Isto respondeu aos meus receios sobre possíveis problemas de desempenho, porque afinal estávamos de facto a utilizar JSON para uma base de dados, e numa estrutura de logdb que, tanto quanto sei, ninguém mais tinha utilizado desde os primórdios da computação.

Com efeito, a reprodução de grandes conjuntos de alterações no arranque da aplicação tornou-se lenta ao longo do tempo, por isso acrescentei linhas de instantâneo completas para evitar as longas reproduções da revisão inicial. Depois disso, o desempenho excedeu todas as minhas necessidades.

Mas o ponto principal não é o desempenho, é a simplicidade. Ao assumir que a nossa aplicação pode viver num único processo de trabalho e manter o estado completo na sua memória, removemos a maioria dos problemas que vêm com a base de dados típica.

Devido à estrutura de registo, rebobinar a história é gratuito. Eu até desfiz uma série de transacções no meio da história — para resgatar um utilizador que tinha eliminado parte do projeto e depois feito mais alterações.

Este website também é construído no Kanta (Pagerite CMS).

Migrações

imagem
Inspecione qualquer fatia através da sua base de dados, pesquisando os dados que necessita

Ferramentas

Kanta também tem uma pequena CLI para inspecionar bancos de dados diretamente. Pode reproduzir um banco de dados, inspecionar intervalos selecionados da sua história, carregar os tipos de dados reais da aplicação e executar migrações quando necessário, e despejar o estado resultante como JSON. Uma boa ferramenta também é melhor do que tratá-lo como um ficheiro de texto.

O modelo de processo único remove uma grande quantidade de maquinaria, mas não remove o facto de que o software muda.

Ninguém nunca gostou de migrações. Elas são o fardo de fazer quaisquer alterações de manutenção nos dados. Adicionar outra migração SQL ou outro fallback de Mongo e ramo de atualização é simplesmente muito problemático, por isso é melhor evitar essas alterações.

Aqui, novamente, o msgspec faz grande parte do trabalho pesado. Se quisermos adicionar ou remover um campo, basta colocar o novo campo nas estruturas de dados com um valor predefinido, ou remover qualquer campo antigo. Ele irá migrar silenciosamente para o novo formato. Se o formato que está a ser carregado não corresponder às estruturas, obteremos um erro indicando o que e onde está errado.

Cool e simples, mas não suficiente para uma base de dados.

De vez em quando, queremos renomear um campo, alterar os tipos de dados ou reestruturar completamente todos os dados, possivelmente buscando novos dados externos enquanto isso (eu já fiz isso). Isto requer uma função de migração real que saiba o que está a fazer.

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

O formato é simples: o número de revisão da base de dados vem diretamente do nome da função. A função manipula o formato de dicionário simples, para que não precisemos de manter os msgspec.Structs de versões mais antigas. A docstring fornece a descrição registada do que foi feito.

Para evitar encher o resto do nosso programa com estes, as migrações podem ser colocadas no seu próprio módulo Python, ao qual nos referimos simplesmente com o caminho do módulo Python ao definir a base de dados:

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

Os metadados e instantâneos do changeset contêm o número de versão que representam, e nós aplicamos todas as funções de migração encontradas a partir dessa versão, e para completar a migração, fazemos a conversão msgspec em qualquer caso. Se alguma dessas alterações resultou, nós registamos e armazenamos as migrações feitas, e fazemos um instantâneo depois.

As funções de migração mais antigas também podem ser removidas, quando essas versões já não precisam de ser suportadas, mantendo todo este sistema de migração gerenciável.

No formato de disco

O formato padrão permanece deliberadamente não sofisticado: registos JSON, um por linha. O Msgspec codifica bytes e outros tipos nela.

Antes de publicar, queria abordar 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 novas linhas do final do ficheiro para encontrar um instantâneo, os dados binários podem confundir o analisador. Podemos ler desde o início do ficheiro para uma abordagem totalmente determinística, mas se a base de dados fosse grande, preferiríamos procurar perto do final. Também queria mantê-lo apenas em anexo, por isso não podíamos simplesmente escrever no início o deslocamento do instantâneo.

A solução é dupla:

  • Número aleatório imprevisível como palavra de sincronização
  • Blake3 hash para verificação de integridade

Os payloads de alteração em si estão atualmente codificados com MessagePack, que é prontamente suportado em msgspec.

Eu gostaria de substituir isso por algo mais próximo de Protocol Buffers, não armazenando nomes e tipos de campos de todo, aproveitando que o msgspec já conhece a estrutura e os tipos das minhas estruturas, e como tal, deve ser capaz de derivar uma representação binária compacta a partir dessa mesma informação.

Isso manteria a propriedade com que eu me preocupo mais: uma definição da estrutura de dados, em vez de um modelo Python mais um modelo ORM mais um esquema de serialização.

A base de dados é apenas metade do estado

Manter o estado autoritário na memória torna outra coisa difícil de ignorar: a maioria das aplicações interativas já mantém outra cópia noutro local.

O browser também tem um.

Uma vez que a própria base de dados regista um fluxo ordenado de alterações, utilizar esse mesmo fluxo para sincronização torna-se um passo seguinte óbvio.

Sincronizando lojas através de WebSocket

A aplicação FastAPI envia alterações através de um WebSocket e aplica as alterações recebidas de volta à base de dados.

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

A loja é o elo de ligação que oferece reatividade desde a base de dados até à interface do utilizador e vice-versa.

O cliente pode trabalhar offline ou em simultâneo com outros clientes, e as alterações são mescladas quando chegam ao servidor, usando uma mesclagem de três vias com resolução automática. Combinado com a substituição e validação/rejeição — definitivamente não queremos que os conflitos de mesclagem exijam uma solução manual no estilo Git.

Atualmente, tenho esta maquinaria integrada em aplicações, em vez de embalada como parte do Kanta. O próximo passo é extraí-la para um módulo de sincronização separado compatível com o Kanta, com adaptadores também para as interfaces frontend de Javascript.

Isso é provavelmente outro artigo.

Dê uma volta

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

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

user = data.users[user_id]

Quando eu quero mudar alguma coisa, eu faço essa mudança dentro de uma transacção:

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

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

uv add kanta

Adicione-o ao seu projeto com UV ou leia mais em git.zi.fi.

imagem
A demonstração vai deliberadamente um pouco além do ”olá mundo”. Cria uma base de dados com uma versão do modelo de dados, modifica-o através de várias transacções, depois abre a mesma base de dados com um modelo mais recente, executa uma migração, demonstra o rollback e continua a trabalhar com ele normalmente. Esse é, aproximadamente, o tipo de ciclo de vida que eu queria tornar chato.
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())