← Volver a los relatos
· 8 min de lectura

El cache que mostraba precios ajenos: segmentar el FPC con X-Magento-Vary

Una war story de Magento en producción: cómo segmentar el full page cache por grupo de clientes con headers HTTP, sin lógica extra en el backend y sin matar el hit rate.

magentoperformancecache

El reporte llegó como llegan los peores: imposible de reproducir. Un cliente mayorista de un e-commerce enterprise veía, a veces, precios de consumidor final. Otras veces al revés. Mismo navegador, misma URL, resultados distintos según la hora. Nadie del equipo podía provocarlo a voluntad, y el código de precios estaba bien: cada grupo de clientes tenía su catálogo correcto en el backend.

El bug no estaba en el backend. Estaba en la capa que casi nadie mira cuando depura precios: el cache de página completa. Esta es la historia de cómo lo encontramos, qué mecanismo nativo de Magento lo resuelve, y la trampa opuesta en la que es facilísimo caer al arreglarlo. No puedo dar detalles del proyecto por confidencialidad, pero el problema y la solución son cien por ciento generalizables.

Dónde vive el HTML que ve el usuario

En una tienda Magento con tráfico serio, la página de producto que recibe el navegador casi nunca la renderiza PHP en ese momento. La renderizó el backend una vez, hace minutos u horas, y desde entonces la sirve el full page cache (FPC) a través de Varnish o de un CDN como Fastly. Es lo que hace viable la plataforma: el backend solo trabaja en los misses.

Eso funciona perfecto mientras la página sea igual para todos. Y ahí estaba nuestro problema: la página no era igual para todos. Los precios dependían del grupo de clientes, pero la URL era la misma. El CDN guardaba una sola copia por URL —la del primer usuario que pasó después de cada expiración— y se la servía a todo el mundo. Por eso el bug era intermitente: dependía de quién había calentado el cache.

El mecanismo que Magento ya trae: X-Magento-Vary

La solución no fue escribir lógica de precios más defensiva. Fue contarle al cache lo que el backend ya sabía.

Magento mantiene un contexto HTTP (Magento\Framework\App\Http\Context): un conjunto de pares clave-valor que describe todo aquello de lo que depende el HTML además de la URL — si hay sesión iniciada, el grupo de clientes, la moneda, la tienda. Ese contexto se serializa, se le aplica un hash, y el resultado viaja al navegador como la cookie X-Magento-Vary.

La pieza que cierra el circuito está del lado del CDN: el VCL por defecto de Magento incluye el valor de esa cookie en la clave de cache. La clave deja de ser solo la URL y pasa a ser URL + segmento:

KEY = URL + X-MAGENTO-VARYHITMISS Navegador Varnish/ CDN Copia porsegmento Magento(FPC) Respuesta
La clave de cache es URL + X-Magento-Vary: el CDN guarda una copia por segmento y el backend solo ve los misses.

El efecto es exactamente el que se necesita: la misma URL puede tener una copia cacheada por segmento, cada copia compartida por miles de usuarios del mismo grupo. El backend sigue sin enterarse de la mayoría del tráfico, pero ya no puede cruzarse contenido entre segmentos.

No le pidas al backend que se defienda del cache. Dale al cache la información para que no necesite defensa.

Primer arreglo: meter el segmento en el contexto

Cuando la variación es una de las nativas, esto funciona solo. Nuestro caso tenía una dimensión propia —un esquema de precios que no se mapeaba uno a uno con los grupos estándar—, así que hubo que registrarla en el contexto. El patrón es un plugin (o un observer temprano en el request) que declara el valor antes de que se genere el HTML:

<?php
// Acme/PriceSegment/Plugin/AddSegmentToHttpContext.php
declare(strict_types=1);

namespace Acme\PriceSegment\Plugin;

use Acme\PriceSegment\Model\SegmentResolver;
use Magento\Framework\App\ActionInterface;
use Magento\Framework\App\Http\Context as HttpContext;

class AddSegmentToHttpContext
{
    public function __construct(
        private readonly HttpContext $httpContext,
        private readonly SegmentResolver $segmentResolver,
    ) {
    }

    public function beforeExecute(ActionInterface $subject): void
    {
        $this->httpContext->setValue(
            'acme_price_segment',
            $this->segmentResolver->resolve(), // "retail" | "wholesale" | ...
            SegmentResolver::DEFAULT_SEGMENT   // lo que ve un visitante anónimo
        );
    }
}

Dos detalles de esa llamada valen más que el resto del archivo:

  • El tercer argumento es el valor por defecto, y tiene que coincidir exactamente con lo que ve un visitante anónimo. Si el valor resuelto coincide con el default, Magento no varía la cookie; si declaras mal el default, parten en dos el cache de todo el tráfico anónimo —que suele ser el 80–90 % del total— sin ganar nada.
  • El valor debe salir de un conjunto pequeño y enumerable. Sobre esto, la trampa completa más abajo.

