Back to blog
Development 12 min read

Debugging a 47-second query

The anatomy of a slow query investigation - from panic alert to production fix. A real-world case study in database performance optimization.

November 2024 · Updated November 2024

TL;DR

A 47-second query was crippling production during peak hours. The culprit: a missing composite index combined with an implicit type conversion. The fix took 30 seconds to deploy. Finding it took 3 hours of methodical investigation. Here's the full process.

Een query van 47 seconden verlamde productie tijdens piekuren. De boosdoener: een ontbrekende samengestelde index gecombineerd met een impliciete typeconversie. De fix duurde 30 seconden om te deployen. Het vinden ervan kostte 3 uur methodisch onderzoek. Hier is het volledige proces.

Una consulta de 47 segundos estaba paralizando producción durante las horas pico. El culpable: un índice compuesto faltante combinado con una conversión de tipo implícita. La solución tardó 30 segundos en implementarse. Encontrarla tomó 3 horas de investigación metódica. Aquí está el proceso completo.

The alert

It was 2:47 PM on a Thursday when the Slack alert came in: "Database CPU at 98%, response times degraded." The monitoring dashboard confirmed it - average response times had jumped from 200ms to 15 seconds, and climbing.

This is the kind of problem that makes your stomach drop. Production is on fire, users are experiencing failures, and somewhere in your application is a query doing something catastrophically wrong.

Step 1: Identify the offending query

First priority: find what's causing the damage. On MySQL, the process list is your starting point:

SHOW FULL PROCESSLIST;

Several connections showed the same query running for 40+ seconds:

SELECT orders.*, customers.name, customers.email
FROM orders
JOIN customers ON orders.customer_id = customers.id
WHERE orders.status = 'pending'
  AND orders.region_code = '12'
  AND orders.created_at > '2024-01-01'
ORDER BY orders.created_at DESC
LIMIT 50;

On paper, this looks innocent. A simple join, some filters, a sort, a limit. But it was taking 47 seconds to execute.

Step 2: Understand the data volume

Before diving into EXPLAIN, understand what you're dealing with:

SELECT COUNT(*) FROM orders;                    -- 12.4 million rows
SELECT COUNT(*) FROM customers;                  -- 890,000 rows
SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- 2.1 million rows

With 12.4 million orders and 2.1 million pending orders, this wasn't a small dataset. But even so, 47 seconds is inexcusable for a query like this.

Step 3: EXPLAIN Analysis

Time to see what MySQL was actually doing:

EXPLAIN SELECT orders.*, customers.name, customers.email
FROM orders
JOIN customers ON orders.customer_id = customers.id
WHERE orders.status = 'pending'
  AND orders.region_code = '12'
  AND orders.created_at > '2024-01-01'
ORDER BY orders.created_at DESC
LIMIT 50;

The results were illuminating:

+----+-------------+-----------+------+---------------+------+---------+------+----------+-----------------------------+
| id | select_type | table     | type | possible_keys | key  | key_len | ref  | rows     | Extra                       |
+----+-------------+-----------+------+---------------+------+---------+------+----------+-----------------------------+
|  1 | SIMPLE      | orders    | ALL  | customer_id   | NULL | NULL    | NULL | 12400000 | Using where; Using filesort |
|  1 | SIMPLE      | customers | ref  | PRIMARY       | PRIMARY | 4    | orders.customer_id | 1 | NULL         |
+----+-------------+-----------+------+---------------+------+---------+------+----------+-----------------------------+

Red flags everywhere:

  • type: ALL - A full table scan on 12.4 million rows
  • key: NULL - No index being used for the orders table
  • Using filesort - Sorting on disk instead of using an index

Step 4: Investigating the index situation

Let's check what indexes exist:

SHOW INDEX FROM orders;

The table had these indexes:

  • PRIMARY on id
  • customer_id on customer_id
  • status on status
  • created_at on created_at

Individual indexes existed, but no composite index that matched the query's WHERE clause. MySQL can only use one index per table in a simple query, and none of these individual indexes were selective enough.

Step 5: The hidden problem

But wait - why wasn't MySQL using at least the status index? It should reduce 12.4 million rows to 2.1 million.

SHOW CREATE TABLE orders;

The region_code column was defined as INT, but the query was comparing it to a string:

WHERE orders.region_code = '12'  -- String comparison on INT column

This forced MySQL to perform an implicit type conversion on every row, preventing index usage entirely. This is a classic performance killer that's easy to miss.

The hidden cost of type conversion

When you compare a numeric column to a string value, MySQL converts the column value for every row. This prevents index usage because the index stores the original (numeric) values, not the converted ones.

Step 6: The fix

Two changes were needed:

1. Fix the type mismatch

In the application code:

// Before: String comparison
$orders = Order::where('region_code', '12')->get();

// After: Integer comparison
$orders = Order::where('region_code', 12)->get();

2. Add a composite index

CREATE INDEX idx_orders_status_region_created
ON orders (status, region_code, created_at DESC);

The index order matters. We put status first because it's used in an equality comparison, then region_code (also equality), then created_at for the range query and sorting.

Step 7: Verify the fix

EXPLAIN SELECT orders.*, customers.name, customers.email
FROM orders
JOIN customers ON orders.customer_id = customers.id
WHERE orders.status = 'pending'
  AND orders.region_code = 12  -- Now an integer
  AND orders.created_at > '2024-01-01'
ORDER BY orders.created_at DESC
LIMIT 50;

New results:

+----+-------------+-----------+-------+-----------------------------------+-----------------------------------+---------+------+------+-------------+
| id | select_type | table     | type  | possible_keys                     | key                               | key_len | ref  | rows | Extra       |
+----+-------------+-----------+-------+-----------------------------------+-----------------------------------+---------+------+------+-------------+
|  1 | SIMPLE      | orders    | range | idx_orders_status_region_created  | idx_orders_status_region_created  | 12      | NULL | 847  | Using where |
|  1 | SIMPLE      | customers | ref   | PRIMARY                           | PRIMARY                           | 4       | orders.customer_id | 1 | NULL |
+----+-------------+-----------+-------+-----------------------------------+-----------------------------------+---------+------+------+-------------+

The improvements:

  • type: range instead of ALL - Using an index range scan
  • rows: 847 instead of 12,400,000 - Only scanning matching rows
  • No filesort - The index handles ordering

Query time: 47 seconds to 23 milliseconds.

Lessons learned

1. Type consistency matters

Always ensure your comparison types match your column types. This is easy to miss, especially when values come from user input (which is usually strings) or configuration.

// Laravel: Cast route parameters
Route::get('/orders/{region}', function (int $region) {
    return Order::where('region_code', $region)->get();
});

2. Composite indexes beat multiple single-column indexes

MySQL can only use one index per table (with some exceptions for index merging). A composite index that matches your common query patterns will almost always outperform multiple single-column indexes.

3. EXPLAIN before you deploy

Make EXPLAIN analysis part of your code review process. Any new query that touches large tables should be analyzed before it hits production.

// Enable query logging in development
DB::enableQueryLog();

// Run your operations
$orders = Order::where(...)->get();

// Check what ran
dd(DB::getQueryLog());

4. Monitor slow queries continuously

Enable MySQL's slow query log:

# my.cnf
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1  # Log queries taking longer than 1 second

The full investigation toolkit

For future reference, here's my go-to toolkit for query debugging:

-- Current running queries
SHOW FULL PROCESSLIST;

-- Query execution plan
EXPLAIN SELECT ...;
EXPLAIN ANALYZE SELECT ...;  -- MySQL 8.0.18+

-- Table indexes
SHOW INDEX FROM table_name;

-- Table statistics
SHOW TABLE STATUS LIKE 'table_name';

-- Index usage statistics (MySQL 8.0+)
SELECT * FROM sys.schema_index_statistics
WHERE table_name = 'orders';

-- Unused indexes
SELECT * FROM sys.schema_unused_indexes;

Prevention is better than cure

After this incident, we implemented several preventive measures:

  1. Query analysis in CI - EXPLAIN runs automatically for new queries
  2. Slow query alerts - Immediate notification for queries over 5 seconds
  3. Type casting standards - Explicit casting in all database interactions
  4. Index review sessions - Monthly review of query patterns vs. existing indexes

The 47-second query was a painful lesson, but it led to a more robust approach to database performance across the entire application.

De waarschuwing

Het was 14:47 op een donderdag toen de Slack-waarschuwing binnenkwam: "Database CPU op 98%, responstijden verslechterd." Het monitoringdashboard bevestigde het - gemiddelde responstijden waren gesprongen van 200ms naar 15 seconden, en stijgend.

Dit is het soort probleem dat je maag doet samentrekken. Productie staat in brand, gebruikers ervaren fouten, en ergens in je applicatie doet een query iets catastrofaal verkeerds.

Stap 1: Identificeer de boosdoener

Eerste prioriteit: vind wat de schade veroorzaakt. Op MySQL is de proceslijst je startpunt:

SHOW FULL PROCESSLIST;

Verschillende verbindingen toonden dezelfde query die al 40+ seconden liep:

SELECT orders.*, customers.name, customers.email
FROM orders
JOIN customers ON orders.customer_id = customers.id
WHERE orders.status = 'pending'
  AND orders.region_code = '12'
  AND orders.created_at > '2024-01-01'
ORDER BY orders.created_at DESC
LIMIT 50;

Op papier ziet dit er onschuldig uit. Een simpele join, wat filters, een sort, een limit. Maar het duurde 47 seconden om uit te voeren.

Stap 2: Begrijp het datavolume

Voor je in EXPLAIN duikt, begrijp waar je mee te maken hebt:

SELECT COUNT(*) FROM orders;                    -- 12,4 miljoen rijen
SELECT COUNT(*) FROM customers;                  -- 890.000 rijen
SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- 2,1 miljoen rijen

Met 12,4 miljoen orders en 2,1 miljoen pending orders was dit geen kleine dataset. Maar zelfs dan zijn 47 seconden onacceptabel voor een query als deze.

Stap 3: EXPLAIN-analyse

Tijd om te zien wat MySQL eigenlijk deed:

EXPLAIN SELECT orders.*, customers.name, customers.email
FROM orders
JOIN customers ON orders.customer_id = customers.id
WHERE orders.status = 'pending'
  AND orders.region_code = '12'
  AND orders.created_at > '2024-01-01'
ORDER BY orders.created_at DESC
LIMIT 50;

De resultaten waren veelzeggend:

+----+-------------+-----------+------+---------------+------+---------+------+----------+-----------------------------+
| id | select_type | table     | type | possible_keys | key  | key_len | ref  | rows     | Extra                       |
+----+-------------+-----------+------+---------------+------+---------+------+----------+-----------------------------+
|  1 | SIMPLE      | orders    | ALL  | customer_id   | NULL | NULL    | NULL | 12400000 | Using where; Using filesort |
|  1 | SIMPLE      | customers | ref  | PRIMARY       | PRIMARY | 4    | orders.customer_id | 1 | NULL         |
+----+-------------+-----------+------+---------------+------+---------+------+----------+-----------------------------+

Overal rode vlaggen:

  • type: ALL - Een volledige tabelscan op 12,4 miljoen rijen
  • key: NULL - Geen index wordt gebruikt voor de orders-tabel
  • Using filesort - Sorteren op schijf in plaats van gebruik van een index

Stap 4: Onderzoek de indexsituatie

Laten we controleren welke indexes bestaan:

SHOW INDEX FROM orders;

De tabel had deze indexes:

  • PRIMARY op id
  • customer_id op customer_id
  • status op status
  • created_at op created_at

Individuele indexes bestonden, maar geen samengestelde index die overeenkwam met de WHERE-clause van de query. MySQL kan slechts één index per tabel gebruiken in een simpele query, en geen van deze individuele indexes was selectief genoeg.

Stap 5: Het verborgen probleem

Maar wacht - waarom gebruikte MySQL niet minstens de status index? Het zou 12,4 miljoen rijen moeten reduceren naar 2,1 miljoen.

SHOW CREATE TABLE orders;

De region_code kolom was gedefinieerd als INT, maar de query vergeleek het met een string:

WHERE orders.region_code = '12'  -- String-vergelijking op INT-kolom

Dit dwong MySQL om een impliciete typeconversie uit te voeren op elke rij, waardoor indexgebruik volledig werd voorkomen. Dit is een klassieke performance killer die gemakkelijk over het hoofd wordt gezien.

De verborgen kosten van typeconversie

Wanneer je een numerieke kolom vergelijkt met een stringwaarde, converteert MySQL de kolomwaarde voor elke rij. Dit voorkomt indexgebruik omdat de index de originele (numerieke) waarden opslaat, niet de geconverteerde.

Stap 6: De oplossing

Twee wijzigingen waren nodig:

1. Repareer de type mismatch

In de applicatiecode:

// Voor: String-vergelijking
$orders = Order::where('region_code', '12')->get();

// Na: Integer-vergelijking
$orders = Order::where('region_code', 12)->get();

2. Voeg een samengestelde index toe

CREATE INDEX idx_orders_status_region_created
ON orders (status, region_code, created_at DESC);

