Caché multinivel en Laravel: estrategias L1/L2 con Redis y PostgreSQL para máximo rendimiento
Introducción: por qué Redis solo no es suficiente
Redis es una herramienta excelente, y la mayoría de las aplicaciones Laravel lo usan como único nivel de caché. Sin embargo, en cuanto la aplicación empieza a atender miles de solicitudes por segundo, Redis por sí solo deja de ser suficiente. Las razones son diversas: latencia de red en cada acceso a Redis (incluso 0,5–2 ms por solicitud se acumulan en cientos de milisegundos bajo carga), "arranque en frío" al invalidar claves grandes, y situaciones en las que resulta más económico almacenar datos agregados directamente en la base de datos en formato listo para usar.
El caché multinivel resuelve este problema mediante una jerarquía de almacenes con diferente velocidad y coste de acceso. El esquema clásico es el siguiente:
- L0 — caché in-process (driver memory, compatible con Octane): acceso en nanosegundos, vive dentro de un único worker.
- L1 — Redis: acceso en microsegundos por red, compartido entre todos los workers.
- L2 — PostgreSQL Materialized Views: agregados precalculados directamente en la base de datos, actualizados por programación.
En este artículo construiremos este sistema en una aplicación Laravel paso a paso: desde la arquitectura hasta las métricas y los antipatrones.
Arquitectura L1/L2: tres niveles de caché
L0 — caché in-process (array driver / Octane)
Si usas Laravel Octane (Swoole o RoadRunner), el worker vive entre solicitudes. Esto permite almacenar datos "calientes" directamente en la memoria del proceso PHP mediante el driver array, sin ningún acceso de red. El tiempo de acceso es de unos pocos microsegundos. La desventaja: los datos están aislados dentro del worker y se pierden al reiniciarlo.
L1 — Redis
Redis actúa como caché compartido para todos los workers y servidores. Almacena objetos PHP serializados, admite TTL, operaciones atómicas y Pub/Sub para invalidación. La latencia de acceso es de 0,1–2 ms según la red.
L2 — PostgreSQL Materialized Views
Una Materialized View es el resultado de una consulta SQL almacenado físicamente. A diferencia de una VIEW ordinaria, los datos se guardan en disco y no se recalculan en cada SELECT. Para estadísticas agregadas (totales de ventas, productos destacados, dashboards), este es el nivel L2 ideal: los datos ya están en la base, sin necesidad de transferir grandes volúmenes desde Redis por la red.
Implementación del Cache Manager en Laravel
Driver multinivel personalizado
Laravel permite registrar drivers propios mediante Cache::extend(). Crearemos la clase TieredCacheStore, que implementa la lógica de fallback: primero comprueba L0, luego L1 (Redis), luego L2 (PostgreSQL) y, ante un fallo, rellena los niveles superiores.
<?php
namespace App\Cache;
use Illuminate\Cache\Repository;
use Illuminate\Contracts\Cache\Store;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TieredCacheStore implements Store
{
private array $l0 = [];
private Repository $l1; // Redis
private int $l0Ttl;
private int $l1Ttl;
public function __construct(Repository $l1, int $l0Ttl = 5, int $l1Ttl = 300)
{
$this->l1 = $l1;
$this->l0Ttl = $l0Ttl;
$this->l1Ttl = $l1Ttl;
}
public function get($key): mixed
{
// L0: in-process
if (isset($this->l0[$key]) && $this->l0[$key]['expires_at'] > now()->timestamp) {
return $this->l0[$key]['value'];
}
// L1: Redis
$value = $this->l1->get($key);
if ($value !== null) {
$this->storeL0($key, $value);
return $value;
}
// L2: PostgreSQL materialized view o consulta lenta
$value = $this->fetchFromL2($key);
if ($value !== null) {
$this->l1->put($key, $value, $this->l1Ttl);
$this->storeL0($key, $value);
}
return $value;
}
private function storeL0(string $key, mixed $value): void
{
$this->l0[$key] = [
'value' => $value,
'expires_at' => now()->timestamp + $this->l0Ttl,
];
}
private function fetchFromL2(string $key): mixed
{
// Ejemplo: clave con formato "catalog:category:{id}:top"
if (preg_match('/^catalog:category:(\d+):top$/', $key, $m)) {
return DB::select(
'SELECT * FROM mv_category_top_products WHERE category_id = ?',
[(int) $m[1]]
);
}
return null;
}
public function put($key, $value, $seconds): bool
{
$this->storeL0($key, $value);
return $this->l1->put($key, $value, $seconds);
}
public function forget($key): bool
{
unset($this->l0[$key]);
return $this->l1->forget($key);
}
// El resto de métodos Store delegan en L1
public function many(array $keys): array { return $this->l1->many($keys); }
public function putMany(array $values, $seconds): bool { return $this->l1->putMany($values, $seconds); }
public function increment($key, $value = 1): int|bool { return $this->l1->increment($key, $value); }
public function decrement($key, $value = 1): int|bool { return $this->l1->decrement($key, $value); }
public function forever($key, $value): bool { return $this->l1->forever($key, $value); }
public function flush(): bool { $this->l0 = []; return $this->l1->flush(); }
public function getPrefix(): string { return 'tiered:'; }
}
Registro del driver en ServiceProvider
<?php
namespace App\Providers;
use App\Cache\TieredCacheStore;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;
class TieredCacheServiceProvider extends ServiceProvider
{
public function boot(): void
{
Cache::extend('tiered', function ($app) {
$l1 = Cache::store('redis');
return Cache::repository(new TieredCacheStore($l1, l0Ttl: 5, l1Ttl: 300));
});
}
}
Tras el registro, añade lo siguiente en config/cache.php:
'stores' => [
// ...
'tiered' => [
'driver' => 'tiered',
],
],
'default' => env('CACHE_DRIVER', 'tiered'),
PostgreSQL Materialized Views como caché L2
Creación de una Materialized View para el catálogo de productos
Crearemos una vista materializada que precalcula los 20 mejores productos por categoría con valoraciones agregadas y número de ventas:
CREATE MATERIALIZED VIEW mv_category_top_products AS
SELECT
p.category_id,
p.id AS product_id,
p.name,
p.price,
p.slug,
COALESCE(AVG(r.rating), 0)::NUMERIC(3,2) AS avg_rating,
COUNT(DISTINCT oi.id) AS total_orders,
ROW_NUMBER() OVER (
PARTITION BY p.category_id
ORDER BY COUNT(DISTINCT oi.id) DESC, AVG(r.rating) DESC
) AS rank
FROM products p
LEFT JOIN reviews r ON r.product_id = p.id
LEFT JOIN order_items oi ON oi.product_id = p.id
WHERE p.is_active = true
GROUP BY p.category_id, p.id, p.name, p.price, p.slug
HAVING ROW_NUMBER() OVER (
PARTITION BY p.category_id
ORDER BY COUNT(DISTINCT oi.id) DESC
) <= 20
WITH DATA;
CREATE UNIQUE INDEX ON mv_category_top_products (category_id, product_id);
CREATE INDEX ON mv_category_top_products (category_id, rank);
El índice único es obligatorio para usar REFRESH MATERIALIZED VIEW CONCURRENTLY, que permite actualizar la vista sin bloquear las lecturas.
Invalidación de caché: enfoque orientado a eventos
Observer + Redis Pub/Sub
La invalidación es la parte más compleja del caché multinivel. Usaremos el patrón Observer en Laravel para rastrear cambios en el modelo y Redis Pub/Sub para enviar señales de invalidación a todos los workers.
<?php
namespace App\Observers;
use App\Models\Product;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
class ProductObserver
{
public function saved(Product $product): void
{
$this->invalidateProductCache($product);
}
public function deleted(Product $product): void
{
$this->invalidateProductCache($product);
}
private function invalidateProductCache(Product $product): void
{
$keys = [
"catalog:category:{$product->category_id}:top",
"product:{$product->id}",
"product:{$product->id}:details",
];
// Eliminamos de Redis (L1)
foreach ($keys as $key) {
Cache::store('redis')->forget($key);
}
// Publicamos el evento para invalidar L0 en todos los workers
Redis::publish('cache:invalidate', json_encode([
'keys' => $keys,
'category_id'=> $product->category_id,
'timestamp' => now()->toISOString(),
]));
}
}
Suscriptor para invalidación de L0 en Octane
En el worker de Octane, lanzamos un listener en segundo plano de Redis Pub/Sub que limpia L0 al recibir el evento:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
class CacheInvalidationListenerCommand extends Command
{
protected $signature = 'cache:listen-invalidation';
protected $description = 'Suscribirse a Redis Pub/Sub para invalidar el caché L0';
public function handle(): void
{
$this->info('Escuchando eventos de invalidación de caché...');
Redis::subscribe(['cache:invalidate'], function (string $message) {
$payload = json_decode($message, true);
// Accedemos al singleton TieredCacheStore
// y limpiamos su array L0 para las claves indicadas
app('cache.tiered')->forgetL0($payload['keys']);
$this->line('Invalidadas: ' . implode(', ', $payload['keys']));
});
}
}
Actualización de Materialized Views por programación
Las Materialized Views de PostgreSQL no se actualizan automáticamente: hay que hacerlo de forma explícita. Usamos Laravel Scheduler para refrescarlas periódicamente sin bloquear a los lectores:
<?php
// app/Console/Kernel.php o routes/console.php (Laravel 11+)
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
// CONCURRENTLY no bloquea SELECT durante la actualización
DB::statement('REFRESH MATERIALIZED VIEW CONCURRENTLY mv_category_top_products');
// Tras la actualización, invalidamos L1 para todas las claves afectadas
$categories = DB::table('mv_category_top_products')
->distinct()
->pluck('category_id');
foreach ($categories as $categoryId) {
Cache::store('redis')->forget("catalog:category:{$categoryId}:top");
}
logger()->info('Vista materializada actualizada', ['categories' => $categories->count()]);
})->everyFiveMinutes()->withoutOverlapping()->name('refresh-mv-category-top');
// Estadísticas agregadas — menos frecuente, ya que es más costoso
Schedule::call(function () {
DB::statement('REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sales_stats_daily');
})->hourly()->withoutOverlapping()->name('refresh-mv-sales-stats');
Importante:
REFRESH MATERIALIZED VIEW CONCURRENTLYrequiere un índice único en la vista y se ejecuta en una transacción separada. Durante este proceso, los datos antiguos siguen estando disponibles para lectura hasta que finaliza la actualización.
Métricas de rendimiento del caché
Medición del hit rate y la latencia
Sin métricas es imposible saber si tu sistema de caché está funcionando. Añadiremos instrumentación directamente en TieredCacheStore:
<?php
// Añadimos en TieredCacheStore
private array $stats = ['l0_hits' => 0, 'l1_hits' => 0, 'l2_hits' => 0, 'misses' => 0];
public function get($key): mixed
{
$start = hrtime(true);
// L0
if (isset($this->l0[$key]) && $this->l0[$key]['expires_at'] > now()->timestamp) {
$this->stats['l0_hits']++;
$this->recordLatency('l0', hrtime(true) - $start);
return $this->l0[$key]['value'];
}
// L1
$value = $this->l1->get($key);
if ($value !== null) {
$this->stats['l1_hits']++;
$this->storeL0($key, $value);
$this->recordLatency('l1', hrtime(true) - $start);
return $value;
}
// L2
$value = $this->fetchFromL2($key);
if ($value !== null) {
$this->stats['l2_hits']++;
$this->l1->put($key, $value, $this->l1Ttl);
$this->storeL0($key, $value);
$this->recordLatency('l2', hrtime(true) - $start);
return $value;
}
$this->stats['misses']++;
return null;
}
private function recordLatency(string $level, int $nanoseconds): void
{
$ms = $nanoseconds / 1_000_000;
// Enviamos a StatsD, Prometheus o simplemente registramos en log
logger()->debug("Cache hit [{$level}]", ['latency_ms' => $ms]);
}
public function getStats(): array
{
$total = array_sum($this->stats);
if ($total === 0) return $this->stats;
return array_merge($this->stats, [
'hit_rate' => round(($total - $this->stats['misses']) / $total * 100, 2),
'l0_hit_rate' => round($this->stats['l0_hits'] / $total * 100, 2),
'l1_hit_rate' => round($this->stats['l1_hits'] / $total * 100, 2),
]);
}
Objetivos de rendimiento
- L0 (array/Octane): latencia <0,01 ms, hit rate objetivo — 60–70% para claves calientes.
- L1 (Redis): latencia 0,1–2 ms, hit rate objetivo — 25–35% de las solicitudes restantes.
- L2 (PostgreSQL MV): latencia 1–10 ms, cubre el 5–10% restante.
- Fallo de caché (cold query): 50–500 ms — debe ocurrir raramente.
Escenarios prácticos de uso
Escenario 1: Catálogo de productos de una tienda online
La página de categoría muestra 20 productos ordenados por popularidad. Los datos cambian con cada compra o reseña. Estrategia:
- L0 almacena el resultado durante 5 segundos — protege ante picos de tráfico en una misma página.
- L1 (Redis) almacena durante 5 minutos — caché compartido para todos los servidores.
- L2 (MV) se actualiza cada 5 minutos mediante Scheduler y sirve como fuente para precalentar L1.
Escenario 2: Dashboard con estadísticas agregadas
Gráficos de ventas, DAU, ingresos por hora. Los datos son costosos de calcular (JOIN de varias tablas grandes), pero se tolera un desfase de 15–60 minutos:
- Creamos las MV
mv_sales_stats_dailyymv_hourly_revenue. - Se actualizan cada hora mediante
REFRESH MATERIALIZED VIEW CONCURRENTLY. - Redis almacena los resultados de las peticiones a la API durante 30 minutos.
- L0 no se utiliza — los datos rara vez se solicitan repetidamente dentro de un mismo worker.
Escollos y antipatrones
1. Cache stampede (estampida de caché)
Cuando una clave grande expira simultáneamente, cientos de solicitudes intentan recalcularla. La solución es Cache::lock() (bloqueo atómico mediante Redis) o el patrón de "expiración anticipada probabilística" (probabilistic early expiration).
$value = Cache::remember('expensive:key', 300, function () {
// Solo un worker entrará aquí gracias al lock interno de remember
return DB::select('...');
});
// O de forma explícita:
$lock = Cache::lock('lock:expensive:key', 10);
if ($lock->get()) {
try {
$value = computeExpensiveValue();
Cache::put('expensive:key', $value, 300);
} finally {
$lock->release();
}
}
2. TTL demasiado corto en L0
Un TTL de 1–2 segundos en L0 con alto RPS aporta poco valor — el caché no llega a amortiguar la carga. Para datos estables (configuración, diccionarios) usa 30–60 segundos.
3. Invalidación por patrón (KEYS/SCAN)
Nunca uses Redis::keys('catalog:*') en producción — es una operación bloqueante. Usa claves explícitas o etiquetas mediante Cache::tags() (compatible solo con Redis y Memcached).
4. Ignorar el desfase de las MV
Una Materialized View es una instantánea de los datos. Si el negocio exige precisión en "tiempo real", las MV no son adecuadas para L2. Usa Redis Sorted Sets o consultas indexadas en PostgreSQL en lugar de MV.
5. Ausencia de precalentamiento del caché (cache warming)
Tras un despliegue o un flush, todos los niveles están vacíos. Implementa un comando de precalentamiento que se ejecute tras el despliegue y rellene L1 con datos de L2:
// artisan cache:warm
$categories = DB::table('categories')->pluck('id');
foreach ($categories as $id) {
$data = DB::select('SELECT * FROM mv_category_top_products WHERE category_id = ?', [$id]);
Cache::store('redis')->put("catalog:category:{$id}:top", $data, 300);
}
Conclusiones
El caché multinivel en Laravel con Redis como L1 y PostgreSQL Materialized Views como L2 no es sobreingeniería, sino una necesidad para aplicaciones con alta carga. Una jerarquía bien configurada permite alcanzar un hit rate superior al 95%, reducir la latencia media de respuesta de 200–500 ms a 5–20 ms y descargar la base de datos principal entre 10 y 50 veces.
Principios clave a recordar: cada nivel debe tener una responsabilidad y un TTL claros; la invalidación debe ser orientada a eventos, no basada en temporizadores; las métricas de hit rate y latencia son una parte obligatoria del sistema, no una opción. Comienza implementando L1 (Redis) y L2 (MV) para las consultas más costosas, mide el impacto y solo entonces añade L0 para los workers de Octane.
Tecnologías
Etiquetas
Ruslan Ismailov
Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →