SQL, NoSQL y por qué empecé Kanta DB

SQL sigue siendo la opción por defecto por una razón…

Si estuviera construyendo hoy un servicio convencional en Python, probablemente empezaría con PostgreSQL, SQLAlchemy y Alembic. PostgreSQL me da UUIDs reales, JSONB, arrays, transacciones, constraints y una indexación excelente. SQLAlchemy mapea casi todo eso limpiamente a Python. Alembic mantiene los cambios de esquema explícitos y versionados.

Eso me lleva bastante lejos antes de que algo empiece a molestarme.

… pero el modelo tiene bordes

Un esquema de base de datos y una estructura de datos en Python no son exactamente lo mismo.

Con SQLAlchemy puedo definir modelos tipados, usar uuid.UUID directamente contra columnas UUID de PostgreSQL, mapear JSONB a contenedores de Python y mantener la mayoría de las conversiones rutinarias fuera de mi propio código. Eso es considerablemente mejor que tratar cada valor de la base de datos como una cadena o ensamblar SQL manualmente.

Aun así, el modelo del ORM se convierte en un tipo especial de objeto. Carga con columnas, relaciones, comportamiento de sesión y reglas de persistencia. Si el resto del programa quiere estructuras de aplicación simples, o dejo que las preocupaciones de la base de datos se extiendan hacia afuera, o añado otra capa de conversión.

El problema se hace más visible cuando el esquema cambia.

Añadir un campo a una estructura de Python parece trivial. Añadir una columna a datos persistidos significa cambiar el modelo y crear una migración. Cambios más complejos requieren transformaciones de datos y decisiones de compatibilidad. Alembic maneja esto de forma sensata, pero aún tengo que mantener un historial de cambios estructurales solo para que las filas antiguas puedan convertirse en filas nuevas.

Eso no es un defecto de PostgreSQL. La base de datos se ha comprometido con un esquema, así que cambiarlo tiene consecuencias.

El historial crea otra capa. Si quiero saber quién cambió un valor, cuándo lo cambió, o cómo era el registro el martes pasado, necesito modelarlo. Puedo añadir tablas de auditoría, triggers, columnas de marcas de tiempo o una capa de event sourcing. PostgreSQL puede soportar todo esto bastante bien.

Pero la fila normal sigue representando lo que existe ahora. El historial sigue siendo algo que construyo a su alrededor.

Alternativas a SQL

¿Qué tal una buena taza de NoSQL?

MongoDB elimina algo de fricción

MongoDB acerca la forma de los datos mucho más a la forma que uso en el programa.

Puedo almacenar documentos anidados directamente, añadir campos sin alterar una tabla, y dejar que registros antiguos y nuevos coexistan mientras la aplicación entiende ambos. Eso a menudo hace que la evolución del esquema sea menos ceremonial. En lugar de migrar toda la base de datos primero, a veces puedo actualizar los documentos antiguos cuando los leo o los modifico.

El esquema sigue existiendo. Mi código sigue esperando ciertos campos con ciertos significados. MongoDB simplemente me da más libertad sobre cuándo hago cumplir ese acuerdo.

Redis me da piezas excelentes

Redis es maravillosamente práctico.

Si necesito una caché, una cola, un contador, un sorted set, un lock distribuido o un estado compartido efímero, Redis suele tener una respuesta compacta.

Por supuesto, cuando mis datos vienen en JSON u otro formato estructurado, también depende completamente de mí descomponerlos en esas primitivas de Redis, o simplemente hacer un dumps de todo ignorando todas las herramientas finas.

Push y pull

Avísame cuando alguien toque mis datos

Firebase parte de la sincronización

Firebase toma una ruta más directa. Sus bases de datos tratan las actualizaciones en vivo a los clientes como parte del producto.

Puedo adjuntar un listener a los datos y hacer que el cliente reciba los cambios a medida que ocurren. El comportamiento offline y la reconexión también pertenecen al mismo sistema en lugar de aparecer después como un proyecto WebSocket.

