MySQL vs PostgreSQL para SaaS multitenant: aislamiento de inquilinos que resiste el crecimiento

¿Te fue útil?

El SaaS multitenant empieza como un truco elegante para ahorrar costes: una app, una base de datos, muchos clientes. Luego creces.
Un cliente grande ejecuta un informe al mediodía, la latencia p95 se duplica y tu arquitectura de “todo compartido” se convierte en
sufrimiento compartido.

Este artículo es para el momento en que te das cuenta de que “aislamiento de inquilinos” no es una casilla para marcar: es una propiedad del sistema que debe sobrevivir:
al crecimiento, a migraciones, a auditorías de cumplimiento, a un despliegue fallido y al ocasional cliente que piensa que “Exportar todo” es un estilo de vida.

Qué significa realmente el aislamiento de inquilinos en producción

“Aislamiento de inquilinos” no es solo “tenant_id está en cada tabla.” Eso es etiquetado de datos. El aislamiento es la propiedad de que un
inquilino no puede:

  • Leer los datos de otro inquilino (obvio, pero los bugs divertidos se esconden en joins, vistas y jobs en background).
  • Corromper o borrar datos de otro inquilino (incluyendo errores en migraciones y secuencias compartidas).
  • Dejar al sistema sin recursos para que otros inquilinos hagan timeout (“vecino ruidoso” es la forma educada de decir “mi incidente”).
  • Forzarte a una única vía de migración y escalado (porque tu inquilino más grande siempre dicta la arquitectura).
  • Romper límites de cumplimiento (residencia de datos, retención, alcance de cifrado, límites de control de acceso).

El aislamiento también es operativo. Necesitas la capacidad de responder, rápida y con confianza:

  • ¿Qué inquilino causó el pico de carga?
  • ¿Puedo limitar o poner en cuarentena a ese inquilino sin tumbar a los demás?
  • ¿Puedo migrar a un inquilino de forma segura (cambio de esquema, mover shard, restaurar) mientras los demás siguen funcionando?

Eso significa que el aislamiento tiene capas: aislamiento de datos, aislamiento de consultas, aislamiento de recursos,
aislamiento de fallos y aislamiento operativo. La elección de la base de datos influye en las cinco, pero no te salvará
de un modelo de tenencia que esté estructuralmente condenado.

Modelos de tenencia que escalan (y los que silenciosamente no lo hacen)

Modelo A: Tablas compartidas, esquema compartido (columna tenant_id)

Un conjunto de tablas. Cada fila incluye tenant_id. Es lo más barato para comenzar y lo más difícil de mantener correcto.
Puede escalar si lo diseñas desde el día uno: límites estrictos de acceso (RLS o rutas de consulta verificadas),
particionado y un plan para mover inquilinos.

Cuando funciona:

  • Muchos inquilinos pequeños, cargas de trabajo relativamente uniformes.
  • Necesitas migraciones simples (un único esquema).
  • Tu producto no requiere extensiones a nivel de inquilino ni índices personalizados.

Cómo falla:

  • Algunos “inquilinos elefante” dominan el I/O y la contención de locks.
  • Cada consulta debe ser perfecta para siempre (spoiler: no lo será).
  • Backfills y analíticas se convierten accidentalmente en escaneos globales de tablas.

Modelo B: Esquema por inquilino (una base de datos, muchos esquemas)

Cada inquilino tiene su propio esquema: tenant_123.orders, tenant_456.orders. Esto es un límite operativo fuerte
para nombres de objetos, migraciones y movimientos parciales. PostgreSQL es especialmente bueno aquí; el “schema” de MySQL es
básicamente una “base de datos”, así que la forma difiere.

Cuando funciona:

  • Los inquilinos necesitan índices personalizados, extensiones o operaciones pesadas periódicas.
  • Quieres mayor control del radio de explosión para migraciones y restauraciones.
  • Puedes tolerar recuentos altos de catálogos/objetos y gestionar migraciones a escala.

Cómo falla:

  • Demasiados objetos (tablas/índices) pueden ralentizar operaciones de metadatos y ventanas de mantenimiento.
  • El pool de conexiones se complica si dependes de SET search_path.
  • La analítica entre inquilinos se vuelve más cara y desordenada.

Modelo C: Base de datos por inquilino (o clúster por inquilino)

El radio de explosión más limpio. La mayor proliferación. Esto es aislamiento por multiplicación.
También es como acabar ejecutando un pequeño proveedor cloud dentro de tu compañía.

Cuando funciona:

  • Requisitos de cumplimiento altos (límites claros, claves de cifrado por inquilino, residencia).
  • Los inquilinos tienen tamaños de carga de trabajo muy distintos.
  • Necesitas programación de mantenimiento y garantías de restauración por inquilino.

Cómo falla:

  • Sobrehead operativo: migraciones, monitorización, backups, failover, credenciales.
  • Fragmentación de capacidad: muchas BD pequeñas desperdician memoria (buffers, caches).
  • Los cambios en toda la flota se vuelven lentos y riesgosos.

Modelo D: Shards de inquilinos (mapeo tenant→shard)

Los inquilinos se asignan a shards (múltiples instancias DB). Cada shard puede usar tablas compartidas o esquema por inquilino internamente.
El sharding es lo que haces cuando aceptas la realidad: superarás una instancia y necesitas distribución controlada.

