SQL, NoSQL y por qué comencé Kanta DB

SQL sigue siendo el predeterminado por una razón…

Si estuviera construyendo un servicio Python convencional hoy, probablemente comenzaría con PostgreSQL, SQLAlchemy y Alembic. PostgreSQL me proporciona UUID reales, JSONB, matrices, transacciones, restricciones y una excelente indexación. SQLAlchemy mapea la mayor parte de eso de forma limpia en Python. Alembic mantiene los cambios de esquema explícitos y versionados.

Eso llega bastante lejos antes de que nada empiece a molestarme.

… pero el modelo tiene bordes

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

Con SQLAlchemy puedo definir modelos tipados, usar uuid.UUID Directamente contra las 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 base de datos como una cadena o ensamblar SQL manualmente.

Aún así, el modelo ORM se convierte en un tipo especial de objeto. Contiene columnas, relaciones, comportamiento de sesión y reglas de persistencia. Si el resto del programa quiere estructuras de aplicación simples, o bien dejo que las preocupaciones de la base de datos se extiendan hacia el exterior o agrego 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 los datos persistentes significa cambiar el modelo y crear una migración. Los cambios más complejos requieren transformaciones de datos y decisiones de compatibilidad. Alembic maneja esto de manera sensata, pero todavía tengo que mantener un historial de cambios estructurales solo para que las filas antiguas puedan convertirse en nuevas filas.

Eso no es un fallo en PostgreSQL. La base de datos se ha comprometido con un esquema, por lo que cambiarlo tiene consecuencias.

La historia crea otra capa. Si quiero saber quién cambió un valor, cuándo lo cambió o cómo se veía el registro el martes pasado, necesito modelar eso. Puedo agregar tablas de auditoría, desencadenadores, columnas de marca de tiempo o una capa de event-sourcing. PostgreSQL puede soportar todo esto bastante bien.

Pero la fila normal todavía representa lo que existe ahora. La historia sigue siendo algo que construyo a su alrededor.

Alternativas a SQL

Qué tal una buena taza de NoSQL

MongoDB elimina cierta fricción

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

Puedo almacenar documentos anidados directamente, agregar campos sin alterar una tabla y permitir que los registros más antiguos y más 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 documentos antiguos cuando los leo o los modifico.

El esquema todavía existe. Mi código todavía espera 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 un caché, cola, contador, conjunto ordenado, bloqueo distribuido o estado compartido efímero, Redis generalmente tiene una respuesta compacta.

Por supuesto, cuando mis datos llegan en JSON u otro formato estructurado, también depende completamente de mí desglosarlos en esas primitivas de Redis, o simplemente descargarlos en su totalidad ignorando todas las herramientas sofisticadas.

Empujando y jalando

Háganme saber cuando alguien toque mis datos

Firebase comienza desde la sincronización

Firebase toma una ruta más directa. Sus bases de datos tratan las actualizaciones en vivo de 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 fuera de línea y la reconexión también pertenecen al mismo sistema en lugar de aparecer más tarde como un proyecto de WebSocket.

Eso es atractivo.

Tenemos todos los cambios

MongoDB tiene una respuesta integrada sólida para esto. Los flujos de cambio me permiten monitorear una colección, base de datos o despliegue completo y recibir inserciones, actualizaciones, eliminaciones, etc. Las actualizaciones normalmente incluyen los campos modificados, y cada evento lleva un token de resumen, por lo que puedo volver a conectarme y continuar desde donde lo dejé, siempre y cuando el oplog todavía contenga ese punto.

PostgreSQL también puede exponer los cambios reales de la base de datos a través de la decodificación lógica y la replicación. Redis Change Streams ofrece una solución similar en ese ámbito.

Los sistemas de captura de datos de cambio construyen pipelines muy capaces encima de eso, pero luego necesitamos analizar las sentencias SQL o los comandos de Redis y hacer un seguimiento del estado nosotros mismos.

Publicar y suscribirse

En PostgreSQL y Redis también ofrecen canales de mensajes de la vieja escuela.

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 bus de mensajes no relacionado.

Pero todavía tengo que crear y recibir mensajes de notificación para decidir qué leer y qué enviar. Y esto conlleva condiciones de carrera entre el cambio real y la notificación.

Para mi problema, se sentía como comenzar bastante por debajo de la abstracción que realmente quería.

No solo quería saber que el estado había cambiado.
Quería el cambio en sí, y el estado antes y después.

¿Quizás debería hacer el mío propio?

Después de haber utilizado todo lo anterior, y siempre encontrando que el ORM se infiltraba en mi base de código y se volvía insoportable, finalmente comencé a trabajar en una idea que había desarrollado silenciosamente durante años y años.

Para hacer mi propia base de datos. Sabes, algo que siempre te dicen que ni siquiera intentes hacer.

Deje que el registro sea la base de datos

Eso se convirtió en el punto de partida para Kanta.

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

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

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

Una estructura de principio a fin

Msgspec me proporciona estructuras de datos compactas y escritas con una serialización muy rápida. Similar a las clases de datos o Pydantic, maneja estructuras anidadas y tipos nativos comunes como UUID, enums y fechas y horas sin convertir los objetos en entidades de ORM. Definir tus estructuras de datos se vuelve tan simple como esto:

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 a través de la red. Puedo persistir sus cambios. Puedo reconstruirlo en el 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 que medien entre todos ellos.

Está vivo

Hackeé juntamente un simple registrador JSONL, un cambio por línea, con un registro de cambios jsondiff en él. No era un formato binario como podría haber preferido, pero es algo muy sencillo 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!

Escribe que se necesita pensar más. 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 cambio.

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

