- 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
Opiniones en Hacker News
están usando el enfoque de "una base de datos por tenant" con alrededor de 1 millón de usuarios
les gusta SQLite, pero se preguntan si las bases de datos OLTP tradicionales necesitan descargar de memoria parte de sus índices
la mayoría de la gente no necesita una base de datos por tenant, y no es el enfoque habitual
como enfoque intermedio, se puede considerar lo siguiente
casualmente, están trabajando en FeebDB para Elixir
Forward Email hace algo parecido usando una sqlite db cifrada para cada buzón/usuario
el nombre es muy bueno. Hace pensar en Sean Connery
el workflow de "una base de datos por tenant" recién está empezando
usaron algo similar en el pasado y quedaron muy satisfechos
rm username.sqlcuando 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