Cuando funciona:

  • Tienes límites claros por inquilino y poca necesidad de joins entre inquilinos.
  • Puedes construir y mantener una capa de enrutamiento (a nivel de app o proxy).
  • Planeas movimientos de inquilinos (re-balanceo) como operación rutinaria.

Cómo falla:

  • La analítica entre inquilinos se vuelve un problema de consultas distribuidas.
  • Subestimar el re-balanceo: se convierte en un “proyecto especial” cada vez.
  • Los inquilinos calientes siguen existiendo; el sharding reduce probabilidades, no la física.

MySQL vs PostgreSQL: mecánicas de aislamiento que importan

Ambos pueden ejecutar grandes flotas SaaS. Ambos pueden hacerte daño. La diferencia es cómo te empujan hacia valores seguros por defecto (o te dejan
construir una fábrica de pistolas que disparan el pie con excelente uptime).

Aislamiento de datos: primitivas de aplicación

PostgreSQL: Row Level Security (RLS) es una herramienta de aislamiento de primera clase

RLS de PostgreSQL te permite imponer filtros por inquilino dentro de la base de datos. Bien implementado, convierte “siempre añadimos tenant_id”
en “la base de datos rechazará tu consulta si lo olvidas.” Eso no es un lujo; es un rasgo de supervivencia.

RLS no es magia. Las políticas pueden ser omitidas por roles con privilegios BYPASSRLS, y políticas mal diseñadas pueden afectar el rendimiento.
Pero te da una baranda declarativa.

MySQL: el aislamiento es mayormente un problema de disciplina

MySQL no tiene RLS al estilo PostgreSQL. Puedes aproximarlo con vistas, rutinas almacenadas, derechos de definer o una capa de consultas muy estricta.
En la práctica, la mayoría de equipos confía en el filtrado en capa de aplicación y en permisos.

Eso puede estar bien—hasta que no lo está. En postmortems de incidentes, “asumimos que todos los caminos de código añaden tenant_id” aparece igual que
“asumimos que los backups funcionaban.” Es una frase que envejece mal.

Concurrencia y bloqueo: cómo nacen los vecinos ruidosos

PostgreSQL: MVCC con las realidades del vacuum

MVCC de PostgreSQL significa que las lecturas no bloquean escrituras, lo cual es encantador para cargas mixtas. Pero los tuples muertos se acumulan y
deben ser vacuumed. Los sistemas multitenant frecuentemente crean churn desigual: un inquilino actualiza agresivamente y de repente
autovacuum trabaja de más mientras todos los demás se preguntan por qué la latencia subió.

MySQL (InnoDB): MVCC con distintos cantos afilados

InnoDB también usa MVCC, pero los comportamientos de locking y los gap locks en ciertos niveles de aislamiento pueden sorprender a los equipos,
especialmente con consultas de rango e índices secundarios. Las transacciones largas son el enemigo universal, pero la “forma” del dolor difiere.

Aislamiento operativo: mover inquilinos, restaurar inquilinos, limitar inquilinos

Ventajas de PostgreSQL

  • Esquema por inquilino es natural y está bien soportado.
  • RLS puede hacer que las “tablas compartidas” sean más seguras a escala.
  • La replicación lógica habilita patrones selectivos de replicación/migración (con diseño cuidadoso).

Ventajas de MySQL

  • Madurez del ecosistema de replicación: es excelente; las herramientas operativas están muy entendidas.
  • Predecibilidad de rendimiento para ciertos patrones OLTP es sólida, y muchas organizaciones tienen músculo profundo en MySQL.
  • Base de datos por inquilino es directa cuando ya tratas “schema = database.”

Si quieres una recomendación directa: si estás comprometido con multi-tenancy de tablas compartidas y quieres que la base de datos
haga cumplir los límites, PostgreSQL es el socio más indulgente. Si haces base de datos-por-inquilino con mucha automatización operativa
y una capa de consultas estable, MySQL puede ser perfectamente válido. El modelo de tenencia es la gran decisión; el motor es
el multiplicador de cuán dolorosos serán los errores.

Cómo falla el aislamiento a medida que creces

Modo de fallo 1: “Siempre podemos añadir tenant_id después”

Añadir límites de inquilino a posteriori es como poner cinturones después de haber empezado a correr. Puedes hacerlo, pero descubrirás cuántas cosas dependían
de la ausencia de restricciones: suposiciones de unicidad global, secuencias compartidas, jobs en background, caches con claves incorrectas y consultas analíticas que
de pronto se vuelven caras.

Modo de fallo 2: Una migración se convierte en N migraciones

Con esquema-por-inquilino o base-de-datos-por-inquilino, los cambios de esquema se vuelven una operación de flota. La herramienta de migración que funcionó
para una base de datos ahora necesita batching, reintentos, idempotencia y observabilidad. Esto no es opcional; es el precio del aislamiento.

Modo de fallo 3: La base de datos se convierte en tu scheduler

Los “batch jobs” multitenant (cobros, exports, generación de informes) a menudo terminan siendo “solo una consulta.” Entonces se ejecutan en todos los inquilinos a la vez,
y tu base de datos se convierte en un clúster de cómputo compartido sin control de admisión.

