En el desarrollo de aplicaciones web modernas, la gestión eficiente de datos en entornos multitenant es un desafío constante. Una arquitectura multitenant implica que múltiples clientes o “inquilinos” comparten la misma aplicación, pero sus datos deben estar perfectamente aislados para garantizar la seguridad y el rendimiento. Dentro de este contexto, surge el patrón de base de datos por cliente, una solución que ofrece aislamiento total mediante el uso de una base de datos independiente para cada cliente. Este esquema es especialmente eficaz cuando se utiliza SQLite3 como motor de base de datos para manejar pequeños volúmenes de datos distribuidos en un elevado número de inquilinos, una práctica que podría parecer contraintuitiva a primera vista, pero que tiene muchas ventajas prácticas y técnicas si se implementa correctamente. La implementación del patrón de base de datos por cliente con Rails y ActiveRecord, sin embargo, presenta desafíos únicos derivados de la forma en que Rails maneja las conexiones a bases de datos.
Tradicionalmente, ActiveRecord asocia un modelo con una conexión a base de datos estática, definida a través del archivo de configuración database.yml o mediante el uso de métodos como establish_connection. Estos enfoques funcionan para arquitecturas donde las bases de datos son fijas y conocidas al momento de la configuración, como en sistemas monolíticos o con una configuración limitada de bases de datos centralizadas. Pero cuando la necesidad es crear, eliminar o cambiar conexiones sobre la marcha, para cientos o miles de clientes – cada uno con su propia base de datos SQLite3 –, las soluciones convencionales resultan insuficientes o propensas a errores. Además, el ecosistema Rails ha evolucionado a lo largo de varias versiones, introduciendo funcionalidades como connection pools, soporte para shards y funcionalidades de rol dentro del manejo de conexiones.
A pesar de estas mejoras, no existe un soporte nativo claro y seguro para gestionar dinámicamente conexiones a bases de datos por inquilino en tiempo de ejecución, especialmente en entornos multihilo y con solicitudes concurrentes, lo que es común en aplicaciones web escalables. Uno de los grandes obstáculos que enfrentan los desarrolladores es la gestión correcta del ciclo de vida de las conexiones. En el caso de SQLite3, la conexión es simplemente un archivo en disco, lo que facilita la creación y eliminación de bases de datos pequeñas. A diferencia de otros motores que mantienen recursos intensivos como descriptores de archivos o cachés de memoria. Esto hace a SQLite3 ideal para escenarios de “datos pequeños” y “bases de datos múltiples” donde cada base de datos representa a un único cliente o sitio.
En aplicaciones donde los datos de cada sitio son ampliamente independientes y rara vez se combinan, el patrón de base de datos por cliente es ventajoso. Proporciona aislamiento natural, simplifica las copias de seguridad y restauraciones a nivel granular y facilita la depuración, dado que se puede operar directamente con la base de datos de un único inquilino sin interferir en otros. Además, gestionar las migraciones de esquema por separado para cada base de datos permite una mayor flexibilidad y reduce el riesgo de errores ocasionados por operaciones globales sobre múltiples clientes. Sin embargo, la implementación práctica de este patrón en Rails requiere una gestión delicada de múltiples conexiones. Usar establish_connection en cada modelo no es factible por sí solo, ya que Rails está diseñado para utilizar conexiones compartidas y cacheadas, controladas centralizadamente.
Intentar crear conexiones por demanda en múltiples hilos puede derivar en errores frecuentes como ActiveRecord::ConnectionNotEstablished, lo cual ocurre cuando las conexiones se cierran o no están disponibles en el momento de la consulta. La solución a estos problemas necesita aprovechar las capacidades recientes de ActiveRecord introducidas a partir de Rails 6, principalmente el método connected_to, que permite dentro de un bloque específico cambiar el contexto de conexión a un rol o shard definido. Esto facilita la abstracción para cambiar dinámicamente la conexión con un manejo implícito del pool de conexiones, reduciendo la complejidad del código y mejorando la seguridad de la operación en ambientes concurrentes. No obstante, para utilizar esta funcionalidad en un entorno de base de datos por cliente con SQLite3, es necesario generar dinámicamente las configuraciones de conexión y gestionar la creación o eliminación de pools de conexión según el inquilino solicitado. Por esta razón, se ha desarrollado una librería middleware denominada Shardines que implementa esta lógica de forma eficiente y segura.
Shardines funciona interceptando la solicitud entrante y determinando el nombre del sitio o inquilino a partir del entorno (como la cabecera SERVER_NAME). A partir de este dato, construye la configuración de conexión de forma dinámica apuntando al archivo SQLite3 correspondiente. Mediante un mutex, garantiza la creación sincronizada de los pools de conexión, evitando condiciones de carrera al momento de establecer conexiones para nuevos inquilinos. Una vez creada la conexión, se utiliza la función ActiveRecord::Base.connected_to para ejecutar el resto del ciclo de vida de la solicitud HTTP bajo el contexto del rol asignado al inquilino especificado.
Esto asegura que todas las consultas generadas por ActiveRecord durante la solicitud operen únicamente sobre la base de datos correcta, preservando la integridad y el aislamiento de los datos. Un aspecto crítico que no debe ser olvidado en esta solución es el manejo apropiado de cuerpos de respuesta en Rack cuando se utiliza streaming. Dado que el cuerpo de la respuesta puede ejecutar consultas a base de datos incluso después de que la llamada principal ha finalizado, Shardines emplea Fibers y Rack::BodyProxy para mantener el contexto de conexión activo hasta que la respuesta ha sido completamente enviada, evitando cierres prematuros de conexiones y asegurando la correcta liberación de recursos. La implementación práctica de esta solución aporta beneficios tangibles para proyectos multitenant con bases de datos SQLite3. Permite escalar la cantidad de sitios o clientes atendidos sin incurrir en la sobrecarga y complejidad de una base de datos monolítica gigantesca.
Facilita la gestión y portabilidad de datos a nivel granular y mejora la estabilidad del sistema evitando errores de conexión bajo carga. Es importante señalar que algunos retos permanecen abiertos, como la falta de soporte para eliminar conexiones cuando un inquilino es dado de baja o la ausencia de integración directa con shards para aprovechar réplicas de lectura en bases SQLite3. Sin embargo, la comunidad y el ecosistema Rails están en constante evolución, y es probable que estas funcionalidades se incorporen o mejoren en el futuro cercano. Finalmente, el patrón de base de datos por cliente usando SQLite3 con ActiveRecord y Rails no solo es viable sino ampliamente beneficioso para escenarios de aplicaciones pequeñas o medianas con gran cantidad de inquilinos. Permite que desarrolladores y empresas adopten una arquitectura limpia, segura y eficiente sin depender de infraestructuras complejas de bases de datos centralizadas y pesadas.
La solución Shardines representa un paso significativo hacia la madurez de esta práctica, contribuyendo a un manejo más natural y avanzado de multitenencia en el ecosistema Rails. El futuro de la multitenencia con bases de datos pequeñas repartidas, usando SQLite3 y ActiveRecord, se ve prometedor y plantea nuevas oportunidades para diseñar sistemas robustos y escalables con un enfoque ágil y minimalista. Adaptarse a estos avances y aprovechar herramientas como Shardines puede ser el factor diferencial para proyectos que buscan eficiencia, simplicidad y escalabilidad en su gestión de datos.