Claves foráneas: deja de preguntar si son rápidas
Pregunta para qué estás optimizando realmente.
La optimización de bases de datos más cara que he visto empezó con alguien eliminando todas las claves foráneas.
No porque hubieran medido un cuello de botella. No porque las escrituras fueran realmente lentas. Porque leyeron en algún lado que “las claves foráneas no escalan”. Seis meses después, tenían 2 mil millones de registros huérfanos, un sistema de facturación cobrando a usuarios eliminados y analíticas desviadas un 40%.
¿Y cuando intentaron volver a añadir las restricciones? La base de datos se paralizó intentando validar datos existentes que ya estaban corruptos.
Existe esta idea generalizada en el desarrollo web de que las claves foráneas son inherentemente lentas, que son ruedas de entrenamiento que quitas una vez que pasas a sistemas “reales”. Pero eso es perder de vista para qué sirve una restricción. No estás eligiendo entre rápido y lento. Estás eligiendo entre distintos modos de fallo.
Piénsalo así: los cristales de seguridad, los cinturones y los airbags añaden peso a tu coche. Sin duda lo hacen más lento y menos eficiente en combustible. Pero no los arrancas para optimizar tu aceleración de 0 a 100, porque estás optimizando para algo completamente distinto.
La pregunta no es si las claves foráneas te ralentizan. Claro que lo hacen. La pregunta es qué obtienes a cambio y si realmente lo necesitas.
Lo que realmente estás intercambiando
Déjame darte un ejemplo concreto. Estás construyendo un sistema de monitorización meteorológica con tablas para estaciones meteorológicas, dispositivos sensores, lecturas de sensores y estados de EE. UU.
¿Pones claves foráneas conectándolo todo? Pensemos en qué cambia realmente y cuáles son las consecuencias:
Los estados de EE. UU. probablemente no van a cambiar. Wyoming no va a cambiar de nombre pronto. No necesitas una clave foránea para validar códigos de estado en cada inserción cuando sabes que los datos de referencia son estáticos. Eso es sobrecarga inútil.
Las estaciones meteorológicas se añaden, se mueven y se desmantelan. Pero aquí va una pregunta: ¿quieres que las lecturas históricas “pierdan” su estación si alguien elimina accidentalmente un registro de estación? Quizás realmente quieras que esos datos permanezcan intactos incluso si la estación desaparece. Eso significaría que estás tratando las lecturas como una instantánea histórica en lugar de una referencia viva, lo cual cambia si una clave foránea tiene sentido siquiera.
Las lecturas de sensores se insertan miles de veces por minuto. Cada comprobación de clave foránea significa una búsqueda. Cada búsqueda genera contención en tus tablas. Si una validación lenta hace que tu cola de inserciones se atasque y pierdes datos en tiempo real, eso es un tipo de pérdida de datos distinto a tener un registro huérfano.
Ya ves por dónde va esto. La elección no es rendimiento contra corrección como conceptos abstractos. Se trata de qué fallo específico estás más dispuesto a tolerar dadas tus restricciones reales y tus consecuencias reales.
Si referencias incorrectas significan datos de facturación corruptos o violaciones regulatorias, probablemente quieres claves foráneas protegiéndote independientemente del coste de rendimiento. Si una validación lenta significa que pierdes datos de sensores en tiempo real para siempre porque tu cola se desborda, entonces quizás la validación sea el intercambio equivocado.
Cuándo las escrituras rápidas realmente importan
Así que has decidido que necesitas máxima velocidad de escritura. Tu cola se está acumulando, las transacciones están agotando el tiempo de espera y las comprobaciones de claves foráneas están causando problemas legítimos que realmente has medido (no solo teorizado).
Tienes algunas opciones. Podrías cambiar tu nivel de aislamiento de transacciones de SERIALIZABLE a READ COMMITTED, que es más rápido pero sacrifica algunas garantías de consistencia. Podrías agrupar tus commits, insertando 1000 filas por transacción en lugar de una a una para amortizar la sobrecarga de las FK. O podrías desnormalizar en una estructura de log de solo append donde ni siquiera intentas validar referencias.
Esa tercera opción no es hacer trampa, por cierto. Es simplemente un diseño distinto:
CREATE TABLE sensor_log ( id BIGSERIAL PRIMARY KEY, recorded_at TIMESTAMPTZ NOT NULL, data JSONB NOT NULL -- { station_id, sensor_id, temp, humidity, ... });
CREATE INDEX ON sensor_log USING GIN (data);CREATE INDEX ON sensor_log (recorded_at);Sin joins. Sin comprobaciones de claves foráneas. Solo añadir datos y consultar por rango de tiempo o índice GIN sobre el blob JSONB. ¿Es esto “best practice”? Probablemente no en el sentido que enseñan los libros de bases de datos. ¿Funciona cuando estás insertando 50 000 filas por minuto en una Raspberry Pi? Absolutamente.
La desconexión ocurre cuando la gente trata “best practice” como un imperativo moral en lugar de un patrón que funciona bien en escenarios comunes pero puede no encajar en el tuyo.
La trampa de la normalización
Los cursos de bases de datos adoran enseñar normalización. Evita la duplicación a toda costa. Tercera Forma Normal o nada.
Así que terminas con algo como: Orders → OrderItems → Products → Variants → Colors → Sizes
Seis joins de tablas solo para responder “¿Pedí la camiseta roja o la azul la Navidad pasada?” Y ni hablemos de que necesites incluir el nombre del producto, porque eso son tres joins más en la jerarquía del catálogo.
Pero espera. La justificación suele ser “¿Y si la marca cambia cómo etiqueta el azul?” Si eso pasa, ¿realmente quieres que los pedidos históricos cambien de color retroactivamente? Por supuesto que no. Cuando alguien hizo ese pedido, compró una “Camiseta azul, talla M” tal como existía en ese momento, no como alguna referencia abstracta a una entrada de catálogo que podría actualizarse más tarde.
Esto vale la pena detenerse porque es sutil. Algunos datos son fundamentalmente una instantánea, no una referencia. Cuando tratas datos de instantánea como si fueran una referencia en vivo, terminas con esta proliferación absurda de joins para reconstruir algo que simplemente debería haberse desnormalizado en el momento de la escritura.
Guarda {"color": "blue", "size": "M"} directamente en el pedido. Ya está.
Reconociendo datos de instantánea
¿Cómo sabes cuándo algo debería ser una instantánea? Pregúntate si es un registro en un punto en el tiempo:
Los pedidos capturan detalles del producto tal como existían en el momento de la compra. Los logs de auditoría registran el estado del usuario cuando realizó una acción. Las tablas de historial preservan el estado del registro antes de una actualización. Los streams de eventos capturan qué ocurrió, cuándo, con qué datos.
Si la respuesta es “sí, esto está registrando un momento en el tiempo”, deja de normalizarlo. Empieza a hacer instantáneas.
Blobs opacos
Hay otra categoría más allá de las instantáneas: datos en los que nunca consultas dentro. Solo los almacenas y los recuperas completos.
Las configuraciones de modelos LLM como {"model": "gpt-4", "temperature": 0.7, "max_tokens": 2000} no son algo que consultes por temperatura. Recuperas la configuración completa por ID de solicitud cuando la necesitas. Los payloads de JWT después de decodificar, los registros de solicitudes/respuestas de API para depuración, los objetos de preferencias de usuario con configuraciones de tema y flags de notificación. Todos estos son blobs opacos. No necesitas normalización. No necesitas claves foráneas. Mételos en JSONB y sigue con tu vida.
¿El join de 6 tablas para averiguar qué color de camiseta se pidió? Eso no es normalización adecuada. Eso es pensamiento confundido sobre si estás almacenando una referencia o un valor.
(Aunque ten cuidado: esto puede salir mal espectacularmente si más tarde necesitas consultar esos datos. Consulta The JSONB Seduction para ver cuándo este enfoque crea su propia pesadilla.)
La escala es contexto
Escucharás que la gente dice “las claves foráneas no escalan”. Pero la escala es completamente relativa a tu hardware y arquitectura.
¿Una Raspberry Pi registrando 10 000 lecturas de sensores por minuto en una tarjeta microSD? Eso es legítimamente alta escala para ese hardware. ¿AWS Aurora con IOPS aprovisionado manejando miles de millones de filas? Puedes usar claves foráneas sin problemas.
El límite real no tiene que ver con el conteo de filas o el volumen de escritura. Tiene que ver con el particionamiento.
Cuando tu tabla Users vive en el Servidor A y tu tabla Orders vive en el Servidor B, las claves foráneas físicamente no pueden funcionar. La base de datos no tiene mecanismo para imponer una restricción a través de límites de red. En ese punto, ya estás ejecutando jobs en segundo plano para encontrar huérfanos e implementando patrones de consistencia eventual.
Esto sucede en SaaS multi-tenant donde cada tenant obtiene su propia base de datos aislada por cumplimiento, o en despliegues IoT donde tienes 50 000 dispositivos edge ejecutando SQLite localmente. Una vez llegas ahí, las claves foráneas están fuera de la mesa (literalmente) independientemente de las consideraciones de rendimiento.
Pero hasta que alcances ese límite arquitectónico, quizá no optimices prematuramente para los problemas de Netflix cuando estás construyendo una herramienta interna de 10 usuarios.
Cómo se ve esto realmente en la práctica
En lugar de preguntar “¿debería usar claves foráneas?”, intenta preguntar estas tres cosas:
¿Qué se rompe si esta referencia es incorrecta? ¿Es una demanda, facturación corrupta, violación regulatoria? ¿O es simplemente un join faltante que devuelve null en tu dashboard de analíticas?
¿Qué se rompe si la validación es lenta? ¿Pierdes datos en tiempo real irremplazables? ¿O tus consultas solo toman 50 milisegundos extra?
¿Son estos datos una instantánea o una referencia? ¿Estás registrando cómo se veía algo en un momento específico, o estás apuntando al valor actual autorizado?
A partir de ahí, los patrones emergen bastante naturalmente:
Las transacciones financieras, las sesiones de autenticación, cualquier cosa donde la corrupción de datos signifique responsabilidad legal probablemente quiere claves foráneas sin importar la sobrecarga de rendimiento.
Los logs de alto volumen, los datos de series temporales de solo append, cualquier cosa donde estés escribiendo un millón de eventos por minuto probablemente no necesita sobrecarga de validación en cada escritura.
Las instantáneas históricas como pedidos y logs de auditoría, los datos que siempre recuperas como un blob completo como preferencias de usuario, los esquemas que no controlas como payloads de webhooks de APIs externas… estos a menudo funcionan mejor desnormalizados.
Pero fíjate que dije “probablemente” y “a menudo”. Porque el contexto importa, y tu contexto es diferente al mío.
Reflexiones finales
Las claves foráneas no son un problema de rendimiento. Son un intercambio entre velocidad de escritura e integridad de datos, y si ese intercambio tiene sentido depende enteramente de tus cuellos de botella específicos y tus consecuencias específicas.
El problema real es cuando la gente elimina claves foráneas por algo que leyó sobre “escala web” sin medir realmente si tienen un problema de rendimiento de escritura o considerar lo que están sacrificando. Terminas haciendo culto al cargo de la arquitectura de Netflix en un proyecto nuevo que procesa 100 transacciones por día.
Quizá el coste de rendimiento merezca la pena para tu caso de uso. Quizá no. Pero al menos toma esa decisión basándote en lo que realmente estás optimizando, no en lo que crees que deberías estar optimizando.
¿Para qué estás optimizando?
Recursos
- PostgreSQL Foreign Key Constraints Documentation
- PostgreSQL Performance Tips
- Use The Index, Luke! - Foreign Keys
- Database Normalization vs Denormalization