Broma #1: Una base de datos es una cola terrible, pero es un lugar excelente para guardar la evidencia de que lo intentaste de todas formas.

Modo de fallo 4: Tratar los movimientos de inquilinos como raros

Si haces sharding, debes poder mover inquilinos de forma rutinaria. Trata los movimientos como rutinarios y construirás tooling, checksums,
escrituras duales (si hacen falta), playbooks de corte y rutas de rollback. Trátalos como raros y cada movimiento será un incidente hecho a medida,
con ejecutivos “uniéndose solo para escuchar.”

Modo de fallo 5: Tu aislamiento funciona, pero tu observabilidad no

Incluso si los límites de datos son correctos, todavía necesitas atribuir carga por inquilino. Sin métricas y logs etiquetados por tenant,
cada problema de rendimiento es un juego de adivinanzas. Adivinar es caro. Además suele pasar durante outages, cuando todos están emocionalmente invertidos en equivocarse rápido.

Datos interesantes y contexto histórico (lo que cambia decisiones)

  1. El linaje de PostgreSQL se remonta al proyecto POSTGRES en UC Berkeley en los 80; heredó una cultura de investigación que se refleja en características como MVCC y extensibilidad.
  2. MySQL se volvió un estándar web en la era LAMP porque era fácil de ejecutar y rápido para patrones comunes, no porque tuviera el conjunto relacional más estricto.
  3. InnoDB no siempre fue el predeterminado; MySQL históricamente incluía MyISAM por defecto, que carecía de transacciones—una historia de origen que aún influye en folklore y despliegues legados.
  4. RLS de PostgreSQL llegó en la v9.5 (mediados de los 2010), por eso las pilas SaaS antiguas a menudo construyeron sus propias capas de aislamiento con vistas o convenciones de ORM.
  5. Autovacuum existe por MVCC; el modelo de concurrencia de PostgreSQL es potente, pero la “recolección de basura” es un impuesto operativo que debes presupuestar en churn multitenant.
  6. La replicación de MySQL evolucionó por etapas: basada en sentencias, luego en filas y formatos mixtos, cambiando cómo son de seguras y predecibles las migraciones a nivel de tenant bajo distintas cargas.
  7. El particionado maduró con el tiempo en ambos motores; las implementaciones anteriores eran más limitadas, por eso diseños antiguos evitaban particiones y luego pagaron con tablas gigantes.
  8. El manejo de conexiones difiere culturalmente: PostgreSQL asume conexiones más pesadas y fomenta pooling; MySQL tiene una larga historia de muchas conexiones cortas en stacks web, lo que moldea defaults y tooling.

Tres micro-historias corporativas (y lo que enseñan)

Micro-historia #1: El incidente causado por una suposición equivocada

Un SaaS B2B de mercado medio usaba un modelo de tablas compartidas en MySQL. Cada tabla tenía tenant_id. Su ORM también tenía un “default scope”
que filtraba automáticamente por inquilino. Se sentía seguro. Era rápido. Todos siguieron con su vida.

Entonces se introdujo un job en background para “limpiar sesiones antiguas.” Se escribió en un servicio separado que no usaba el ORM.
El ingeniero usó una simple consulta DELETE en la tabla sessions, asumiendo que el filtro por tenant “se manejaba en otro lugar.” No fue así.
La consulta borró sesiones a través de todos los inquilinos que cumplían la condición de edad.

El outage no fue pérdida de datos, pero fue caos visible para usuarios: cierres de sesión masivos, tickets de soporte y un día de liderazgo preguntando
por qué “el aislamiento multitenant” no lo evitó. La respuesta real: el aislamiento era una convención, no un límite aplicado.

La solución no fue solo “tener más cuidado.” Implementaron una capa de acceso a la BD segura para tenants para servicios en background, añadieron linting de consultas
para filtros por tenant y establecieron una regla dura: cualquier consulta cross-tenant debe nombrarse explícitamente y revisarse.
También planearon adoptar RLS de PostgreSQL para las tablas compartidas de mayor riesgo—porque los humanos son creativos, sobre todo cuando están cansados.

Micro-historia #2: La optimización que salió mal

Otra compañía usaba PostgreSQL con tablas compartidas y RLS. Un inquilino creció rápido y empezó a generar grandes cantidades de datos de eventos.
Ingeniería decidió “optimizar” añadiendo un índice parcial afinado al patrón de consultas común de ese inquilino.
Parecía inteligente: consultas más rápidas para el cliente grande, menos carga en general.

El índice parcial se definió con una predicado que incluía tenant_id y una columna de estado. Aceleró las consultas objetivo.
Pero también incrementó la amplificación de escrituras y el coste de mantenimiento precisamente en las tablas con mayor churn. Autovacuum empezó
a quedarse atrás, aumentó el bloat y otros inquilinos—que ni siquiera se beneficiaban del índice—vieron subir la latencia.

El equipo intentó compensar con ajustes más agresivos de autovacuum y máquinas más grandes. Eso estabilizó los síntomas pero no la causa.
Finalmente quitaron el índice y en su lugar movieron al inquilino elefante a su propio shard, donde su estrategia de indexado podía ser tan rara como quisiera.