Eso es atractivo.

Tenemos todos los cambios

MongoDB tiene la respuesta integrada más fuerte en esto. Los Change Streams me permiten observar una colección, una base de datos o un despliegue completo y recibir inserts, updates, deletes, etc. Las actualizaciones normalmente incluyen los campos modificados, y cada evento lleva un resume token, así que puedo reconectarme y continuar desde donde lo dejé mientras el oplog aún contenga ese punto.

PostgreSQL también puede exponer los cambios reales de la base de datos mediante logical decoding y replicación. Los Change Streams de Redis ofrecen una solución similar en ese ámbito.

Los sistemas de change-data-capture construyen pipelines muy capaces sobre eso, pero entonces necesitamos parsear sentencias SQL o comandos de Redis y llevar nosotros mismos la cuenta del estado.

Publicar y suscribirse

PostgreSQL y Redis también ofrecen canales de mensajes a la antigua.

Podría usar LISTEN/NOTIFY o PUB/SUB y emitir una notificación desde la misma transacción que modifica los datos. Eso evita parte de la fealdad de un message bus ajeno.

Pero aún tengo que crear y recibir mensajes de notificación para decidir qué leer y qué enviar. Y viene con race conditions entre el cambio real y la notificación.

Para mi problema parecía empezar bastante por debajo de la abstracción que realmente quería.

Yo no quería simplemente saber que el estado había cambiado.
Quería el cambio en sí, y el estado antes y después.

¿Quizá debería hacer la mía propia?

Habiendo usado todo lo anterior, y encontrando siempre que la invasión del ORM sobre mi código se volvía insoportable, finalmente comencé a trabajar en una idea que había desarrollado silenciosamente durante años y años.

Hacer mi propia base de datos. Ya sabes, eso que siempre te dicen que ni lo intentes.

Dejar que el log sea la base de datos

Ese se convirtió en el punto de partida de Kanta.

En lugar de almacenar el último estado como el registro principal y añadir el historial a su alrededor, quería almacenar cambios.

Un objeto comienza con un estado vacío. Cada operación posterior registra solo lo que cambió, junto con una marca de tiempo y cualquier otro metadato que pertenezca a ese cambio.

El estado actual proviene de aplicar el log. Ahora el historial ya no necesita su propio esquema. Las consultas ya no necesitan encontrar la marca de tiempo más reciente porque trabajan sobre el objeto de estado en cualquier momento dado.

Una estructura de principio a fin

Msgspec me da estructuras de datos tipadas y compactas con una serialización muy rápida. Similar a dataclasses o Pydantic, maneja estructuras anidadas y tipos nativos comunes como UUIDs, enums y datetimes sin convertir los objetos en entidades de ORM. Definir tus estructuras de datos se vuelve así de simple:

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

Eso significa que puedo usar el mismo tipo de objeto en todo el programa. Puedo serializarlo. Puedo enviarlo por la red. Puedo persistir sus cambios. Puedo reconstruirlo al otro lado.

No necesito una clase para mi aplicación, otra para SQLAlchemy, otro esquema para la serialización, y pequeñas funciones de conversión mediando entre todas ellas.

Está vivo

Monté a toda prisa un simple logger JSONL, un cambio por línea, con un registro de cambios de jsondiff. No era un formato binario como hubiera preferido, pero es algo tremendamente simple de editar y depurar.

¡Las lecturas son simplemente lecturas de variables de Python, mucho más rápidas que Redis o cualquier base de datos externa!

Las escrituras necesitaron reflexión adicional. En lugar de decirle a la base de datos qué cambiar, editaríamos el estado y la base de datos persistiría un registro de cambios.

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

