¿Qué es MySQL?
MySQL es una base de datos relacional de código abierto que se convirtió en la capa de almacenamiento predeterminada de los inicios de la web. Fue lanzada en 1995 con un enfoque en la velocidad y la simplicidad para sitios con una alta carga de lectura, y creció junto al stack LAMP — Linux, Apache, MySQL y PHP — hasta convertirse en una de las bases de datos más desplegadas que existen.
Actualmente es desarrollada por Oracle, pero una gran comunidad y un amplio conjunto de servicios gestionados hacen que esté presente en todas partes. WordPress, Magento, plataformas de comercio electrónico estilo Shopify e innumerables aplicaciones personalizadas funcionan con MySQL. Si has utilizado un sitio web con un formulario de inicio de sesión, es muy probable que haya intervenido una tabla de MySQL.
El MySQL moderno no es el motor simple de los años 90. Desde la versión 8 cuenta con un diccionario de datos transaccional, common table expressions, window functions y un soporte capaz para JSON. Aprenderlo hoy significa aprender una base de datos relacional seria que, además, cuenta con un soporte extraordinario.
Dónde se ejecuta MySQL
La mayor ventaja práctica de MySQL es su ubicuidad. Casi todos los hostings compartidos, proveedores de nube y plataformas como servicio (PaaS) lo ofrecen. Los frameworks incluyen sus drivers, los ORM lo soportan de forma nativa y los DBAs cuentan con décadas de experiencia utilizándolo. Esto reduce el coste de todo lo que rodea a la base de datos: contratación, herramientas, monitoreo y migración.
Es la elección habitual para sistemas de gestión de contenidos, e-commerce, backends de SaaS y cualquier aplicación donde el ecosistema sea tan importante como el motor. Escala desde una única instancia pequeña hasta clusters con sharding, y el camino de una opción a la otra está ampliamente documentado.
InnoDB es el motor que importa
MySQL tiene una arquitectura de motores de almacenamiento enchufables, pero en la práctica utilizarás InnoDB. Este proporciona:
- Transacciones ACID con
COMMITyROLLBACK. - Bloqueo a nivel de fila (row-level locking), para que los escritores no bloqueen a los lectores.
- Claves foráneas y cumplimiento de restricciones.
- Recuperación ante fallos mediante un redo log.
- MVCC para lecturas consistentes.
El antiguo motor MyISAM carecía de transacciones y de bloqueo a nivel de fila. No tiene cabida en un esquema nuevo. Declara siempre ENGINE=InnoDB explícitamente para que la elección sea visible y no dependa de los valores predeterminados del servidor.
CREATE TABLE products (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
sku VARCHAR(64) NOT NULL,
name VARCHAR(200) NOT NULL,
price_cents INT UNSIGNED NOT NULL,
stock INT NOT NULL DEFAULT 0,
attributes JSON NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_products_sku (sku)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Tipos de datos y AUTO_INCREMENT
El sistema de tipos de MySQL es pragmático. Las opciones más comunes son:
INTyBIGINT— números enteros; añadeUNSIGNEDpara IDs y contadores que no puedan ser negativos.VARCHAR(n)— texto de longitud variable con un máximo. A diferencia de Postgres, en MySQL realmente conviene definir una longitud sensata.TEXT— texto extenso que se almacena fuera de la fila cuando es necesario.DECIMAL(p, s)— decimales exactos, el tipo correcto para dinero si no utilizas unidades menores en formato entero.TIMESTAMPyDATETIME— marcas de tiempo;TIMESTAMPconvierte a UTC y tiene un límite de rango, mientras queDATETIMEalmacena exactamente lo que le envíes.JSON— un documento JSON validado y almacenado de manera eficiente.ENUM— un conjunto fijo de cadenas; es conveniente, pero cambiar la lista requiere un cambio en el esquema.
AUTO_INCREMENT genera el siguiente entero para una columna, casi siempre la clave primaria. Es rápido y tolera huecos: los inserts que sufren un rollback consumen un valor, y los inserts concurrentes pueden no producir números contiguos. Nunca confíes en que el ID no tenga huecos ni en que su orden signifique algo.
INSERT INTO products (sku, name, price_cents, stock, attributes)
VALUES ('SKU-1', 'Widget', 999, 10, JSON_OBJECT('colour', 'blue'));
SELECT LAST_INSERT_ID();
CRUD sin sorpresas
Las cuatro operaciones básicas se mapean a cuatro sentencias. Léelas como un conjunto, ya que la estructura se repite.
INSERT INTO products (sku, name, price_cents, stock, attributes)
VALUES ('SKU-2', 'Gadget', 1499, 5, JSON_OBJECT('colour', 'red'));
SELECT id, sku, name, price_cents
FROM products
WHERE stock > 0
ORDER BY price_cents ASC
LIMIT 20 OFFSET 40;
UPDATE products
SET price_cents = 1299, stock = stock - 1
WHERE id = 42;
DELETE FROM products
WHERE stock = 0 AND created_at < NOW() - INTERVAL 90 DAY;
Dos hábitos previenen los accidentes clásicos. Primero, incluye siempre una cláusula WHERE en UPDATE y DELETE; sin ella, todas las filas cambiarán. Segundo, ejecuta primero el SELECT equivalente para confirmar qué filas estás a punto de afectar. MySQL tiene un modo sql_safe_updates que rechaza sentencias sin una clave en el WHERE, y activarlo en desarrollo es una salvaguarda económica.
LIMIT con OFFSET pagina los resultados, pero los offsets grandes escanean y descartan filas. Para una paginación profunda, prefiere la paginación por claves (keyset pagination): WHERE id > :last_id ORDER BY id LIMIT 20.
Joins y agregación
Los joins combinan tablas basándose en una condición de coincidencia, exactamente igual que en el SQL estándar.
SELECT c.name AS category,
COUNT(*) AS product_count,
SUM(p.price_cents) AS inventory_value
FROM products p
JOIN categories c ON c.id = p.category_id
WHERE p.stock > 0
GROUP BY c.id, c.name
ORDER BY inventory_value DESC
LIMIT 20;
JOIN mantiene los pares coincidentes, LEFT JOIN mantiene todas las filas de la izquierda y rellena las columnas faltantes de la derecha con NULL. Los agregados como COUNT, SUM, AVG, MIN y MAX colapsan grupos; cada columna seleccionada que no esté agregada debe estar agrupada.
Históricamente, MySQL permitía seleccionar columnas no agrupadas y devolvía un valor arbitrario, lo que ocultaba errores. Con ONLY_FULL_GROUP_BY habilitado —el valor predeterminado en MySQL 8— el servidor rechaza las consultas ambiguas, que es lo ideal. No lo deshabilites para que una consulta antigua funcione; corrige la consulta.
WHERE filtra las filas antes de agrupar y HAVING filtra después, por lo que las condiciones sobre agregados pertenecen a HAVING.
Índices y EXPLAIN
Un índice es una estructura ordenada que evita el escaneo completo de una tabla. MySQL crea uno automáticamente para la clave primaria y para cada restricción UNIQUE. Añade otros para las columnas que utilices en filtros, joins y ordenamientos.
CREATE INDEX idx_products_price ON products (price_cents);
Un índice compuesto cubre varias columnas y sigue la regla del prefijo izquierdo: un índice en (category_id, price_cents) ayuda a las consultas que filtran por category_id, o por category_id y price_cents, pero no por price_cents solo. Ordena las columnas desde la más selectiva y la que se filtre con más frecuencia hacia afuera.
Pregunta al optimizador qué es lo que hará:
EXPLAIN
SELECT id, name, price_cents
FROM products
WHERE price_cents < 2500
ORDER BY price_cents
LIMIT 25;
Lee primero la columna type: const, eq_ref y ref son buenos; range está bien; index y ALL significan un escaneo. La columna key muestra qué índice fue seleccionado, y rows estima cuántos examinará. Un rows elevado para un resultado pequeño suele significar que falta un índice o que no se puede utilizar. Usa EXPLAIN ANALYZE en MySQL 8 para ejecutar la consulta y ver los tiempos reales.
Vale la pena mencionar los índices cubridores (covering indexes). Si un índice contiene cada columna que una consulta necesita, MySQL puede responder utilizando únicamente el índice sin tocar la fila. Añadir una columna a un índice puramente para convertirlo en un índice cubridor suele representar una mejora considerable.
utf8mb4 y la trampa del charset
Los conjuntos de caracteres son el punto donde MySQL suele sorprender a los desarrolladores. Durante la mayor parte de su historia, el utf8 predeterminado permitía un máximo de tres bytes por carácter, lo que cubre la mayoría de los textos pero no los puntos de código de cuatro bytes, como los emoji y muchos alfabetos poco comunes. Intentar almacenar un emoji en una columna utf8 provoca un error o trunca el valor, dependiendo del modo del servidor.
La solución es utf8mb4, que es UTF-8 real y lo almacena todo. Configúralo en todos los niveles: servidor, base de datos, tabla y conexión:
CREATE DATABASE shop
CHARACTER SET utf8mb4
COLLATE utf8mb4_0900_ai_ci;
La collation decide cómo se comparan y ordenan las cadenas. utf8mb4_0900_ai_ci es insensible a acentos y mayúsculas, que es generalmente lo que los usuarios esperan en una búsqueda. Las collations también afectan el comportamiento de los índices, así que mantenlas consistentes en las columnas que se unan mediante joins; una discrepancia fuerza conversiones que pueden hacer que un índice sea inutilizable.
Transacciones y niveles de aislamiento
InnoDB te ofrece transacciones. Agrupa escrituras relacionadas para que tengan éxito o fallen en conjunto.
START TRANSACTION;
UPDATE inventory SET quantity = quantity - 1
WHERE product_id = 42 AND quantity >= 1;
INSERT INTO orders (product_id, quantity)
VALUES (42, 1);
COMMIT;
Verifica las filas afectadas y ROLLBACK si la actualización protegida no cambió nada. El nivel de aislamiento predeterminado es Repeatable Read, el cual proporciona una instantánea consistente para la transacción y, en InnoDB, aplica gap locks que evitan las filas fantasma. Los otros niveles son Read Uncommitted, Read Committed y Serializable.
Una diferencia útil respecto a Postgres: debido al gap locking bajo Repeatable Read, las inserciones concurrentes en un rango pueden bloquearse o generar deadlocks con mayor facilidad. Mantén las transacciones cortas, actualiza las filas en un orden consistente y prepárate para reintentar en caso de un deadlock, el cual InnoDB reporta como un error en lugar de corromper el estado.
Replicación y escalado de lectura
La mayoría de los despliegues de MySQL escalan las lecturas antes que las escrituras. El primario registra cada cambio en su binary log, y una o más réplicas se conectan y reproducen dicho log. La replicación es asíncrona por defecto, por lo que una réplica puede tener un retraso (lag) respecto al primario de milisegundos o más.
El patrón estándar consiste en enviar las escrituras al primario y distribuir las lecturas entre las réplicas, aceptando que una lectura pueda devolver brevemente datos ligeramente desactualizados. Para lograr una consistencia de lectura posterior a la escritura (read-after-write consistency), redirige las lecturas de un usuario al primario durante un breve periodo después de su escritura, o lee de la réplica únicamente los datos que toleren el retraso.
La replicación también proporciona alta disponibilidad. Si el primario falla, una réplica puede ser promovida. Herramientas como los orquestadores y los servicios gestionados automatizan ese failover, pero aun así es necesario comprender la compensación entre los modos síncrono y asíncrono: el modo síncrono espera a las réplicas y pone en riesgo la disponibilidad, mientras que el asíncrono corre el riesgo de perder las últimas transacciones durante el failover.
Upserts con ON DUPLICATE KEY UPDATE
El upsert idiomático de MySQL es INSERT ... ON DUPLICATE KEY UPDATE. Cuando el insert violaría una clave primaria o única, se ejecuta en su lugar la cláusula de update.
INSERT INTO products (sku, name, price_cents, stock, attributes)
VALUES ('SKU-1', 'Widget', 1099, 5, JSON_OBJECT('colour', 'blue'))
ON DUPLICATE KEY UPDATE
price_cents = VALUES(price_cents),
stock = stock + VALUES(stock);
Esto es atómico, lo cual es fundamental para contadores e inventarios. La alternativa —hacer un select y luego decidir si insertar o actualizar— presenta una ventana de condición de carrera (race condition) donde dos sesiones podrían no detectar la fila y ambas intentar insertar. Utiliza el upsert siempre que la operación sea genuinamente de insertar o actualizar.
Columnas JSON
El tipo JSON de MySQL almacena un documento validado y admite funciones para leer y escribir partes del mismo.
SELECT id, name,
attributes->>'$.colour' AS colour
FROM products
WHERE attributes->>'$.colour' = 'blue';
Puedes indexar JSON mediante columnas generadas. MySQL no puede indexar una expresión JSON directamente, por lo que debes extraerla en una columna generada almacenada e indexar esa columna:
ALTER TABLE products
ADD COLUMN colour VARCHAR(32)
GENERATED ALWAYS AS (attributes->>'$.colour') STORED,
ADD INDEX idx_products_colour (colour);
Utiliza JSON para atributos variables y payloads de terceros. Mantén los campos que filtres constantemente como columnas reales; el truco de la columna generada funciona, pero una columna nativa con un tipo real es más sencilla y rápida.
Procedimientos almacenados: úsalos con moderación
MySQL admite procedimientos almacenados, funciones, triggers y eventos programados. Estos pueden reducir los viajes de ida y vuelta (round trips) y centralizar la lógica, pero también trasladan la lógica de negocio a un lenguaje que es más difícil de testear, versionar y depurar que el código de tu aplicación.
Utilízalos para tareas que realmente pertenezcan al ámbito de la base de datos: mantenimiento masivo, migraciones de datos y trabajos que deban ejecutarse cerca de los datos. Evita colocar reglas fundamentales del dominio en procedimientos que solo un equipo comprenda. Los triggers son especialmente fáciles de olvidar; un UPDATE que dispara silenciosamente tres triggers es difícil de analizar, y los efectos secundarios ocultos acaban sorprendiendo a todo el mundo tarde o temprano.
Usuarios, permisos y respaldos
Crea un usuario de aplicación dedicado que tenga únicamente los privilegios necesarios, en lugar de conectarte como root.
CREATE USER 'app'@'%' IDENTIFIED BY 'a-strong-password';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'app'@'%';
FLUSH PRIVILEGES;
Mantén el DDL y las migraciones en una cuenta separada y con más privilegios. Restringe los hosts siempre que sea posible, requiere TLS y rota las credenciales.
Para los respaldos, mysqldump genera un volcado lógico que es fácil de mover y restaurar:
mysqldump --single-transaction --routines --triggers shop > shop.sql
mysql shop_restore < shop.sql
El flag --single-transaction toma una instantánea consistente de las tablas InnoDB sin bloquearlas durante todo el volcado. Para bases de datos grandes donde el tiempo de volcado sea prohibitivo, utiliza una herramienta física como Percona XtraBackup. De cualquier forma, registra la posición del binary log para poder realizar una recuperación a un punto específico en el tiempo y prueba las restauraciones.
MySQL vs MariaDB
MariaDB comenzó como un fork comunitario de MySQL tras la adquisición por parte de Oracle y ha divergido desde entonces. Mantiene la mayor parte de la sintaxis de MySQL y añade sus propios motores de almacenamiento y funcionalidades. Por otro lado, MySQL ha avanzado rápidamente desde la versión 8 con un nuevo diccionario de datos, window functions y un soporte mejorado para JSON.
Para la mayoría de las aplicaciones, las diferencias son mínimas. Elige MySQL si tu proveedor gestionado o contrato de soporte se basan en él, y MariaDB si prefieres un proyecto gobernado por la comunidad o necesitas alguno de sus motores. Los conceptos relacionales, SQL y los patrones operativos son transferibles entre ambos, por lo que la elección rara vez es una decisión irreversible.
Búsqueda de texto completo en InnoDB
InnoDB incluye un índice de texto completo, por lo que una funcionalidad de búsqueda no requiere un servicio independiente. Crea un índice FULLTEXT sobre las columnas de texto y consúltalo con MATCH ... AGAINST.
CREATE FULLTEXT INDEX ft_products_search
ON products (name, attributes);
SELECT id, name,
MATCH(name, attributes) AGAINST ('running shoe' IN NATURAL LANGUAGE MODE) AS score
FROM products
WHERE MATCH(name, attributes) AGAINST ('running shoe' IN NATURAL LANGUAGE MODE)
ORDER BY score DESC
LIMIT 20;
NATURAL LANGUAGE MODE clasifica por relevancia e ignora las palabras que aparecen en la mayoría de las filas. BOOLEAN MODE te ofrece operadores como +must, -exclude y "exact phrase" para tener un mayor control. El índice tiene una longitud mínima de token controlada por innodb_ft_min_token_size, por lo que es posible que se omitan las palabras muy cortas.
La búsqueda de texto completo es ideal para catálogos de productos y búsqueda de artículos. Cambia a un motor dedicado cuando necesites tolerancia a errores tipográficos, faceting o análisis multilingüe que MySQL no proporcione.
Vistas y columnas generadas
Una vista es una consulta con nombre que se comporta como una tabla, lo cual es útil para empaquetar una estructura común y para limitar qué columnas puede ver un usuario de reportes.
CREATE VIEW in_stock AS
SELECT id, sku, name, price_cents, stock
FROM products
WHERE stock > 0;
SELECT * FROM in_stock WHERE price_cents < 2500;
MySQL 8 soporta window functions, por lo que las vistas también pueden predefinir la forma de los resultados analíticos. Recuerda que una vista ejecuta su consulta subyacente cada vez que se llama, a menos que se materialice manualmente en una tabla; MySQL no tiene vistas materializadas nativas, por lo que los equipos suelen utilizar una tabla de resumen que se actualiza mediante un horario o un evento.
Las columnas generadas, detalladas en la sección de JSON, son la otra parte de este concepto: una columna generada almacenada (stored) se calcula al escribir y puede ser indexada, mientras que una virtual se calcula al leer. Utiliza una columna almacenada cuando necesites indexar la expresión, y una virtual cuando solo necesites el valor ocasionalmente.
Cambiar esquemas en una base de datos en producción
ALTER TABLE en una tabla InnoDB grande puede reconstruir la tabla y mantener bloqueos durante mucho tiempo. MySQL 8 soporta DDL online para muchas operaciones a través de ALGORITHM=INPLACE, que realiza la reconstrucción sin bloquear las lecturas y escrituras concurrentes en muchos casos.
ALTER TABLE products
ADD COLUMN updated_at TIMESTAMP NULL,
ALGORITHM=INPLACE, LOCK=NONE;
ALTER TABLE products
ADD INDEX idx_products_updated (updated_at),
ALGORITHM=INPLACE, LOCK=NONE;
No todos los cambios son online. Cambiar el tipo de una columna, añadir un índice FULLTEXT o reconstruir una clave primaria a menudo sigue implicando la copia de la tabla. Para estos casos, utiliza una herramienta como pt-online-schema-change o gh-ost, que crean una tabla espejo, copian las filas en lotes y realizan el intercambio con un bloqueo mínimo. Independientemente del método, ejecuta los cambios de esquema mediante migraciones versionadas y pruébalos primero en una copia de los datos de producción.
Cómo encontrar consultas lentas
MySQL registra las consultas que superan long_query_time en el slow query log, y EXPLAIN muestra cómo se ejecuta una consulta específica. Juntos, representan el camino más rápido para pasar de “la aplicación está lenta” a una solución concreta.
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.2;
SHOW VARIABLES LIKE 'slow_query_log_file';
El performance_schema y el esquema sys resumen los mismos datos. Comienza con las sentencias que consumen más tiempo total, no con la consulta individual más lenta, y revisa EXPLAIN para detectar full scans en tablas grandes. SHOW PROFILE y EXPLAIN ANALYZE en MySQL 8 añaden tiempos por etapa cuando necesitas profundizar más.
Carga de datos rápida
Un bucle INSERT fila por fila es la forma más lenta de cargar datos. Agrupa varias filas en una sola sentencia; esto reduce los viajes de ida y vuelta (round trips) y permite que InnoDB escriba las páginas de manera eficiente.
INSERT INTO products (sku, name, price_cents, stock, attributes)
VALUES
('SKU-10', 'Widget', 999, 5, JSON_OBJECT('colour', 'blue')),
('SKU-11', 'Gadget', 1499, 3, JSON_OBJECT('colour', 'red')),
('SKU-12', 'Gizmo', 2499, 7, JSON_OBJECT('colour', 'green'));
Para importaciones masivas, LOAD DATA INFILE transmite un archivo directamente a una tabla y es drásticamente más rápido que cualquier forma de INSERT.
LOAD DATA LOCAL INFILE '/data/products.csv'
INTO TABLE products
FIELDS TERMINATED BY ',' ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 ROWS
(sku, name, price_cents, stock, @attributes)
SET attributes = CAST(@attributes AS JSON);
Envuelve las cargas grandes en una transacción para que un fallo no deje una tabla importada a medias. Considera desactivar los índices secundarios o las comprobaciones de unicidad durante una carga masiva puntual y reconstruirlos después. Para las escrituras cotidianas de la aplicación, un INSERT agrupado es la opción predeterminada correcta.
Mejores prácticas
- Usa InnoDB para cada tabla y decláralo explícitamente.
- Crea bases de datos, tablas y conexiones con
utf8mb4y una collation consistente. - Almacena el dinero como unidades menores enteras o
DECIMAL, nunca comoFLOAToDOUBLE. - Añade índices para las consultas que realmente ejecutes y confírmalos con
EXPLAIN. - Mantén el modo
ONLY_FULL_GROUP_BYpredeterminado y escribe consultasGROUP BYcorrectas. - Usa
INSERT ... ON DUPLICATE KEY UPDATEpara upserts atómicos en lugar de la estrategia check-then-act. - Mantén las transacciones cortas, actualiza las filas en un orden estable y reintenta en caso de deadlock.
- Asigna a la aplicación un usuario con privilegios mínimos y mantén el DDL en una cuenta de migraciones.
- Realiza backups consistentes con
--single-transaction, registra la posición del binlog y prueba las restauraciones. - Prefiere la paginación por keyset sobre los escaneos extensos de
LIMIT ... OFFSET.
Errores comunes
- Dejar tablas en MyISAM y perder las transacciones y el bloqueo a nivel de fila.
- Usar el antiguo
utf8de tres bytes y luego preguntarse por qué fallan los emoji al guardarse. - Almacenar dinero en
FLOATy acumular errores de redondeo. - Ejecutar
UPDATEoDELETEsin unWHEREy reescribir la tabla completa. - Indexar cada columna en lugar de las consultas que realmente importan, lo que ralentiza las escrituras.
- Confiar en que
AUTO_INCREMENTsea continuo o tenga un significado. - Hacer operaciones de lectura-modificación-escritura en el código de la aplicación en lugar de un upsert atómico.
- Leer de una réplica inmediatamente después de una escritura y ver datos obsoletos.
- Desactivar
ONLY_FULL_GROUP_BYpara ocultar una agregación incorrecta. - Permitir que la aplicación se conecte como
root.
Próximos pasos
MySQL y sus conceptos son muy versátiles. Si quieres comparar con el otro motor relacional principal, lee la guía de PostgreSQL, que cubre los mismos conceptos pero con diferentes valores predeterminados. Para profundizar en el lenguaje que sustenta a ambos, la guía de SQL explica los joins, CTEs y window functions desde los principios básicos. Y dado que la mayoría de los despliegues de MySQL se apoyan en una caché, Redis y el modelo de documentos de MongoDB son complementos naturales.