Tabellenlayout
Gestalten Sie die Tabelle neu, um die Öffnungszeiten (Betriebsstunden) als Satz von tsrange
zu speichern (Bereich von timestamp without time zone
) Werte. Erfordert Postgres 9.2 oder höher .
Wählen Sie eine zufällige Woche aus, um Ihre Öffnungszeiten festzulegen. Ich mag die Woche:
1996-01-01 (Montag) bis 1996-01-07 (Sonntag)
Das ist das letzte Schaltjahr, in dem der 1. Januar praktischerweise ein Montag ist. In diesem Fall kann es sich jedoch um eine beliebige Woche handeln. Sei einfach konsequent.
Installieren Sie das Zusatzmodul btree_gist
zuerst:
CREATE EXTENSION btree_gist;
Siehe:
- Äquivalent zur Ausschlussbeschränkung, bestehend aus Ganzzahl und Bereich
Erstellen Sie dann die Tabelle wie folgt:
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]')
);
Der Eine Spalte hours
ersetzt alle Ihre Spalten:
opens_on, closes_on, opens_at, closes_at
Beispielsweise Öffnungszeiten ab Mittwoch, 18:30 bis Donnerstag, 05:00 UTC werden eingegeben als:
'[1996-01-03 18:30, 1996-01-04 05:00]'
Die Ausschlussbeschränkung hoo_no_overlap
verhindert überlappende Einträge pro Shop. Es wird mit einem GiST-Index implementiert , was auch unsere Abfragen unterstützt. Beachten Sie das Kapitel "Index und Leistung" unten Erörterung von Indizierungsstrategien.
Die Check-Einschränkung hoo_bounds_inclusive
erzwingt inklusive Grenzen für Ihre Bereiche, mit zwei bemerkenswerten Konsequenzen:
- Ein Zeitpunkt, der genau auf die untere oder obere Grenze fällt, ist immer enthalten.
- Benachbarte Einträge für denselben Shop sind effektiv unzulässig. Bei inklusiven Grenzen würden sich diese "überschneiden" und die Ausschlussbeschränkung würde eine Ausnahme auslösen. Angrenzende Einträge müssen stattdessen zu einer einzigen Zeile zusammengeführt werden. Außer wenn sie gegen Sonntag Mitternacht enden , in diesem Fall müssen sie in zwei Zeilen aufgeteilt werden. Die Funktion
f_hoo_hours()
unten kümmert sich darum.
Die Check-Einschränkung hoo_standard_week
erzwingt die äußeren Grenzen der Staging-Woche mit dem Operator „Bereich ist enthalten in“ <@
.
Mit inklusive Grenzen, müssen Sie einen Eckfall beachten wo die Zeit um Sonntag Mitternacht umläuft:
'1996-01-01 00:00+0' = '1996-01-08 00:00+0'
Mon 00:00 = Sun 24:00 (= next Mon 00:00)
Sie müssen nach beiden Zeitstempeln gleichzeitig suchen. Hier ist ein verwandter Fall mit exklusiv Obergrenze, die diesen Mangel nicht aufweisen würde:
- Benachbarte/überlappende Einträge mit EXCLUDE in PostgreSQL verhindern
Funktion f_hoo_time(timestamptz)
Um jeden gegebenen timestamp with time zone
zu "normalisieren". :
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
nur für Postgres 9.6 oder höher.
Die Funktion benötigt timestamptz
und gibt timestamp
zurück . Es addiert das verstrichene Intervall der jeweiligen Woche ($1 - date_trunc('week', $1)
in UTC-Zeit zum Startpunkt unserer Staging-Woche. (date
+ interval
erzeugt timestamp
.)
Funktion f_hoo_hours(timestamptz, timestamptz)
Um Bereiche zu normalisieren und diejenigen aufzuteilen, die Mo 00:00 überschreiten. Diese Funktion nimmt ein beliebiges Intervall (als zwei timestamptz
) und erzeugt ein oder zwei normalisierte tsrange
Werte. Es deckt alle ab legale Eingabe und verbietet den 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$;
Zum INSERT
eine einzelne Eingabezeile:
INSERT INTO hoo(shop_id, hours)
SELECT 123, f_hoo_hours('2016-01-11 00:00+04', '2016-01-11 08:00+04');
Für alle Anzahl der Eingabezeilen:
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);
Jeder kann zwei Zeilen einfügen, wenn ein Bereich um Mo 00:00 UTC geteilt werden muss.
Abfrage
Mit dem angepassten Design Ihre ganze große, komplexe, teure Abfrage kann durch ... ersetzt werden:
SELECT *
FROM hoo
WHERE hours @> f_hoo_time(now());
Für ein wenig Spannung habe ich eine Spoilerplatte über die Lösung gelegt. Bewegen Sie die Maus über es.
Die Abfrage wird durch den GiST-Index unterstützt und ist schnell, selbst für große Tabellen.
db<>hier fummeln (mit weiteren Beispielen)
Altes sqlfiddle
Wenn Sie die Gesamtöffnungszeiten (pro Geschäft) berechnen möchten, finden Sie hier ein Rezept:
- Arbeitszeit zwischen 2 Daten in PostgreSQL berechnen
Index und Leistung
Der Containment-Operator für Bereichstypen kann mit einem GiST oder SP-GiST unterstützt werden Index. Beide können verwendet werden, um eine Ausschlussbeschränkung zu implementieren, aber nur GiST unterstützt mehrspaltige Indizes:
Derzeit unterstützen nur die Indextypen B-Tree, GiST, GIN und BRIN mehrspaltige Indizes.
Und die Reihenfolge der Indexspalten ist wichtig:
Ein GiST-Index mit mehreren Spalten kann mit Abfragebedingungen verwendet werden, die eine beliebige Teilmenge der Spalten des Index umfassen. Bedingungen für zusätzliche Spalten schränken die vom Index zurückgegebenen Einträge ein, aber die Bedingung für die erste Spalte ist die wichtigste, um zu bestimmen, wie viel vom Index gescannt werden muss. Ein GiST-Index ist relativ wirkungslos, wenn seine erste Spalte nur wenige unterschiedliche Werte enthält, selbst wenn es viele unterschiedliche Werte in zusätzlichen Spalten gibt.
Wir haben also widersprüchliche Interessen hier. Für große Tabellen gibt es viel mehr eindeutige Werte für shop_id
als für hours
.
- Ein GiST-Index mit führender
shop_id
ist schneller zu schreiben und die Ausschlussbeschränkung durchzusetzen. - Aber wir suchen
hours
in unserer Abfrage. Es wäre besser, diese Spalte zuerst zu haben. - Wenn wir nach
shop_id
suchen müssen Bei anderen Abfragen ist ein einfacher Btree-Index dafür viel schneller. - Zur Krönung habe ich noch ein SP-GiST gefunden Index auf nur
hours
am schnellsten zu sein für die Abfrage.
Benchmark
Neuer Test mit Postgres 12 auf einem alten Laptop. Mein Skript zum Generieren von Dummy-Daten:
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;
Ergibt ~ 141.000 zufällig generierte Zeilen, ~ 30.000 eindeutige shop_id
, ~ 12.000 verschiedene hours
. Tabellengröße 8 MB.
Ich habe die Ausschlussbeschränkung gelöscht und neu erstellt:
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 ist für diese Distribution ~ 4x schneller.
Außerdem habe ich zwei weitere auf Leseleistung getestet:
CREATE INDEX hoo_hours_gist_idx on hoo USING gist (hours);
CREATE INDEX hoo_hours_spgist_idx on hoo USING spgist (hours); -- !!
Nach VACUUM FULL ANALYZE hoo;
, habe ich zwei Abfragen ausgeführt:
- Q1 :spät in der Nacht, nur 35 Zeilen finden
- Q2 :am Nachmittag, Finden von 4547 Zeilen .
Ergebnisse
Sie haben einen Nur-Index-Scan erhalten für jeden (außer natürlich "kein Index"):
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 und GiST sind gleichauf bei Abfragen, die wenige Ergebnisse liefern (GiST ist sogar noch schneller für sehr wenige).
- SP-GiST skaliert besser mit einer wachsenden Anzahl von Ergebnissen und ist auch kleiner.
Wenn Sie viel mehr lesen als schreiben (typischer Anwendungsfall), behalten Sie die eingangs vorgeschlagene Ausschlussbeschränkung bei und erstellen Sie einen zusätzlichen SP-GiST-Index, um die Leseleistung zu optimieren.