Disposición de la mesa
Rediseñar la tabla para almacenar los horarios de apertura (horas de funcionamiento) como un conjunto de tsrange
(rango de timestamp without time zone
) valores. Requiere Postgres 9.2 o posterior .
Elija una semana al azar para organizar su horario de apertura. Me gusta la semana:
1996-01-01 (lunes) al 1996-01-07 (domingo)
Ese es el año bisiesto más reciente donde el 1 de enero resulta convenientemente un lunes. Pero puede ser cualquier semana aleatoria para este caso. Solo sé consistente.
Instale el módulo adicional btree_gist
primero:
CREATE EXTENSION btree_gist;
Ver:
- Equivalente a la restricción de exclusión compuesta por un número entero y un rango
Luego crea la tabla así:
CREATE TABLE hoo (
hoo_id serial PRIMARY KEY
, shop_id int NOT NULL -- REFERENCES shop(shop_id) -- reference to shop
, hours tsrange NOT NULL
, CONSTRAINT hoo_no_overlap EXCLUDE USING gist (shop_id with =, hours WITH &&)
, CONSTRAINT hoo_bounds_inclusive CHECK (lower_inc(hours) AND upper_inc(hours))
, CONSTRAINT hoo_standard_week CHECK (hours <@ tsrange '[1996-01-01 0:0, 1996-01-08 0:0]')
);
El uno columna hours
reemplaza todas sus columnas:
opens_on, closes_on, opens_at, closes_at
Por ejemplo, el horario de atención a partir del miércoles a las 18:30 al jueves, 05:00 UTC se ingresan como:
'[1996-01-03 18:30, 1996-01-04 05:00]'
La restricción de exclusión hoo_no_overlap
evita la superposición de entradas por tienda. Se implementa con un índice GiST , que también respalda nuestras consultas. Considere el capítulo "Índice y rendimiento" a continuación se analizan las estrategias de indexación.
La restricción de comprobación hoo_bounds_inclusive
impone límites inclusivos para sus rangos, con dos consecuencias notables:
- Siempre se incluye un punto en el tiempo que se encuentra exactamente en el límite inferior o superior.
- Las entradas adyacentes para la misma tienda no están permitidas. Con límites inclusivos, estos se "superpondrán" y la restricción de exclusión generaría una excepción. En su lugar, las entradas adyacentes deben combinarse en una sola fila. Excepto cuando alrededor de la medianoche del domingo , en cuyo caso deben dividirse en dos filas. La función
f_hoo_hours()
a continuación se encarga de esto.
La restricción de comprobación hoo_standard_week
hace cumplir los límites exteriores de la semana de preparación utilizando el operador <@
"el rango está contenido por" .
Con inclusivo límites, tienes que observar un caso de esquina donde el tiempo termina en la medianoche del domingo:
'1996-01-01 00:00+0' = '1996-01-08 00:00+0'
Mon 00:00 = Sun 24:00 (= next Mon 00:00)
Tienes que buscar ambas marcas de tiempo a la vez. Aquí hay un caso relacionado con exclusivo límite superior que no mostraría esta deficiencia:
- Evitar entradas adyacentes/superpuestas con EXCLUDE en PostgreSQL
Función f_hoo_time(timestamptz)
Para "normalizar" cualquier timestamp with time zone
:
CREATE OR REPLACE FUNCTION f_hoo_time(timestamptz)
RETURNS timestamp
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS
$func$
SELECT timestamp '1996-01-01' + ($1 AT TIME ZONE 'UTC' - date_trunc('week', $1 AT TIME ZONE 'UTC'))
$func$;
PARALLEL SAFE
solo para Postgres 9.6 o posterior.
La función toma timestamptz
y devuelve timestamp
. Agrega el intervalo transcurrido de la semana respectiva ($1 - date_trunc('week', $1)
en horario UTC hasta el punto de inicio de nuestra semana de preparación. (date
+ interval
produce timestamp
.)
Función f_hoo_hours(timestamptz, timestamptz)
Para normalizar rangos y dividir los que cruzan lun 00:00. Esta función toma cualquier intervalo (como dos timestamptz
) y produce uno o dos tsrange
normalizados valores. Cubre cualquier entrada legal y rechaza el resto:
CREATE OR REPLACE FUNCTION f_hoo_hours(_from timestamptz, _to timestamptz)
RETURNS TABLE (hoo_hours tsrange)
LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE COST 500 ROWS 1 AS
$func$
DECLARE
ts_from timestamp := f_hoo_time(_from);
ts_to timestamp := f_hoo_time(_to);
BEGIN
-- sanity checks (optional)
IF _to <= _from THEN
RAISE EXCEPTION '%', '_to must be later than _from!';
ELSIF _to > _from + interval '1 week' THEN
RAISE EXCEPTION '%', 'Interval cannot span more than a week!';
END IF;
IF ts_from > ts_to THEN -- split range at Mon 00:00
RETURN QUERY
VALUES (tsrange('1996-01-01', ts_to , '[]'))
, (tsrange(ts_from, '1996-01-08', '[]'));
ELSE -- simple case: range in standard week
hoo_hours := tsrange(ts_from, ts_to, '[]');
RETURN NEXT;
END IF;
RETURN;
END
$func$;
Para INSERT
un soltero fila de entrada:
INSERT INTO hoo(shop_id, hours)
SELECT 123, f_hoo_hours('2016-01-11 00:00+04', '2016-01-11 08:00+04');
Para cualquier número de filas de entrada:
INSERT INTO hoo(shop_id, hours)
SELECT id, f_hoo_hours(f, t)
FROM (
VALUES (7, timestamptz '2016-01-11 00:00+0', timestamptz '2016-01-11 08:00+0')
, (8, '2016-01-11 00:00+1', '2016-01-11 08:00+1')
) t(id, f, t);
Cada uno puede insertar dos filas si es necesario dividir un rango a las 00:00 UTC del lunes.
Consulta
Con el diseño ajustado, toda su consulta grande, compleja y costosa se puede reemplazar con... esto:
SELECT *
FROM hoo
WHERE hours @> f_hoo_time(now());
Para un poco de suspenso, puse un spoiler sobre la solución. Mueve el mouse sobre eso.
La consulta está respaldada por dicho índice GiST y es rápida, incluso para tablas grandes.
db<>violín aquí (con más ejemplos)
Sqlfiddle antiguo
Si quieres calcular el horario total de apertura (por tienda), aquí tienes una receta:
- Calcular horas de trabajo entre 2 fechas en PostgreSQL
Índice y rendimiento
El operador de contención para tipos de rango se puede admitir con GiST o SP-GiST índice. Cualquiera puede usarse para implementar una restricción de exclusión, pero solo GiST admite índices de varias columnas:
Actualmente, solo los tipos de índice B-tree, GiST, GIN y BRIN admiten índices de varias columnas.
Y el orden de las columnas de índice importa:
Se puede usar un índice GiST de varias columnas con condiciones de consulta que involucren cualquier subconjunto de las columnas del índice. Las condiciones en columnas adicionales restringen las entradas devueltas por el índice, pero la condición en la primera columna es la más importante para determinar cuánto del índice debe escanearse. Un índice GiST será relativamente ineficaz si su primera columna tiene solo unos pocos valores distintos, incluso si hay muchos valores distintos en columnas adicionales.
Así que tenemos intereses en conflicto aquí. Para tablas grandes, habrá muchos más valores distintos para shop_id
que por hours
.
- Un índice GiST con
shop_id
líder es más rápido de escribir y de hacer cumplir la restricción de exclusión. - Pero estamos buscando
hours
en nuestra consulta. Tener esa columna primero sería mejor. - Si necesitamos buscar
shop_id
en otras consultas, un índice btree simple es mucho más rápido para eso. - Para colmo, encontré un SP-GiST índice en solo
hours
ser más rápido para la consulta.
Valor de referencia
Nueva prueba con Postgres 12 en una laptop antigua. Mi script para generar datos ficticios:
INSERT INTO hoo(shop_id, hours)
SELECT id
, f_hoo_hours(((date '1996-01-01' + d) + interval '4h' + interval '15 min' * trunc(32 * random())) AT TIME ZONE 'UTC'
, ((date '1996-01-01' + d) + interval '12h' + interval '15 min' * trunc(64 * random() * random())) AT TIME ZONE 'UTC')
FROM generate_series(1, 30000) id
JOIN generate_series(0, 6) d ON random() > .33;
Da como resultado ~ 141 000 filas generadas aleatoriamente, ~ 30 000 shop_id
distintos , ~ 12k hours
distintas . Tamaño de la tabla 8 MB.
Solté y recreé la restricción de exclusión:
ALTER TABLE hoo
DROP CONSTRAINT hoo_no_overlap
, ADD CONSTRAINT hoo_no_overlap EXCLUDE USING gist (shop_id WITH =, hours WITH &&); -- 3.5 sec; index 8 MB
ALTER TABLE hoo
DROP CONSTRAINT hoo_no_overlap
, ADD CONSTRAINT hoo_no_overlap EXCLUDE USING gist (hours WITH &&, shop_id WITH =); -- 13.6 sec; index 12 MB
shop_id
first es ~ 4 veces más rápido para esta distribución.
Además, probé dos más para el rendimiento de lectura:
CREATE INDEX hoo_hours_gist_idx on hoo USING gist (hours);
CREATE INDEX hoo_hours_spgist_idx on hoo USING spgist (hours); -- !!
Después de VACUUM FULL ANALYZE hoo;
, realicé dos consultas:
- Q1 :tarde en la noche, encontrando solo 35 filas
- Q2 :por la tarde, encontrando 4547 filas .
Resultados
Obtuve un escaneo de solo índice para cada uno (excepto "sin índice", por supuesto):
index idx size Q1 Q2
------------------------------------------------
no index 38.5 ms 38.5 ms
gist (shop_id, hours) 8MB 17.5 ms 18.4 ms
gist (hours, shop_id) 12MB 0.6 ms 3.4 ms
gist (hours) 11MB 0.3 ms 3.1 ms
spgist (hours) 9MB 0.7 ms 1.8 ms -- !
- SP-GiST y GiST están a la par para consultas que encuentran pocos resultados (GiST es incluso más rápido para muy pocos).
- SP-GiST escala mejor con un número creciente de resultados y también es más pequeño.
Si lee mucho más de lo que escribe (caso de uso típico), mantenga la restricción de exclusión como se sugirió al principio y cree un índice SP-GiST adicional para optimizar el rendimiento de lectura.