Tabelindeling
Herontwerp de tabel om openingstijden (openingsuren) op te slaan als een set van tsrange
(bereik van timestamp without time zone
) waarden. Vereist Postgres 9.2 of hoger .
Kies een willekeurige week om je openingstijden in te delen. Ik hou van de week:
1996-01-01 (maandag) tot 1996/01/07 (zondag)
Dat is het meest recente schrikkeljaar waarin 1 januari toevallig een maandag is. Maar in dit geval kan het elke willekeurige week zijn. Wees gewoon consistent.
Installeer de extra module btree_gist
eerst:
CREATE EXTENSION btree_gist;
Zie:
- Equivalent aan uitsluitingsbeperking bestaande uit geheel getal en bereik
Maak dan de tabel als volgt aan:
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]')
);
De ene kolom hours
vervangt al uw kolommen:
opens_on, closes_on, opens_at, closes_at
Bijvoorbeeld openingstijden vanaf woensdag 18:30 tot donderdag, 05:00 UTC worden ingevoerd als:
'[1996-01-03 18:30, 1996-01-04 05:00]'
De uitsluitingsbeperking hoo_no_overlap
voorkomt overlappende boekingen per winkel. Het is geïmplementeerd met een GiST-index , die ook onze vragen ondersteunt. Bekijk het hoofdstuk "Index en prestaties" hieronder bespreken we indexeringsstrategieën.
De controlebeperking hoo_bounds_inclusive
handhaaft inclusieve grenzen voor uw bereiken, met twee opmerkelijke gevolgen:
- Een tijdstip dat precies op de onder- of bovengrens valt, is altijd inbegrepen.
- Aangrenzende inzendingen voor dezelfde winkel zijn in feite niet toegestaan. Met inclusieve grenzen zouden die "overlappen" en zou de uitsluitingsbeperking een uitzondering veroorzaken. Aangrenzende items moeten in plaats daarvan worden samengevoegd tot één rij. Behalve wanneer ze rond zondag middernacht rondlopen , in welk geval ze in twee rijen moeten worden gesplitst. De functie
f_hoo_hours()
hieronder zorgt hiervoor.
De controlebeperking hoo_standard_week
dwingt de buitengrenzen van de staging-week af met behulp van de "range is bevat door"-operator <@
.
Met inclusief grenzen, moet u een hoekgeval . in acht nemen waar de tijd rond zondag middernacht loopt:
'1996-01-01 00:00+0' = '1996-01-08 00:00+0'
Mon 00:00 = Sun 24:00 (= next Mon 00:00)
U moet beide tijdstempels tegelijk zoeken. Hier is een verwante zaak met exclusief bovengrens die deze tekortkoming niet zou vertonen:
- Aangrenzende/overlappende items voorkomen met EXCLUDE in PostgreSQL
Functie f_hoo_time(timestamptz)
Om een gegeven timestamp with time zone
te "normaliseren" :
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
alleen voor Postgres 9.6 of hoger.
De functie duurt timestamptz
en retourneert timestamp
. Het voegt het verstreken interval van de respectievelijke week toe ($1 - date_trunc('week', $1)
in UTC-tijd naar het startpunt van onze stageweek. (date
+ interval
produceert timestamp
.)
Functie f_hoo_hours(timestamptz, timestamptz)
Om de bereiken te normaliseren en de overschrijdingen van ma 00:00 te splitsen. Deze functie heeft elk interval (als twee timestamptz
) en produceert een of twee genormaliseerde tsrange
waarden. Het dekt elke juridische invoer en verbiedt de rest:
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$;
Naar INSERT
een enkele invoerrij:
INSERT INTO hoo(shop_id, hours)
SELECT 123, f_hoo_hours('2016-01-11 00:00+04', '2016-01-11 08:00+04');
Voor elke aantal invoerrijen:
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);
Elk kan twee rijen invoegen als een bereik moet worden gesplitst om ma 00:00 UTC.
Zoekopdracht
Met het aangepaste ontwerp je hele grote, complexe, dure vraag kan worden vervangen door ... dit:
SELECT *
FROM hoo
WHERE hours @> f_hoo_time(now());
Voor een beetje spanning heb ik een spoilerplaat over de oplossing gelegd. Beweeg de muis over het.
De zoekopdracht wordt ondersteund door de GiST-index en is snel, zelfs voor grote tabellen.
db<>viool hier (met meer voorbeelden)
Oude sqlfiddle
Als je de totale openingstijden (per winkel) wilt berekenen, is hier een recept:
- Bereken werkuren tussen 2 datums in PostgreSQL
Index en prestaties
De insluitingsoperator voor bereiktypen kan worden ondersteund met een GiST of SP-GiST inhoudsopgave. Beide kunnen worden gebruikt om een uitsluitingsbeperking te implementeren, maar alleen GiST ondersteunt indexen met meerdere kolommen:
Momenteel ondersteunen alleen de indextypen B-tree, GiST, GIN en BRIN indexen met meerdere kolommen.
En de volgorde van indexkolommen is van belang:
Een GiST-index met meerdere kolommen kan worden gebruikt met queryvoorwaarden die betrekking hebben op een subset van de kolommen van de index. Voorwaarden op extra kolommen beperken de invoer die door de index wordt geretourneerd, maar de voorwaarde in de eerste kolom is de belangrijkste om te bepalen hoeveel van de index moet worden gescand. Een GiST-index is relatief ondoeltreffend als de eerste kolom slechts een paar verschillende waarden heeft, zelfs als er veel verschillende waarden in extra kolommen zijn.
We hebben dus tegenstrijdige belangen hier. Voor grote tabellen zijn er veel meer verschillende waarden voor shop_id
dan voor hours
.
- Een GiST-index met toonaangevende
shop_id
is sneller om te schrijven en om de uitsluitingsbeperking af te dwingen. - Maar we zoeken
hours
in onze vraag. Het zou beter zijn om die kolom eerst te hebben. - Als we
shop_id
moeten opzoeken in andere zoekopdrachten is een gewone btree-index veel sneller daarvoor. - Als klap op de vuurpijl vond ik een SP-GiST index op slechts
hours
om snelste te zijn voor de vraag.
Benchmark
Nieuwe test met Postgres 12 op een oude laptop. Mijn script om dummy-gegevens te genereren:
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;
Resultaten in ~ 141k willekeurig gegenereerde rijen, ~ 30k verschillende shop_id
, ~ 12k verschillende hours
. Tabelgrootte 8 MB.
Ik heb de uitsluitingsbeperking verwijderd en opnieuw gemaakt:
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
eerste is ~ 4x sneller voor deze distributie.
Daarnaast heb ik er nog twee getest op leesprestaties:
CREATE INDEX hoo_hours_gist_idx on hoo USING gist (hours);
CREATE INDEX hoo_hours_spgist_idx on hoo USING spgist (hours); -- !!
Na VACUUM FULL ANALYZE hoo;
, Ik heb twee zoekopdrachten uitgevoerd:
- Q1 :'s avonds laat, slechts 35 rijen gevonden
- Q2 :'s middags vinden 4547 rijen .
Resultaten
Heb een alleen-index scan voor elk (behalve voor "geen index", natuurlijk):
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 en GiST zijn vergelijkbaar voor zoekopdrachten die weinig resultaten opleveren (GiST is zelfs sneller voor zeer paar).
- SP-GiST schaalt beter met een groeiend aantal resultaten en is ook kleiner.
Als u veel meer leest dan u schrijft (typisch gebruiksscenario), houdt u dan aan de uitsluitingsbeperking zoals aan het begin gesuggereerd en maakt u een extra SP-GiST-index om de leesprestaties te optimaliseren.