Caché en Memoria Multitenant: 4 Backends, un Solo Contrato
Cada lookup repetido contra el RDBMS de un dato que ya sabías de antemano que ibas a reutilizar es una query evitable — geo, feriados, catálogo de módulos, permisos de rol. La arquitectura real que resuelve esto, auto-scoped por tenant, con Redis, Swoole y una extensión propia en Rust ya en producción, no en el roadmap.
El Problema Real
No Toda Query Necesita Volver a Preguntarle al RDBMS
Hay datos que un sistema consulta una y otra vez y que, deliberadamente, sabemos que va a reutilizar: tasas de conversión geográfica, calendario de feriados, catálogo de módulos habilitados por tenant, el set de permisos efectivo de un perfil. Ninguno de esos cambia en el segundo entre una consulta y la siguiente — pero sin una capa de caché explícita, cada request vuelve a golpear el RDBMS (la base de datos relacional — MySQL, Postgres) igual, como si el dato pudiera ser distinto esta vez.
El síntoma no es lentitud aleatoria: es la misma query, exactamente la misma, repitiéndose cientos de veces por minuto contra una tabla que un lookup en memoria hubiera resuelto sin tocar disco.
El problema no es exclusivo del modelo relacional. El mismo desperdicio existe en cualquier motor: un documento que se vuelve a leer de una base NoSQL/documental (IndexedDB, Mongo) sin haber cambiado, o un embedding que se vuelve a calcular contra una base vectorial (VDB) cuando ya se resolvió antes — Qdrant, Pinecone, Weaviate, Milvus o pgvector, el motor específico no cambia el problema: es el mismo lookup repetido, solo que contra una base de vectores en vez de una tabla. Aclaración honesta: la experiencia de producción que documentamos acá es con Qdrant — los otros los nombramos porque el lector puede venir de cualquiera de esos stacks, no porque los hayamos operado nosotros; cada uno resuelve su propio caché interno distinto, esto no es una comparación entre ellos. La separación RDBMS/EAV/Qdrant que documentamos para RAG vale igual acá: cada capa tiene su propia responsabilidad, y cachear no cambia cuál es.
La Arquitectura
Un Contrato, 4 Backends Según Dónde Corre el Proceso
El código que pide o guarda un dato (Cache::get / Cache::getOrSet) nunca sabe ni le importa cuál de los cuatro backends responde detrás — el contrato es el mismo, lo que cambia es dónde vive la memoria. La razón de que existan 4, y no uno solo, es un patrón universal, no una rareza de PHP: hay runtimes que matan el proceso en cada request (PHP-FPM, un worker WSGI/Gunicorn de Python, una función serverless) y runtimes que mantienen el proceso vivo entre requests (Swoole, Node.js, un servidor async en Rust con Tokio o Actix) — cada familia necesita una estrategia de memoria distinta.
ArrayBackend
Array estático en el proceso. Vive y muere con el request en cualquier runtime de vida corta (PHP-FPM, un worker Python, una Lambda) — sirve para no repetir un lookup dos veces dentro de la misma ejecución, nada más.
ChannelCacheBackend
Cuando el proceso que atiende la request es de vida corta (PHP-FPM), proxea por HTTP a un proceso aparte que sí vive entre requests — el Channel Server, sobre Swoole, cumpliendo el mismo rol que un servidor Node persistente o un actor de larga vida en Rust. Circuit breaker propio: si ese proceso no responde, degrada sin tumbar el request.
SwooleTableBackend
Memoria compartida real dentro de un solo proceso de vida larga (Swoole\Table): no es un HashMap que crece solo, es una tabla de capacidad fija preasignada (hasta 65.536 entradas, declaradas de antemano). El equivalente en Rust no es un HashMap suelto: es un Vec de tamaño fijo indexado por hash (o un HashMap con capacidad reservada), protegido por lock — mismo trade-off, memoria acotada a cambio de cero reasignaciones. Todas las coroutines o tareas concurrentes de ese proceso leen el mismo dato sin serializar por red.
Redis\CacheBackend
El único genuinamente compartido entre todos los procesos y transportes a la vez, sin importar el runtime — de vida corta o larga, PHP o no: FPM, un script CLI y Swoole leen el mismo dato sin distinción, igual que lo harían un worker Python y un servicio en Rust apuntando a la misma instancia. Detalle en la siguiente sección.
Auto-Scope por Tenant
Ningún Handler Tiene que Acordarse del Tenant
La key interna del caché se arma automáticamente con el scope_entity_id del perfil en sesión — el mismo mecanismo de aislamiento multitenant que usa RBAC para decidir qué puede ver cada usuario, aplicado ahora a qué dato queda cacheado para quién. Un namespace normal se aísla solo por tenant sin que el handler tenga que pedirlo; un namespace con el prefijo global: se comparte a propósito entre todos los tenants — para catálogos que son iguales para cualquiera (país, moneda, feriados).
Es el mismo principio que documentamos en la arquitectura RBAC multitenant: la relación con la entidad decide el alcance, no una bandera que cada desarrollador tiene que recordar poner.
Prueba, no Promesa
La Misma Capa de Caché que Describe este Reporte, Sirviendo Ahora Mismo
Este contador vive detrás del caché global:gitactivity: no es un diagrama, es la misma arquitectura que acabas de leer, corriendo.
6.090 entregas de mejora continua este año, en la misma infraestructura de caché y datos que describe este reporte
Redis en Producción
3 Decisiones que Separan un Caché de Juguete de uno que Aguanta Producción
Serialización compacta con igbinary, no el serialize() nativo
Cada valor se guarda con igbinary_serialize — formato binario más chico y más rápido de decodificar que el serialize de PHP. Un decode corrupto (bit rot, versión de igbinary distinta tras un deploy) degrada a un miss silencioso, nunca revienta el request.
SCAN en vez de KEYS para invalidar por prefijo
Un Redis compartido entre varios proyectos no puede permitirse un KEYS bloqueante O(N) — dejaría a todos los demás proyectos esperando. El flush por namespace usa SCAN con cursor, en lotes, sin bloquear el resto del tráfico.
La conexión nunca se cachea en el objeto — se resuelve por coroutine
Bajo Swoole, guardar el socket de Redis en una propiedad del backend es un bug clásico: dos coroutines terminan compartiendo el mismo socket y se pisan. Cada operación re-resuelve la conexión, aislada por coroutine — y si Redis falla, la conexión rota se descarta y el error queda absorbido como un miss, con log, nunca como un request caído.
Swoole, Rust y Observabilidad
Lo que Ya Está en Producción, no Solo en el Roadmap
El caché no vive aislado: corre sobre el mismo bus asíncrono construido encima de Swoole (adaptador y bus de respuesta propios) que también mueve las notificaciones en tiempo real del sitio — el mismo Channel Server que sirve SwooleTableBackend expone un endpoint interno de métricas con el estado de Swoole, conexiones autenticadas, canales activos y estadísticas del caché en un solo lugar, no hay que adivinar el hit-rate, se consulta.
Rust ya está en el stack en más de un punto, no solo mencionado: una extensión propia de PHP para cálculos geodésicos compila nativo en Rust, y el motor de serialización que usamos para telemetría de alta frecuencia (wire-to-parsed en minería subterránea) también corre sobre Rust compilado. Eventualmente vamos a llevar más de esta capa de caché al mismo terreno — hoy el caché en sí es PHP+Redis/Swoole, el resto del stack Rust ya validado en otros módulos es la dirección, no una promesa sin evidencia.
Caso Real
El Mismo Contador que Ves en Este Sitio
El widget de actividad técnica que aparece en esta misma página usa exactamente esta arquitectura: Cache::getOrSet('global:gitactivity', 'current_year', 60, …) — TTL de 60 segundos, namespace global (mismo dato para todo visitante, sin scope de tenant), invalidado explícitamente apenas llega un push nuevo, no solo cuando expira el TTL.
6.090 entregas de mejora continua este año, en los sistemas de nuestros clientes
El mismo mecanismo, con el eje multitenant activado esta vez, corre en producción en lafrutologia.cl: cuando cambian los roles de un colaborador, el sistema invalida explícitamente el caché de navegación controlada (Cache::delete('global:nav:controlled', 'prefixes')) — la capa de caché y RBAC no son dos sistemas aparte, comparten el mismo mecanismo de invalidación.
Estado Real — Sin Inflar
Sin Singleflight: N Misses Concurrentes Ejecutan N Loaders
El mecanismo getOrSet no tiene locking entre requests concurrentes: si diez requests llegan a la vez con la misma key vacía, los diez ejecutan el loader en paralelo antes de que el primero termine de guardar el resultado — aceptable para queries livianas (geo, feriados, catálogos), no para un loader pesado, donde hace falta un lock externo que este mecanismo, tal como está hoy, no resuelve solo.
Contenido relacionado
RBAC Multitenant: Organigrama → Paquete → Rol
El mismo eje de auto-scope por tenant que usa este caché es el que decide qué puede ver y hacer cada usuario.
Leer el mecanismo - Grafo, Snapshot y Multi-PerfilRBAC como Medida de Seguridad Apropiada (Ley 21.719)
El panorama completo del control de acceso: este reporte es la evidencia técnica detrás de "stateless, con caché en memoria".
Ver la arquitectura - 6 Scopes de Ruta + Ley 21.719Latencia en Minería Subterránea: el Costo de Cada Byte
El mismo Rust que corre en la extensión de caché geodésica, aplicado a telemetría de alta frecuencia con QUIC.
Ver el reporte - QUIC + Rust en Telemetría