Todo producto SaaS atiende a más de un cliente desde el mismo código. Una pregunta muy concreta decide una parte sorprendente de tu arquitectura: cuando el cliente A y el cliente B guardan datos en tu sistema, ¿cómo evitas que A llegue a ver las filas de B?
Hay dos respuestas populares. Una base de datos compartida con una columna que indica a quién pertenece cada fila, o una base de datos independiente para cada cliente. La mayoría de las comparaciones se quedan en «depende». Este artículo trata de aquello de lo que realmente depende: los costes de ingeniería concretos que cada enfoque te traslada y cómo saber qué coste puede asumir tu producto.
Uso Laravel para los ejemplos concretos porque es con lo que trabajo, pero el compromiso en sí no depende del framework ni del lenguaje. La misma decisión aparece en Rails, Django, Spring y SQL puro.
Es un espectro, no una disyuntiva
Antes de las dos opciones, conviene ver que el «aislamiento» no es un interruptor único. Es un dial que suele describirse de pool (todo compartido) a silo (todo separado):
POOL ──────────────────────────────────────────────► SILO más barato, más denso más aislado, más caro
BD compartida ──► BD compartida ──► Base de datos ──► Infra separada esquema un esquema por por tenant por tenant compartido tenant (columna tenant_id)Cuanto más a la derecha, más fuerte es el aislamiento y mayor el coste por tenant. Las dos opciones que compara este artículo, el esquema compartido y la base de datos por tenant, son los dos puntos de este dial entre los que la mayoría de los equipos elige realmente. Los extremos (un esquema por tenant o infraestructura totalmente separada) son variaciones del mismo compromiso, no compromisos distintos.
La idea clave es lo que mide realmente el dial: si el aislamiento de los tenants lo impone tu código de aplicación o tu infraestructura. Todo lo que sigue se deriva de esa única distinción.
Enfoque 1: base de datos compartida, aislamiento en el código
Las filas de todos los tenants viven en las mismas tablas. Cada fila lleva una columna (tenant_id, organization_id, a veces solo user_id) que indica quién es su propietario. Aislar significa: toda consulta se filtra por esa columna, sin excepción.
-- Una tabla, con los datos de todos los tenants entremezcladosSELECT * FROM invoices WHERE tenant_id = ? AND status = 'overdue';El peligro está en las palabras «sin excepción». Si olvidas el filtro una sola vez, en una consulta en bruto, un informe o un job en segundo plano, le has mostrado a un cliente los datos de otro. Por eso las bases de código maduras no confían en que los desarrolladores se acuerden. Hacen que el filtro sea automático y difícil de eludir.
En Laravel eso es un global scope: una regla a nivel de modelo que añade en silencio la condición tenant_id a cada consulta, de modo que un simple Invoice::all() ya significa «las facturas de este tenant». Un trait rellena la columna al insertar para que tampoco puedas olvidarla al escribir:
trait BelongsToTenant{ protected static function booted(): void { static::creating(fn ($model) => $model->tenant_id ??= currentTenant()->id); static::addGlobalScope('tenant', fn ($query) => $query->where('tenant_id', currentTenant()->id) ); }}Ese patrón (una columna, un filtro automático al leer, un sellado automático al escribir) es toda la idea, en cualquier framework. Conviene añadir una segunda capa, las políticas de autorización, para que una solicitud que adivine por ID el registro de otro tenant reciba un 403 explícito en lugar de desaparecer en silencio de una lista filtrada.
¿Y la seguridad a nivel de fila?
Esta es la objeción natural a todo lo anterior: si el riesgo es un WHERE olvidado, ¿por qué no hacer que sea la base de datos la que imponga el filtro de tenant, de modo que una cláusula olvidada no pueda filtrar nada? Eso es exactamente lo que hace la seguridad a nivel de fila (RLS, row-level security), y es la opción más sólida a la que la mayoría de los equipos nunca recurre.
En PostgreSQL se activa sobre la tabla y se le asocia una política que lee el tenant actual de una variable de sesión:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices USING (tenant_id = current_setting('app.tenant_id')::int);Después la aplicación establece esa variable una vez por solicitud, y desde ese momento cada consulta sobre invoices la filtra la base de datos, tanto si la aplicación se acordó de añadir la condición como si no:
SET app.tenant_id = '42'; -- se define al inicio de la solicitudSELECT * FROM invoices; -- la base de datos solo devuelve las filas del tenant 42Un filtro olvidado, una consulta en bruto, un job de informes que se saltó el scope: ninguno puede ya filtrar datos entre tenants, porque la imposición ya no vive en el código de la aplicación. Esto desplaza de verdad el aislamiento del esquema compartido de «impuesto por el código» hacia «impuesto por la infraestructura», y estrecha la brecha de radio de impacto con la base de datos por tenant más que ninguna otra cosa de esta lista.
No es gratis, sin embargo, y las salvedades son la razón por la que se usa poco:
- La variable de sesión debe establecerse correctamente en cada conexión. Es la nueva trampa. Con un pool de conexiones que reparte conexiones reutilizadas, una variable que quedó de una solicitud anterior es en sí misma una fuga entre tenants, así que debe limitarse a la transacción (
SET LOCAL) o reiniciarse cada vez. - Los propietarios de tablas y los superusuarios eluden la RLS por defecto. El rol con el que se conecta tu aplicación debe ser un rol sin propiedad, o tienes que usar
FORCE ROW LEVEL SECURITY; de lo contrario, la política no hace nada para ese rol sin avisar. - Cada consulta lleva el predicado de la política, lo que tiene implicaciones reales en el planificador y el rendimiento (normalmente aceptable con un índice sobre
tenant_id, pero no es cero). - Es una función de Postgres / SQL Server, no universal. MySQL no tiene RLS nativa, y como la pila por defecto de Laravel usa MySQL, es una opción a la que te acoges con otra base de datos, no algo que te dé el framework. Laravel tampoco configurará la variable de sesión por ti; la establecerías en un listener de conexión o en un middleware de solicitud.
Dónde encaja: la RLS te permite conservar la densidad y la simplicidad operativa de una única base de datos compartida y, a la vez, obtener un aislamiento que impone la base de datos en lugar de confiar en que cada consulta se porte bien. Si estás en Postgres y el aislamiento importa, suele ser una respuesta mejor que saltar directamente a una base de datos por tenant.
Enfoque 2: base de datos por tenant, aislamiento en la infraestructura
Cada tenant tiene su propia base de datos. El ciclo de vida de la solicitud gana un paso al principio:
- Llega una solicitud, por ejemplo para
acme.app.com. - La aplicación determina de qué tenant se trata, normalmente a partir del subdominio, un dominio personalizado o un token.
- Apunta la conexión de base de datos a la base de datos de ese tenant (
acme_db). - Todo lo que sigue es una aplicación normal de un solo tenant: consultas ordinarias, sin filtrado.
No hay ninguna columna tenant_id en ninguna parte, porque no hay nada que filtrar: la conexión es la frontera.
Una cláusula WHERE que falta ya no puede filtrar datos entre tenants, porque los datos del otro tenant no son accesibles en esta conexión. Ese es el atractivo en una frase: el aislamiento se mantiene incluso cuando tu código tiene un fallo.
En Laravel la herramienta habitual es stancl/tenancy, que se ocupa de la resolución del tenant, de crear y migrar la base de datos de cada tenant al registrarse y, sobre todo, de hacer que la caché, las colas y el almacenamiento de archivos también tengan en cuenta al tenant, no solo la conexión de base de datos. (Un paquete más ligero, spatie/laravel-multitenancy, solo identifica el tenant actual y te deja la lógica de separación de datos.) Pero, de nuevo, el mecanismo es universal: resolver el tenant, cambiar de conexión, ejecutar consultas normales.
Los costes que realmente deciden
Aquí es donde la mayoría de las comparaciones se quedan en lo vago. La elección es un intercambio de qué problemas prefieres tener. Expuesto directamente:
| Dimensión | BD compartida | BD por tenant |
|---|---|---|
| Radio de impacto de un bug | Filtra a todos los tenants | Contenido a uno solo |
| Aislamiento impuesto por | El código de la aplicación | La infraestructura |
| Migraciones | Se ejecutan una vez | Se ejecutan × N bases |
| Coste por tenant | Casi cero | Sobrecoste fijo |
| Vecinos ruidosos | Contención compartida | Aislados por naturaleza |
| Consultas entre tenants | Una consulta | Fan-out sobre N |
| Eliminar un tenant | Borrados filtrados | Eliminar la base de datos |
| Conexiones | Un solo pool | Límites de pool por base |
Cuatro de estos puntos valen más que una celda de tabla.
El radio de impacto es el verdadero titular
Es la dimensión que más se infravalora. En una base de datos compartida cuyo aislamiento solo lo impone el código, el peor caso es catastrófico y total: un único filtro olvidado en un endpoint de informes puede exponer los datos de todos los clientes a la vez. (Esa es exactamente la brecha que cierra la seguridad a nivel de fila, y por eso merece el esfuerzo en Postgres.) En la base de datos por tenant, el mismo tipo de bug queda contenido: los datos físicamente no están en la conexión, así que no hay nada que filtrar. Aquí no compras comodidad, compras un peor caso más pequeño. Para algunos productos (historiales de salud, datos financieros, cualquier cosa con una ley de notificación de brechas asociada), ese peor caso más pequeño es toda la decisión.
La carga operativa crece con el número de tenants
Una base de datos compartida tiene un esquema que migrar, una cosa que respaldar, un conjunto de métricas que vigilar. La base de datos por tenant multiplica todo eso por tu número de tenants. Una migración ya no es una sola sentencia. Es la misma sentencia ejecutada contra cada base de datos de tenant por turno, y una mala puede completarse a medias en cientos de ellas antes de que lo notes. Los respaldos, la monitorización y la pregunta «¿está realmente cada base de datos en el último esquema?» pasan a ser problemas de flota en lugar de problemas de un único objeto.
La densidad de recursos es un coste, no una nota al pie
Toda base de datos tiene un sobrecoste fijo (memoria, huella de conexiones, almacenamiento de respaldos, un tamaño mínimo), tanto si contiene una fila como un millón. Una base de datos compartida lo amortiza entre todos los tenants. Las bases de datos por tenant lo pagan por tenant. Si tus tenants son miles de usuarios pequeños y baratos de atender, ese sobrecoste puede dominar tu factura de infraestructura sin ningún beneficio de aislamiento que esos usuarios necesiten realmente. Por eso el modelo compartido domina los productos B2C de alto volumen y bajo precio por tenant: a menudo es el único enfoque cuya economía unitaria siquiera funciona.
La gestión de conexiones es el punto delicado. Un pool de conexiones a una base de datos es rutina. No puedes mantener abierto un pool hacia diez mil bases de datos de tenants a la vez; la resolución tiene que conectarse, trabajar y liberar, y hacerlo mal es una forma clásica de agotar el límite de conexiones de un servidor de base de datos bajo carga.
Vecinos ruidosos y crecimiento desigual
En una base de datos compartida, los tenants compiten por la misma CPU, la misma E/S y la misma caché del planificador de consultas. Un cliente que ejecuta un informe enorme, o que simplemente crece diez veces más rápido que los demás, degrada la experiencia de todos. Se combate con índices, réplicas de lectura y límites de consultas, pero no se puede eliminar del todo; la contención es estructural.
La base de datos por tenant convierte ese mismo crecimiento desigual en una opción en lugar de un problema: como cada tenant ya es una base de datos independiente, un tenant que supera al resto puede trasladarse a hardware dedicado sin tocar la configuración de nadie más. Pero esa opción no es gratuita; se paga en otra moneda. En cuanto un tenant vive en otro sitio, tu infraestructura es heterogénea: migraciones, despliegues, respaldos y monitorización tienen que tener en cuenta tenants en lugares distintos, y «¿dónde vive realmente este tenant?» se convierte en una pregunta que tu sistema debe responder correctamente en cada solicitud. Has cambiado un problema de contención de rendimiento por un problema de gestión de flota. Cuál de los dos puede asumir mejor tu equipo es una parte real de la decisión.
Un marco para elegir
Quitando los detalles, todo se reduce a cuatro preguntas:
- ¿La separación física es un requisito, no una preferencia? Si un contrato con un cliente, un regulador o una ley de residencia de datos dice que los datos de un tenant deben estar físicamente separados de los de otro, eso lo decide: recurre a la base de datos por tenant (o más a la derecha en el espectro) con independencia de todo lo demás. Es la razón más común por la que los sistemas reales siguen este camino y, notablemente, es un requisito de negocio, no técnico.
- ¿Cómo es tu base de tenants? Un puñado de clientes grandes y de alto valor hace fácil justificar el sobrecoste por tenant. Miles de tenants pequeños y de bajo precio lo vuelven ruinoso; el argumento de la densidad gana con claridad.
- ¿Qué importancia tienen los datos entre tenants para el producto? Analítica de toda la plataforma, facturación por uso, paneles de administración que abarcan a todos los clientes: todo es trivial en una base de datos compartida y un problema de ingeniería real sobre bases separadas. Si esto es central en lo que construyes, dale mucho peso.
- ¿Qué fallo puedes permitirte menos? Esta es en la que conviene detenerse. El peor día de una base de datos compartida es una fuga de datos entre tenants. El peor día de una configuración por tenant es operativo: una migración que corrompió un subconjunto de bases de datos, un tenant atascado en un esquema antiguo. Elige la arquitectura cuyo peor caso puedan sobrevivir realmente tu producto y tu equipo.
La opción por defecto y cuándo abandonarla
El consejo acumulado en la comunidad backend llega a un punto coherente, y el desglose de costes explica por qué: empieza con una base de datos compartida y un alcance por columna, y pasa a la base de datos por tenant solo cuando algo concreto te obligue.
«Algo concreto» casi nunca es la escala por sí sola; una base de datos compartida bien indexada llega muy lejos. Es un requisito: un contrato firmado que exige aislamiento, una normativa sobre dónde se ubican físicamente los datos, un único tenant tan grande que necesita su propio hardware. A falta de alguno de ellos, el modelo compartido te da menos superficie operativa, mejor economía unitaria y consultas entre tenants triviales, y conservas la opción de sacar más adelante a un tenant exigente, lo cual es genuinamente más fácil que devolver una flota entera de silos a una sola base de datos.
El error no es elegir una u otra. Es elegir por defecto la arquitectura más aislada y más cara porque parece más rigurosa, y luego cargar con su peso operativo por una garantía de aislamiento que ninguno de tus clientes pidió nunca.