De indexvolgorde is belangrijk. We zetten status eerst omdat het gebruikt wordt in een gelijkheidsvergelijking, dan region_code (ook gelijkheid), dan created_at voor de range query en sortering.

Stap 7: Verifieer de oplossing

EXPLAIN SELECT orders.*, customers.name, customers.email
FROM orders
JOIN customers ON orders.customer_id = customers.id
WHERE orders.status = 'pending'
  AND orders.region_code = 12  -- Nu een integer
  AND orders.created_at > '2024-01-01'
ORDER BY orders.created_at DESC
LIMIT 50;

Nieuwe resultaten:

+----+-------------+-----------+-------+-----------------------------------+-----------------------------------+---------+------+------+-------------+
| id | select_type | table     | type  | possible_keys                     | key                               | key_len | ref  | rows | Extra       |
+----+-------------+-----------+-------+-----------------------------------+-----------------------------------+---------+------+------+-------------+
|  1 | SIMPLE      | orders    | range | idx_orders_status_region_created  | idx_orders_status_region_created  | 12      | NULL | 847  | Using where |
|  1 | SIMPLE      | customers | ref   | PRIMARY                           | PRIMARY                           | 4       | orders.customer_id | 1 | NULL |
+----+-------------+-----------+-------+-----------------------------------+-----------------------------------+---------+------+------+-------------+

De verbeteringen:

  • type: range in plaats van ALL - Gebruik van een index range scan
  • rows: 847 in plaats van 12.400.000 - Alleen overeenkomende rijen scannen
  • Geen filesort - De index regelt de ordening

Querytijd: 47 seconden naar 23 milliseconden.

Geleerde lessen

1. Type-consistentie is belangrijk

Zorg er altijd voor dat je vergelijkingstypes overeenkomen met je kolomtypes. Dit is gemakkelijk te missen, vooral wanneer waarden afkomstig zijn van gebruikersinvoer (wat meestal strings zijn) of configuratie.

// Laravel: Cast route parameters
Route::get('/orders/{region}', function (int $region) {
    return Order::where('region_code', $region)->get();
});

2. Samengestelde indexes verslaan meerdere single-column indexes

MySQL kan slechts één index per tabel gebruiken (met enkele uitzonderingen voor index merging). Een samengestelde index die overeenkomt met je veel voorkomende querypatronen zal bijna altijd beter presteren dan meerdere single-column indexes.

3. EXPLAIN voordat je deploy

Maak EXPLAIN-analyse onderdeel van je code review proces. Elke nieuwe query die grote tabellen raakt moet geanalyseerd worden voordat het productie haalt.

// Schakel query logging in tijdens development
DB::enableQueryLog();

// Voer je operaties uit
$orders = Order::where(...)->get();

// Controleer wat er draaide
dd(DB::getQueryLog());

4. Monitor langzame queries continu

Schakel MySQL's slow query log in:

# my.cnf
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1  # Log queries die langer dan 1 seconde duren

De volledige onderzoekstoolkit

Voor toekomstige referentie, hier is mijn go-to toolkit voor query debugging:

-- Huidige draaiende queries
SHOW FULL PROCESSLIST;

-- Query execution plan
EXPLAIN SELECT ...;
EXPLAIN ANALYZE SELECT ...;  -- MySQL 8.0.18+

-- Tabel indexes
SHOW INDEX FROM table_name;

-- Tabelstatistieken
SHOW TABLE STATUS LIKE 'table_name';

-- Index usage statistieken (MySQL 8.0+)
SELECT * FROM sys.schema_index_statistics
WHERE table_name = 'orders';

-- Ongebruikte indexes
SELECT * FROM sys.schema_unused_indexes;

Voorkomen is beter dan genezen

Na dit incident implementeerden we verschillende preventieve maatregelen:

  1. Query-analyse in CI - EXPLAIN draait automatisch voor nieuwe queries
  2. Slow query alerts - Onmiddellijke notificatie voor queries boven 5 seconden
  3. Type casting standaarden - Expliciete casting in alle database-interacties
  4. Index review sessies - Maandelijkse review van querypatronen versus bestaande indexes

De 47-seconden query was een pijnlijke les, maar het leidde tot een robuustere aanpak voor databaseprestaties in de hele applicatie.

La alerta

Eran las 14:47 de un jueves cuando llegó la alerta de Slack: "CPU de la base de datos al 98%, tiempos de respuesta degradados." El panel de monitoreo lo confirmó: los tiempos de respuesta promedio habían saltado de 200ms a 15 segundos, y seguían subiendo.