Tenga en cuenta que no hay async with O await Allí, aunque estamos trabajando en Python asíncrono, la transacción es inmediata y sincrónica. No requieres ningún bloqueo o sincronización al respecto. Los estados antes y después se almacenan y se comparan para producir la diferencia de cambio. En caso de error, se restaura el estado anterior, haciendo retroceder todo lo que ya se ha hecho en esa transacción.

Los cambios se persisten en el disco por un hilo en segundo plano, en un archivo de solo apéndice que se puede reparar fácilmente en caso de cualquier corrupción debida a una pérdida de energía o a fallos.

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

¿Por qué no probarlo en producción?

Después de probarlo con mis propios proyectos, rápidamente lo conecté 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 después de todo, de hecho estábamos utilizando JSON para una base de datos, y en una estructura de logdb que, que yo sepa, nadie más había utilizado desde los primeros tiempos de la informática.

Efectivamente, volver a reproducir grandes conjuntos de cambios al iniciar la aplicación se volvió lento con el tiempo, así que agregué líneas de instantáneas completas para evitar las largas reproducciones de la revisión inicial. Después de eso, el rendimiento ha superado todas mis necesidades.

Pero el punto principal no es el rendimiento, sino la simplicidad. Al asumir que nuestra aplicación puede vivir en un único proceso de trabajo 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 registro, rebobinar la historia es gratuito. Incluso he deshecho una serie de transacciones en medio de la historia, para rescatar a un usuario que había eliminado parte del proyecto y luego realizado más cambios.

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

Migraciones

Imagen
Inspecciona cualquier sección de tu base de datos, buscando los datos que necesitas

Herramientas

Kanta también tiene una pequeña CLI para inspeccionar bases de datos directamente. Puede reproducir una base de datos, inspeccionar rangos seleccionados de su historial, cargar los tipos de datos reales de la aplicación y ejecutar migraciones cuando sea necesario, y volcar el estado resultante como JSON. Una buena herramienta también es mejor que tratarlo como un archivo de texto.

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

A nadie nunca le han gustado las migraciones. Son la carga de hacer cualquier cambio de mantenimiento en los datos. Añadir otra migración SQL o otra rama de actualización y fallback de Mongo es simplemente demasiado problemático, por lo que prefieres evitar esos cambios.

Aquí de nuevo, msgspec hace gran parte del trabajo pesado. Si queremos añadir o eliminar un campo, simplemente colocamos el nuevo campo en las estructuras de datos con un valor predeterminado, o eliminamos cualquier campo antiguo. Se migrará silenciosamente al nuevo formato. Si el formato que se está cargando no coincide con las estructuras, obtenemos un error que indica qué y dónde está el problema.

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

De vez en cuando queremos renombrar un campo, cambiar tipos de datos o reestructurar por completo todos los datos, posiblemente obteniendo nuevos datos externos mientras tanto (yo he hecho eso). 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 proviene directamente del nombre de la función. La función manipula el formato de diccionario simple para que no necesitemos mantener los Structs de versiones anteriores de msgspec. El docstring proporciona la descripción registrada de lo que se hizo.

Para evitar llenar el resto de nuestro programa con estos, las migraciones se pueden poner 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 y las instantáneas del conjunto de cambios contienen el número de versión que representan, y aplicamos todas las funciones de migración encontradas desde esa versión, y para completar la migración, realizamos la conversión de msgspec en cualquier caso. Si alguno de estos cambios resultó, registramos y almacenamos las migraciones realizadas, y una instantánea posterior.

Las funciones de migración más antiguas también pueden eliminarse cuando ya no es necesario soportar dichas versiones, manteniendo todo este sistema de migración manejable.

Sobre el formato del disco

El formato predeterminado 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 sin duda yo mismo haría sobre la falta de soporte binario, implementando eso como una opción.

Aunque con JSONL podemos buscar nuevas líneas desde el final del archivo para encontrar una instantánea, los datos binarios podrían confundir al analizador. 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 solo para añadir, por lo que no podíamos simplemente escribir el desplazamiento de la instantánea al principio.

La solución es doble:

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

Las cargas útiles de cambio en sí mismas están actualmente codificadas con MessagePack, que es fácilmente compatible con msgspec.

Me gustaría reemplazar eso con algo más cercano a Protocol Buffers, sin almacenar los nombres y tipos de campo en absoluto, aprovechando que msgspec ya conoce la estructura y los tipos de mis estructuras, y por lo tanto debería poder derivar una representación binaria compacta a partir de esa misma información.

Eso mantendría la propiedad que más me importa: una 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 autoritario en la memoria hace que otra cosa sea difícil de ignorar: la mayoría de las aplicaciones interactivas ya mantienen otra copia en algún otro lugar.

El navegador también tiene uno.

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

Sincronizando tiendas a través de WebSocket

La aplicación FastAPI envía cambios a través de un WebSocket y aplica los cambios entrantes de vuelta a la base de datos.

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

La tienda es el enlace de conexión que ofrece reactividad desde la base de datos hasta la interfaz de usuario y viceversa.

El cliente puede trabajar fuera de línea o de forma concurrente con otros clientes, y los cambios se fusionan cuando llegan al servidor, utilizando una fusión a tres vías con resolución automática. Combinado con la sobrescritura y la validación/rechazo — definitivamente no queremos que los conflictos de fusión requieran una solución manual al estilo de Git.

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

Ese es probablemente otro artículo.

Dale una vuelta

Todas estas opciones de diseño solo importan si hacen que el código de la aplicación ordinaria sea 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 en objetos Python normales. 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 consulta intermedio, ningún objeto ORM que traducir de vuelta a mi modelo de aplicación, y ningún 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](/_f/13b09b907d2b.png “La demostración va deliberadamente un poco más allá de “Hola, mundo”. Crea una base de datos con una versión del modelo de datos, la modifica a través de 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 hacer 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())