Fíjate en que no hay async with ni await ahí aunque estemos trabajando con Python async. La transacción es inmediata y síncrona. No necesitas ningún lock ni sincronización a su alrededor. Los estados antes y después se almacenan y comparan para producir el diff del cambio. En caso de error se restaura el estado anterior, haciendo rollback de todo lo ya hecho en esa transacción.

Los cambios se persisten a disco por un hilo en segundo plano, en un archivo append-only que se repara fácilmente en caso de cualquier corrupción debida a cortes de luz o caídas.

Ese fue aproximadamente el punto en que Kanta dejó de parecer un experimento de base de datos y empezó a parecer un modelo coherente sobre el que construir.

¿Por qué no probarlo en producción?

Después de probarlo con mis propios proyectos, lo conecté rápidamente a una aplicación más seria que tenía datos más pesados y muchos usuarios. Esto respondió a mis temores sobre posibles problemas de rendimiento, porque al fin y al cabo estábamos de hecho usando JSON para una base de datos, y en una estructura logdb que otros, que yo sepa, no habían usado desde los primeros tiempos de la computación.

Efectivamente, reproducir grandes changesets al iniciar la aplicación se volvió lento con el tiempo, así que añadí líneas de snapshot completas para evitar las largas reproducciones desde la revisión inicial. Después de eso el rendimiento ha superado todas mis necesidades.

Pero el punto principal no es el rendimiento, es la simplicidad. Al asumir que nuestra app puede vivir en un solo proceso worker y mantener el estado completo en su memoria, eliminamos la mayoría de los problemas que vienen con la base de datos típica.

Debido a la estructura de log, rebobinar el historial es gratis. Incluso he deshecho una serie de transacciones en medio del historial — para rescatar a un usuario que había borrado parte del proyecto y luego había hecho más cambios después.

Este sitio web también está construido sobre Kanta (Pagerite CMS).

Migraciones

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

Herramientas

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.

El modelo de un solo proceso elimina una gran cantidad de maquinaria, pero no elimina el hecho de que el software cambia.

A nadie le gustaron nunca las migraciones. Son la carga de hacer cualquier cambio de mantenimiento sobre los datos. Añadir otra migración SQL u otro fallback legacy de Mongo y una rama de actualización es demasiado problema, así que prefieres evitar esos cambios.

Aquí de nuevo msgspec hace gran parte del trabajo pesado. Si queremos añadir o eliminar un campo, simplemente ponemos el nuevo campo en las estructuras de datos con un valor por defecto, o eliminamos cualquier campo antiguo. Migrará silenciosamente al nuevo formato. Si el formato que se carga no coincide con las estructuras, obtenemos un error que dice qué y dónde está mal.

Genial y simple, pero no suficiente para una base de datos.

De vez en cuando queremos renombrar un campo, cambiar tipos de datos o directamente reestructurar todos los datos, posiblemente obteniendo nuevos datos externos mientras tanto (yo lo he hecho). Esto requiere una función de migración real que sepa lo que está haciendo.

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

El formato es simple: el número de revisión de la base de datos viene directamente del nombre de la función. La función manipula el formato dict plano para que no necesitemos mantener msgspec.Structs de versiones antiguas. El docstring da la descripción registrada en el log de lo que se hizo.

Para evitar ensuciar el resto de nuestro programa con estas, las migraciones pueden ponerse en su propio módulo de Python, al que simplemente nos referimos con la ruta del módulo de Python al definir la base de datos:

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

Los metadatos del changeset y los snapshots contienen el número de versión que representan, y aplicamos todas las funciones migrate encontradas hacia arriba desde esa versión, y para completar la migración hacemos la conversión de msgspec en cualquier caso. Si algo de eso produjo cambios, registramos y almacenamos las migraciones hechas, y hacemos snapshot después.

Las funciones de migración más antiguas también pueden eliminarse, cuando tales versiones ya no necesiten ser soportadas, manteniendo todo este sistema de migraciones manejable.

Formato en disco