Lección: las optimizaciones específicas de inquilino dentro de tablas compartidas a menudo se convierten en impuestos específicos de inquilino pagados por todos. Si el inquilino es lo bastante
grande para merecer indexado personalizado, es lo bastante grande para merecer su propio radio de explosión.

Micro-historia #3: La práctica aburrida pero correcta que salvó el día

Un SaaS financiero usaba esquema-por-inquilino en PostgreSQL. No era glamoroso y requería un runner de migraciones que pudiera
aplicar cambios inquilino por inquilino con orden estricto. Cada esquema tenía las mismas tablas, las mismas restricciones y el mismo
conjunto de extensiones. Sin excepciones.

Su estrategia de backups nocturnos era igualmente aburrida: backups completos periódicos, archivado frecuente de WAL y ejercicios trimestrales de restauración
donde restauraban un solo esquema de inquilino en un clúster de staging y ejecutaban una suite de verificación. Parecía papeleo,
hasta que no lo fue.

Un despliegue introdujo un bug en una migración que eliminó un índice y lo reconstruyó concurrentemente, pero un timeout y reintento causaron
un estado inconsistente en un subconjunto de esquemas de inquilinos. Algunos inquilinos vieron consultas lentas; otros estaban bien. El sistema estaba “arriba”, pero
la experiencia del cliente no.

Porque tenían ejercicios de restauración a nivel de inquilino y una checklist de verificación por esquema, identificaron rápidamente los inquilinos afectados,
reconstruyeron índices de forma controlada y restauraron los peores casos desde el punto consistente más reciente. Sin heroicidades. Sin conjeturas.
Solo procedimientos practicados.

Tareas prácticas: comandos, salidas y la decisión que tomas

No obtienes aislamiento de inquilinos discutiendo en una revisión de diseño. Lo obtienes pudiendo probar cosas a las 02:00 con un shell
y un sentido calmado de traición. Abajo hay tareas prácticas que puedes ejecutar hoy.

Tarea 1: Encuentra las consultas más ruidosas en MySQL

cr0x@server:~$ mysql -e "SELECT DIGEST_TEXT, COUNT_STAR, SUM_TIMER_WAIT/1000000000000 AS total_s, AVG_TIMER_WAIT/1000000000000 AS avg_s FROM performance_schema.events_statements_summary_by_digest ORDER BY SUM_TIMER_WAIT DESC LIMIT 5;"
+--------------------------------------------+------------+----------+---------+
| DIGEST_TEXT                                | COUNT_STAR | total_s  | avg_s   |
+--------------------------------------------+------------+----------+---------+
| SELECT * FROM orders WHERE tenant_id = ?   |      92831 |  1842.11 | 0.01984 |
| SELECT ... JOIN ... WHERE created_at > ?   |       8421 |  1201.33 | 0.14266 |
| UPDATE events SET status = ? WHERE id = ?  |    2419921 |   992.77 | 0.00041 |
| SELECT ... WHERE tenant_id = ? ORDER BY ?  |     182003 |   774.55 | 0.00425 |
| DELETE FROM sessions WHERE expires_at < ?  |      12044 |   701.62 | 0.05825 |
+--------------------------------------------+------------+----------+---------+

Qué significa: tiempo total gastado por digest. Alto total_s significa gran impacto agregado; alto avg_s significa lento por llamada.

Decisión: Si una consulta es alta en total y tiene filtros por tenant, atribúyela a inquilinos en la capa de aplicación (añade etiquetas por tenant) y considera limitar por inquilino o mover al inquilino.

Tarea 2: Identificar esperas de lock en MySQL (quién bloquea a quién)

cr0x@server:~$ mysql -e "SELECT r.trx_id waiting_trx, r.trx_mysql_thread_id waiting_thread, b.trx_id blocking_trx, b.trx_mysql_thread_id blocking_thread, TIMESTAMPDIFF(SECOND, b.trx_started, NOW()) blocking_s FROM information_schema.innodb_lock_waits w JOIN information_schema.innodb_trx b ON b.trx_id=w.blocking_trx_id JOIN information_schema.innodb_trx r ON r.trx_id=w.requesting_trx_id ORDER BY blocking_s DESC LIMIT 5;"
+-------------+---------------+-------------+----------------+-----------+
| waiting_trx | waiting_thread| blocking_trx| blocking_thread| blocking_s|
+-------------+---------------+-------------+----------------+-----------+
| 4519281     | 3221          | 4519012     | 3189           | 97        |
+-------------+---------------+-------------+----------------+-----------+

Qué significa: una transacción ha estado bloqueando otras por ~97 segundos. Normalmente es una transacción larga o falta de índice.

Decisión: Si está ligado al job de un inquilino, limita o pausa ese job. Si es sistémico, inspecciona la tabla bloqueada y añade el índice necesario o acorta transacciones.

Tarea 3: Revisar la longitud del historial de InnoDB (presión de undo)

cr0x@server:~$ mysql -e "SHOW ENGINE INNODB STATUS\G" | grep -E "History list length|TRANSACTIONS"
TRANSACTIONS
History list length 138429

Qué significa: una gran history list length sugiere que el purge está atrasado, a menudo debido a transacciones de larga duración.

Decisión: Busca transacciones largas; corrige jobs y consultas de informes que mantienen snapshots mucho tiempo. Este es un clásico escenario multitenant: “un cliente corrió un informe por una hora”.