Este es el tipo de problema que hace que se te revuelva el estómago. Producción está en llamas, los usuarios están experimentando fallos, y en algún lugar de tu aplicación hay una consulta haciendo algo catastróficamente mal.

Paso 1: Identificar la consulta problemática

Primera prioridad: encontrar qué está causando el daño. En MySQL, la lista de procesos es tu punto de partida:

SHOW FULL PROCESSLIST;

Varias conexiones mostraban la misma consulta ejecutándose durante más de 40 segundos:

SELECT orders.*, customers.name, customers.email
FROM orders
JOIN customers ON orders.customer_id = customers.id
WHERE orders.status = 'pending'
  AND orders.region_code = '12'
  AND orders.created_at > '2024-01-01'
ORDER BY orders.created_at DESC
LIMIT 50;

En papel, esto parece inocente. Un simple join, algunos filtros, un ordenamiento, un límite. Pero estaba tomando 47 segundos ejecutarse.

Paso 2: Entender el volumen de datos

Antes de profundizar en EXPLAIN, entiende con qué estás tratando:

SELECT COUNT(*) FROM orders;                    -- 12,4 millones de filas
SELECT COUNT(*) FROM customers;                  -- 890.000 filas
SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- 2,1 millones de filas

Con 12,4 millones de pedidos y 2,1 millones de pedidos pendientes, esto no era un conjunto de datos pequeño. Pero aun así, 47 segundos es inexcusable para una consulta como esta.

Paso 3: Análisis EXPLAIN

Tiempo de ver qué estaba haciendo realmente MySQL:

EXPLAIN SELECT orders.*, customers.name, customers.email
FROM orders
JOIN customers ON orders.customer_id = customers.id
WHERE orders.status = 'pending'
  AND orders.region_code = '12'
  AND orders.created_at > '2024-01-01'
ORDER BY orders.created_at DESC
LIMIT 50;

Los resultados fueron reveladores:

+----+-------------+-----------+------+---------------+------+---------+------+----------+-----------------------------+
| id | select_type | table     | type | possible_keys | key  | key_len | ref  | rows     | Extra                       |
+----+-------------+-----------+------+---------------+------+---------+------+----------+-----------------------------+
|  1 | SIMPLE      | orders    | ALL  | customer_id   | NULL | NULL    | NULL | 12400000 | Using where; Using filesort |
|  1 | SIMPLE      | customers | ref  | PRIMARY       | PRIMARY | 4    | orders.customer_id | 1 | NULL         |
+----+-------------+-----------+------+---------------+------+---------+------+----------+-----------------------------+

Banderas rojas por todas partes:

  • type: ALL - Un escaneo completo de tabla en 12,4 millones de filas
  • key: NULL - No se está usando ningún índice para la tabla orders
  • Using filesort - Ordenando en disco en lugar de usar un índice

Paso 4: Investigar la situación de índices

Veamos qué índices existen:

SHOW INDEX FROM orders;

La tabla tenía estos índices:

  • PRIMARY en id
  • customer_id en customer_id
  • status en status
  • created_at en created_at

Existían índices individuales, pero ningún índice compuesto que coincidiera con la cláusula WHERE de la consulta. MySQL solo puede usar un índice por tabla en una consulta simple, y ninguno de estos índices individuales era lo suficientemente selectivo.

Paso 5: El problema oculto

Pero espera - ¿por qué MySQL no estaba usando al menos el índice status? Debería reducir 12,4 millones de filas a 2,1 millones.

SHOW CREATE TABLE orders;

La columna region_code estaba definida como INT, pero la consulta la estaba comparando con una cadena:

WHERE orders.region_code = '12'  -- Comparación de cadena en columna INT

Esto forzó a MySQL a realizar una conversión de tipo implícita en cada fila, evitando completamente el uso de índices. Este es un asesino clásico del rendimiento que es fácil pasar por alto.

El costo oculto de la conversión de tipos

Cuando comparas una columna numérica con un valor de cadena, MySQL convierte el valor de la columna para cada fila. Esto evita el uso de índices porque el índice almacena los valores originales (numéricos), no los convertidos.

Paso 6: La solución

Se necesitaron dos cambios:

1. Corregir el desajuste de tipo

En el código de la aplicación:

// Antes: Comparación de cadena
$orders = Order::where('region_code', '12')->get();

// Después: Comparación de entero
$orders = Order::where('region_code', 12)->get();

2. Agregar un índice compuesto

CREATE INDEX idx_orders_status_region_created
ON orders (status, region_code, created_at DESC);

