Query Store SQL Server: el silencioso fallback que invalida tus planes forzados
Este artículo documenta un patrón observado en producción durante una incidencia real. Los nombres de queries, IDs y bases de datos han sido enmascarados; el comportamiento descrito y los datos de rendimiento son los originales.
El falso positivo más peligroso de Query Store
Query Store es una de las funcionalidades más útiles que ha añadido SQL Server en los últimos años. Permite forzar un plan concreto cuando el optimizador empieza a comportarse de forma errática — el típico caso del "ayer iba bien y hoy va mal".
El flujo que casi todo el mundo conoce es:
- La query empieza a ir lenta.
- Vas a Query Store, encuentras un plan antiguo que era rápido.
- Lo fuerzas con
sp_query_store_force_plan. - Verificas que
is_forced_plan = 1. - Asumes que el problema está resuelto.
El paso 5 es el problema. Hay un escenario en el que is_forced_plan = 1 y force_failure_count = 0, pero el optimizador no está usando ese plan. Y nadie te avisa.
El caso real
Una cadena ETL del data warehouse había empezado a tardar tres veces más de lo habitual. El proceso pr_carga_trazabilidad, que normalmente terminaba en 8 minutos, llevaba más de 25 minutos cada noche.
Diagnóstico inicial: una query interna del SP había cambiado de plan. Query Store mostraba dos planes para la misma query:
- Plan A (histórico, rápido):
avg_durationde 4.200 ms. - Plan B (actual, lento):
avg_durationde 1.150.000 ms (~19 minutos).
La solución parecía obvia: forzar el Plan A.
-- Query Store: forzar plan A
EXEC sp_query_store_force_plan @query_id = 2105, @plan_id = 8847;
Confirmación:
SELECT plan_id, query_id, is_forced_plan, force_failure_count
FROM sys.query_store_plan
WHERE query_id = 2105;
| plan_id | query_id | is_forced_plan | force_failure_count |
|---|---|---|---|
| 8847 | 2105 | 1 | 0 |
| 19234 | 2105 | 0 | 0 |
Plan forzado, contador de fallos a cero, todo aparentemente correcto. Sin embargo, el SP seguía tardando 25 minutos.
El truco: comparar planificación contra ejecución real
La trampa es asumir que is_forced_plan = 1 y force_failure_count = 0 significa que el plan se está aplicando. No es así. Significa solo que SQL Server marcó el plan como forzado y que ningún intento explícito de forzarlo ha fallado de forma "limpia" (incompatibilidad de schema, recursos, etc.).
Pero hay casos en los que el optimizador silenciosamente decide que no puede aplicar el plan forzado y elige otro — sin incrementar el contador de fallos. Ocurre, por ejemplo, cuando:
- Las estadísticas usadas al generar el plan original han cambiado dramáticamente.
- Hay cardinality estimation con feedback que sobreescribe el plan forzado.
- Los hints de Query Store entran en conflicto con un cambio de DB compatibility level reciente.
La forma de detectarlo es comparar last_execution_time y avg_duration de cada plan en Query Store:
SELECT
qp.plan_id,
qp.query_id,
qp.is_forced_plan,
qp.force_failure_count,
qrs.last_execution_time,
CAST(qrs.avg_duration / 1000 AS INT) AS avg_duration_ms,
qrs.count_executions
FROM sys.query_store_plan qp
JOIN sys.query_store_runtime_stats qrs
ON qp.plan_id = qrs.plan_id
WHERE qp.query_id = 2105
AND qrs.last_execution_time > DATEADD(HOUR, -2, GETUTCDATE())
ORDER BY qrs.last_execution_time DESC;
Resultado en el caso real:
| plan_id | is_forced_plan | force_failure_count | last_execution_time | avg_duration_ms | count_executions |
|---|---|---|---|---|---|
| 19234 | 0 | 0 | 2026-04-01 03:42:11 | 1.148.000 | 1 |
| 8847 | 1 | 0 | 2026-03-28 11:20:55 | 4.190 | 0 |
La columna que delata el fallback silencioso: last_execution_time del plan forzado es de hace 4 días, mientras que el plan que is_forced_plan = 0 está ejecutándose esta misma madrugada. SQL Server está usando el plan lento (19234) y dejando intacto el plan forzado (8847) sin tocar el force_failure_count.
Por qué Microsoft no incrementa force_failure_count en estos casos
El contador force_failure_count solo se incrementa ante errores "duros" del proceso de forzado: schema cambiado, plan no aplicable a la query actual, recursos insuficientes. Cuando el optimizador toma una decisión de calidad (por ejemplo, decide que las estadísticas actuales hacen que el plan forzado sea demasiado peligroso de aplicar), no lo considera un fallo — solo prefiere otro plan.
Este comportamiento está documentado de forma muy escueta en sys.query_store_plan, donde se indica que la columna captura "fallos" pero no decisiones del optimizador. En la práctica, significa que no puedes confiar en el contador para validar que un force plan está activo.
La verdadera comprobación
La señal de salud real de un plan forzado no es el contador de fallos. Es que last_execution_time del plan forzado se actualice cada vez que la query se ejecuta, y que count_executions siga creciendo en ese plan.
Una query mucho más útil para auditoría diaria de planes forzados:
WITH planes_forzados AS (
SELECT qp.plan_id, qp.query_id
FROM sys.query_store_plan qp
WHERE qp.is_forced_plan = 1
)
SELECT
pf.query_id,
pf.plan_id AS plan_forzado,
qrs.count_executions AS ejecuciones_24h,
CASE
WHEN qrs.last_execution_time IS NULL THEN 'NUNCA EJECUTADO'
WHEN qrs.last_execution_time < DATEADD(DAY, -1, GETUTCDATE())
THEN 'SOSPECHOSO: no ejecutado en 24h'
ELSE 'OK'
END AS estado
FROM planes_forzados pf
LEFT JOIN sys.query_store_runtime_stats qrs
ON pf.plan_id = qrs.plan_id
AND qrs.last_execution_time > DATEADD(DAY, -1, GETUTCDATE());
Si estado = 'SOSPECHOSO: no ejecutado en 24h' en una query que sabes que se ejecuta a diario, has detectado un fallback silencioso.
La solución definitiva: no depender de force plan
Force plan es una solución de emergencia. No debe ser tu solución definitiva. Las razones:
- Es frágil: cualquier cambio en estadísticas, schema o compatibility level puede provocar fallback.
- Es invisible para el resto del equipo: si alguien ve la query y no mira Query Store, no sabe que tiene un plan forzado.
- Genera deuda técnica acumulativa.
En el caso real, la solución definitiva fue:
- Crear un índice de cobertura que el optimizador eligiese de forma natural (sin necesidad de forzar nada).
- Actualizar estadísticas con FULLSCAN sobre la tabla principal del SP (las estadísticas estaban al 0,3% de muestreo).
- Quitar el force plan una vez verificado que el plan elegido era estable durante una semana.
-- Tras 7 días con plan estable sin forzar:
EXEC sp_query_store_unforce_plan @query_id = 2105, @plan_id = 8847;
Resumen práctico
is_forced_plan = 1yforce_failure_count = 0no garantiza que el plan esté aplicándose.- La señal real es
last_execution_timeycount_executionsdel plan forzado. - Audita los planes forzados al menos semanalmente en cualquier instancia con Query Store activo.
- Force plan es una tirita, no una cura. Trátalo como tal y planifica la solución estructural (índice, estadísticas, reescritura).
Si tienes un servidor con docenas de planes forzados acumulados a lo largo de los años, probablemente la mitad ya no estén aplicándose. Una auditoría con la query de arriba puede ahorrarte muchas sorpresas.