Tarea 4: Detectar consultas que omiten tenant en PostgreSQL via pg_stat_statements

cr0x@server:~$ psql -d appdb -c "SELECT query, calls, total_exec_time::int AS total_ms, mean_exec_time::int AS mean_ms FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 5;"
                                                     query                                                      | calls | total_ms | mean_ms
----------------------------------------------------------------------------------------------------------------+-------+----------+---------
 SELECT * FROM orders WHERE created_at > $1 ORDER BY created_at DESC LIMIT $2                                     | 12021 |  8123340 |     675
 SELECT * FROM orders WHERE tenant_id = $1 AND created_at > $2 ORDER BY created_at DESC LIMIT $3                 | 99311 |  4901221 |      49
 UPDATE events SET status = $1 WHERE id = $2                                                                       | 89122 |   811220 |       9
 SELECT ... JOIN ... WHERE tenant_id = $1 AND state = $2                                                           | 24011 |   644331 |      26
 DELETE FROM sessions WHERE tenant_id = $1 AND expires_at < now()                                                   |  8012 |   499112 |      62

Qué significa: la consulta superior no tiene filtro por tenant. En tenencia de tablas compartidas, eso es una luz roja intermitente.

Decisión: Si confías en la aplicación para la enforcement, parchea inmediatamente. Si usas RLS, verifica que RLS esté habilitado y realmente aplicado a esa tabla.

Tarea 5: Verificar que RLS está habilitado y hay políticas (PostgreSQL)

cr0x@server:~$ psql -d appdb -c "\dp+ public.orders"
                                     Access privileges
 Schema | Name   | Type  | Access privileges | Column privileges | Policies
--------+--------+-------+-------------------+-------------------+-------------------------------
 public | orders | table | app_user=arwdDxt/app_owner           |                   | tenant_isolation (RLS)

Qué significa: hay políticas presentes y RLS está activo para esta tabla (se muestra info de políticas).

Decisión: Si la columna “Policies” está vacía para tablas compartidas, confías en el código de aplicación. Decide si eso es aceptable bajo tu modelo de riesgo.

Tarea 6: Comprobar si tu rol puede omitir RLS (PostgreSQL)

cr0x@server:~$ psql -d appdb -c "SELECT rolname, rolbypassrls FROM pg_roles WHERE rolname IN ('app_user','app_owner');"
  rolname  | rolbypassrls
-----------+-------------
 app_user  | f
 app_owner | t

Qué significa: app_owner puede omitir RLS. Esto puede estar bien para migraciones, desastroso para runtime de la app.

Decisión: Asegura que la aplicación use un rol con rolbypassrls = false. Separa roles de migración/administración de roles de runtime.

Tarea 7: Encontrar transacciones de larga duración en PostgreSQL

cr0x@server:~$ psql -d appdb -c "SELECT pid, usename, now()-xact_start AS xact_age, state, left(query,80) AS q FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start ASC LIMIT 5;"
 pid  | usename  |   xact_age   | state  | q
------+----------+--------------+--------+--------------------------------------------------------------------------------
 8421 | app_user | 01:22:11     | active | SELECT * FROM orders WHERE created_at > $1 ORDER BY created_at DESC

Qué significa: una transacción ha estado abierta 82 minutos. Eso puede bloquear vacuum y provocar bloat en tablas.

Decisión: Si es un informe/export por tenant, muévelo a una réplica, añade timeouts o rediseñalo (paginación por keyset, exportaciones incrementales).

Tarea 8: Revisar presión de autovacuum y señales de bloat (PostgreSQL)

cr0x@server:~$ psql -d appdb -c "SELECT relname, n_live_tup, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 5;"
  relname  | n_live_tup | n_dead_tup |        last_autovacuum
-----------+------------+------------+-------------------------------
 events    |   81299311 |   22099122 | 2025-12-31 10:42:11.12345+00
 orders    |    9921121 |    2200122 | 2025-12-31 10:39:01.01234+00

Qué significa: muchas tuplas muertas: churn. En sistemas multitenant, suele dominarlo un pequeño conjunto de inquilinos.

Decisión: Ajusta autovacuum para tablas calientes, pero considera también aislar al inquilino que causa el churn (mover shard, esquema/DB separado).

Tarea 9: Confirmar que una consulta usa el índice por tenant (PostgreSQL)

cr0x@server:~$ psql -d appdb -c "EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE tenant_id = 42 AND created_at > now()-interval '7 days' ORDER BY created_at DESC LIMIT 50;"
                                                                      QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.43..102.55 rows=50 width=128) (actual time=0.214..1.992 rows=50 loops=1)
   Buffers: shared hit=391
   ->  Index Scan Backward using orders_tenant_created_at_idx on orders  (cost=0.43..18233.22 rows=8931 width=128) (actual time=0.212..1.978 rows=50 loops=1)
         Index Cond: ((tenant_id = 42) AND (created_at > (now() - '7 days'::interval)))
 Planning Time: 0.311 ms
 Execution Time: 2.041 ms

Qué significa: index scan usando un índice compuesto que incluye tenant_id. Los buffers son hits, no lecturas: amigable con la caché.

Decisión: Si ves sequential scans en tablas compartidas para consultas con scope por tenant, arregla índices o particionado antes de escalar el número de inquilinos.