El orden del índice importa. Ponemos status primero porque se usa en una comparación de igualdad, luego region_code (también igualdad), luego created_at para la consulta de rango y ordenamiento.

Paso 7: Verificar la solución

EXPLAIN SELECT orders.*, customers.name, customers.email
FROM orders
JOIN customers ON orders.customer_id = customers.id
WHERE orders.status = 'pending'
  AND orders.region_code = 12  -- Ahora un entero
  AND orders.created_at > '2024-01-01'
ORDER BY orders.created_at DESC
LIMIT 50;

Nuevos resultados:

+----+-------------+-----------+-------+-----------------------------------+-----------------------------------+---------+------+------+-------------+
| id | select_type | table     | type  | possible_keys                     | key                               | key_len | ref  | rows | Extra       |
+----+-------------+-----------+-------+-----------------------------------+-----------------------------------+---------+------+------+-------------+
|  1 | SIMPLE      | orders    | range | idx_orders_status_region_created  | idx_orders_status_region_created  | 12      | NULL | 847  | Using where |
|  1 | SIMPLE      | customers | ref   | PRIMARY                           | PRIMARY                           | 4       | orders.customer_id | 1 | NULL |
+----+-------------+-----------+-------+-----------------------------------+-----------------------------------+---------+------+------+-------------+

Las mejoras:

  • type: range en lugar de ALL - Usando un escaneo de rango de índice
  • rows: 847 en lugar de 12.400.000 - Solo escaneando filas coincidentes
  • Sin filesort - El índice maneja el ordenamiento

Tiempo de consulta: 47 segundos a 23 milisegundos.

Lecciones aprendidas

1. La consistencia de tipos importa

Siempre asegúrate de que tus tipos de comparación coincidan con los tipos de columna. Esto es fácil de pasar por alto, especialmente cuando los valores provienen de entrada del usuario (que generalmente son cadenas) o configuración.

// Laravel: Cast de parámetros de ruta
Route::get('/orders/{region}', function (int $region) {
    return Order::where('region_code', $region)->get();
});

2. Los índices compuestos superan a múltiples índices de columna única

MySQL solo puede usar un índice por tabla (con algunas excepciones para la fusión de índices). Un índice compuesto que coincida con tus patrones de consulta comunes casi siempre superará a múltiples índices de columna única.

3. EXPLAIN antes de desplegar

Haz del análisis EXPLAIN parte de tu proceso de revisión de código. Cualquier consulta nueva que toque tablas grandes debe ser analizada antes de llegar a producción.

// Habilitar registro de consultas en desarrollo
DB::enableQueryLog();

// Ejecutar tus operaciones
$orders = Order::where(...)->get();

// Verificar qué se ejecutó
dd(DB::getQueryLog());

4. Monitorear consultas lentas continuamente

Habilita el log de consultas lentas de MySQL:

# my.cnf
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1  # Registrar consultas que tomen más de 1 segundo

El kit de herramientas de investigación completo

Para referencia futura, aquí está mi kit de herramientas para depuración de consultas:

-- Consultas en ejecución actual
SHOW FULL PROCESSLIST;

-- Plan de ejecución de consulta
EXPLAIN SELECT ...;
EXPLAIN ANALYZE SELECT ...;  -- MySQL 8.0.18+

-- Índices de tabla
SHOW INDEX FROM table_name;

-- Estadísticas de tabla
SHOW TABLE STATUS LIKE 'table_name';

-- Estadísticas de uso de índices (MySQL 8.0+)
SELECT * FROM sys.schema_index_statistics
WHERE table_name = 'orders';

-- Índices no utilizados
SELECT * FROM sys.schema_unused_indexes;

Prevenir es mejor que curar

Después de este incidente, implementamos varias medidas preventivas:

  1. Análisis de consultas en CI - EXPLAIN se ejecuta automáticamente para nuevas consultas
  2. Alertas de consultas lentas - Notificación inmediata para consultas de más de 5 segundos
  3. Estándares de conversión de tipos - Conversión explícita en todas las interacciones con la base de datos
  4. Sesiones de revisión de índices - Revisión mensual de patrones de consulta versus índices existentes

La consulta de 47 segundos fue una lección dolorosa, pero llevó a un enfoque más robusto para el rendimiento de la base de datos en toda la aplicación.

Struggling with slow database queries? I've optimized databases handling billions of rows. Let's talk.

Related posts

Database performance issues slowing you down?

From query optimization to schema design, I can help your application handle any scale.

Get in touch