1 puntos por GN⁺ 2025-04-29 | 1 comentarios | Compartir por WhatsApp
  • Explica cómo construir una arquitectura que use una base de datos separada por cada tenant en Rails y los desafíos del proceso
  • ActiveRecord está diseñado asumiendo una sola conexión a BD por defecto, por lo que cambiar conexiones por tenant resulta complejo y delicado
  • Propone usar la función connected_to de Rails 6 o superior para cambiar dinámicamente la conexión en tiempo de ejecución
  • SQLite3 es adecuado para manejar muchas BD independientes y pequeñas, lo que facilita tareas como respaldo, depuración y eliminación
  • A diferencia de la infraestructura de Rails, que ha evolucionado enfocándose en la optimización de sistemas a gran escala, se enfatiza que también es posible una arquitectura centrada en bases de datos pequeñas e independientes

Por qué usar una base de datos separada por tenant

  • Si se separa por unidad de tenant (Site), que opera de forma independiente dentro del modelo de datos, el aislamiento y la gestión de los datos se vuelven más sencillos
  • Guardar los datos de cada tenant en una BD distinta también resulta ventajoso para la escalabilidad de sitios grandes y para temas de seguridad
  • Con SQLite, se puede operar una base de datos con un solo archivo sin configuración de servidor, lo que la hace simple y flexible

Dificultades en Rails

  • Aunque las operaciones básicas de SQLite como open/close son muy simples, ActiveRecord tiene internamente una estructura compleja de gestión de conexiones
  • ActiveRecord está diseñado para usar conexiones fijadas a los modelos, por lo que cambiar de tenant en tiempo de ejecución es difícil
  • El pool de conexiones, el caché de consultas y el caché de esquema dependen de la conexión, así que cambiarla cada vez implica una carga considerable

Historia del manejo de múltiples bases de datos en Rails

  • Rails 1: se podía especificar la BD a nivel de ActiveRecord::Base
  • Rails 3: se introdujo el pool de conexiones
  • Rails 4: se añadió connection_handling
  • Rails 6: se introdujo connected_to
  • Rails 7: se amplió connected_to y se añadió soporte para sharding
  • Pero incluso así, escenarios como "agregar/eliminar tenants dinámicamente en tiempo de ejecución" siguen sin tener soporte nativo

Ventajas de una base de datos por tenant

  • Se pueden respaldar o restaurar solo los archivos de cada tenant, lo que simplifica la operación y la depuración
  • Eliminar un tenant puede hacerse simplemente borrando el archivo (unlink)
  • Los grandes servidores de bases de datos optimizan BD de decenas de terabytes, mientras que SQLite está optimizado para miles de BD pequeñas
  • De hecho, iCloud también adopta una estructura en la que almacena millones de pequeñas BD SQLite sobre Cassandra

Proceso para resolver el problema

  • El enfoque anterior (establish_connection manual) provocaba errores de ConnectionNotEstablished en entornos con múltiples accesos
  • Adaptándose al enfoque posterior a Rails 6, se cambió a una estructura que deja el manejo a Rails en lugar de administrar manualmente el pool de conexiones
  • Se crea dinámicamente un connection pool para cada tenant y se envuelven las operaciones dentro de un bloque connected_to
  • Se mejoró el enfoque usando middleware para preparar y liberar dinámicamente la conexión a la BD necesaria en el momento de la solicitud

Patrón de código clave

  • Verificar el pool de conexiones y crearlo si no existe
MUX.synchronize do  
  if ActiveRecord::Base.connection_handler.connection_pool_list(role_name).none?  
    ActiveRecord::Base.connection_handler.establish_connection(database_config_hash, role: role_name)  
  end  
end  
  • Tras conectarse, ejecutar consultas de forma segura dentro del bloque connected_to
ActiveRecord::Base.connected_to(role: role_name) do  
  pages = Page.order(created_at: :desc).limit(10)  
end  

Manejo de streaming en Rack

  • Si la respuesta de Rack es streaming, para gestionar la conexión de forma segura se usan Rack::BodyProxy y Fiber para cerrar la conexión correctamente
connected_to_context_fiber = Fiber.new do  
  ActiveRecord::Base.connected_to(role: role_name) do  
    Fiber.yield  
  end  