Tarea 10: Confirmar que una consulta usa el índice por tenant (MySQL)

cr0x@server:~$ mysql -e "EXPLAIN SELECT * FROM orders WHERE tenant_id=42 AND created_at > NOW() - INTERVAL 7 DAY ORDER BY created_at DESC LIMIT 50\G"
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: range
possible_keys: idx_tenant_created_at
          key: idx_tenant_created_at
      key_len: 12
          ref: NULL
         rows: 8500
     filtered: 100.00
        Extra: Using where; Using index

Qué significa: MySQL eligió tu índice compuesto. type: range es esperado para escaneos por ventana temporal. “Using index” sugiere comportamiento de índice cubriente.

Decisión: Si aparece type: ALL (scan completo), no tienes aislamiento a escala—tienes un fallo futuro.

Tarea 11: Atribuir carga por usuario/host (MySQL) como proxy de tenant

cr0x@server:~$ mysql -e "SELECT user, host, SUM_TIMER_WAIT/1000000000000 AS total_s FROM performance_schema.events_statements_summary_by_user_by_event_name ORDER BY SUM_TIMER_WAIT DESC LIMIT 5;"
+----------+-----------+----------+
| user     | host      | total_s  |
+----------+-----------+----------+
| app_user | 10.0.2.%  | 9921.22  |
| app_user | 10.0.9.%  | 6211.55  |
+----------+-----------+----------+

Qué significa: un subconjunto de hosts de la app son responsables de la mayor parte del tiempo DB. A menudo esos hosts ejecutan una carga específica (exports, backfills).

Decisión: Usa esto para acotar el servicio/job responsable, luego correlaciona con logs a nivel de tenant en la aplicación.

Tarea 12: Revisar acumulación de conexiones (PostgreSQL)

cr0x@server:~$ psql -d appdb -c "SELECT state, count(*) FROM pg_stat_activity GROUP BY state ORDER BY count DESC;"
 state  | count
--------+-------
 idle   |   412
 active |    62

Qué significa: tienes cientos de conexiones idle. Eso es memoria y overhead; a menudo señala mal pooling o patrones de conexión por inquilino.

Decisión: Pon un pooler delante (y configúralo correctamente), o reduce el conteo de conexiones. Los sistemas multitenant mueren por mil cortes de “una conexión más”.

Tarea 13: Buscar hotspots a nivel de tabla por tamaño (PostgreSQL)

cr0x@server:~$ psql -d appdb -c "SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) AS total_size FROM pg_catalog.pg_statio_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 5;"
 relname | total_size
---------+------------
 events  | 412 GB
 orders  | 126 GB

Qué significa: una tabla domina el almacenamiento y probablemente el I/O. En sistemas multitenant, la tabla más grande suele ser donde vive el inquilino más grande.

Decisión: Considera particionar por tenant o por tiempo, o mover a los inquilinos que generan la mayoría de filas a un shard separado.

Tarea 14: Revisar lag de replicación (MySQL) para decidir dónde ejecutar lecturas pesadas

cr0x@server:~$ mysql -e "SHOW REPLICA STATUS\G" | egrep "Seconds_Behind_Source|Replica_IO_Running|Replica_SQL_Running"
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
Seconds_Behind_Source: 37

Qué significa: 37 segundos de retraso. Ejecutar exports de inquilinos en esta réplica puede dar resultados “más recientes” inconsistentes.

Decisión: Si el producto promete exports casi en tiempo real, no externalices a una réplica con lag; limita o aisla la carga del inquilino.

Playbook de diagnóstico rápido (encuentra el cuello de botella antes de que te encuentre)

Cuando un SaaS multitenant se pone lento, necesitas responder dos preguntas rápido:
(1) ¿está realmente la base de datos siendo el cuello de botella? (2) en caso afirmativo, ¿qué inquilino o carga lo está causando?

Primero: ¿La base de datos está saturada o simplemente esperando?

  • CPU saturada (ineficiencia en consultas, índices faltantes, demasiadas tareas paralelas).
  • I/O saturado (fallos de buffer, escaneos pesados, checkpoints, bloat).
  • Bloqueos (transacciones largas, cambios de esquema, filas calientes).
  • Conexiones saturadas (agotamiento de pool, contención de hilos, demasiadas conexiones idle).

Segundo: Identificar la clase de ofensor principal

  • Consultas top por tiempo total (digest / pg_stat_statements).
  • Top de esperas (locks, I/O, contención de buffers).
  • Transacciones largas y cadenas de bloqueo.

Tercero: Atribuir al inquilino y decidir contención

  • Si tienes etiquetas por tenant en logs/métricas: aisla al inquilino (limitar, mover job, mover shard).
  • Si no las tienes: usa correlación por usuario/host/job, luego arregla la observabilidad permanentemente.

Broma #2: La forma más rápida de reducir carga en la base de datos es dejar de ejecutar la consulta que la está causando. Revolucionario, lo sé.