Con el segmento dentro del contexto, la cookie cambia, la clave de cache cambia, y cada grupo recibe su copia. Sin tocar una línea de la lógica de precios.

Segundo round: el AJAX que nadie miró

Semanas después, una variante del mismo síntoma: precios cruzados, pero solo al hacer scroll en el listado de productos. Las páginas completas ya estaban segmentadas; el bug volvió por otra puerta.

El listado usaba infinite scroll de un módulo de terceros: a partir de la página dos, los productos llegaban por una petición AJAX que devolvía HTML. Esa respuesta era cacheable —correcto, es contenido pesado y repetido— pero el endpoint no declaraba de qué dependía. El CDN guardaba la primera respuesta y la servía a todos los segmentos.

El arreglo fue de una línea conceptual: la respuesta tiene que declarar su variación con el header estándar de HTTP, para que el CDN incorpore la cookie a la clave también en ese endpoint:

$response->setHeader('Vary', 'Cookie', true);

La lección me importa más que el fix puntual: el contexto de Magento cubre las páginas que pasan por su FPC, pero todo endpoint cacheable que genere contenido dependiente del usuario es responsable de declarar su propia variación. Los módulos de terceros que agregan AJAX cacheable son el lugar exacto donde esto se olvida, porque funcionan perfecto en local y en staging — donde no hay CDN delante.

La trampa opuesta: segmentar hasta matar el cache

Hasta aquí parece que la respuesta a todo es “agrega más cosas al contexto”. Es la trampa opuesta, y es peor que el bug original porque no rompe contenido: rompe el rendimiento, en silencio.

Cada dimensión que entra al contexto multiplica las copias que el CDN debe mantener por URL. Cinco grupos de clientes por tres monedas son quince variantes de cada página; el hit rate se reparte entre ellas, las variantes poco visitadas expiran antes de ser reutilizadas, y el backend empieza a recibir tráfico que antes no veía. El caso extremo es ponerle al contexto algo con cardinalidad de usuario —un ID de cliente, un token— y convertir el FPC en un cache privado por persona: hit rate cercano a cero, con el costo de infraestructura de un cache completo.

Las reglas con las que me quedé:

  • Segmenta por valores enumerables y pocos: el conjunto debe caber en una mano, no crecer con los usuarios.
  • Antes de agregar una dimensión, multiplica: variantes actuales × valores nuevos. Si el número te incomoda, la dimensión está mal elegida.
  • Lo que de verdad es por usuario —el minicarrito, el nombre en el header, los totales— no se segmenta: se privatiza, con sections de customer-data del lado del cliente o bloques privados, fuera del HTML cacheado.
  • Después de desplegar, mira el hit rate del CDN durante días, no minutos: la fragmentación se nota cuando expiran las variantes frías.

Checklist: contenido por segmento detrás de un CDN

  1. Lista todas las dimensiones de las que depende el HTML además de la URL: grupo, moneda, tienda, catálogos especiales.
  2. Verifica cuáles ya viajan en X-Magento-Vary y registra las propias en el contexto HTTP, con el default exacto del visitante anónimo.
  3. Audita los endpoints AJAX cacheables —sobre todo los de módulos de terceros— y haz que declaren Vary.
  4. Calcula la cardinalidad total antes de agregar dimensiones: variantes = producto de los valores posibles.
  5. Mueve lo verdaderamente personal a customer-data/bloques privados; no lo segmentes.
  6. Prueba con dos sesiones de segmentos distintos contra el entorno con CDN delante, no contra local.
  7. Vigila el hit rate del CDN tras cada cambio de contexto durante al menos una semana.

Cierre

El arreglo completo de aquel bug intermitente no agregó lógica de negocio: agregó información a la clave de cache. Esa es la tesis que me llevo de esta historia y de varias parecidas: cuando el contenido depende del usuario, la primera pregunta no es “¿cómo lo calculo más seguro?” sino “¿el cache sabe de qué depende esto?”. HTTP ya tiene el vocabulario —claves de cache, Vary, cookies de contexto— y usarlo bien reemplaza capas enteras de lógica defensiva en la aplicación.

Esta es la primera de una serie de war stories de Magento en producción. La siguiente: qué hacer cuando un solo método de envío por orden no alcanza — multi-shipping real sobre MSI, con reservas de stock por ventana horaria.

Si estás peleando con un cache que sirve contenido cruzado —o con un hit rate que se desplomó sin explicación— es el tipo de problema en el que trabajo. Puedes escribirme.