El formato por defecto sigue siendo deliberadamente poco sofisticado: registros JSON, uno por línea. Msgspec codifica bytes y otros tipos en él.

Antes de publicar, quería abordar una crítica que yo mismo haría sin duda sobre la falta de soporte binario, implementándolo como opción.

Mientras que con JSONL podemos buscar saltos de línea desde el final del archivo para encontrar un snapshot, los datos binarios podrían confundir al parser. Podemos leer desde el inicio del archivo para un enfoque totalmente determinista, pero si la base de datos fuera grande preferiríamos buscar cerca del final. También quería mantenerlo append-only, así que no podíamos simplemente escribir al inicio el offset del snapshot.

La solución es doble:

  • Un nonce aleatorio impredecible como palabra de sincronización
  • Hash Blake3 para verificación de integridad

Los payloads de los cambios en sí están actualmente codificados con MessagePack, que está soportado de serie en msgspec.

Me gustaría reemplazarlo por algo más cercano a Protocol Buffers, sin almacenar nombres de campos ni tipos en absoluto, aprovechando que msgspec ya conoce la estructura y los tipos de mis structs, y como tal debería ser capaz de derivar una representación binaria compacta de esa misma información.

Eso mantendría la propiedad que más me importa: una única definición de la estructura de datos, en lugar de un modelo de Python más un modelo de ORM más un esquema de serialización.

La base de datos es solo la mitad del estado

Mantener el estado autoritativo en memoria hace que otra cosa sea difícil de ignorar: la mayoría de las aplicaciones interactivas ya mantienen otra copia en otro lugar.

El navegador también tiene una.

Una vez que la propia base de datos registra un flujo ordenado de cambios, usar ese mismo flujo para la sincronización se convierte en un siguiente paso obvio.

Sincronizando stores por WebSocket

La app FastAPI envía cambios por un WebSocket y aplica los cambios entrantes de vuelta a la base de datos.

En cada cliente mantenemos una copia sombra, que es el último estado del servidor visto, y el estado de trabajo actual del cliente en su store nativo de Vue Pinia. O los equivalentes de Svelte o React.

El store es el eslabón conector que ofrece reactividad desde la base de datos hasta la interfaz de usuario y de vuelta.

El cliente puede trabajar offline o concurrentemente con otros clientes, y los cambios se fusionan cuando llegan al servidor, usando un three-way merge con resolución automática. Combinado con sobrescritura y validación/rechazo — definitivamente no queremos que los conflictos de merge requieran una solución manual al estilo git.

Actualmente tengo esta maquinaria integrada en las aplicaciones en lugar de empaquetada como parte de Kanta. El siguiente paso es extraerla en un módulo de sincronización separado compatible con Kanta, con adaptadores también para los frontends de Javascript.

Eso probablemente sea otro artículo.

Pruébalo

Todas estas decisiones de diseño solo importan si hacen el código de aplicación ordinario más simple.

Con Kanta, la base de datos no es algo que consulto a través de otro modelo. Defino el estado que quiero, lo abro, y trabajo con objetos normales de Python. Las lecturas son solo lecturas:

user = data.users[user_id]

Cuando quiero cambiar algo, hago ese cambio dentro de una transacción:

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

No hay un lenguaje de consultas en medio, ni un objeto ORM que traducir de vuelta a mi modelo de aplicación, ni un código de auditoría separado que recordar. La transacción, el historial, el rollback y la persistencia provienen todos de la misma operación.

uv add kanta

Añádelo a tu proyecto con uv o lee más en git.zi.fi.

imagen
La demo va deliberadamente un poco más allá del hello world. Crea una base de datos con una versión del modelo de datos, la modifica mediante varias transacciones, luego abre la misma base de datos con un modelo más nuevo, ejecuta una migración, demuestra el rollback, y continúa trabajando con ella normalmente. Ese es aproximadamente el tipo de ciclo de vida que quería volver aburrido.
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())