Una escalera práctica de contención (de menos a más invasivo)

  1. Cancelar la consulta / matar la sesión que está causando daño inmediato.
  2. Limitar al inquilino en el borde de la aplicación (rate limit exports, batch jobs).
  3. Mover lecturas pesadas a una réplica (si el lag y la consistencia lo permiten).
  4. Añadir el índice faltante o reescribir la consulta (con explain plans, no con esperanza).
  5. Particionar o shardear para separar inquilinos ruidosos.
  6. Cambiar el modelo de tenencia (la respuesta dolorosa pero a veces correcta).

Errores comunes: síntoma → causa raíz → solución

1) Síntoma: exposición ocasional de datos cross-tenant en informes

Causa raíz: un camino de código se saltó el filtrado por tenant; las convenciones en la app no son enforcement.

Solución: PostgreSQL: implementar RLS y asegurar que los roles de runtime no puedan omitirla. MySQL: imponer una capa de acceso a datos segura para tenants, prohibir SQL en crudo para tablas con scope por tenant y añadir tests de linting para predicados de tenant.

2) Síntoma: un inquilino causa picos p95 para todos durante un “export”

Causa raíz: el export corre en el primario, escaneando rangos grandes sin paginación o índices adecuados.

Solución: ejecutar exports en réplica (si es aceptable), usar paginación por keyset, precomputar exports y limitar por tenant. Si el inquilino es consistentemente pesado, muévelo a un shard separado.

3) Síntoma: PostgreSQL se vuelve más lento en semanas y mejora tras mantenimiento

Causa raíz: bloat por tablas de alto churn; autovacuum no da abasto; transacciones largas bloquean la limpieza.

Solución: encontrar y eliminar transacciones largas, afinar autovacuum por tablas calientes y considerar particionado o aislar la carga churn-heavy del inquilino.

4) Síntoma: aumento de lag de replicación MySQL durante jobs por tenant

Causa raíz: transacciones grandes y picos de escrituras; el hilo SQL de la réplica no aplica lo suficientemente rápido.

Solución: dividir jobs en transacciones más pequeñas, añadir índices adecuados y programar jobs pesados. Para inquilinos grandes, considera infraestructura dedicada.

5) Síntoma: las migraciones se vuelven riesgosas y lentas al crecer el número de tenants

Causa raíz: esquema-por-inquilino o base-de-datos-por-inquilino sin tooling de migración de nivel flota; sin batching, sin observabilidad, sin plan de rollback.

Solución: construir un migration runner con: tracking por-tenant del estado, reintentos, timeouts, control de concurrencia y consultas de verificación post-migración. Trátalo como un sistema de despliegue.

6) Síntoma: “Escalamos la instancia pero sigue lenta”

Causa raíz: contención e ineficiencia, no capacidad bruta. Máquinas más grandes no arreglan índices faltantes o esperas de lock.

Solución: identifica consultas top y esperas; arregla las causas raíz. Solo escala una vez que puedas explicar por qué ayuda.

Listas de verificación / plan paso a paso

Plan paso a paso: elige un modelo de tenencia que sobreviva al crecimiento

  1. Clasifica inquilinos por carga: pequeño/mediano/elefante; lectura-vs-escritura; batch-vs-interactivo.
  2. Decide tu objetivo de aislamiento: solo límite de datos, o también límite de recursos/fallos. El cumplimiento a menudo obliga al segundo.
  3. Escoge el modelo inicial:
    • Si necesitas fuerte enforcement en BD con tablas compartidas: PostgreSQL + RLS es el default pragmático.
    • Si necesitas límites duros por inquilino: base-de-datos-por-inquilino o shard-por-inquilino, independientemente del motor.
  4. Diseña claves para movilidad: evita secuencias globales que compliquen mover inquilinos; prefiere UUIDs o IDs cuidadosamente acotados si el sharding será futuro.
  5. Haz que mover inquilinos sea rutinario: implementa tooling para exportar/importar un inquilino, verificar conteos/checksums y hacer el cutover.
  6. Implementa observabilidad consciente de tenant: cada ruta de consulta a la BD debe ser atribuible a tenant (al menos en límites de request/job).
  7. Define límites por inquilino: rate limits, tamaños máximos de export, timeouts, límites de concurrencia.
  8. Establece músculo de recuperación aburrido: tests regulares de restauración a granularidad de inquilino.

Checklist: multitenancy con tablas compartidas (recomendado PostgreSQL)

  • RLS habilitado en cada tabla con scope por tenant.
  • El rol de runtime no puede omitir RLS; roles admin/migración separados.
  • Cada consulta por tenant tiene un índice compuesto empezando por tenant_id (o una estrategia de particionado que haga el acceso por tenant barato).
  • pg_stat_statements habilitado y monitorizado; consultas top revisadas regularmente.
  • Alertas de transacciones largas; timeouts de sentencia configurados.
  • Autovacuum afinado para tablas calientes; bloat monitoreado.

Checklist: esquema-por-inquilino (punto fuerte de PostgreSQL)

  • El runner de migraciones soporta batching de tenants, idempotencia y verificación.
  • Convenciones de nombres de esquema por inquilino aplicadas; sin snowflakes manuales.
  • Estrategia de pooling evita churn de conexiones por request; uso cuidadoso de search_path.
  • Backups y drills de restauración pueden restaurar un único esquema de inquilino limpiamente.

