Hoy he tenido uno de esos días en los que el servidor de base de datos decide que ya no puede más. Tenemos un portal corriendo sobre MySQL 5.0 y un servidor con 8GB de RAM que hasta ahora iba sobrado. Pero la cosa ha crecido, las visitas concurrentes han subido como la espuma y esta mañana los discos SATA estaban haciendo un ruido que parecía que iban a despegar. ¿El culpable? Un mísero panel de control que hace un JOIN entre cuatro tablas enormes para mostrar el histórico de mensajes de los usuarios.

El purista que llevo dentro, el mismo que se aprendió las formas normales en la carrera, me decía: "¡No toques el esquema, está perfectamente en Tercera Forma Normal (3FN)!". Pero la realidad, me han recordado una lección fundamental: en producción, a veces hay que ensuciarse las manos y romper las reglas para ganar velocidad.

El problema de ser demasiado puro

Para los que estén despistados, la Tercera Forma Normal (3FN) básicamente dicta que cada columna de una tabla debe depender únicamente de la clave primaria. Cero redundancia. Si el nombre del usuario ya está en la tabla usuarios, no lo repitas en la tabla mensajes.

El problema es que cuando tienes millones de registros y la RAM de tu servidor no da para tener todos los índices en memoria, la base de datos tiene que ir a buscar los datos al disco duro. Y los discos mecánicos odian las lecturas aleatorias. Hacer un JOIN masivo implica un montón de saltos en el cabezal del disco, y ahí es donde el rendimiento se desploma.

Desnormalizando con cabeza

La solución para nuestro cuello de botella ha sido aplicar lo que llamamos desnormalización. O sea, introducir redundancia a propósito para evitar cálculos costosos en tiempo de lectura.

Imaginemos el clásico carrito de la compra. Para saber el total gastado por un usuario este mes, tendríamos que cruzar la tabla pedidos con lineas_pedido.

SELECT p.id_usuario, SUM(l.precio * l.cantidad) AS total_gastado
FROM pedidos p
JOIN lineas_pedido l ON p.id_pedido = l.id_pedido
WHERE p.fecha > '2007-10-01'
GROUP BY p.id_usuario;

Cuando tienes cincuenta millones de líneas de pedido, esa consulta tira el servidor abajo. ¿Qué podemos hacer? Añadir una columna gasto_total_mensual directamente en la tabla usuarios. Sí, es un dato redundante. Sí, viola la 3FN. Pero ahora la consulta de lectura es instantánea.

Para mantener este dato sincronizado sin volvernos locos desde el código PHP, como ya comenté cuando hablé de mejorar el rendimiento en el backend, hemos tirado de la vieja confiable: un Trigger en la base de datos.

DELIMITER //

CREATE TRIGGER actualizar_gasto_usuario 
AFTER INSERT ON lineas_pedido
FOR EACH ROW 
BEGIN
    UPDATE usuarios u
    JOIN pedidos p ON u.id_usuario = p.id_usuario
    SET u.gasto_total_mensual = u.gasto_total_mensual + (NEW.precio * NEW.cantidad)
    WHERE p.id_pedido = NEW.id_pedido;
END;//

DELIMITER ;

Caché contra esquema: ¿Por qué no usar Memcached?

Alguien podría decir: "¡Usa Memcached para eso!". Y sí, tener los totales en RAM es fantástico y lo usamos para acelerar la portada del sitio. Pero cuando necesitas poder filtrar, ordenar y paginar usuarios basándote en esos totales desde un panel de administración interno, la caché de clave-valor no te sirve de nada. Necesitas que el dato resida en el motor relacional para poder tirarle un ORDER BY sin piedad.

El peaje a pagar

Romper la normalización no es gratis. Acabamos de cambiar tiempo de CPU y de lectura en disco por dos cosas: espacio de almacenamiento (que ahora mismo, con discos de 500GB tirados de precio, apenas importa) y, lo más crítico, lentitud en las escrituras. Cada vez que alguien compra algo, la base de datos tiene que trabajar el doble para actualizar el registro extra.

Pero en aplicaciones web donde el 95% de las operaciones son lecturas y solo un 5% son escrituras, este intercambio merece la pena.

Reflexiones desde la trinchera

Me pregunto si llegará el momento en que el hardware sea tan salvajemente potente o la memoria RAM tan barata que podamos tener bases de datos enteras cargadas en memoria, haciendo que la desnormalización sea un recuerdo del pasado. O quizás surjan nuevos tipos de motores que no se basen puramente en el modelo relacional para manejar grandes volúmenes de datos.

Pero por ahora, la realidad es que el I/O del disco sigue siendo nuestro mayor enemigo. Si tu base de datos se ahoga, no te sientas mal por meter una columna redundante. Al final del día, los usuarios no ven si tu esquema es puro o no; solo ven si la página carga rápido o si se queda pensando. Y yo prefiero una base de datos "sucia" pero rápida, que un esquema perfecto que nadie puede usar.