Luego de casi dos semanas sin postear, les presento un caso curioso que me ocurrió en estos días. Pues ocurre que, como parte de una tarea de afinamiento de aplicaciones que me solicitó un cliente, encontré con ayuda de dbms_monitor y tkprof que la sentencia responsable del problema de desempeño era una muy peculiar, se trataba de un query que siendo estático estaba escrito de tal forma que pretendía satisfacer varios criterios de búsqueda, esto sin saber de antemano cuáles han sido proporcionados.
Les mostraré cómo esta forma de programar genera código con un pésimo desempeño y veremos desde luego cómo resolverlo, todo esto empleando nuestras conocidas tablas EMP y DEPT.
El Problema Original
SELECT e.empno, e.sal, d.dname
FROM emp e JOIN dept d USING (deptno)
WHERE ( e.empno = :empno OR :empno IS NULL )
AND ( e.mgr = :mgr OR :mgr IS NULL )
El programador no sabe si el usuario final proporcionará los valores de :empno y :mgr por lo que, luego de meditarlo, recurre a un «artificio» que le permite escribir una sola sentencia que satisfaga todos los escenarios posibles. Misión cumplida, el query compila y trae resultados, eso es suficiente, lo pasa a producción y sigue con sus tareas, con la plena convicción de que ha hecho un «faenón» .
Pues bien, cuando la aplicación empieza a ser utilizada los problemas aparecen: aún cuando el usuario final proporciona los datos de la clave primaria de la tabla EMP, los resultados tardan mucho en aparecer, y es que no podía ser de otra forma, la sentencia está escrita de forma que el Optimizador se confunde y no saca provecho de los índices existentes:
SQL> variable empno number;
SQL> variable mgr number;
SQL> execute :empno := 7369;
SQL> execute :mgr := NULL;
SQL> SELECT e.empno, e.sal, d.dname
FROM emp e JOIN dept d USING (deptno)
WHERE ( e.empno = :empno OR :empno IS NULL )
AND ( e.mgr = :mgr OR :mgr IS NULL );
-----------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)|
-----------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | | | 4 (100)|
| 1 | NESTED LOOPS | | 1 | 28 | 4 (0)|
|* 2 | TABLE ACCESS FULL | EMP | 1 | 15 | 3 (0)|
| 3 | TABLE ACCESS BY INDEX ROWID| DEPT | 1 | 13 | 1 (0)|
|* 4 | INDEX UNIQUE SCAN | PK_DEPT | 1 | | 0 (0)|
-----------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
2 - filter(((:EMPNO IS NULL OR "E"."EMPNO"=:EMPNO) AND ("E"."MGR"=:MGR OR
:MGR IS NULL)))
4 - access("E"."DEPTNO"="D"."DEPTNO")
Variantes del Problema
Desde luego esta no es la única forma de codificar una sentencia estática que pretende ser dinámica, abundan las ideas ingeniosas y por tanto hay variantes pero, como es de prever, con los mismos pésimos resultados:
1. Variante usando NVL
SELECT e.empno, e.sal, d.dname
FROM emp e JOIN dept d USING (deptno)
WHERE e.empno = NVL(:empno,e.empno)
AND e.mgr = NVL(:mgr,e.mgr);
SELECT e.empno, e.sal, d.dname
FROM emp e JOIN dept d USING (deptno)
WHERE e.empno = DECODE(:empno,NULL,e.empno,:empno)
AND e.mgr = DECODE(:mgr,NULL,e.mgr,:mgr);
-------------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)|
-------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | | | 6 (100)|
| 1 | CONCATENATION | | | | |
|* 2 | FILTER | | | | |
| 3 | NESTED LOOPS | | 2 | 56 | 4 (0)|
|* 4 | TABLE ACCESS BY INDEX ROWID| EMP | 2 | 30 | 2 (0)|
|* 5 | INDEX FULL SCAN | PK_EMP | 14 | | 1 (0)|
| 6 | TABLE ACCESS BY INDEX ROWID| DEPT | 1 | 13 | 1 (0)|
|* 7 | INDEX UNIQUE SCAN | PK_DEPT | 1 | | 0 (0)|
|* 8 | FILTER | | | | |
| 9 | NESTED LOOPS | | 1 | 28 | 2 (0)|
|* 10 | TABLE ACCESS BY INDEX ROWID| EMP | 1 | 15 | 1 (0)|
|* 11 | INDEX RANGE SCAN | PK_EMP | 1 | | 0 (0)|
| 12 | TABLE ACCESS BY INDEX ROWID| DEPT | 4 | 52 | 1 (0)|
|* 13 | INDEX UNIQUE SCAN | PK_DEPT | 1 | | 0 (0)|
-------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
2 - filter(:EMPNO IS NULL)
4 - filter("E"."MGR"=NVL(:MGR,"E"."MGR"))
5 - filter("E"."EMPNO" IS NOT NULL)
7 - access("E"."DEPTNO"="D"."DEPTNO")
8 - filter(:EMPNO IS NOT NULL)
10 - filter("E"."MGR"=NVL(:MGR,"E"."MGR"))
11 - access("E"."EMPNO"=:EMPNO)
13 - access("E"."DEPTNO"="D"."DEPTNO")
Pero observemos que, sin llegar a ser óptimo, el plan está mejor pensado: el optimizador crea un plan de ejecución condicional, dependiendo de si se proporciona o no un valor para :empno y que se nota por la presencia de FILTER en los pasos 2 y 8:
a. Plan de ejecución si :empno es NULL
| 3 | NESTED LOOPS | | 2 | 56 | 4 (0)|
|* 4 | TABLE ACCESS BY INDEX ROWID| EMP | 2 | 30 | 2 (0)|
|* 5 | INDEX FULL SCAN | PK_EMP | 14 | | 1 (0)|
| 6 | TABLE ACCESS BY INDEX ROWID| DEPT | 1 | 13 | 1 (0)|
|* 7 | INDEX UNIQUE SCAN | PK_DEPT | 1 | | 0 (0)|
| 9 | NESTED LOOPS | | 1 | 28 | 2 (0)|
|* 10 | TABLE ACCESS BY INDEX ROWID| EMP | 1 | 15 | 1 (0)|
|* 11 | INDEX RANGE SCAN | PK_EMP | 1 | | 0 (0)|
| 12 | TABLE ACCESS BY INDEX ROWID| DEPT | 4 | 52 | 1 (0)|
|* 13 | INDEX UNIQUE SCAN | PK_DEPT | 1 | | 0 (0)|
La Solución
. . .
v_query := 'SELECT e.empno, e.sal, d.dname
FROM emp e JOIN dept d USING (deptno)';
v_filtro := ' WHERE 1=1';
IF empno IS NOT NULL THEN
v_filtro := v_filtro || ' AND e.empno = sys_context(''ctx'',''empno'')';
dbms_session.set_context ('ctx','empno',empno);
END IF;
IF mgr IS NOT NULL THEN
v_filtro := v_filtro || ' AND e.mgr = sys_context(''ctx'',''mgr'')';
dbms_session.set_context ('ctx','mgr',mgr);
END IF;
. . .
Los planes de ejecución se ven mejor ahora:
1. Se proporciona solamente :empno
SELECT e.empno, e.sal, d.dname
FROM emp e JOIN dept d USING (deptno)
WHERE e.empno = :empno
-----------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)|
-----------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | | | 2 (100)|
| 1 | NESTED LOOPS | | 1 | 24 | 2 (0)|
| 2 | TABLE ACCESS BY INDEX ROWID| EMP | 1 | 11 | 1 (0)|
|* 3 | INDEX RANGE SCAN | PK_EMP | 1 | | 0 (0)|
| 4 | TABLE ACCESS BY INDEX ROWID| DEPT | 4 | 52 | 1 (0)|
|* 5 | INDEX UNIQUE SCAN | PK_DEPT | 1 | | 0 (0)|
-----------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
3 - access("E"."EMPNO"=:EMPNO)
5 - access("E"."DEPTNO"="D"."DEPTNO")
SELECT e.empno, e.sal, d.dname
FROM emp e JOIN dept d USING (deptno)
WHERE mgr = :mgr
-----------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)|
-----------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | | | 3 (100)|
| 1 | NESTED LOOPS | | 1 | 28 | 3 (0)|
| 2 | TABLE ACCESS BY INDEX ROWID| EMP | 1 | 15 | 2 (0)|
|* 3 | INDEX RANGE SCAN | EMP_MGR | 1 | | 1 (0)|
| 4 | TABLE ACCESS BY INDEX ROWID| DEPT | 1 | 13 | 1 (0)|
|* 5 | INDEX UNIQUE SCAN | PK_DEPT | 1 | | 0 (0)|
-----------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
3 - access("E"."MGR"=:MGR)
5 - access("E"."DEPTNO"="D"."DEPTNO")
SELECT e.empno, e.sal, d.dname
FROM emp e JOIN dept d USING (deptno)
WHERE e.empno = :empno
AND e.mgr = :mgr
-----------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)|
-----------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | | | 2 (100)|
| 1 | NESTED LOOPS | | 1 | 28 | 2 (0)|
|* 2 | TABLE ACCESS BY INDEX ROWID| EMP | 1 | 15 | 1 (0)|
|* 3 | INDEX RANGE SCAN | PK_EMP | 1 | | 0 (0)|
| 4 | TABLE ACCESS BY INDEX ROWID| DEPT | 4 | 52 | 1 (0)|
|* 5 | INDEX UNIQUE SCAN | PK_DEPT | 1 | | 0 (0)|
-----------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
2 - filter("E"."MGR"=:MGR)
3 - access("E"."EMPNO"=:EMPNO)
5 - access("E"."DEPTNO"="D"."DEPTNO")
SELECT e.empno, e.sal, d.dname
FROM emp e JOIN dept d USING (deptno)
-----------------------------------------------------------------------------
| Id | Operation | Name | Rows | Bytes | Cost (%CPU)|
-----------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | | | 6 (100)|
| 1 | MERGE JOIN | | 14 | 336 | 6 (17)|
| 2 | TABLE ACCESS BY INDEX ROWID| DEPT | 4 | 52 | 2 (0)|
| 3 | INDEX FULL SCAN | PK_DEPT | 4 | | 1 (0)|
|* 4 | SORT JOIN | | 14 | 154 | 4 (25)|
| 5 | TABLE ACCESS FULL | EMP | 14 | 154 | 3 (0)|
-----------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
4 - access("E"."DEPTNO"="D"."DEPTNO")
filter("E"."DEPTNO"="D"."DEPTNO")
Conclusiones
Quizás parte del razonamiento en este caso incluyó la consideración de que más vale tener una sentencia que varias y así evitar saturar el Shared Pool, pero en tanto Oracle permite sentencias SQL estáticas y dinámicas, debemos usar estas últimas cuando no sabemos con anticipación lo que el usuario final desea filtrar, no importa tanto que se generen múltiples sentencias.
Los «artificios» permiten crear una sola sentencia pero aún cuando el Optimizador haga su mejor esfuerzo, los planes de ejecución obtenidos serán sub-óptimos para la mayor parte de los casos, por tanto es preferible invertir algo más de tiempo y codificar una sentencia dinámica que nos da la garantía de planes de ejecución eficientes para cada caso.