Checklist: base-de-datos-por-inquilino (MySQL o PostgreSQL)

  • Provisionamiento automatizado: crear DB, usuarios, grants, monitorización, backups.
  • Tooling de migración de la flota; rollouts por etapas; canaries.
  • Directorio de servicio central para enrutamiento (tenant → database mapping) con logging de auditoría.
  • Controles de coste: evita un inquilino diminuto por instancia sobredimensionada a menos que el cumplimiento lo requiera.

Preguntas frecuentes

1) ¿Debo usar PostgreSQL RLS para SaaS multitenant?

Si usas tablas compartidas y te tomas en serio evitar acceso cross-tenant, sí. RLS convierte filtros de tenant faltantes
de un bug latente de seguridad en un error de consulta. No es gratis—las políticas deben diseñarse y probarse—pero es una herramienta real de enforcement.

2) ¿Puede MySQL hacer de forma segura multi-tenancy con tablas compartidas?

Sí, pero dependes más de disciplina de aplicación y procesos de revisión. Puedes construir patrones seguros (vistas, procedimientos almacenados,
cuentas restringidas), pero la base de datos no impondrá naturalmente filtros por tenant como PostgreSQL con RLS.

3) ¿Es esquema-por-inquilino “demasiados objetos” en PostgreSQL?

Puede serlo. Miles de esquemas con muchas tablas e índices pueden estresar catálogos y mantenimiento. Si eliges esta vía, invierte en
tooling de migración, evita excepciones por inquilino y evalúa periódicamente si shardear (menos inquilinos por clúster) es más simple.

4) ¿Cuál es el mejor modelo para inquilinos con requisitos de cumplimiento?

Base-de-datos-por-inquilino o shard-por-inquilino es la respuesta común, porque ofrece límites claros para backups, restauraciones, alcance de cifrado
y controles de acceso. Tablas compartidas pueden pasar auditorías, pero requieren controles rigurosos y evidencia clara.

5) ¿Cómo prevengo incidentes de “vecino ruidoso”?

Empieza por la atribución (métricas conscientes de tenant). Luego implementa contención: límites por tenant, timeouts de consultas, separación de cargas
(réplicas para lecturas) y una ruta para aislar elefantes (moverlos a su propio shard/DB).

6) ¿El sharding soluciona el aislamiento de inquilinos?

El sharding ayuda al aislamiento de recursos y fallos al reducir el radio de explosión, pero no resuelve automáticamente el aislamiento de datos dentro de un shard.
Aún necesitas límites de acceso correctos y rutas de consulta seguras.

7) ¿Qué estrategia de pooling funciona mejor para PostgreSQL multitenant?

Usa un pooler y mantiene los conteos de conexiones sensatos. Ten cuidado con el estado a nivel de sesión (como search_path o ajustes por inquilino).
El pooling por transacción es eficiente pero requiere disciplina; el pooling por sesión es más simple pero consume más conexiones.

8) ¿Debería tenant_id formar parte de cada clave primaria?

A menudo sí en diseños de tablas compartidas, al menos como parte de la estrategia de clave, porque mejora localidad y selectividad de índices.
Pero no compongas claves ciegamente en todas partes. Elige según patrones de consulta, requisitos de unicidad y planes futuros de shard/movimiento.

9) ¿Es buena idea particionar por tenant?

A veces. Puede acelerar operaciones por inquilino y mejorar el objetivo del vacuum/mantenimiento en PostgreSQL. Pero puede explotar el número de particiones
si tienes muchos inquilinos. El particionado por tiempo suele ser un mejor valor por defecto para tablas de eventos, con tenant_id indexado dentro de particiones.

10) ¿Cuándo debería mover a un inquilino a su propia base de datos?

Cuando la carga específica del inquilino o requisitos de cumplimiento repetidamente obligan a tuning específico que perjudica a otros, o cuando necesitas escalado independiente
y ventanas de mantenimiento. Si lo debates cada semana, ya sabes la respuesta.

Próximos pasos que realmente puedes ejecutar

La elección de la base de datos es importante, pero no es el evento principal. El evento principal es si tu estrategia de aislamiento es aplicable, observable
y operativamente movilizable.

  1. Elige un modelo de tenencia que puedas operar, no uno que se vea elegante en una pizarra.
  2. Si estás en PostgreSQL con tablas compartidas, implementa RLS en las tablas de mayor riesgo primero y separa roles de runtime de roles admin.
  3. Si estás en MySQL con tablas compartidas, formaliza una capa de consultas segura para tenants, añade linting/tests para predicados de tenant y construye throttles por inquilino.
  4. Instrumenta la atribución por tenant (logs, métricas, traces). Si no puedes nombrar al inquilino que causa carga, no puedes aislarlo.
  5. Haz que mover inquilinos sea rutinario. Escribe el playbook, automatízalo y haz ejercicios cuando nadie esté en llamas.
  6. Programa el trabajo aburrido: tests de restauración, alertas de transacciones largas, revisiones de índices y canaries de migración. Estas tareas no son “para después”; son renta.

Una cita para poner en la pared—idea parafraseada de John Allspaw: la confiabilidad viene del sistema, no de heroísmos individuales.
La multitenencia es la prueba máxima de eso.

← Anterior
VPN de oficina Zero Trust: reemplace redes planas por acceso basado en roles
Siguiente →
Por qué dos GPUs suelen ser peores que una (las razones reales)

Deja un comentario