end  
connected_to_context_fiber.resume  
  
status, headers, body = @app.call(env)  
body_with_close = Rack::BodyProxy.new(body) { connected_to_context_fiber.resume }  
  
[status, headers, body_with_close]  

Estructura final del middleware

  • Se crea el middleware Shardine::Middleware, que en cada solicitud busca la conexión de BD adecuada, cambia con connected_to y hace la limpieza al terminar la respuesta
  • Puede aplicarse en el archivo config.ru del proyecto Rails así:
use Shardine::Middleware do |env|  
  site_name = env["SERVER_NAME"]  
  {adapter: "sqlite3", database: "sites/#{site_name}.sqlite3"}  
end  

Tareas pendientes

  • En ActiveRecord 6 todavía no se aprovecha la función shard, pero en versiones posteriores también es posible separar lectura y escritura
  • Aún no se implementa una función para limpiar el pool de conexiones al eliminar un tenant, porque todavía no ha sido necesaria
  • Es muy probable que en el futuro reciba más atención una arquitectura que maneje "muchas bases de datos pequeñas"

1 comentarios

 
GN⁺ 2025-04-29
Opiniones en Hacker News
  • están usando el enfoque de "una base de datos por tenant" con alrededor de 1 millón de usuarios

    • este enfoque encaja bien en apps centradas en lectura, y como la mayoría de los tenants son pequeños y no tienen muchos registros por tabla, incluso los joins complejos son muy rápidos
    • el principal problema es que hay que migrar cada base de datos individualmente, por lo que los tiempos de release pueden aumentar mucho
    • si aparece schema drift o data drift, el release se detiene y hay que averiguar por qué una función no está funcionando para algunos tenants
  • les gusta SQLite, pero se preguntan si las bases de datos OLTP tradicionales necesitan descargar de memoria parte de sus índices

    • al usar una base de datos por usuario, no se mantiene nada en memoria para usuarios inactivos o usuarios activos solo en otras instancias
    • esto es similar a la situación de JSON en Mongo, y Postgres es dos veces más rápido que Mongo
  • la mayoría de la gente no necesita una base de datos por tenant, y no es el enfoque habitual

    • hay casos específicos en los que eso compensa desventajas como las migraciones y el schema drift
    • que se pueda usar no significa que necesariamente se deba usar
    • hay que avanzar con cuidado y saber que realmente necesitas una base de datos por tenant
  • como enfoque intermedio, se puede considerar lo siguiente

    • identificar a los N tenants principales
    • separar la DB para esos tenants
    • esos N principales se determinan según IOPS, importancia (en términos de ingresos), etc.
    • el modelo de datos debe estar diseñado para poder extraer las filas correspondientes a cada tenant
  • casualmente, están trabajando en FeebDB para Elixir

    • puede verse como una alternativa a Ecto, que no funciona bien cuando hay miles de bases de datos
    • empezó principalmente como un experimento divertido, pero habría sido de gran ayuda en todos los lugares donde trabajaron antes
    • el objetivo es eliminar o reducir los problemas típicos del enfoque de una base de datos por tenant
    • garantía de un solo escritor por cada base de datos
    • gestión mejorada de conexiones para todos los tenants
    • soporte para migraciones y backups cuando haga falta
    • soporte para operaciones de map/reduce/filter sobre múltiples DB
    • soporte para despliegue en clúster
  • Forward Email hace algo parecido usando una sqlite db cifrada para cada buzón/usuario

    • es una excelente forma de diferenciar la protección por usuario
  • el nombre es muy bueno. Hace pensar en Sean Connery

  • el workflow de "una base de datos por tenant" recién está empezando

    • James Edward Gray habló de esto en RailsConf 2012
  • usaron algo similar en el pasado y quedaron muy satisfechos

    • si el usuario quiere sus datos, se le puede entregar toda la base de datos
    • si el usuario elimina su cuenta, se puede resolver fácilmente con rm username.sql
    • el compliance se vuelve muy sencillo
  • cuando los datos están aislados entre sí y no hay problemas de escalado dentro de un solo tenant, es difícil hacer un mal diseño

    • casi cualquier cosa va a funcionar