La definición de un operador de PostgreSQL puede incluir varias cláusulas opcionales que le indican al sistema cosas útiles sobre cómo se comporta el operador. Estas cláusulas deben proporcionarse siempre que sea apropiado, porque pueden lograr aceleraciones considerables en la ejecución de las consultas que utilizan el operador. ¡Pero si las proporcionas, debes estar seguro de que son correctas! El uso incorrecto de una cláusula de optimización puede dar como resultado consultas lentas, salidas sutilmente erróneas u otras cosas malas (Bad Things). Siempre puedes omitir una cláusula de optimización si no estás seguro de ella; la única consecuencia es que las consultas podrían ejecutarse más lentamente de lo necesario.
Se podrían agregar cláusulas de optimización adicionales en futuras versiones de PostgreSQL. Las que se describen aquí son todas las que la versión 18.4 entiende.
También es posible asociar una función de soporte del planificador (planner support function) a la función subyacente de un operador, lo que proporciona otra forma de informarle al sistema sobre el comportamiento del operador. Consulta la sección Section 36.11 para obtener más información.
COMMUTATOR #
La cláusula COMMUTATOR, si se proporciona, nombra a un operador que es el
conmutador del operador que se está definiendo. Decimos que el operador A es el
conmutador del operador B si (x A y) es igual a (y B x) para todos los posibles valores
de entrada x, y. Ten en cuenta que B también es el conmutador de A. Por ejemplo, los
operadores < y > para un tipo de datos particular suelen ser conmutadores
el uno del otro, y el operador + suele ser conmutativo consigo mismo.
Pero el operador - no suele ser conmutativo con nada.
El tipo del operando izquierdo de un operador conmutable es el mismo que el
tipo del operando derecho de su conmutador, y viceversa. Por lo tanto, el nombre de
el operador conmutador es todo lo que PostgreSQL
necesita recibir para buscar el conmutador, y eso es todo lo que necesita ser
proporcionado en la cláusula COMMUTATOR.
Es fundamental proporcionar información del conmutador para los operadores que
se utilizarán en índices y cláusulas de unión (join), porque esto permite al
optimizador de consultas “dar la vuelta” a dicha cláusula a las formas
necesarias para diferentes tipos de planes. Por ejemplo, considera una consulta con
una cláusula WHERE como tab1.x = tab2.y, donde tab1.x
y tab2.y son de un tipo definido por el usuario, y supón que
tab2.y está indexado. El optimizador no puede generar un
escaneo de índice a menos que pueda determinar cómo dar la vuelta a la cláusula a
tab2.y = tab1.x, porque el mecanismo de escaneo de índice espera
ver la columna indexada a la izquierda del operador que recibe.
PostgreSQL no asumirá simplemente
que esta es una transformación válida — el creador del operador
= debe especificar que es válida, marcando el
operador con la información del conmutador.
NEGATOR #
La cláusula NEGATOR, si se proporciona, nombra a un operador que es el
negador del operador que se está definiendo. Decimos que el operador A
es el negador del operador B si ambos devuelven resultados booleanos y
(x A y) es igual a NOT (x B y) para todas las entradas posibles x, y.
Ten en cuenta que B también es el negador de A.
Por ejemplo, < y >= son un par negador para la mayoría de los tipos de datos.
Un operador nunca puede ser válidamente su propio negador.
A diferencia de los conmutadores, un par de operadores unarios podría marcarse válidamente como negadores el uno del otro; eso significaría que (A x) es igual a NOT (B x) para todo x.
El negador de un operador debe tener los mismos tipos de operando izquierdo y/o derecho
que el operador a definir, por lo que, al igual que con COMMUTATOR, solo se necesita dar
el nombre del operador en la cláusula NEGATOR.
Proporcionar un negador es muy útil para el optimizador de consultas, ya que
permite simplificar expresiones como NOT (x = y) en
x <> y. Esto ocurre con más frecuencia de lo que se podría pensar, porque
las operaciones NOT pueden insertarse como consecuencia de otros reordenamientos.
RESTRICT #
La cláusula RESTRICT, si se proporciona, nombra a una función de estimación
de selectividad de restricción para el operador. (Ten en cuenta que este es el nombre de una
función, no el nombre de un operador). Las cláusulas RESTRICT solo tienen sentido para
operadores binarios que devuelven boolean. La idea detrás de un estimador de
selectividad de restricción es adivinar qué fracción de las filas de una
tabla satisfará una condición de la cláusula WHERE de la forma:
column OP constant
para el operador actual y un valor constante particular.
Esto ayuda al optimizador al
darle una idea de cuántas filas serán eliminadas por las cláusulas WHERE
que tienen esta forma. (¿Qué pasa si la constante está a la izquierda?,
te estarás preguntando. Bueno, esa es una de las cosas para las que sirve
COMMUTATOR...)
Escribir nuevas funciones de estimación de selectividad de restricción está muy fuera del alcance de este capítulo, pero afortunadamente por lo general puedes usar uno de los estimadores estándar del sistema para muchos de tus propios operadores. Estos son los estimadores de restricción estándar:
eqsel para = |
neqsel para <> |
scalarltsel para < |
scalarlesel para <= |
scalargtsel para > |
scalargesel para >= |
Con frecuencia puedes salirte con la tuya usando eqsel o neqsel para
operadores que tienen una selectividad muy alta o muy baja, incluso si
no son realmente de igualdad o desigualdad. Por ejemplo, los
operadores geométricos de igualdad aproximada usan eqsel bajo la suposición de que
por lo general solo coincidirán con una pequeña fracción de las entradas de una tabla.
Puedes utilizar scalarltsel, scalarlesel,
scalargtsel y scalargesel para comparaciones en
tipos de datos que tengan algún medio sensato de ser convertidos en escalares
numéricos para comparaciones de rango. Si es posible, añade el tipo de datos a los
entendidos por la función convert_to_scalar() en
src/backend/utils/adt/selfuncs.c.
(Eventualmente, esta función debería ser reemplazada por funciones por tipo de datos
identificadas a través de una columna del catálogo del sistema pg_type; pero eso aún no ha sucedido).
Si no haces esto, las cosas seguirán funcionando, pero las estimaciones del optimizador
no serán tan buenas como podrían ser.
Otra función de estimación de selectividad incorporada útil
es matchingsel, que funcionará para casi cualquier
operador binario, si se recopilan estadísticas estándar de MCV y/o
histograma para los tipos de datos de entrada. Su estimación por defecto se establece en
el doble de la estimación por defecto utilizada en eqsel, lo que
la hace más adecuada para operadores de comparación que son algo menos
estrictos que la igualdad. (O podrías llamar a la
función subyacente generic_restriction_selectivity,
proporcionando una estimación por defecto diferente).
Hay funciones de estimación de selectividad adicionales diseñadas para operadores geométricos
en src/backend/utils/adt/geo_selfuncs.c: areasel, positionsel,
y contsel. En el momento de escribir esto, son solo esqueletos (stubs), pero de todos modos es
posible que desees utilizarlas (o mejor aún, mejorarlas).
JOIN #
La cláusula JOIN, si se proporciona, nombra a una función de estimación de
selectividad de unión (join) para el operador. (Ten en cuenta que este es el nombre de una
función, no el nombre de un operador). Las cláusulas JOIN solo tienen sentido para
operadores binarios que devuelven boolean. La idea detrás de un estimador de
selectividad de unión es adivinar qué fracción de las filas en un
par de tablas satisfará una condición de la cláusula WHERE de la forma:
table1.column1 OP table2.column2
para el operador actual. Al igual que con la cláusula RESTRICT, esto ayuda
al optimizador de manera muy sustancial al permitirle averiguar cuál
de las diversas secuencias de unión posibles es probable que requiera el menor trabajo.
Como antes, este capítulo no intentará explicar cómo escribir una función de estimación de selectividad de unión, sino que simplemente sugerirá que utilices uno de los estimadores estándar si alguno es aplicable:
eqjoinsel para = |
neqjoinsel para <> |
scalarltjoinsel para < |
scalarlejoinsel para <= |
scalargtjoinsel para > |
scalargejoinsel para >= |
matchingjoinsel para operadores de coincidencia genéricos |
areajoinsel para comparaciones basadas en áreas en 2D |
positionjoinsel para comparaciones basadas en la posición en 2D |
contjoinsel para comparaciones basadas en la contención en 2D |
HASHES #
La cláusula HASHES, si está presente, le indica al sistema que
es permisible usar el método de unión por hash (hash join) para una unión basada en este
operador. HASHES solo tiene sentido para un operador binario que
devuelve boolean, y en la práctica el operador debe representar
la igualdad para algún tipo de datos o par de tipos de datos.
La suposición subyacente a la unión por hash es que el operador de unión solo puede
devolver true para pares de valores izquierdos y derechos que se asocian al
mismo código hash. Si dos valores se colocan en diferentes buckets de hash, la
unión nunca los comparará en absoluto, asumiendo implícitamente que el
resultado del operador de unión debe ser false. Por lo tanto, nunca tiene sentido
especificar HASHES para operadores que no representan
alguna forma de igualdad. En la mayoría de los casos, solo es práctico admitir
el hashing para operadores que toman el mismo tipo de datos en ambos lados.
Sin embargo, a veces es posible diseñar funciones hash compatibles
para dos o más tipos de datos; es decir, funciones que generarán los
mismos códigos hash para valores “iguales”, a pesar de que los valores
tengan diferentes representaciones. Por ejemplo, es bastante simple
organizar esta propiedad al aplicar hash a enteros de diferentes anchos.
Para marcarse como HASHES, el operador de unión debe aparecer
en una familia de operadores de índice hash. Esto no se impone cuando creas
el operador, ya que, por supuesto, la familia de operadores de referencia no
podría existir todavía. Pero los intentos de usar el operador en uniones por hash
fallarán en tiempo de ejecución si no existe tal familia de operadores. El sistema
necesita la familia de operadores para encontrar las funciones hash específicas del
tipo de datos para los tipos de datos de entrada del operador. Por supuesto, también
debes crear funciones hash adecuadas antes de poder crear la familia de operadores.
Se debe tener cuidado al preparar una función hash, porque hay formas dependientes de
la máquina en las que podría no hacer lo correcto. Por ejemplo, si tu tipo de datos es
una estructura en la que podría haber bits de relleno (pad bits) sin interés, no puedes
simplemente pasar la estructura completa a hash_any. (A menos que
escribas tus otros operadores y funciones para asegurar que los bits no utilizados sean siempre
cero, que es la estrategia recomendada).
Otro ejemplo es que en las máquinas que cumplen con el estándar de coma flotante
IEEE, el cero negativo y el cero positivo son valores diferentes
(diferentes patrones de bits) pero están definidos para compararse como iguales.
Si un valor float pudiera contener cero negativo, se necesitan pasos adicionales
para asegurar que genere el mismo valor hash que el cero positivo.
Un operador utilizable en uniones por hash debe tener un conmutador (él mismo si los dos tipos de datos de los operandos son iguales, o un operador de igualdad relacionado si son diferentes) que aparezca en la misma familia de operadores. Si este no es el caso, podrían ocurrir errores del planificador cuando se utiliza el operador. Además, es una buena idea (pero no estrictamente necesario) que una familia de operadores hash que admita múltiples tipos de datos proporcione operadores de igualdad para cada combinación de los tipos de datos; esto permite una mejor optimización.
La función subyacente a un operador utilizable en uniones por hash debe estar marcada como immutable o stable. Si es volatile, el sistema nunca intentará usar el operador para una unión por hash.
Si un operador utilizable en uniones por hash tiene una función subyacente que está marcada
como strict, la función también debe ser completa: es decir, debe devolver true o
false, nunca null, para cualquier par de entradas no nulas. Si no se sigue
esta regla, la optimización por hash de las operaciones IN podría
generar resultados incorrectos. (Específicamente, IN podría devolver
false donde la respuesta correcta según el estándar sería null;
o podría producir un error quejándose de que no estaba preparado para un
resultado nulo).
MERGES #
La cláusula MERGES, si está presente, le indica al sistema que
es permisible usar el método de unión por mezcla (merge join) para una unión basada en este
operador. MERGES solo tiene sentido para un operador binario que
devuelve boolean, y en la práctica el operador debe representar
la igualdad para algún tipo de datos o par de tipos de datos.
La unión por mezcla se basa en la idea de ordenar las tablas izquierda y derecha
y luego escanearlas en paralelo. Por lo tanto, ambos tipos de datos deben
ser capaces de ordenarse por completo, y el operador de unión debe ser uno
que solo pueda tener éxito para pares de valores que caen en el
“mismo lugar”
en el orden de clasificación. En la práctica, esto significa que el operador de unión debe
comportarse como la igualdad. Pero es posible unir por mezcla dos
tipos de datos distintos siempre que sean lógicamente compatibles. Por
ejemplo, el operador de igualdad de smallint contra integer
es utilizable en uniones por mezcla.
Solo necesitamos operadores de ordenación que lleven ambos tipos de datos a una
secuencia lógicamente compatible.
Para marcarse como MERGES, el operador de unión debe aparecer
como un miembro de igualdad de una familia de operadores de índice btree.
Esto no se impone cuando creas el operador, ya que, por supuesto, la familia de operadores
de referencia no podría existir todavía. Pero el operador no se utilizará realmente para
uniones por mezcla a menos que se pueda encontrar una familia de operadores coincidente. El
flag MERGES actúa así como una sugerencia al planificador de que
vale la pena buscar una familia de operadores coincidente.
Un operador utilizable en uniones por mezcla debe tener un conmutador (él mismo si los dos
tipos de datos de los operandos son el mismo, o un operador de igualdad relacionado
si son diferentes) que aparezca en la misma familia de operadores.
Si este no es el caso, podrían ocurrir errores del planificador cuando se utiliza el
operador. Además, es una buena idea (pero no estrictamente necesario) que
una familia de operadores btree que admita múltiples tipos de datos proporcione
operadores de igualdad para cada combinación de los tipos de datos; esto
permite una mejor optimización.
La función subyacente a un operador utilizable en uniones por mezcla debe estar marcada como immutable o stable. Si es volatile, el sistema nunca intentará usar el operador para una unión por mezcla.